Compare commits
20 Commits
b933db88df
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0916c3050 | ||
|
|
47de1efe86 | ||
|
|
4a64256ef4 | ||
|
|
c410133c4c | ||
|
|
cbbb88c052 | ||
|
|
448b3d7c44 | ||
|
|
3c70eb5ff7 | ||
|
|
c93955fa55 | ||
|
|
aed79078de | ||
|
|
0bf26f8eb5 | ||
|
|
675a449ce8 | ||
|
|
03621f16f4 | ||
|
|
9c69cf8911 | ||
|
|
6376739f48 | ||
|
|
7b6c62b23d | ||
|
|
6b7889ef4e | ||
|
|
17cea4aa9e | ||
|
|
04ac33430f | ||
|
|
f784ddd62d | ||
|
|
525679cf8b |
1
.claude/scheduled_tasks.lock
Normal file
1
.claude/scheduled_tasks.lock
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"sessionId":"b4e65d8e-b477-417b-9390-f3d14033ef91","pid":3138376,"procStart":"71699559","acquiredAt":1777673126273}
|
||||||
124
CLAUDE.md
Normal file
124
CLAUDE.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Start with nodemon auto-reload
|
||||||
|
npm start # Production start
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
npm test # Run all tests once (vitest run)
|
||||||
|
npm run test:watch # Watch mode
|
||||||
|
npm run test:coverage
|
||||||
|
|
||||||
|
# Run a single test file
|
||||||
|
npx vitest run src/modules/3-turn-engine/orderModel.test.js
|
||||||
|
|
||||||
|
# Database migrations (requires DATABASE_URL in .env)
|
||||||
|
npm run migrate:up
|
||||||
|
npm run migrate:down
|
||||||
|
npm run migrate:redo
|
||||||
|
npm run migrate:status
|
||||||
|
npm run seed # Seed a tenant via scripts/seed-tenant.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
No lint command is configured.
|
||||||
|
|
||||||
|
## Product goal
|
||||||
|
|
||||||
|
The bot must be **conversational and intelligent**, not a menu-driven flow. Customers reach out via WhatsApp **with intent to buy** — the bot's job is to:
|
||||||
|
|
||||||
|
1. **Engage in conversation** — answer questions about products, prices, availability/stock; recommend; clarify.
|
||||||
|
2. **Take orders** — build a cart through natural dialogue (multi-product turns, quantities, units).
|
||||||
|
3. **Collect delivery data** — address, delivery vs pickup, payment method.
|
||||||
|
4. **Operate within store rules** — delivery zones, days/hours, pickup windows. These config tables (`delivery_zones`, store schedule in `tenant_settings`) will be populated later; the bot has to read and respect them when present.
|
||||||
|
|
||||||
|
Repetitive, hardcoded responses are a known quality problem and the focus of the active improvement plan (see `~/.claude/plans/ok-creo-que-tiene-humming-sutton.md`). The system is **not yet in production** — refactors that change behavior are acceptable.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This is a **mono-tenant WhatsApp e-commerce chatbot** powered by Express.js. The store operator hooks the bot to a single WooCommerce shop; customers interact via WhatsApp to browse products, build carts, and place orders.
|
||||||
|
|
||||||
|
The DB schema retains `tenant_id` columns (it was originally multi-tenant) but the app boots with a single tenant resolved at startup. The single id is exposed via `src/modules/shared/tenant.js` (`getTenantId()`); webhook handlers and intake routes read from there instead of looking up tenants per-request.
|
||||||
|
|
||||||
|
### Request flow
|
||||||
|
|
||||||
|
```
|
||||||
|
WhatsApp → Evolution API webhook → /webhook/evolution (or /sim/send)
|
||||||
|
↓
|
||||||
|
1-intake: route & normalize message
|
||||||
|
↓
|
||||||
|
2-identity/pipeline.processMessage (idempotency, history, side effects)
|
||||||
|
↓
|
||||||
|
3-turn-engine/agent: tool-calling LLM loop
|
||||||
|
↓
|
||||||
|
Response persisted to DB + sent back via Evolution API
|
||||||
|
```
|
||||||
|
|
||||||
|
### Turn engine: tool-calling agent (DeepSeek)
|
||||||
|
|
||||||
|
`src/modules/3-turn-engine/agent/` es el único motor. Cada turno arma un **WorkingMemory** (cart, pending, last_shown_options, store, history truncado, customer_profile, preparsed quantity) y se lo pasa al LLM como user message. El LLM decide qué tools llamar:
|
||||||
|
|
||||||
|
- `search_catalog`, `add_to_cart`, `set_quantity`, `select_candidate`, `remove_from_cart`
|
||||||
|
- `set_shipping`, `set_address`, `confirm_order`
|
||||||
|
- `pause`, `escalate_to_human`
|
||||||
|
- `say` (último siempre — es el reply al usuario)
|
||||||
|
|
||||||
|
El system prompt es **estático** (en `agent/systemPrompt.js` como `SYSTEM_PROMPT` const) para que DeepSeek lo cachée prefix-cache automáticamente. Cache hit ratio típico ≥70% después de 2 turnos. El parser de cantidades (`agent/quantityParser.js`) preprocesa el texto y se pasa como `working_memory.preparsed` (fracciones, "media docena", "cuarto kilo", etc.).
|
||||||
|
|
||||||
|
La FSM (`fsm.js`) sigue siendo guardrail: estados `IDLE / CART / SHIPPING / PAUSED / AWAITING_HUMAN` con transiciones validadas. PAUSED tiene TTL 7d (cart preservado para "después te digo").
|
||||||
|
|
||||||
|
### Module structure (numbered layers)
|
||||||
|
|
||||||
|
- **`src/modules/0-UI/`** — Admin dashboard: REST controllers para products, conversations, settings, takeovers, recommendations, aliases.
|
||||||
|
|
||||||
|
- **`src/modules/1-intake/`** — Message ingestion. Routes: `/simulator` (dev UI), `/webhook/evolution` (WhatsApp).
|
||||||
|
|
||||||
|
- **`src/modules/2-identity/`** — User mapping (WhatsApp ↔ WooCommerce customer), encrypted WooCommerce credentials, pipeline orchestrator.
|
||||||
|
|
||||||
|
- **`src/modules/3-turn-engine/`** — Agente tool-calling (`agent/`), FSM (`fsm.js`), order model (`orderModel.js`), catalog retrieval (`catalogRetrieval.js`), store context (`storeContext.js`).
|
||||||
|
|
||||||
|
- **`src/modules/4-woo-orders/`** — WooCommerce order sync (lectura). El bot crea orders nuevas vía `wooOrders.createOrder` desde `pipeline.js` cuando emite la action `create_order`.
|
||||||
|
|
||||||
|
- **`src/modules/shared/`** — DB pool, SSE, WooSnapshot, tenant resolver (`getTenantId()`), debug.
|
||||||
|
|
||||||
|
### Key integrations
|
||||||
|
|
||||||
|
| System | Purpose | Config |
|
||||||
|
|--------|---------|--------|
|
||||||
|
| LLM (DeepSeek) | Agente tool-calling — único motor | `OPENAI_API_KEY`, `OPENAI_BASE_URL=https://api.deepseek.com/v1`, `OPENAI_MODEL=deepseek-chat` |
|
||||||
|
| Evolution API | WhatsApp send/receive | `EVOLUTION_*`, `EVOLUTION_SEND_ENABLED` |
|
||||||
|
| WooCommerce REST API | Products, orders, customers | `WOO_BASE_URL`, `WOO_CONSUMER_KEY`, `WOO_CONSUMER_SECRET` |
|
||||||
|
| PostgreSQL | Primary database | `DATABASE_URL` |
|
||||||
|
|
||||||
|
### Database
|
||||||
|
|
||||||
|
Migrations live in `db/migrations/` as timestamped SQL files managed by `dbmate`. Key tables:
|
||||||
|
- `tenants`, `tenant_config`, `tenant_settings`, `tenant_ecommerce_config`, `tenant_channels`
|
||||||
|
- `wa_identity_map` — WhatsApp ↔ WooCommerce customer mapping
|
||||||
|
- `wa_conversation_state` — FSM state + context (cart, pending, last_shown_options, paused_until) en JSONB
|
||||||
|
- `wa_messages` — Message history (idempotencia por message_id)
|
||||||
|
- `woo_products_snapshot` — Cached product catalog (con índices pg_trgm en aliases)
|
||||||
|
- `product_aliases`, `alias_product_mappings` — fuzzy alias resolution
|
||||||
|
- `woo_orders_cache` + `woo_order_items` — orders sync para customer_profile / stats
|
||||||
|
- `human_takeovers`, `audit_log`, `conversation_runs`
|
||||||
|
|
||||||
|
### Feature flags (env vars)
|
||||||
|
|
||||||
|
- `AGENT_MAX_TOOL_CALLS=10` — cap de tool calls por turno
|
||||||
|
- `AGENT_TURN_TIMEOUT_MS=25000` — timeout total del turno
|
||||||
|
- `EVOLUTION_SEND_ENABLED=1` — enviar a WhatsApp real (off en dev)
|
||||||
|
- `DEBUG_PERF`, `DEBUG_WOO_HTTP`, `DEBUG_LLM`, `DEBUG_EVOLUTION` — debug logs granular
|
||||||
|
|
||||||
|
### Métricas
|
||||||
|
|
||||||
|
- `GET /api/metrics/agent` — turns, avg tool calls, fallback rate, escalations, **cache_hit_ratio** (prompt caching de DeepSeek)
|
||||||
|
|
||||||
|
### Local development
|
||||||
|
|
||||||
|
Copy `env.example` to `.env` and fill in values. Use `docker-compose.override.yaml` for local overrides. Run `docker compose up` to start app + Postgres + Redis. The Dockerfile runs migrations automatically on startup (`migrate:up && seed && start`).
|
||||||
|
|
||||||
|
Test files use Vitest with `globals: true` — no need to import `describe`, `it`, `expect`.
|
||||||
17
db/migrations/20260501100000_product_aliases_trgm.sql
Normal file
17
db/migrations/20260501100000_product_aliases_trgm.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- pg_trgm para fuzzy matching de aliases:
|
||||||
|
-- - Captura plurales (vacio↔vacios), diminutivos (costillita↔costilla),
|
||||||
|
-- typos (vasio↔vacio) sin escribir reglas.
|
||||||
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS product_aliases_norm_trgm_idx
|
||||||
|
ON product_aliases USING gin (normalized_alias gin_trgm_ops);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS alias_product_mappings_alias_trgm_idx
|
||||||
|
ON alias_product_mappings USING gin (alias gin_trgm_ops);
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP INDEX IF EXISTS alias_product_mappings_alias_trgm_idx;
|
||||||
|
DROP INDEX IF EXISTS product_aliases_norm_trgm_idx;
|
||||||
|
-- Intencionalmente NO se hace DROP EXTENSION pg_trgm:
|
||||||
|
-- puede ser usada por otras consultas/migraciones futuras.
|
||||||
24
db/migrations/20260501110000_reply_templates.sql
Normal file
24
db/migrations/20260501110000_reply_templates.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- Templates de respuestas con variantes para evitar repetición.
|
||||||
|
-- Filosofía: cada slot semántico (ej. cart.ask_more) tiene N variantes;
|
||||||
|
-- el código rota entre ellas excluyendo las recientemente usadas.
|
||||||
|
CREATE TABLE reply_templates (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
||||||
|
template_key VARCHAR(80) NOT NULL,
|
||||||
|
variant INTEGER NOT NULL DEFAULT 1,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
weight INTEGER NOT NULL DEFAULT 1,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT uq_reply_variant UNIQUE(tenant_id, template_key, variant)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_reply_active
|
||||||
|
ON reply_templates(tenant_id, template_key)
|
||||||
|
WHERE is_active = true;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP INDEX IF EXISTS idx_reply_active;
|
||||||
|
DROP TABLE IF EXISTS reply_templates;
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- Seed mono-tenant: settings de tienda (horarios + zonas de delivery) +
|
||||||
|
-- reply_templates con todas las variantes hoy hardcodeadas en DEFAULTS.
|
||||||
|
-- Esto desbloquea editar respuestas vía UI/SQL sin redeploy.
|
||||||
|
|
||||||
|
-- 1) tenant_settings: schedule + delivery_zones para piaf
|
||||||
|
UPDATE tenant_settings
|
||||||
|
SET
|
||||||
|
store_name = COALESCE(NULLIF(store_name, 'Mi Negocio'), 'Piaf'),
|
||||||
|
bot_name = COALESCE(NULLIF(bot_name, 'Bot'), 'Piaf'),
|
||||||
|
pickup_enabled = true,
|
||||||
|
schedule = '{
|
||||||
|
"pickup": {
|
||||||
|
"lun": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||||
|
"mar": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||||
|
"mie": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||||
|
"jue": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||||
|
"vie": {"enabled": true, "start": "09:00", "end": "20:00"},
|
||||||
|
"sab": {"enabled": true, "start": "09:00", "end": "13:00"}
|
||||||
|
}
|
||||||
|
}'::jsonb,
|
||||||
|
delivery_zones = '{}'::jsonb
|
||||||
|
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
|
||||||
|
|
||||||
|
-- 2) reply_templates: seed con DEFAULTS de replyTemplates.js para piaf
|
||||||
|
INSERT INTO reply_templates (tenant_id, template_key, variant, content, weight)
|
||||||
|
SELECT
|
||||||
|
'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid,
|
||||||
|
v.template_key,
|
||||||
|
v.variant,
|
||||||
|
v.content,
|
||||||
|
v.weight
|
||||||
|
FROM (VALUES
|
||||||
|
-- IDLE
|
||||||
|
('idle.greeting', 1, '¡Hola! ¿En qué te puedo ayudar?', 1),
|
||||||
|
('idle.greeting', 2, '¡Hola! Estoy para ayudarte con tu pedido. ¿Qué andás buscando?', 1),
|
||||||
|
('idle.greeting', 3, 'Buenas. ¿Querés que te muestre algo en particular o hacemos un pedido?', 1),
|
||||||
|
('idle.help_prompt', 1, 'Decime qué necesitás. Podés pedirme productos, precios, o armar el pedido directo.', 1),
|
||||||
|
('idle.help_prompt', 2, '¿Qué te tiro? Podés pedir algo, preguntar precios o consultar disponibilidad.', 1),
|
||||||
|
-- CART
|
||||||
|
('cart.ask_more', 1, '¿Algo más?', 1),
|
||||||
|
('cart.ask_more', 2, '¿Querés agregar algo más al pedido?', 1),
|
||||||
|
('cart.ask_more', 3, '¿Sumamos algo más o cerramos así?', 1),
|
||||||
|
('cart.empty_prompt', 1, 'Tu carrito está vacío. ¿Qué querés agregar?', 1),
|
||||||
|
('cart.empty_prompt', 2, 'Todavía no hay nada en el carrito. ¿Por dónde empezamos?', 1),
|
||||||
|
('cart.not_found', 1, 'No encontré "{{query}}". ¿Podés decirlo de otra forma?', 1),
|
||||||
|
('cart.not_found', 2, 'Mmm, no tengo "{{query}}" exacto. ¿Probamos con otra cosa?', 1),
|
||||||
|
('cart.not_found', 3, 'No me aparece "{{query}}". Si querés, dame otro nombre o detalle más.', 1),
|
||||||
|
('cart.didnt_understand', 1, 'Perdón, no te entendí.', 1),
|
||||||
|
('cart.didnt_understand', 2, 'No me quedó claro, ¿me lo decís de otra forma?', 1),
|
||||||
|
('cart.didnt_understand', 3, 'No te seguí, ¿podés repetir?', 1),
|
||||||
|
('cart.skip_acknowledged', 1, 'Ok, lo dejamos.', 1),
|
||||||
|
('cart.skip_acknowledged', 2, 'Listo, no lo agregamos.', 1),
|
||||||
|
('cart.confirm_to_shipping', 1, 'Buenísimo. ¿Es para delivery o lo pasás a buscar?', 1),
|
||||||
|
('cart.confirm_to_shipping', 2, 'Perfecto. ¿Te lo enviamos o lo retirás?', 1),
|
||||||
|
('cart.pending_before_close', 1, 'Antes de cerrar, ¿qué hacemos con lo que quedó pendiente?', 1),
|
||||||
|
('cart.pending_before_close', 2, 'Tenemos algo pendiente para resolver antes de cerrar el pedido.', 1),
|
||||||
|
('cart.added_confirm', 1, 'Anoté {{summary}}. ¿Algo más?', 1),
|
||||||
|
('cart.added_confirm', 2, 'Listo, {{summary}} agregado. ¿Sumamos algo más?', 1),
|
||||||
|
('cart.added_confirm', 3, 'Sumé {{summary}}. ¿Querés agregar algo más?', 1),
|
||||||
|
('cart.added_confirm', 4, 'Va {{summary}}. ¿Algo más?', 1),
|
||||||
|
('cart.ask_what_product', 1, '¿Qué producto querés?', 1),
|
||||||
|
('cart.ask_what_product', 2, 'Decime el producto y lo busco.', 1),
|
||||||
|
('cart.price_no_query', 1, '¿De qué producto querés saber el precio?', 1),
|
||||||
|
('cart.price_no_query', 2, 'Decime el producto y te paso el precio.', 1),
|
||||||
|
('cart.price_results_header', 1, 'Estos son los precios:', 1),
|
||||||
|
('cart.price_results_header', 2, 'Precios disponibles:', 1),
|
||||||
|
-- SHIPPING (incluye {{delivery_zones_summary}} y {{delivery_hours}} cuando hay datos)
|
||||||
|
('shipping.ask_method', 1, '¿Lo enviamos a domicilio o lo pasás a buscar?', 1),
|
||||||
|
('shipping.ask_method', 2, '¿Es para delivery o pickup?', 1),
|
||||||
|
('shipping.ask_address', 1, 'Pasame la dirección de entrega.', 1),
|
||||||
|
('shipping.ask_address', 2, 'Decime dónde lo entregamos (calle y altura). Hacemos delivery en {{delivery_zones_summary}}.', 1),
|
||||||
|
('shipping.address_recorded', 1, 'Anotado: {{address}}.', 1),
|
||||||
|
('shipping.address_recorded', 2, 'Listo, dirección guardada: {{address}}.', 1),
|
||||||
|
-- ORDER CLOSE
|
||||||
|
('order.confirmed', 1, '¡Listo! Anotamos tu pedido. Te coordinamos por acá la entrega.', 1),
|
||||||
|
('order.confirmed', 2, 'Perfecto, ya quedó registrado. Te confirmamos en breve los detalles de entrega.', 1),
|
||||||
|
('order.confirmed', 3, 'Genial, anotado. Cualquier ajuste avisame por acá.', 1)
|
||||||
|
) AS v(template_key, variant, content, weight)
|
||||||
|
ON CONFLICT (tenant_id, template_key, variant) DO NOTHING;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DELETE FROM reply_templates
|
||||||
|
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
|
||||||
|
-- Settings: limpiar schedule/zones (no borrar la fila)
|
||||||
|
UPDATE tenant_settings
|
||||||
|
SET schedule = '{}'::jsonb, delivery_zones = '{}'::jsonb
|
||||||
|
WHERE tenant_id = 'eb71b9a7-9ccf-430e-9b25-951a0c589c0f'::uuid;
|
||||||
13
db/migrations/20260502181148_drop_legacy_zones_format.sql
Normal file
13
db/migrations/20260502181148_drop_legacy_zones_format.sql
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- Limpiar formato legacy de delivery_zones (caba.barrios, flat) y dejar solo
|
||||||
|
-- el schema nuevo { zones: [...] }. Pre-prod: no preservamos data legacy.
|
||||||
|
UPDATE tenant_settings
|
||||||
|
SET delivery_zones = '{}'::jsonb
|
||||||
|
WHERE
|
||||||
|
delivery_zones IS NULL
|
||||||
|
OR NOT (delivery_zones ? 'zones')
|
||||||
|
OR jsonb_typeof(delivery_zones->'zones') <> 'array';
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
-- noop: no preservamos el formato legacy.
|
||||||
|
SELECT 1;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- Borrar columnas obsoletas de tenant_settings: ahora cada zona de delivery
|
||||||
|
-- tiene su propio costo, días y rango horario. Schedule.delivery también queda
|
||||||
|
-- obsoleto (sólo pickup hace sentido como horario único de la tienda física).
|
||||||
|
ALTER TABLE tenant_settings
|
||||||
|
DROP COLUMN IF EXISTS delivery_enabled,
|
||||||
|
DROP COLUMN IF EXISTS delivery_days,
|
||||||
|
DROP COLUMN IF EXISTS delivery_hours_start,
|
||||||
|
DROP COLUMN IF EXISTS delivery_hours_end,
|
||||||
|
DROP COLUMN IF EXISTS delivery_min_order;
|
||||||
|
|
||||||
|
UPDATE tenant_settings
|
||||||
|
SET schedule = (schedule - 'delivery')
|
||||||
|
WHERE schedule ? 'delivery';
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
-- noop: no preservamos los campos legacy.
|
||||||
|
SELECT 1;
|
||||||
48
db/migrations/20260502214940_system_users_and_sessions.sql
Normal file
48
db/migrations/20260502214940_system_users_and_sessions.sql
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- Operadores del sistema (admin del bot) + sesiones server-side.
|
||||||
|
-- No tienen tenant_id porque el sistema es mono-tenant; si en algún momento se
|
||||||
|
-- vuelve multi-tenant, se agrega y listo.
|
||||||
|
|
||||||
|
CREATE EXTENSION IF NOT EXISTS citext;
|
||||||
|
|
||||||
|
CREATE TABLE system_users (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
email CITEXT UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
last_login_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE system_sessions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id BIGINT NOT NULL REFERENCES system_users(id) ON DELETE CASCADE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
ip INET,
|
||||||
|
user_agent TEXT,
|
||||||
|
revoked_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX system_sessions_user_id_idx ON system_sessions (user_id);
|
||||||
|
CREATE INDEX system_sessions_expires_at_idx ON system_sessions (expires_at);
|
||||||
|
|
||||||
|
-- Trigger para mantener updated_at en system_users.
|
||||||
|
CREATE OR REPLACE FUNCTION system_users_touch_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at := NOW();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER system_users_updated_trigger
|
||||||
|
BEFORE UPDATE ON system_users
|
||||||
|
FOR EACH ROW EXECUTE FUNCTION system_users_touch_updated_at();
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP TRIGGER IF EXISTS system_users_updated_trigger ON system_users;
|
||||||
|
DROP FUNCTION IF EXISTS system_users_touch_updated_at();
|
||||||
|
DROP TABLE IF EXISTS system_sessions;
|
||||||
|
DROP TABLE IF EXISTS system_users;
|
||||||
24
db/migrations/20260502214941_audit_log_extend.sql
Normal file
24
db/migrations/20260502214941_audit_log_extend.sql
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- Extiende audit_log para trazar acciones de operadores con contexto rico.
|
||||||
|
-- La columna 'actor' (text) preexistente queda como fallback para escrituras
|
||||||
|
-- del bot/sistema sin user (ej. webhook woo).
|
||||||
|
|
||||||
|
ALTER TABLE audit_log
|
||||||
|
ADD COLUMN IF NOT EXISTS actor_user_id BIGINT REFERENCES system_users(id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN IF NOT EXISTS actor_email TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS actor_ip INET,
|
||||||
|
ADD COLUMN IF NOT EXISTS action_path TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS summary TEXT;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_log_created_at_idx ON audit_log (created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS audit_log_actor_user_id_idx ON audit_log (actor_user_id);
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
DROP INDEX IF EXISTS audit_log_actor_user_id_idx;
|
||||||
|
DROP INDEX IF EXISTS audit_log_created_at_idx;
|
||||||
|
ALTER TABLE audit_log
|
||||||
|
DROP COLUMN IF EXISTS summary,
|
||||||
|
DROP COLUMN IF EXISTS action_path,
|
||||||
|
DROP COLUMN IF EXISTS actor_ip,
|
||||||
|
DROP COLUMN IF EXISTS actor_email,
|
||||||
|
DROP COLUMN IF EXISTS actor_user_id;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- migrate:up
|
||||||
|
-- entity_id era NOT NULL pero los nuevos eventos (login/logout, settings,
|
||||||
|
-- auth-related) no tienen una entity natural. Relajamos a NULL.
|
||||||
|
ALTER TABLE audit_log ALTER COLUMN entity_id DROP NOT NULL;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
ALTER TABLE audit_log ALTER COLUMN entity_id SET NOT NULL;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3001:3000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -13,3 +13,5 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- .:/usr/src/app
|
- .:/usr/src/app
|
||||||
- /usr/src/app/node_modules
|
- /usr/src/app/node_modules
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
|
|||||||
37
env.example
37
env.example
@@ -12,25 +12,23 @@ PG_IDLE_TIMEOUT_MS=30000
|
|||||||
PG_CONN_TIMEOUT_MS=5000
|
PG_CONN_TIMEOUT_MS=5000
|
||||||
APP_ENCRYPTION_KEY=your-32-char-encryption-key-here
|
APP_ENCRYPTION_KEY=your-32-char-encryption-key-here
|
||||||
|
|
||||||
|
# Bootstrap del primer operador (solo se usa si la tabla system_users está vacía).
|
||||||
|
# Después podés crear/editar operadores desde la UI en /operadores.
|
||||||
|
ADMIN_EMAIL=admin@local
|
||||||
|
ADMIN_PASSWORD=change-this-please
|
||||||
|
ADMIN_NAME=Admin
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# OpenAI
|
# LLM (OpenAI-compatible: DeepSeek, OpenAI, Anthropic via gateway, etc.)
|
||||||
# ===================
|
# ===================
|
||||||
|
# Default actual: DeepSeek V3.x con tool-calling nativo + prompt caching automático.
|
||||||
OPENAI_API_KEY=sk-xxx
|
OPENAI_API_KEY=sk-xxx
|
||||||
OPENAI_MODEL=gpt-4o-mini
|
OPENAI_BASE_URL=https://api.deepseek.com/v1
|
||||||
|
OPENAI_MODEL=deepseek-chat
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# Turn Engine
|
# WooCommerce (fallback si el tenant no tiene credenciales en DB)
|
||||||
# ===================
|
# ===================
|
||||||
# v1 = pipeline actual (heurísticas + guardrails + LLM plan final)
|
|
||||||
# v2 = LLM-first NLU, deterministic core (nuevo motor)
|
|
||||||
TURN_ENGINE=v1
|
|
||||||
|
|
||||||
# ===================
|
|
||||||
# WooCommerce (usado por seed y fallback)
|
|
||||||
# Estas variables son leídas por scripts/seed-tenant.mjs para crear
|
|
||||||
# la configuración inicial del tenant en la base de datos.
|
|
||||||
# ===================
|
|
||||||
WOO_BASE_URL=https://tu-tienda.com
|
|
||||||
WOO_CONSUMER_KEY=ck_xxx
|
WOO_CONSUMER_KEY=ck_xxx
|
||||||
WOO_CONSUMER_SECRET=cs_xxx
|
WOO_CONSUMER_SECRET=cs_xxx
|
||||||
|
|
||||||
@@ -42,12 +40,23 @@ EVOLUTION_API_KEY=your-api-key
|
|||||||
EVOLUTION_INSTANCE_NAME=piaf
|
EVOLUTION_INSTANCE_NAME=piaf
|
||||||
EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producción)
|
EVOLUTION_SEND_ENABLED=0 # 0=solo BD (pruebas), 1=envía a WhatsApp (producción)
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Limits
|
||||||
|
# ===================
|
||||||
|
LIMIT_CONVERSATIONS=100
|
||||||
|
MAX_CHARS_PER_MESSAGE=4000
|
||||||
|
|
||||||
|
# ===================
|
||||||
|
# Agent (tool-calling) — único motor de turno
|
||||||
|
# ===================
|
||||||
|
AGENT_MAX_TOOL_CALLS=10
|
||||||
|
AGENT_TURN_TIMEOUT_MS=25000
|
||||||
|
|
||||||
# ===================
|
# ===================
|
||||||
# Debug Flags (1/true/yes/on para activar)
|
# Debug Flags (1/true/yes/on para activar)
|
||||||
# ===================
|
# ===================
|
||||||
DEBUG_PERF=0
|
DEBUG_PERF=0
|
||||||
DEBUG_WOO_HTTP=0
|
DEBUG_WOO_HTTP=0
|
||||||
DEBUG_WOO_PRODUCTS=0
|
|
||||||
DEBUG_LLM=0
|
DEBUG_LLM=0
|
||||||
DEBUG_EVOLUTION=0
|
DEBUG_EVOLUTION=0
|
||||||
DEBUG_DB=0
|
DEBUG_DB=0
|
||||||
|
|||||||
19
index.js
19
index.js
@@ -1,10 +1,11 @@
|
|||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import { ensureTenant } from "./src/modules/2-identity/db/repo.js";
|
import { ensureTenant } from "./src/modules/2-identity/db/repo.js";
|
||||||
|
import { setTenant } from "./src/modules/shared/tenant.js";
|
||||||
import { createApp } from "./src/app.js";
|
import { createApp } from "./src/app.js";
|
||||||
|
import { ensureBootstrapAdmin } from "./src/modules/auth/services/bootstrap.js";
|
||||||
|
|
||||||
async function configureUndiciDispatcher() {
|
async function configureUndiciDispatcher() {
|
||||||
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts “fantasma” por keep-alive/pooling.
|
// Node 18+ usa undici debajo de fetch. Esto suele arreglar timeouts "fantasma" por keep-alive/pooling.
|
||||||
// Nota: si el módulo `undici` no está disponible, no rompemos el arranque (solo logueamos warning).
|
|
||||||
try {
|
try {
|
||||||
const { setGlobalDispatcher, Agent } = await import("undici");
|
const { setGlobalDispatcher, Agent } = await import("undici");
|
||||||
setGlobalDispatcher(
|
setGlobalDispatcher(
|
||||||
@@ -21,21 +22,15 @@ async function configureUndiciDispatcher() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* --- Tenant ---
|
|
||||||
*/
|
|
||||||
const TENANT_KEY = process.env.TENANT_KEY || "piaf";
|
const TENANT_KEY = process.env.TENANT_KEY || "piaf";
|
||||||
let TENANT_ID = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* --- Boot ---
|
|
||||||
*/
|
|
||||||
const port = process.env.PORT || 3000;
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
(async function boot() {
|
(async function boot() {
|
||||||
await configureUndiciDispatcher();
|
await configureUndiciDispatcher();
|
||||||
TENANT_ID = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
|
const tenantId = await ensureTenant({ key: TENANT_KEY, name: TENANT_KEY.toUpperCase() });
|
||||||
const app = createApp({ tenantId: TENANT_ID });
|
setTenant({ id: tenantId, key: TENANT_KEY });
|
||||||
|
await ensureBootstrapAdmin().catch((err) => console.error("[auth] bootstrap failed:", err));
|
||||||
|
const app = createApp({ tenantId });
|
||||||
app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`));
|
app.listen(port, () => console.log(`UI: http://localhost:${port} (tenant=${TENANT_KEY})`));
|
||||||
})().catch((err) => {
|
})().catch((err) => {
|
||||||
console.error("Boot failed:", err);
|
console.error("Boot failed:", err);
|
||||||
|
|||||||
76
package-lock.json
generated
76
package-lock.json
generated
@@ -10,19 +10,22 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
|
"dbmate": "^2.0.0",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"mysql2": "^3.16.2",
|
"mysql2": "^3.16.2",
|
||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"undici": "^7.16.0",
|
"undici": "^7.16.0",
|
||||||
|
"xstate": "^5.31.0",
|
||||||
"zod": "^4.3.4"
|
"zod": "^4.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"dbmate": "^2.0.0",
|
|
||||||
"nodemon": "^3.0.3",
|
"nodemon": "^3.0.3",
|
||||||
"vitest": "^4.0.18"
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
@@ -94,7 +97,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -108,7 +110,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -122,7 +123,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -136,7 +136,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -150,7 +149,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -164,7 +162,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -178,7 +175,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1266,6 +1262,20 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bcrypt": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"node-addon-api": "^8.3.0",
|
||||||
|
"node-gyp-build": "^4.8.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -1437,6 +1447,25 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie-parser": {
|
||||||
|
"version": "1.4.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "0.7.2",
|
||||||
|
"cookie-signature": "1.0.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cookie-parser/node_modules/cookie-signature": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
@@ -1466,7 +1495,6 @@
|
|||||||
"version": "2.28.0",
|
"version": "2.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",
|
"resolved": "https://registry.npmjs.org/dbmate/-/dbmate-2.28.0.tgz",
|
||||||
"integrity": "sha512-kbJ+Aqna/SOsS86RuimX8X/qmo9ItG00EYMUfUQmo3xGoXBac4+PZwOYV0fVPIGakIRljNolhfJySkPAFjmZsA==",
|
"integrity": "sha512-kbJ+Aqna/SOsS86RuimX8X/qmo9ItG00EYMUfUQmo3xGoXBac4+PZwOYV0fVPIGakIRljNolhfJySkPAFjmZsA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"dbmate": "dist/cli.js"
|
"dbmate": "dist/cli.js"
|
||||||
@@ -2330,6 +2358,26 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-addon-api": {
|
||||||
|
"version": "8.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
|
||||||
|
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^18 || ^20 || >= 21"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/node-gyp-build": {
|
||||||
|
"version": "4.8.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||||
|
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build": "bin.js",
|
||||||
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
"node-gyp-build-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "3.1.11",
|
"version": "3.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.11.tgz",
|
||||||
@@ -3412,6 +3460,16 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xstate": {
|
||||||
|
"version": "5.31.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xstate/-/xstate-5.31.0.tgz",
|
||||||
|
"integrity": "sha512-5B+0DqC0uNUrcLUEY3pn3iNy+swvK2E0ZpYp5gnV3oxMX5y87vzXkU5YXv9CAtyG5c5FOJ1SzvTWHrwE8fMZNQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/xstate"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xtend": {
|
"node_modules/xtend": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"csv-parse": "^6.1.0",
|
"csv-parse": "^6.1.0",
|
||||||
"dbmate": "^2.0.0",
|
"dbmate": "^2.0.0",
|
||||||
@@ -30,6 +32,7 @@
|
|||||||
"openai": "^6.15.0",
|
"openai": "^6.15.0",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"undici": "^7.16.0",
|
"undici": "^7.16.0",
|
||||||
|
"xstate": "^5.31.0",
|
||||||
"zod": "^4.3.4"
|
"zod": "^4.3.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -10,17 +10,35 @@ import "./components/aliases-crud.js";
|
|||||||
import "./components/recommendations-crud.js";
|
import "./components/recommendations-crud.js";
|
||||||
import "./components/quantities-crud.js";
|
import "./components/quantities-crud.js";
|
||||||
import "./components/orders-crud.js";
|
import "./components/orders-crud.js";
|
||||||
import "./components/test-panel.js";
|
|
||||||
import "./components/prompts-crud.js";
|
|
||||||
import "./components/takeovers-crud.js";
|
import "./components/takeovers-crud.js";
|
||||||
|
import "./components/zone-map-editor.js";
|
||||||
import "./components/settings-crud.js";
|
import "./components/settings-crud.js";
|
||||||
|
import "./components/system-users-crud.js";
|
||||||
|
import "./components/audit-log.js";
|
||||||
import { connectSSE } from "./lib/sse.js";
|
import { connectSSE } from "./lib/sse.js";
|
||||||
import { initRouter } from "./lib/router.js";
|
import { initRouter } from "./lib/router.js";
|
||||||
|
|
||||||
connectSSE();
|
(async function bootstrapShell() {
|
||||||
|
// Gate de sesión: si no hay cookie válida, redirigimos a /login.
|
||||||
|
// El HTML del shell carga sin auth, pero la data API exige cookie, así que
|
||||||
|
// sin sesión todo el SPA quedaría con 401s vacíos.
|
||||||
|
let user = null;
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
if (data?.ok) user = data.user;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
// Inicializar router después de que los componentes estén registrados
|
if (!user) {
|
||||||
// Usa setTimeout para asegurar que el DOM esté listo
|
const next = encodeURIComponent(window.location.pathname + window.location.search);
|
||||||
setTimeout(() => {
|
window.location.replace(`/login?next=${next}`);
|
||||||
initRouter();
|
return;
|
||||||
}, 0);
|
}
|
||||||
|
|
||||||
|
window.__USER__ = user;
|
||||||
|
|
||||||
|
connectSSE();
|
||||||
|
setTimeout(() => initRouter(), 0);
|
||||||
|
})();
|
||||||
|
|||||||
@@ -20,45 +20,45 @@ class AliasesCrud extends HTMLElement {
|
|||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
button:hover { background:#1a5fd0; }
|
button:hover { background:var(--accent-hover); }
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
button.secondary { background:#253245; }
|
button.secondary { background:var(--border-hi); }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
button.secondary:hover { background:var(--border-hi); }
|
||||||
button.danger { background:#e74c3c; }
|
button.danger { background:var(--err); }
|
||||||
button.danger:hover { background:#c0392b; }
|
button.danger:hover { background:var(--err); }
|
||||||
button.small { padding:4px 8px; font-size:11px; }
|
button.small { padding:4px 8px; font-size:11px; }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.list { flex:1; overflow-y:auto; }
|
||||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||||
.item:hover { border-color:#1f6feb; }
|
.item:hover { border-color:var(--accent); }
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.item-alias { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:15px; }
|
.item-alias { font-weight:600; color:var(--text); margin-bottom:4px; font-size:15px; }
|
||||||
.item-products { font-size:12px; color:#8aa0b5; }
|
.item-products { font-size:12px; color:var(--text-muted); }
|
||||||
.item-boost { color:#2ecc71; font-size:11px; }
|
.item-boost { color:var(--ok); font-size:11px; }
|
||||||
|
|
||||||
.form { flex:1; overflow-y:auto; }
|
.form { flex:1; overflow-y:auto; }
|
||||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||||
.field { margin-bottom:16px; }
|
.field { margin-bottom:16px; }
|
||||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
|
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
||||||
|
|
||||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||||
|
|
||||||
/* Product mappings table */
|
/* Product mappings table */
|
||||||
.mappings-table { width:100%; border-collapse:collapse; margin-top:8px; }
|
.mappings-table { width:100%; border-collapse:collapse; margin-top:8px; }
|
||||||
.mappings-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
|
.mappings-table th { text-align:left; font-size:11px; color:var(--text-muted); padding:8px 4px; border-bottom:1px solid var(--border-hi); }
|
||||||
.mappings-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
|
.mappings-table td { padding:6px 4px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||||
.mappings-table input[type="number"] { width:70px; padding:6px 8px; font-size:12px; }
|
.mappings-table input[type="number"] { width:70px; padding:6px 8px; font-size:12px; }
|
||||||
.mappings-table .product-name { font-size:13px; color:#e7eef7; }
|
.mappings-table .product-name { font-size:13px; color:var(--text); }
|
||||||
.mappings-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
|
.mappings-table .btn-remove { background:var(--err); padding:4px 8px; font-size:11px; }
|
||||||
|
|
||||||
.add-mapping-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
|
.add-mapping-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
|
||||||
.add-mapping-row .field { margin-bottom:0; }
|
.add-mapping-row .field { margin-bottom:0; }
|
||||||
@@ -67,19 +67,19 @@ class AliasesCrud extends HTMLElement {
|
|||||||
.product-selector { position:relative; }
|
.product-selector { position:relative; }
|
||||||
.product-dropdown {
|
.product-dropdown {
|
||||||
position:absolute; top:100%; left:0; right:0; z-index:100;
|
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||||
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
|
||||||
max-height:200px; overflow-y:auto; display:none;
|
max-height:200px; overflow-y:auto; display:none;
|
||||||
}
|
}
|
||||||
.product-dropdown.open { display:block; }
|
.product-dropdown.open { display:block; }
|
||||||
.product-option {
|
.product-option {
|
||||||
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
|
||||||
display:flex; justify-content:space-between; align-items:center;
|
display:flex; justify-content:space-between; align-items:center;
|
||||||
}
|
}
|
||||||
.product-option:hover { background:#1a2535; }
|
.product-option:hover { background:var(--panel-2); }
|
||||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
.product-option .price { font-size:11px; color:var(--text-muted); }
|
||||||
|
|
||||||
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
|
.empty-hint { color:var(--text-muted); font-size:12px; font-style:italic; }
|
||||||
.badge { display:inline-block; padding:2px 6px; border-radius:999px; font-size:10px; background:#253245; color:#8aa0b5; margin-left:4px; }
|
.badge { display:inline-block; padding:2px 6px; border-radius:999px; font-size:10px; background:var(--border-hi); color:var(--text-muted); margin-left:4px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
202
public/components/audit-log.js
Normal file
202
public/components/audit-log.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { api } from "../lib/api.js";
|
||||||
|
import { modal } from "../lib/modal.js";
|
||||||
|
|
||||||
|
class AuditLog extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
this.items = [];
|
||||||
|
this.actors = [];
|
||||||
|
this.loading = false;
|
||||||
|
this.filters = { actor_id: "", entity_type: "", since: "", q: "" };
|
||||||
|
this.limit = 50;
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display:block; height:100%; padding:16px 24px 24px; overflow:auto; }
|
||||||
|
* { box-sizing:border-box; font-family: var(--font-sans); }
|
||||||
|
.container { max-width:1400px; margin:0 auto; }
|
||||||
|
.toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 4px 16px; position:sticky; top:0; background:var(--bg); border-bottom:1px solid var(--border); margin-bottom:16px; flex-wrap:wrap; z-index:5; }
|
||||||
|
.toolbar-title { margin:0; font-size:18px; font-weight:600; }
|
||||||
|
.filters { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||||
|
.filters input, .filters select { padding:7px 10px; font-size:13px; border:1px solid var(--border-hi); border-radius:var(--r-md); background:var(--panel); color:var(--text); }
|
||||||
|
.filters input:focus, .filters select:focus { outline:none; border-color:var(--accent); }
|
||||||
|
button { cursor:pointer; background:var(--accent); color:var(--text-on-acc, #fff); border:none; border-radius:var(--r-md); padding:7px 12px; font-size:12px; font-weight:500; }
|
||||||
|
button:hover { background:var(--accent-hover); }
|
||||||
|
button.secondary { background:var(--panel); color:var(--text); border:1px solid var(--border-hi); }
|
||||||
|
button.secondary:hover { border-color:var(--accent); color:var(--accent-hover); }
|
||||||
|
|
||||||
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; overflow:hidden; }
|
||||||
|
table { width:100%; border-collapse:collapse; font-size:13px; }
|
||||||
|
thead th { text-align:left; padding:10px 12px; background:var(--panel-2); border-bottom:1px solid var(--border); color:var(--text-muted); font-weight:600; font-size:11px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
|
tbody td { padding:10px 12px; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||||
|
tbody tr:last-child td { border-bottom:none; }
|
||||||
|
tbody tr:hover { background:var(--panel-2); }
|
||||||
|
td.ts { font-family: var(--font-mono); color:var(--text-muted); white-space:nowrap; font-size:12px; }
|
||||||
|
td.actor { color:var(--text); }
|
||||||
|
td.actor.system { color:var(--text-muted); font-style:italic; }
|
||||||
|
td.path { font-family: var(--font-mono); font-size:12px; color:var(--accent-hover); white-space:nowrap; }
|
||||||
|
td.summary { color:var(--text); }
|
||||||
|
td.entity { color:var(--text-muted); font-size:12px; }
|
||||||
|
td.actions { text-align:right; }
|
||||||
|
td.actions button { padding:4px 8px; font-size:11px; background:transparent; color:var(--text-muted); border:1px solid var(--border-hi); }
|
||||||
|
td.actions button:hover { color:var(--accent); border-color:var(--accent); background:var(--accent-soft); }
|
||||||
|
|
||||||
|
.empty { padding:32px; text-align:center; color:var(--text-muted); font-size:13px; }
|
||||||
|
.footer { display:flex; justify-content:space-between; align-items:center; padding:12px; background:var(--panel-2); border-top:1px solid var(--border); font-size:12px; color:var(--text-muted); }
|
||||||
|
|
||||||
|
.badge-action { display:inline-block; font-size:11px; padding:2px 8px; border-radius:6px; font-weight:500; }
|
||||||
|
.badge-action.create { background:var(--ok-soft); color:var(--ok); }
|
||||||
|
.badge-action.update { background:var(--accent-soft); color:var(--accent-hover); }
|
||||||
|
.badge-action.delete { background:var(--err-soft); color:var(--err); }
|
||||||
|
.badge-action.login { background:var(--accent-soft); color:var(--accent-hover); }
|
||||||
|
.badge-action.login_failed { background:var(--err-soft); color:var(--err); }
|
||||||
|
.badge-action.logout { background:var(--panel-3); color:var(--text-muted); }
|
||||||
|
</style>
|
||||||
|
<div class="container">
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2 class="toolbar-title">Actividad</h2>
|
||||||
|
<div class="filters">
|
||||||
|
<select id="fActor"><option value="">Todos los actores</option></select>
|
||||||
|
<select id="fEntity">
|
||||||
|
<option value="">Toda entidad</option>
|
||||||
|
<option value="auth">auth</option>
|
||||||
|
<option value="settings">settings</option>
|
||||||
|
<option value="system_user">system_user</option>
|
||||||
|
<option value="product">product</option>
|
||||||
|
<option value="conversations">conversations</option>
|
||||||
|
<option value="takeovers">takeovers</option>
|
||||||
|
</select>
|
||||||
|
<input id="fSince" type="datetime-local" />
|
||||||
|
<input id="fSearch" type="text" placeholder="Buscar en summary..." />
|
||||||
|
<button id="btnApply" type="button">Aplicar</button>
|
||||||
|
<button id="btnRefresh" class="secondary" type="button">↻</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width:160px;">Fecha</th>
|
||||||
|
<th style="width:180px;">Operador</th>
|
||||||
|
<th style="width:100px;">Acción</th>
|
||||||
|
<th style="width:200px;">Ruta</th>
|
||||||
|
<th>Resumen</th>
|
||||||
|
<th style="width:80px;"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="rows"></tbody>
|
||||||
|
</table>
|
||||||
|
<div class="footer" id="footer">—</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.shadowRoot.getElementById("btnApply").addEventListener("click", () => this.applyFilters());
|
||||||
|
this.shadowRoot.getElementById("btnRefresh").addEventListener("click", () => this.load());
|
||||||
|
this.shadowRoot.getElementById("fSearch").addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") this.applyFilters();
|
||||||
|
});
|
||||||
|
this.loadActors();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
this.filters.actor_id = this.shadowRoot.getElementById("fActor").value || "";
|
||||||
|
this.filters.entity_type = this.shadowRoot.getElementById("fEntity").value || "";
|
||||||
|
this.filters.since = this.shadowRoot.getElementById("fSince").value || "";
|
||||||
|
this.filters.q = this.shadowRoot.getElementById("fSearch").value.trim();
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadActors() {
|
||||||
|
try {
|
||||||
|
const data = await api.auditActors();
|
||||||
|
this.actors = data.items || [];
|
||||||
|
const sel = this.shadowRoot.getElementById("fActor");
|
||||||
|
const current = sel.value;
|
||||||
|
sel.innerHTML = `<option value="">Todos los actores</option>` +
|
||||||
|
this.actors.map((a) => `<option value="${a.id}">${this.escapeHtml(a.email)}</option>`).join("");
|
||||||
|
sel.value = current;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.renderRows();
|
||||||
|
try {
|
||||||
|
const params = { limit: this.limit };
|
||||||
|
if (this.filters.actor_id) params.actor_id = this.filters.actor_id;
|
||||||
|
if (this.filters.entity_type) params.entity_type = this.filters.entity_type;
|
||||||
|
if (this.filters.since) params.since = new Date(this.filters.since).toISOString();
|
||||||
|
if (this.filters.q) params.q = this.filters.q;
|
||||||
|
const data = await api.auditLog(params);
|
||||||
|
this.items = data.items || [];
|
||||||
|
this.loading = false;
|
||||||
|
this.renderRows();
|
||||||
|
} catch (e) {
|
||||||
|
this.loading = false;
|
||||||
|
this.items = [];
|
||||||
|
this.renderRows();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderRows() {
|
||||||
|
const rows = this.shadowRoot.getElementById("rows");
|
||||||
|
const footer = this.shadowRoot.getElementById("footer");
|
||||||
|
if (this.loading) {
|
||||||
|
rows.innerHTML = `<tr><td colspan="6" class="empty">Cargando...</td></tr>`;
|
||||||
|
footer.textContent = "—";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!this.items.length) {
|
||||||
|
rows.innerHTML = `<tr><td colspan="6" class="empty">Sin actividad para los filtros aplicados.</td></tr>`;
|
||||||
|
footer.textContent = "0 eventos";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rows.innerHTML = this.items.map((r) => {
|
||||||
|
const ts = new Date(r.created_at);
|
||||||
|
const tsStr = ts.toLocaleString("es-AR", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const actor = r.actor_email || r.actor || "system";
|
||||||
|
const actorClass = r.actor_user_id ? "" : "system";
|
||||||
|
const actionClass = String(r.action || "").replace(/[^a-z_]/g, "");
|
||||||
|
return `
|
||||||
|
<tr data-id="${r.id}">
|
||||||
|
<td class="ts">${this.escapeHtml(tsStr)}</td>
|
||||||
|
<td class="actor ${actorClass}">${this.escapeHtml(actor)}</td>
|
||||||
|
<td><span class="badge-action ${actionClass}">${this.escapeHtml(r.action || "—")}</span></td>
|
||||||
|
<td class="path">${this.escapeHtml(r.action_path || "")}</td>
|
||||||
|
<td class="summary">${this.escapeHtml(r.summary || `${r.entity_type || ""}${r.entity_id ? "#"+r.entity_id : ""}`)}</td>
|
||||||
|
<td class="actions"><button data-action="details" data-id="${r.id}">Detalles</button></td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
rows.querySelectorAll('button[data-action="details"]').forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => this.showDetails(btn.dataset.id));
|
||||||
|
});
|
||||||
|
footer.textContent = `${this.items.length} evento${this.items.length === 1 ? "" : "s"}${this.items.length >= this.limit ? " (mostrando últimos)" : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
showDetails(id) {
|
||||||
|
const r = this.items.find((x) => String(x.id) === String(id));
|
||||||
|
if (!r) return;
|
||||||
|
const lines = [];
|
||||||
|
lines.push(`Fecha: ${new Date(r.created_at).toLocaleString("es-AR")}`);
|
||||||
|
lines.push(`Operador: ${r.actor_email || r.actor || "system"}`);
|
||||||
|
if (r.actor_ip) lines.push(`IP: ${r.actor_ip}`);
|
||||||
|
lines.push(`Acción: ${r.action}`);
|
||||||
|
lines.push(`Entidad: ${r.entity_type || "-"}${r.entity_id ? "#"+r.entity_id : ""}`);
|
||||||
|
if (r.action_path) lines.push(`Ruta: ${r.action_path}`);
|
||||||
|
if (r.summary) lines.push(`Resumen: ${r.summary}`);
|
||||||
|
if (r.changes) lines.push("\nCambios:\n" + JSON.stringify(r.changes, null, 2));
|
||||||
|
modal.info(lines.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("audit-log", AuditLog);
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { api } from "../lib/api.js";
|
import { api } from "../lib/api.js";
|
||||||
import { emit, on } from "../lib/bus.js";
|
import { emit, on } from "../lib/bus.js";
|
||||||
import { modal } from "../lib/modal.js";
|
import { modal } from "../lib/modal.js";
|
||||||
|
import { toast } from "../lib/toast.js";
|
||||||
|
|
||||||
class ChatSimulator extends HTMLElement {
|
class ChatSimulator extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -10,25 +11,49 @@ class ChatSimulator extends HTMLElement {
|
|||||||
this._sending = false;
|
this._sending = false;
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display:block; height:100%; overflow:hidden; }
|
:host { display:block; height:100%; overflow:hidden; font-family: var(--font-sans); background: var(--panel); }
|
||||||
* { box-sizing:border-box; }
|
* { box-sizing:border-box; }
|
||||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:0; height:100%; overflow:hidden; }
|
.container { display:grid; grid-template-columns:1fr 1fr; gap:0; height:100%; min-height:0; overflow:hidden; }
|
||||||
.col { display:flex; flex-direction:column; padding:10px 12px; border-right:1px solid #1e2a3a; min-width:0; overflow:hidden; }
|
.col {
|
||||||
|
display:flex; flex-direction:column;
|
||||||
|
padding: var(--space-4) var(--space-5);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
min-width:0; min-height:0; overflow:hidden;
|
||||||
|
}
|
||||||
.col:last-child { border-right:none; }
|
.col:last-child { border-right:none; }
|
||||||
.muted { color:#8aa0b5; font-size:11px; margin-bottom:6px; }
|
.muted { color: var(--text-muted); font-size: var(--fs-xs); margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.06em; font-weight: var(--fw-semibold); }
|
||||||
.row { display:flex; gap:8px; align-items:center; }
|
.row { display:flex; gap: var(--space-2); align-items:center; }
|
||||||
input,textarea,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:6px; padding:6px 8px; font-size:12px; box-sizing:border-box; }
|
input, textarea, button, select {
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-hi);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font: 400 var(--fs-sm)/1.4 var(--font-sans);
|
||||||
|
box-sizing:border-box;
|
||||||
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
input:focus, textarea:focus, select:focus { outline: none; border-color: var(--accent); box-shadow: var(--focus-ring); }
|
||||||
input { width:100%; min-width:0; }
|
input { width:100%; min-width:0; }
|
||||||
textarea { width:100%; resize:none; height:186px; min-width:0; margin-bottom:10px;}
|
textarea {
|
||||||
button { cursor:pointer; white-space:nowrap; }
|
width:100%; resize:vertical;
|
||||||
button.primary { background:#1f6feb; border-color:#1f6feb; }
|
min-height:120px; max-height:60vh;
|
||||||
button:disabled { opacity:.6; cursor:not-allowed; }
|
min-width:0; margin-bottom: var(--space-3);
|
||||||
.status { font-size:11px; color:#8aa0b5; margin-top:6px; }
|
word-break:break-word; overflow-wrap:anywhere;
|
||||||
.inputs-col { display:flex; flex-direction:column; gap:6px; flex:1; overflow:hidden; min-width:0; }
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
}
|
||||||
|
button { cursor:pointer; white-space:nowrap; font-weight: var(--fw-medium); }
|
||||||
|
button:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
|
||||||
|
button.primary { background: var(--accent); border-color: var(--accent); color: var(--text-on-acc); }
|
||||||
|
button.primary:hover:not(:disabled) { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--text-on-acc); }
|
||||||
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
.status { font-size: var(--fs-xs); color: var(--text-muted); margin-top: 8px; word-break:break-word; }
|
||||||
|
.inputs-col { display:flex; flex-direction:column; gap: 10px; flex:1; min-height:0; min-width:0; overflow-y:auto; padding-right: 4px; }
|
||||||
.field { display:flex; flex-direction:column; min-width:0; }
|
.field { display:flex; flex-direction:column; min-width:0; }
|
||||||
.field label { font-size:10px; color:#8aa0b5; margin-bottom:2px; }
|
.field label { font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: 4px; font-weight: var(--fw-medium); }
|
||||||
.msg-col { display:flex; flex-direction:column; min-width:0; }
|
.msg-col { display:flex; flex-direction:column; min-width:0; min-height:0; flex:1; }
|
||||||
.msg-bottom { display:flex; gap:8px; align-items:center; margin-top:6px; }
|
.msg-bottom { display:flex; gap: var(--space-2); align-items:center; margin-top: 8px; flex-wrap:wrap; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -167,14 +192,15 @@ class ChatSimulator extends HTMLElement {
|
|||||||
console.log("[evolution sim] webhook response:", data);
|
console.log("[evolution sim] webhook response:", data);
|
||||||
|
|
||||||
if (!data.ok) {
|
if (!data.ok) {
|
||||||
statusEl.textContent = "Error enviando (ver consola)";
|
toast({ kind: "error", text: `Sim Evolution: ${data.error || "respuesta no-ok"}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
evoTextEl.value = "";
|
evoTextEl.value = "";
|
||||||
evoTextEl.focus();
|
evoTextEl.focus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
statusEl.textContent = `Error: ${String(e?.message || e)}`;
|
// safeFetch ya disparó toast; sólo logueamos.
|
||||||
|
console.error("[chat-simulator] send error:", e);
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false);
|
setSending(false);
|
||||||
}
|
}
|
||||||
@@ -187,10 +213,10 @@ class ChatSimulator extends HTMLElement {
|
|||||||
api
|
api
|
||||||
.retryLast(chat_id)
|
.retryLast(chat_id)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
if (!r?.ok) statusEl.textContent = `Retry error: ${r?.error || "unknown"}`;
|
if (!r?.ok) toast({ kind: "error", text: `Retry: ${r?.error || "fallo"}` });
|
||||||
else statusEl.textContent = "Retry enviado.";
|
else toast({ kind: "ok", text: "Retry enviado." });
|
||||||
})
|
})
|
||||||
.catch((e) => (statusEl.textContent = `Retry error: ${String(e?.message || e)}`))
|
.catch((e) => console.error("[chat-simulator] retry error:", e))
|
||||||
.finally(() => setSending(false));
|
.finally(() => setSending(false));
|
||||||
};
|
};
|
||||||
retryEl.disabled = false;
|
retryEl.disabled = false;
|
||||||
|
|||||||
@@ -14,34 +14,68 @@ class ConversationInspector extends HTMLElement {
|
|||||||
this._playing = false;
|
this._playing = false;
|
||||||
this._playIdx = 0;
|
this._playIdx = 0;
|
||||||
this._timer = null;
|
this._timer = null;
|
||||||
|
this._userScrolledUp = false;
|
||||||
|
this._scrollRaf = null;
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display:block; padding:12px; height:100%; overflow:hidden; }
|
:host { display:block; padding: var(--space-4); height:100%; overflow:hidden; font-family: var(--font-sans); }
|
||||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box; }
|
.box {
|
||||||
.row { display:flex; gap:8px; align-items:center; }
|
background: var(--panel);
|
||||||
.muted { color:#8aa0b5; font-size:12px; }
|
border: 1px solid var(--border);
|
||||||
.title { font-weight:800; }
|
border-radius: var(--r-lg);
|
||||||
.toolbar { display:flex; gap:8px; margin-top:8px; align-items:center; }
|
padding: var(--space-5);
|
||||||
button { cursor:pointer; background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
|
height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box;
|
||||||
.list { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:6px; margin-top:8px; flex:1; min-height:0; }
|
box-shadow: var(--shadow-sm);
|
||||||
.item { border:1px solid #253245; border-radius:12px; padding:8px 10px; background:#0f1520; font-size:12px; margin-bottom:12px; box-sizing:border-box; }
|
}
|
||||||
.item.in { background:#0f1520; border-color:#2a3a55; }
|
.row { display:flex; gap: var(--space-2); align-items:center; }
|
||||||
.item.out { background:#111b2a; border-color:#2a3a55; }
|
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
|
||||||
.item.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
|
.title { font-weight: var(--fw-semibold); font-size: var(--fs-md); color: var(--text); }
|
||||||
.item-row { display:flex; gap:8px; }
|
.toolbar { display:flex; gap: var(--space-2); margin-top: var(--space-3); align-items:center; }
|
||||||
|
button {
|
||||||
|
cursor:pointer;
|
||||||
|
background: var(--panel); color: var(--text);
|
||||||
|
border: 1px solid var(--border-hi);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 6px 12px;
|
||||||
|
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); color: var(--accent-hover); background: var(--accent-soft); }
|
||||||
|
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||||
|
.list { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right: 8px; margin-top: var(--space-3); flex:1; min-height:0; }
|
||||||
|
.item {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
box-sizing:border-box;
|
||||||
|
transition: border-color .15s;
|
||||||
|
}
|
||||||
|
.item.in { background: var(--panel-2); border-color: var(--border); }
|
||||||
|
.item.out { background: var(--bot-bubble); border-color: var(--bot-border); }
|
||||||
|
.item.active { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
.item-row { display:flex; gap: var(--space-2); }
|
||||||
.item-left { flex:1; min-width:0; }
|
.item-left { flex:1; min-width:0; }
|
||||||
.item-right { display:flex; flex-direction:column; gap:4px; align-items:flex-end; justify-content:flex-start; min-width:60px; }
|
.item-right { display:flex; flex-direction:column; gap: 4px; align-items:flex-end; justify-content:flex-start; min-width:60px; }
|
||||||
.kv { display:grid; grid-template-columns:55px 1fr; gap:4px 6px; }
|
.kv { display:grid; grid-template-columns: 60px 1fr; gap: 4px 8px; }
|
||||||
.k { color:#8aa0b5; font-size:10px; letter-spacing:.3px; text-transform:uppercase; }
|
.k { color: var(--text-muted); font-size: 10px; letter-spacing: 0.06em; text-transform:uppercase; font-weight: var(--fw-semibold); }
|
||||||
.v { font-size:11px; color:#e7eef7; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
.v { font-size: var(--fs-xs); color: var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
.chips { display:flex; flex-direction:column; gap:3px; align-items:flex-end; }
|
.chips { display:flex; flex-direction:column; gap: 4px; align-items:flex-end; }
|
||||||
.chip { display:inline-flex; align-items:center; gap:3px; padding:2px 5px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:9px; color:#8aa0b5; white-space:nowrap; }
|
.chip {
|
||||||
|
display:inline-flex; align-items:center; gap: 4px;
|
||||||
|
padding: 3px 8px; border-radius:999px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
|
font-size: 10px; color: var(--text-muted); white-space:nowrap;
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
}
|
||||||
.chip .dot { flex-shrink:0; }
|
.chip .dot { flex-shrink:0; }
|
||||||
.cart { margin-top:4px; font-size:10px; color:#c7d8ee; line-height:1.3; }
|
.cart { margin-top: 6px; font-size: 11px; color: var(--bot-name); line-height: var(--lh-base); }
|
||||||
.tool { margin-top:6px; font-size:11px; color:#8aa0b5; }
|
.tool { margin-top: 8px; font-size: var(--fs-xs); color: var(--text-muted); }
|
||||||
.dot { width:8px; height:8px; border-radius:50%; }
|
.dot { width:8px; height:8px; border-radius:50%; }
|
||||||
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
|
.ok { background: var(--ok); } .warn { background: var(--warn); } .err { background: var(--err); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
@@ -83,8 +117,11 @@ class ConversationInspector extends HTMLElement {
|
|||||||
this.applyHeights();
|
this.applyHeights();
|
||||||
});
|
});
|
||||||
|
|
||||||
this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop }) => {
|
this._unsubScroll = on("ui:chatScroll", ({ chat_id, scrollTop, userScrolledUp }) => {
|
||||||
if (!this.chatId || chat_id !== this.chatId) return;
|
if (!this.chatId || chat_id !== this.chatId) return;
|
||||||
|
// Si el otro panel está scrolleado-up, sincronizamos. Si no, dejamos
|
||||||
|
// a este panel manejar su propio scroll para evitar saltos cruzados.
|
||||||
|
if (!userScrolledUp) return;
|
||||||
const list = this.shadowRoot.getElementById("list");
|
const list = this.shadowRoot.getElementById("list");
|
||||||
list.scrollTop = scrollTop || 0;
|
list.scrollTop = scrollTop || 0;
|
||||||
});
|
});
|
||||||
@@ -206,13 +243,10 @@ class ConversationInspector extends HTMLElement {
|
|||||||
parts.push(`[${activePending.length} pendiente(s)]`);
|
parts.push(`[${activePending.length} pendiente(s)]`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Checkout info
|
// Checkout info (sólo shipping — el bot no maneja pagos)
|
||||||
const checkoutInfo = [];
|
const checkoutInfo = [];
|
||||||
if (order?.is_delivery === true) checkoutInfo.push("🚚");
|
if (order?.is_delivery === true) checkoutInfo.push("🚚");
|
||||||
if (order?.is_delivery === false) checkoutInfo.push("🏪");
|
if (order?.is_delivery === false) checkoutInfo.push("🏪");
|
||||||
if (order?.payment_type === "cash") checkoutInfo.push("💵");
|
|
||||||
if (order?.payment_type === "link") checkoutInfo.push("💳");
|
|
||||||
if (order?.is_paid) checkoutInfo.push("✅");
|
|
||||||
if (checkoutInfo.length) parts.push(checkoutInfo.join(""));
|
if (checkoutInfo.length) parts.push(checkoutInfo.join(""));
|
||||||
|
|
||||||
return parts.length ? parts.join(" ") : "—";
|
return parts.length ? parts.join(" ") : "—";
|
||||||
@@ -232,7 +266,6 @@ class ConversationInspector extends HTMLElement {
|
|||||||
"ensure_woo_customer": "woo customer",
|
"ensure_woo_customer": "woo customer",
|
||||||
"create_order": "create order",
|
"create_order": "create order",
|
||||||
"update_order": "update order",
|
"update_order": "update order",
|
||||||
"send_payment_link": "payment link",
|
|
||||||
"request_human_takeover": "human takeover",
|
"request_human_takeover": "human takeover",
|
||||||
"add_to_cart": "add to cart",
|
"add_to_cart": "add to cart",
|
||||||
"human_response_sent": "human response",
|
"human_response_sent": "human response",
|
||||||
@@ -340,8 +373,24 @@ class ConversationInspector extends HTMLElement {
|
|||||||
this.rowOrder.push(msgId);
|
this.rowOrder.push(msgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-scroll al final
|
// Auto-scroll al final, salvo que el usuario esté leyendo arriba.
|
||||||
list.scrollTop = list.scrollHeight;
|
if (!this._userScrolledUp) {
|
||||||
|
list.scrollTop = list.scrollHeight;
|
||||||
|
}
|
||||||
|
this._bindScroll(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindScroll(list) {
|
||||||
|
if (this._scrollBound) return;
|
||||||
|
this._scrollBound = true;
|
||||||
|
list.addEventListener("scroll", () => {
|
||||||
|
if (this._scrollRaf) return;
|
||||||
|
this._scrollRaf = requestAnimationFrame(() => {
|
||||||
|
this._scrollRaf = null;
|
||||||
|
const distFromBottom = list.scrollHeight - list.scrollTop - list.clientHeight;
|
||||||
|
this._userScrolledUp = distFromBottom > 150;
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
applyHeights() {
|
applyHeights() {
|
||||||
@@ -442,7 +491,9 @@ class ConversationInspector extends HTMLElement {
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
list.appendChild(el);
|
list.appendChild(el);
|
||||||
|
// Optimistic: el usuario acaba de mandar — forzamos al final.
|
||||||
list.scrollTop = list.scrollHeight;
|
list.scrollTop = list.scrollHeight;
|
||||||
|
this._userScrolledUp = false;
|
||||||
|
|
||||||
this.rowMap.set(msg.message_id, el);
|
this.rowMap.set(msg.message_id, el);
|
||||||
this.rowOrder.push(msg.message_id);
|
this.rowOrder.push(msg.message_id);
|
||||||
|
|||||||
@@ -14,27 +14,68 @@ class ConversationList extends HTMLElement {
|
|||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display:block; padding:12px; }
|
:host { display:block; padding: var(--space-4); font-family: var(--font-sans); }
|
||||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; margin-bottom:10px; }
|
.box {
|
||||||
.row { display:flex; gap:8px; align-items:center; }
|
background: var(--panel);
|
||||||
input,select,button { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
|
border: 1px solid var(--border);
|
||||||
button { cursor:pointer; }
|
border-radius: var(--r-lg);
|
||||||
button.ghost { background:transparent; }
|
padding: var(--space-4);
|
||||||
button:disabled { opacity:.6; cursor:not-allowed; }
|
margin-bottom: var(--space-3);
|
||||||
.tabs { display:flex; gap:8px; margin-bottom:10px; }
|
box-shadow: var(--shadow-sm);
|
||||||
.tab { flex:1; text-align:center; padding:8px; border-radius:8px; border:1px solid #253245; cursor:pointer; color:#8aa0b5; background:#121823; }
|
}
|
||||||
.tab.active { border-color:#1f6feb; color:#e7eef7; background:#0f1520; }
|
.row { display:flex; gap: var(--space-2); align-items:center; }
|
||||||
.list { display:flex; flex-direction:column; gap:8px; }
|
input, select, button {
|
||||||
.item { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; cursor:pointer; }
|
background: var(--panel);
|
||||||
.item:hover { border-color:#2b3b52; }
|
color: var(--text);
|
||||||
.item.active { border-color:#1f6feb; }
|
border: 1px solid var(--border-hi);
|
||||||
.title { font-weight:800; }
|
border-radius: var(--r-md);
|
||||||
.muted { color:#8aa0b5; font-size:12px; }
|
padding: 8px 12px;
|
||||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
font: 400 var(--fs-sm)/1.4 var(--font-sans);
|
||||||
.chip { display:inline-flex; align-items:center; gap:6px; padding:3px 8px; border-radius:999px; background:#1d2a3a; border:1px solid #243247; font-size:12px; color:#8aa0b5; }
|
transition: border-color .15s, box-shadow .15s;
|
||||||
|
}
|
||||||
|
input:focus, select:focus { outline:none; border-color: var(--accent); box-shadow: var(--focus-ring); }
|
||||||
|
button { cursor:pointer; font-weight: var(--fw-medium); }
|
||||||
|
button:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
|
||||||
|
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||||
|
button.ghost { background:transparent; border-color: transparent; }
|
||||||
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
.tabs { display:flex; gap: var(--space-2); margin-bottom: var(--space-3); }
|
||||||
|
.tab {
|
||||||
|
flex:1; text-align:center;
|
||||||
|
padding: 8px 12px; border-radius: var(--r-md);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
cursor:pointer;
|
||||||
|
color: var(--text-muted);
|
||||||
|
background: var(--panel);
|
||||||
|
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.tab:hover { color: var(--text); }
|
||||||
|
.tab.active { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.list { display:flex; flex-direction:column; gap: var(--space-2); }
|
||||||
|
.item {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: var(--space-3) var(--space-4);
|
||||||
|
cursor:pointer;
|
||||||
|
transition: all .15s;
|
||||||
|
}
|
||||||
|
.item:hover { border-color: var(--border-hi); box-shadow: var(--shadow-sm); }
|
||||||
|
.item.active { border-color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.title { font-weight: var(--fw-semibold); font-size: var(--fs-base); color: var(--text); }
|
||||||
|
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
|
||||||
|
.chips { display:flex; flex-wrap:wrap; gap: 6px; margin-top: 8px; }
|
||||||
|
.chip {
|
||||||
|
display:inline-flex; align-items:center; gap: 4px;
|
||||||
|
padding: 3px 8px; border-radius:999px;
|
||||||
|
background: var(--panel-2); border: 1px solid var(--border);
|
||||||
|
font-size: var(--fs-xs); color: var(--text-muted);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
}
|
||||||
.dot { width:8px; height:8px; border-radius:50%; }
|
.dot { width:8px; height:8px; border-radius:50%; }
|
||||||
.ok{ background:#2ecc71 } .warn{ background:#f1c40f } .err{ background:#e74c3c }
|
.ok{ background: var(--ok) } .warn{ background: var(--warn) } .err{ background: var(--err) }
|
||||||
.actions { display:flex; gap:8px; justify-content:flex-end; margin-top:8px; flex-wrap:wrap; }
|
.actions { display:flex; gap: var(--space-2); justify-content:flex-end; margin-top: var(--space-2); flex-wrap:wrap; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
@@ -62,7 +103,10 @@ class ConversationList extends HTMLElement {
|
|||||||
</select>
|
</select>
|
||||||
<select id="state" style="flex:1">
|
<select id="state" style="flex:1">
|
||||||
<option value="">State: all</option>
|
<option value="">State: all</option>
|
||||||
<option>IDLE</option><option>BROWSING</option><option>BUILDING_ORDER</option><option>WAITING_ADDRESS</option><option>WAITING_PAYMENT</option><option>COMPLETED</option>
|
<option>IDLE</option>
|
||||||
|
<option>CART</option>
|
||||||
|
<option>SHIPPING</option>
|
||||||
|
<option>AWAITING_HUMAN</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,40 +18,40 @@ class ConversationsCrud extends HTMLElement {
|
|||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
|
.toolbar { display:flex; gap:8px; margin-bottom:12px; flex-wrap:wrap; }
|
||||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
|
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
input:focus, select:focus { outline:none; border-color:var(--accent); }
|
||||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
button:hover { background:#1a5fd0; }
|
button:hover { background:var(--accent-hover); }
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
button.secondary { background:#253245; }
|
button.secondary { background:var(--border-hi); }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
button.secondary:hover { background:var(--border-hi); }
|
||||||
button.danger { background:#e74c3c; }
|
button.danger { background:var(--err); }
|
||||||
button.danger:hover { background:#c0392b; }
|
button.danger:hover { background:var(--err); }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.list { flex:1; overflow-y:auto; }
|
||||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||||
.item:hover { border-color:#1f6feb; }
|
.item:hover { border-color:var(--accent); }
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.item-header { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
|
.item-header { display:flex; align-items:center; gap:8px; margin-bottom:4px; }
|
||||||
.item-name { font-weight:600; color:#e7eef7; flex:1; }
|
.item-name { font-weight:600; color:var(--text); flex:1; }
|
||||||
.item-meta { font-size:12px; color:#8aa0b5; }
|
.item-meta { font-size:12px; color:var(--text-muted); }
|
||||||
.dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
.dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
||||||
.ok { background:#2ecc71; } .warn { background:#f1c40f; } .err { background:#e74c3c; }
|
.ok { background:var(--ok); } .warn { background:var(--warn); } .err { background:var(--err); }
|
||||||
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
|
.chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; }
|
||||||
.chip { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; }
|
.chip { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); }
|
||||||
|
|
||||||
.detail { flex:1; overflow-y:auto; }
|
.detail { flex:1; overflow-y:auto; }
|
||||||
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||||
.field { margin-bottom:16px; }
|
.field { margin-bottom:16px; }
|
||||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
|
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
|
||||||
|
|
||||||
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
|
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -68,11 +68,9 @@ class ConversationsCrud extends HTMLElement {
|
|||||||
<select id="state">
|
<select id="state">
|
||||||
<option value="">State: todos</option>
|
<option value="">State: todos</option>
|
||||||
<option>IDLE</option>
|
<option>IDLE</option>
|
||||||
<option>BROWSING</option>
|
<option>CART</option>
|
||||||
<option>BUILDING_ORDER</option>
|
<option>SHIPPING</option>
|
||||||
<option>WAITING_ADDRESS</option>
|
<option>AWAITING_HUMAN</option>
|
||||||
<option>WAITING_PAYMENT</option>
|
|
||||||
<option>COMPLETED</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="list" id="list">
|
<div class="list" id="list">
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ class DebugPanel extends HTMLElement {
|
|||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display:block; padding:12px; }
|
:host { display:block; padding:12px; }
|
||||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; }
|
.box { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:10px; }
|
||||||
.muted { color:#8aa0b5; font-size:12px; }
|
.muted { color:var(--text-muted); font-size:12px; }
|
||||||
.kv { display:grid; grid-template-columns:90px 1fr; gap:6px 10px; margin:10px 0 12px; }
|
.kv { display:grid; grid-template-columns:90px 1fr; gap:6px 10px; margin:10px 0 12px; }
|
||||||
.k { color:#8aa0b5; font-size:12px; letter-spacing:.4px; text-transform:uppercase; }
|
.k { color:var(--text-muted); font-size:12px; letter-spacing:.4px; text-transform:uppercase; }
|
||||||
.v { font-size:13px; font-weight:800; color:#e7eef7; }
|
.v { font-size:13px; font-weight:800; color:var(--text); }
|
||||||
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
|
pre { white-space:pre-wrap; word-break:break-word; background:var(--panel-2); border:1px solid var(--border-hi); border-radius:10px; padding:10px; margin:0; font-size:12px; color:var(--text-dim); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
|
|||||||
@@ -10,6 +10,20 @@ function formatNumber(value) {
|
|||||||
return new Intl.NumberFormat("es-AR", { maximumFractionDigits: 1 }).format(value);
|
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 {
|
class HomeDashboard extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
@@ -20,111 +34,84 @@ class HomeDashboard extends HTMLElement {
|
|||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host {
|
:host { display:block; height:100%; min-height:0; overflow-y:auto; font-family: var(--font-sans); }
|
||||||
--bg: #0b0f14;
|
* { box-sizing: border-box; }
|
||||||
--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 {
|
.container {
|
||||||
min-height: 100%;
|
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 16px;
|
padding: var(--space-6);
|
||||||
overflow-y: auto;
|
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex; justify-content: space-between; align-items: baseline;
|
||||||
justify-content: space-between;
|
margin-bottom: var(--space-6);
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
}
|
||||||
.header h1 {
|
.header h1 {
|
||||||
font-size: 24px;
|
font-size: var(--fs-xl);
|
||||||
font-weight: 600;
|
font-weight: var(--fw-semibold);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.sync-info {
|
.sync-info { font-size: var(--fs-sm); color: var(--text-muted); }
|
||||||
font-size: 12px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
gap: 16px;
|
gap: var(--space-6);
|
||||||
}
|
}
|
||||||
.chart-card {
|
.chart-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--border);
|
||||||
padding: 16px;
|
border-radius: var(--r-lg);
|
||||||
}
|
padding: var(--space-5);
|
||||||
.chart-card.full-width {
|
box-shadow: var(--shadow-sm);
|
||||||
grid-column: 1 / -1;
|
|
||||||
}
|
}
|
||||||
|
.chart-card.full-width { grid-column: 1 / -1; }
|
||||||
.chart-title {
|
.chart-title {
|
||||||
font-size: 14px;
|
font-size: var(--fs-xs);
|
||||||
font-weight: 600;
|
font-weight: var(--fw-semibold);
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.06em;
|
||||||
margin-bottom: 12px;
|
margin-bottom: var(--space-4);
|
||||||
}
|
|
||||||
.chart-container {
|
|
||||||
position: relative;
|
|
||||||
height: 250px;
|
|
||||||
}
|
|
||||||
.chart-container.tall {
|
|
||||||
height: 300px;
|
|
||||||
}
|
|
||||||
.chart-container.short {
|
|
||||||
height: 200px;
|
|
||||||
}
|
}
|
||||||
|
.chart-container { position: relative; height: 260px; }
|
||||||
|
.chart-container.tall { height: 320px; }
|
||||||
|
.chart-container.short { height: 200px; }
|
||||||
.loading {
|
.loading {
|
||||||
display: flex;
|
display: flex; align-items: center; justify-content: center;
|
||||||
align-items: center;
|
height: 200px; color: var(--text-muted);
|
||||||
justify-content: center;
|
|
||||||
height: 200px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
}
|
||||||
.kpi-row {
|
.kpi-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: 16px;
|
gap: var(--space-4);
|
||||||
margin-bottom: 20px;
|
margin-bottom: var(--space-6);
|
||||||
}
|
}
|
||||||
.kpi-card {
|
.kpi-card {
|
||||||
background: var(--panel);
|
background: var(--panel);
|
||||||
border-radius: 8px;
|
border: 1px solid var(--border);
|
||||||
padding: 16px;
|
border-radius: var(--r-lg);
|
||||||
text-align: center;
|
padding: var(--space-5);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
.kpi-value {
|
.kpi-value {
|
||||||
font-size: 24px;
|
font-size: var(--fs-xl);
|
||||||
font-weight: 700;
|
font-weight: var(--fw-bold);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
}
|
}
|
||||||
.kpi-label {
|
.kpi-label {
|
||||||
font-size: 12px;
|
font-size: var(--fs-xs);
|
||||||
color: var(--muted);
|
color: var(--text-muted);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
}
|
}
|
||||||
.donut-row {
|
.donut-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
gap: 16px;
|
gap: var(--space-4);
|
||||||
}
|
|
||||||
canvas {
|
|
||||||
max-width: 100%;
|
|
||||||
}
|
}
|
||||||
|
canvas { max-width: 100%; }
|
||||||
</style>
|
</style>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@@ -164,12 +151,6 @@ class HomeDashboard extends HTMLElement {
|
|||||||
<canvas id="shipping-donut"></canvas>
|
<canvas id="shipping-donut"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<div class="chart-card full-width">
|
<div class="chart-card full-width">
|
||||||
<div class="chart-title">Top Productos por Facturación</div>
|
<div class="chart-title">Top Productos por Facturación</div>
|
||||||
@@ -244,7 +225,7 @@ class HomeDashboard extends HTMLElement {
|
|||||||
const kpiRow = this.shadowRoot.querySelector(".kpi-row");
|
const kpiRow = this.shadowRoot.querySelector(".kpi-row");
|
||||||
kpiRow.innerHTML = `
|
kpiRow.innerHTML = `
|
||||||
<div class="kpi-card">
|
<div class="kpi-card">
|
||||||
<div class="kpi-value" style="color: var(--blue)">${formatCurrency(totals.total_revenue)}</div>
|
<div class="kpi-value" style="color: var(--chart-blue)">${formatCurrency(totals.total_revenue)}</div>
|
||||||
<div class="kpi-label">Total Facturado</div>
|
<div class="kpi-label">Total Facturado</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-card">
|
<div class="kpi-card">
|
||||||
@@ -252,11 +233,11 @@ class HomeDashboard extends HTMLElement {
|
|||||||
<div class="kpi-label">Pedidos</div>
|
<div class="kpi-label">Pedidos</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-card">
|
<div class="kpi-card">
|
||||||
<div class="kpi-value" style="color: var(--green)">${formatCurrency(totals.by_source?.whatsapp)}</div>
|
<div class="kpi-value" style="color: var(--chart-green)">${formatCurrency(totals.by_source?.whatsapp)}</div>
|
||||||
<div class="kpi-label">WhatsApp</div>
|
<div class="kpi-label">WhatsApp</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="kpi-card">
|
<div class="kpi-card">
|
||||||
<div class="kpi-value" style="color: var(--blue)">${formatCurrency(totals.by_source?.web)}</div>
|
<div class="kpi-value" style="color: var(--chart-blue)">${formatCurrency(totals.by_source?.web)}</div>
|
||||||
<div class="kpi-label">Web</div>
|
<div class="kpi-label">Web</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -281,8 +262,8 @@ class HomeDashboard extends HTMLElement {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: "Ventas",
|
label: "Ventas",
|
||||||
data: totals,
|
data: totals,
|
||||||
backgroundColor: "rgba(59, 130, 246, 0.8)",
|
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.7),
|
||||||
borderColor: "#3b82f6",
|
borderColor: cssVar("--chart-blue"),
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -295,11 +276,11 @@ class HomeDashboard extends HTMLElement {
|
|||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { color: "#1e2a3a" },
|
grid: { color: cssVar("--border") },
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -328,12 +309,12 @@ class HomeDashboard extends HTMLElement {
|
|||||||
{
|
{
|
||||||
label: "WhatsApp",
|
label: "WhatsApp",
|
||||||
data: waData,
|
data: waData,
|
||||||
backgroundColor: "#25D366",
|
backgroundColor: cssVar("--chart-green"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Web",
|
label: "Web",
|
||||||
data: webData,
|
data: webData,
|
||||||
backgroundColor: "#3b82f6",
|
backgroundColor: cssVar("--chart-blue"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -343,19 +324,19 @@ class HomeDashboard extends HTMLElement {
|
|||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: "top",
|
position: "top",
|
||||||
labels: { color: "#8aa0b5" },
|
labels: { color: cssVar("--text-muted") },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
stacked: true,
|
stacked: true,
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { color: "#1e2a3a" },
|
grid: { color: cssVar("--border") },
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
stacked: true,
|
stacked: true,
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -379,16 +360,16 @@ class HomeDashboard extends HTMLElement {
|
|||||||
{
|
{
|
||||||
label: String(yoy.current_year || "Actual"),
|
label: String(yoy.current_year || "Actual"),
|
||||||
data: yoy.current_year_data || [],
|
data: yoy.current_year_data || [],
|
||||||
borderColor: "#3b82f6",
|
borderColor: cssVar("--chart-blue"),
|
||||||
backgroundColor: "rgba(59, 130, 246, 0.1)",
|
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.15),
|
||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: String(yoy.last_year || "Anterior"),
|
label: String(yoy.last_year || "Anterior"),
|
||||||
data: yoy.last_year_data || [],
|
data: yoy.last_year_data || [],
|
||||||
borderColor: "#9CA3AF",
|
borderColor: cssVar("--chart-gray"),
|
||||||
backgroundColor: "rgba(156, 163, 175, 0.1)",
|
backgroundColor: withAlpha(cssVar("--chart-gray"), 0.15),
|
||||||
fill: true,
|
fill: true,
|
||||||
tension: 0.3,
|
tension: 0.3,
|
||||||
},
|
},
|
||||||
@@ -400,17 +381,17 @@ class HomeDashboard extends HTMLElement {
|
|||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: "top",
|
position: "top",
|
||||||
labels: { color: "#8aa0b5" },
|
labels: { color: cssVar("--text-muted") },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
y: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { color: "#1e2a3a" },
|
grid: { color: cssVar("--border") },
|
||||||
},
|
},
|
||||||
x: {
|
x: {
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -431,7 +412,7 @@ class HomeDashboard extends HTMLElement {
|
|||||||
labels: ["WhatsApp", "Web"],
|
labels: ["WhatsApp", "Web"],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [totals.by_source?.whatsapp || 0, totals.by_source?.web || 0],
|
data: [totals.by_source?.whatsapp || 0, totals.by_source?.web || 0],
|
||||||
backgroundColor: ["#25D366", "#3b82f6"],
|
backgroundColor: [cssVar("--chart-green"), cssVar("--chart-blue")],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
options: this.getDonutOptions(),
|
options: this.getDonutOptions(),
|
||||||
@@ -448,29 +429,13 @@ class HomeDashboard extends HTMLElement {
|
|||||||
labels: ["Delivery", "Retiro"],
|
labels: ["Delivery", "Retiro"],
|
||||||
datasets: [{
|
datasets: [{
|
||||||
data: [totals.by_shipping?.delivery || 0, totals.by_shipping?.pickup || 0],
|
data: [totals.by_shipping?.delivery || 0, totals.by_shipping?.pickup || 0],
|
||||||
backgroundColor: ["#8B5CF6", "#F59E0B"],
|
backgroundColor: [cssVar("--chart-purple"), cssVar("--chart-orange")],
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
options: this.getDonutOptions(),
|
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() {
|
getDonutOptions() {
|
||||||
@@ -480,7 +445,7 @@ class HomeDashboard extends HTMLElement {
|
|||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: "bottom",
|
position: "bottom",
|
||||||
labels: { color: "#8aa0b5" },
|
labels: { color: cssVar("--text-muted") },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -503,8 +468,8 @@ class HomeDashboard extends HTMLElement {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: "Facturación",
|
label: "Facturación",
|
||||||
data,
|
data,
|
||||||
backgroundColor: "rgba(59, 130, 246, 0.8)",
|
backgroundColor: withAlpha(cssVar("--chart-blue"), 0.7),
|
||||||
borderColor: "#3b82f6",
|
borderColor: cssVar("--chart-blue"),
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -518,11 +483,11 @@ class HomeDashboard extends HTMLElement {
|
|||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { color: "#1e2a3a" },
|
grid: { color: cssVar("--border") },
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -547,8 +512,8 @@ class HomeDashboard extends HTMLElement {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: "Kg",
|
label: "Kg",
|
||||||
data,
|
data,
|
||||||
backgroundColor: "rgba(139, 92, 246, 0.8)",
|
backgroundColor: withAlpha(cssVar("--chart-purple"), 0.7),
|
||||||
borderColor: "#8B5CF6",
|
borderColor: cssVar("--chart-purple"),
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -562,11 +527,11 @@ class HomeDashboard extends HTMLElement {
|
|||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { color: "#1e2a3a" },
|
grid: { color: cssVar("--border") },
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
ticks: { color: "#8aa0b5", font: { size: 10 } },
|
ticks: { color: cssVar("--text-muted"), font: { size: 10 } },
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -591,8 +556,8 @@ class HomeDashboard extends HTMLElement {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: "Unidades",
|
label: "Unidades",
|
||||||
data,
|
data,
|
||||||
backgroundColor: "rgba(245, 158, 11, 0.8)",
|
backgroundColor: withAlpha(cssVar("--chart-orange"), 0.7),
|
||||||
borderColor: "#F59E0B",
|
borderColor: cssVar("--chart-orange"),
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
@@ -606,11 +571,11 @@ class HomeDashboard extends HTMLElement {
|
|||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
beginAtZero: true,
|
beginAtZero: true,
|
||||||
ticks: { color: "#8aa0b5" },
|
ticks: { color: cssVar("--text-muted") },
|
||||||
grid: { color: "#1e2a3a" },
|
grid: { color: cssVar("--border") },
|
||||||
},
|
},
|
||||||
y: {
|
y: {
|
||||||
ticks: { color: "#8aa0b5", font: { size: 10 } },
|
ticks: { color: cssVar("--text-muted"), font: { size: 10 } },
|
||||||
grid: { display: false },
|
grid: { display: false },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -12,35 +12,74 @@ class OpsShell extends HTMLElement {
|
|||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { --bg:#0b0f14; --panel:#121823; --muted:#8aa0b5; --text:#e7eef7; --line:#1e2a3a; --blue:#1f6feb; }
|
* { box-sizing:border-box; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
:host { font-family: var(--font-sans); }
|
||||||
.app { height:100vh; background:var(--bg); color:var(--text); display:flex; flex-direction:column; }
|
.app { height:100vh; background:var(--bg); color:var(--text); display:flex; flex-direction:column; }
|
||||||
header { display:flex; gap:12px; align-items:center; padding:12px 16px; border-bottom:1px solid var(--line); flex-wrap:wrap; }
|
header {
|
||||||
header h1 { font-size:14px; margin:0; color:var(--muted); font-weight:700; letter-spacing:.4px; text-transform:uppercase; }
|
display:flex; gap:var(--space-3); align-items:center;
|
||||||
.nav { display:flex; gap:4px; margin-left:24px; flex-wrap:wrap; }
|
padding: var(--space-3) var(--space-6);
|
||||||
.nav-btn { background:transparent; border:1px solid var(--line); color:var(--muted); padding:6px 12px; border-radius:6px; font-size:12px; cursor:pointer; transition:all .15s; text-decoration:none; }
|
background: var(--panel);
|
||||||
.nav-btn:hover { border-color:var(--blue); color:var(--text); }
|
border-bottom: 1px solid var(--border);
|
||||||
.nav-btn.active { background:var(--blue); border-color:var(--blue); color:#fff; }
|
flex-wrap:wrap;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: var(--fs-md);
|
||||||
|
margin:0; color: var(--text);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
letter-spacing:-0.01em;
|
||||||
|
}
|
||||||
|
.nav { display:flex; gap: var(--space-1); margin-left: var(--space-6); flex-wrap:wrap; }
|
||||||
|
.nav-btn {
|
||||||
|
position:relative;
|
||||||
|
background:transparent; border:none;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 8px 12px;
|
||||||
|
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
|
||||||
|
cursor:pointer; transition:color .15s;
|
||||||
|
text-decoration:none;
|
||||||
|
border-radius: var(--r-sm);
|
||||||
|
}
|
||||||
|
.nav-btn:hover { color: var(--text); background: var(--panel-2); }
|
||||||
|
.nav-btn.active { color: var(--accent); background: var(--accent-soft); }
|
||||||
|
.nav-btn:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||||
.spacer { flex:1; }
|
.spacer { flex:1; }
|
||||||
.status { font-size:12px; color:var(--muted); }
|
.status {
|
||||||
|
font-size: var(--fs-sm); color: var(--ok);
|
||||||
|
display:flex; align-items:center; gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: var(--ok-soft);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
.status .dot { width:7px; height:7px; border-radius:50%; background: var(--ok); }
|
||||||
|
.status.disconnected { color: var(--warn); background: var(--warn-soft); }
|
||||||
|
.status.disconnected .dot { background: var(--warn); animation: pulse 1.2s ease-in-out infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:.4; } }
|
||||||
|
|
||||||
/* Notification bell */
|
/* Notification bell */
|
||||||
.notification-bell { position:relative; cursor:pointer; padding:8px; margin-right:12px; }
|
.user-menu { display:flex; align-items:center; gap:8px; padding:4px 4px 4px 10px; border-radius: var(--r-sm); border:1px solid var(--border); }
|
||||||
.notification-bell svg { width:20px; height:20px; fill:var(--muted); transition:fill .15s; }
|
.user-email { font: var(--fw-medium) 12px/1.2 var(--font-sans); color: var(--text); max-width:160px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.notification-bell:hover svg { fill:var(--text); }
|
.logout-btn { background: transparent; border: 1px solid transparent; color: var(--text-muted); padding: 4px 8px; border-radius: var(--r-sm); cursor:pointer; font:var(--fw-medium) 11px/1 var(--font-sans); }
|
||||||
.notification-bell.has-pending svg { fill:#f39c12; }
|
.logout-btn:hover { color: var(--err); border-color: var(--err-soft); background: var(--err-soft); }
|
||||||
|
|
||||||
|
.notification-bell { position:relative; cursor:pointer; padding: 8px; border-radius: var(--r-sm); transition: background .15s; }
|
||||||
|
.notification-bell:hover { background: var(--panel-2); }
|
||||||
|
.notification-bell svg { width:18px; height:18px; fill: var(--text-muted); transition:fill .15s; display:block; }
|
||||||
|
.notification-bell:hover svg { fill: var(--text); }
|
||||||
|
.notification-bell.has-pending svg { fill: var(--warn); }
|
||||||
.notification-bell .badge {
|
.notification-bell .badge {
|
||||||
position:absolute; top:2px; right:2px;
|
position:absolute; top:2px; right:2px;
|
||||||
background:#e74c3c; color:#fff;
|
background: var(--err); color:#fff;
|
||||||
font-size:10px; padding:2px 6px; border-radius:10px;
|
font: var(--fw-bold) 10px/1 var(--font-sans);
|
||||||
font-weight:700; min-width:18px; text-align:center;
|
padding: 3px 6px; border-radius:10px;
|
||||||
|
min-width:18px; text-align:center;
|
||||||
|
box-shadow: 0 0 0 2px var(--panel);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Layout para chat activo (2 columnas: burbujas + inspector) */
|
/* Layout para chat activo (2 columnas: burbujas + inspector) */
|
||||||
.layout-chat { height:100%; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 310px; min-height:0; overflow:hidden; }
|
.layout-chat { height:100%; display:grid; grid-template-columns:1fr 1fr; grid-template-rows:1fr 310px; min-height:0; overflow:hidden; }
|
||||||
.col { border-right:1px solid var(--line); min-height:0; overflow:hidden; }
|
.col { border-right:1px solid var(--border); min-height:0; overflow:hidden; }
|
||||||
.chatTop { grid-column:1; grid-row:1; border-bottom:1px solid var(--line); }
|
.chatTop { grid-column:1; grid-row:1; border-bottom:1px solid var(--border); }
|
||||||
.chatBottom { grid-column:1 / 3; grid-row:2; overflow:hidden; border-top:1px solid var(--line); }
|
.chatBottom { grid-column:1 / 3; grid-row:2; overflow:hidden; border-top:1px solid var(--border); }
|
||||||
.inspectorTop { grid-column:2; grid-row:1; border-right:none; }
|
.inspectorTop { grid-column:2; grid-row:1; border-right:none; }
|
||||||
|
|
||||||
/* Layout para CRUDs */
|
/* Layout para CRUDs */
|
||||||
@@ -52,7 +91,7 @@ class OpsShell extends HTMLElement {
|
|||||||
|
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<header>
|
<header>
|
||||||
<h1>Bot Ops Console</h1>
|
<h1>Piaf Console</h1>
|
||||||
<nav class="nav">
|
<nav class="nav">
|
||||||
<a class="nav-btn active" href="/home" data-view="home">Home</a>
|
<a class="nav-btn active" href="/home" data-view="home">Home</a>
|
||||||
<a class="nav-btn" href="/chat" data-view="chat">Chat</a>
|
<a class="nav-btn" href="/chat" data-view="chat">Chat</a>
|
||||||
@@ -63,16 +102,20 @@ class OpsShell extends HTMLElement {
|
|||||||
<a class="nav-btn" href="/crosssell" data-view="crosssell">Cross-sell</a>
|
<a class="nav-btn" href="/crosssell" data-view="crosssell">Cross-sell</a>
|
||||||
<a class="nav-btn" href="/cantidades" data-view="quantities">Cantidades</a>
|
<a class="nav-btn" href="/cantidades" data-view="quantities">Cantidades</a>
|
||||||
<a class="nav-btn" href="/pedidos" data-view="orders">Pedidos</a>
|
<a class="nav-btn" href="/pedidos" data-view="orders">Pedidos</a>
|
||||||
<a class="nav-btn" href="/config-prompts" data-view="prompts">Prompts</a>
|
|
||||||
<a class="nav-btn" href="/configuracion" data-view="settings">Config</a>
|
<a class="nav-btn" href="/configuracion" data-view="settings">Config</a>
|
||||||
<a class="nav-btn" href="/test" data-view="test">Test</a>
|
<a class="nav-btn" href="/operadores" data-view="operadores">Operadores</a>
|
||||||
|
<a class="nav-btn" href="/actividad" data-view="actividad">Actividad</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<div class="notification-bell" id="notificationBell" title="Takeovers pendientes">
|
<div class="notification-bell" id="notificationBell" title="Takeovers pendientes">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.9 2 2 2zm6-6v-5c0-3.07-1.63-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.64 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2zm-2 1H8v-6c0-2.48 1.51-4.5 4-4.5s4 2.02 4 4.5v6z"/></svg>
|
||||||
<span class="badge" id="takeoverBadge" style="display:none;">0</span>
|
<span class="badge" id="takeoverBadge" style="display:none;">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="status" id="sseStatus">SSE: connecting…</div>
|
<div class="status disconnected" id="sseStatus"><span class="dot"></span><span class="label">Conectando…</span></div>
|
||||||
|
<div class="user-menu" id="userMenu" title="Sesión">
|
||||||
|
<span class="user-email" id="userEmail">—</span>
|
||||||
|
<button class="logout-btn" id="logoutBtn" type="button">Salir</button>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="viewHome" class="view active">
|
<div id="viewHome" class="view active">
|
||||||
@@ -131,18 +174,6 @@ class OpsShell extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="viewTest" class="view">
|
|
||||||
<div class="layout-crud">
|
|
||||||
<test-panel></test-panel>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="viewPrompts" class="view">
|
|
||||||
<div class="layout-crud">
|
|
||||||
<prompts-crud></prompts-crud>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="viewTakeovers" class="view">
|
<div id="viewTakeovers" class="view">
|
||||||
<div class="layout-crud">
|
<div class="layout-crud">
|
||||||
<takeovers-crud></takeovers-crud>
|
<takeovers-crud></takeovers-crud>
|
||||||
@@ -154,6 +185,18 @@ class OpsShell extends HTMLElement {
|
|||||||
<settings-crud></settings-crud>
|
<settings-crud></settings-crud>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="viewOperadores" class="view">
|
||||||
|
<div class="layout-crud">
|
||||||
|
<system-users-crud></system-users-crud>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="viewActividad" class="view">
|
||||||
|
<div class="layout-crud">
|
||||||
|
<audit-log></audit-log>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -161,7 +204,22 @@ class OpsShell extends HTMLElement {
|
|||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
this._unsub = on("sse:status", (s) => {
|
this._unsub = on("sse:status", (s) => {
|
||||||
const el = this.shadowRoot.getElementById("sseStatus");
|
const el = this.shadowRoot.getElementById("sseStatus");
|
||||||
el.textContent = s.ok ? "SSE: connected" : "SSE: disconnected (retrying…)";
|
if (!el) return;
|
||||||
|
el.classList.toggle("disconnected", !s.ok);
|
||||||
|
const label = el.querySelector(".label");
|
||||||
|
if (label) label.textContent = s.ok ? "En vivo" : "Reconectando…";
|
||||||
|
});
|
||||||
|
|
||||||
|
// User session badge + logout.
|
||||||
|
const user = window.__USER__ || null;
|
||||||
|
const emailEl = this.shadowRoot.getElementById("userEmail");
|
||||||
|
if (emailEl) emailEl.textContent = user?.email || "—";
|
||||||
|
this.shadowRoot.getElementById("logoutBtn")?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
|
||||||
|
} finally {
|
||||||
|
window.location.replace("/login");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Listen for view switch requests from other components
|
// Listen for view switch requests from other components
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ function statusLabel(status) {
|
|||||||
|
|
||||||
function statusColor(status) {
|
function statusColor(status) {
|
||||||
const map = {
|
const map = {
|
||||||
pending: "#f59e0b",
|
pending: "var(--warn)",
|
||||||
processing: "#3b82f6",
|
processing: "var(--chart-blue)",
|
||||||
"on-hold": "#8b5cf6",
|
"on-hold": "var(--chart-purple)",
|
||||||
completed: "#22c55e",
|
completed: "var(--ok)",
|
||||||
cancelled: "#6b7280",
|
cancelled: "var(--text-muted)",
|
||||||
refunded: "#ec4899",
|
refunded: "var(--chart-pink)",
|
||||||
failed: "#ef4444",
|
failed: "var(--err)",
|
||||||
};
|
};
|
||||||
return map[status] || "#8aa0b5";
|
return map[status] || "var(--text-muted)";
|
||||||
}
|
}
|
||||||
|
|
||||||
class OrdersCrud extends HTMLElement {
|
class OrdersCrud extends HTMLElement {
|
||||||
@@ -51,15 +51,15 @@ class OrdersCrud extends HTMLElement {
|
|||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host {
|
:host {
|
||||||
--bg: #0b0f14;
|
--bg: var(--bg);
|
||||||
--panel: #121823;
|
--panel: var(--panel);
|
||||||
--muted: #8aa0b5;
|
--muted: var(--text-muted);
|
||||||
--text: #e7eef7;
|
--text: var(--text);
|
||||||
--line: #1e2a3a;
|
--line: var(--border);
|
||||||
--blue: #1f6feb;
|
--blue: var(--accent);
|
||||||
--green: #238636;
|
--green: var(--ok);
|
||||||
--red: #da3633;
|
--red: var(--err);
|
||||||
--orange: #f59e0b;
|
--orange: var(--warn);
|
||||||
}
|
}
|
||||||
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
|
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
|
||||||
.container {
|
.container {
|
||||||
@@ -154,10 +154,10 @@ class OrdersCrud extends HTMLElement {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.badge.test { background: var(--orange); color: #000; }
|
.badge.test { background: var(--orange); color: var(--text); }
|
||||||
.badge.real { background: var(--green); color: #fff; }
|
.badge.real { background: var(--green); color: #fff; }
|
||||||
.badge.whatsapp { background: #25d366; color: #fff; }
|
.badge.whatsapp { background: var(--ok); color: #fff; }
|
||||||
.badge.web { background: var(--muted); color: #000; }
|
.badge.web { background: var(--muted); color: var(--text); }
|
||||||
.status-badge {
|
.status-badge {
|
||||||
padding: 3px 8px;
|
padding: 3px 8px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
@@ -431,7 +431,6 @@ class OrdersCrud extends HTMLElement {
|
|||||||
<th>Tipo</th>
|
<th>Tipo</th>
|
||||||
<th>Estado</th>
|
<th>Estado</th>
|
||||||
<th>Envío</th>
|
<th>Envío</th>
|
||||||
<th>Pago</th>
|
|
||||||
<th>Cliente</th>
|
<th>Cliente</th>
|
||||||
<th>Total</th>
|
<th>Total</th>
|
||||||
<th>Fecha</th>
|
<th>Fecha</th>
|
||||||
@@ -452,13 +451,7 @@ class OrdersCrud extends HTMLElement {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="status-badge" style="background:${statusColor(order.status)}">${statusLabel(order.status)}</span></td>
|
<td><span class="status-badge" style="background:${statusColor(order.status)}">${statusLabel(order.status)}</span></td>
|
||||||
<td><span class="badge" style="background:${order.is_delivery ? '#3b82f6' : '#8b5cf6'};color:#fff">${order.is_delivery ? 'DEL' : 'RET'}</span></td>
|
<td><span class="badge" style="background:${order.is_delivery ? 'var(--chart-blue)' : 'var(--chart-purple)'};color:#fff">${order.is_delivery ? 'DEL' : 'RET'}</span></td>
|
||||||
<td>
|
|
||||||
<div class="badges">
|
|
||||||
<span class="badge" style="background:${order.is_cash ? '#f59e0b' : '#1f6feb'};color:${order.is_cash ? '#000' : '#fff'}">${order.is_cash ? '$' : 'MP'}</span>
|
|
||||||
<span class="badge" style="background:${order.is_paid ? '#22c55e' : '#ef4444'};color:#fff">${order.is_paid ? '✓' : '✗'}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="customer-name" title="${customerName}">${customerName}</td>
|
<td class="customer-name" title="${customerName}">${customerName}</td>
|
||||||
<td class="total">$${Number(order.total || 0).toLocaleString("es-AR")}</td>
|
<td class="total">$${Number(order.total || 0).toLocaleString("es-AR")}</td>
|
||||||
<td>${formatDate(order.date_created)}</td>
|
<td>${formatDate(order.date_created)}</td>
|
||||||
@@ -565,7 +558,7 @@ class OrdersCrud extends HTMLElement {
|
|||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
<span class="detail-label">Método</span>
|
<span class="detail-label">Método</span>
|
||||||
<span class="detail-value">
|
<span class="detail-value">
|
||||||
<span class="badge ${order.is_delivery ? 'delivery' : 'pickup'}" style="background:${order.is_delivery ? '#3b82f6' : '#8b5cf6'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
|
<span class="badge ${order.is_delivery ? 'delivery' : 'pickup'}" style="background:${order.is_delivery ? 'var(--chart-blue)' : 'var(--chart-purple)'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
|
||||||
${order.is_delivery ? 'DELIVERY' : 'RETIRO'}
|
${order.is_delivery ? 'DELIVERY' : 'RETIRO'}
|
||||||
</span>
|
</span>
|
||||||
${order.shipping_method ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${order.shipping_method}</span>` : ''}
|
${order.shipping_method ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${order.shipping_method}</span>` : ''}
|
||||||
@@ -579,28 +572,6 @@ class OrdersCrud extends HTMLElement {
|
|||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="detail-section">
|
|
||||||
<div class="detail-title">Pago</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Método</span>
|
|
||||||
<span class="detail-value">
|
|
||||||
<span class="badge" style="background:${order.is_cash ? '#f59e0b' : '#1f6feb'};color:${order.is_cash ? '#000' : '#fff'};padding:3px 8px;border-radius:4px;font-size:10px;">
|
|
||||||
${order.is_cash ? 'EFECTIVO' : 'LINK'}
|
|
||||||
</span>
|
|
||||||
${order.payment_method_title ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${order.payment_method_title}</span>` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="detail-row">
|
|
||||||
<span class="detail-label">Estado</span>
|
|
||||||
<span class="detail-value">
|
|
||||||
<span class="badge" style="background:${order.is_paid ? '#22c55e' : '#ef4444'};color:#fff;padding:3px 8px;border-radius:4px;font-size:10px;">
|
|
||||||
${order.is_paid ? 'PAGADO' : 'PENDIENTE'}
|
|
||||||
</span>
|
|
||||||
${order.date_paid ? `<span style="margin-left:8px;color:var(--muted);font-size:11px;">${formatDate(order.date_paid)}</span>` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
<div class="detail-title">Cliente</div>
|
<div class="detail-title">Cliente</div>
|
||||||
<div class="detail-row">
|
<div class="detail-row">
|
||||||
|
|||||||
@@ -19,46 +19,46 @@ class ProductsCrud extends HTMLElement {
|
|||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
|
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
input:focus, select:focus { outline:none; border-color:var(--accent); }
|
||||||
input { flex:1; }
|
input { flex:1; }
|
||||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
button:hover { background:#1a5fd0; }
|
button:hover { background:var(--accent-hover); }
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
button.secondary { background:#253245; }
|
button.secondary { background:var(--border-hi); }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
button.secondary:hover { background:var(--border-hi); }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.list { flex:1; overflow-y:auto; }
|
||||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; }
|
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; user-select:none; }
|
||||||
.item:hover { border-color:#1f6feb; }
|
.item:hover { border-color:var(--accent); }
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.item.selected { border-color:#2ecc71; background:#0f2a1a; }
|
.item.selected { border-color:var(--ok); background:var(--ok-soft); }
|
||||||
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
|
.item-name { font-weight:600; color:var(--text); margin-bottom:4px; }
|
||||||
.item-meta { font-size:12px; color:#8aa0b5; }
|
.item-meta { font-size:12px; color:var(--text-muted); }
|
||||||
.item-price { color:#2ecc71; font-weight:600; }
|
.item-price { color:var(--ok); font-weight:600; }
|
||||||
|
|
||||||
.detail { flex:1; overflow-y:auto; }
|
.detail { flex:1; overflow-y:auto; }
|
||||||
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||||
.field { margin-bottom:16px; }
|
.field { margin-bottom:16px; }
|
||||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
|
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
|
||||||
.field-value.json { font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
|
.field-value.json { font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
|
||||||
|
|
||||||
.stats { display:flex; gap:16px; margin-bottom:16px; }
|
.stats { display:flex; gap:16px; margin-bottom:16px; }
|
||||||
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
.stat { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
||||||
.stat:hover { border-color:#1f6feb; }
|
.stat:hover { border-color:var(--accent); }
|
||||||
.stat.active { border-color:#1f6feb; background:#111b2a; }
|
.stat.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
|
.stat-value { font-size:24px; font-weight:700; color:var(--accent); }
|
||||||
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
|
.stat-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; margin-top:4px; }
|
||||||
|
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; margin-left:8px; }
|
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); margin-left:8px; }
|
||||||
.badge.stock { background:#0f2a1a; color:#2ecc71; }
|
.badge.stock { background:var(--ok-soft); color:var(--ok); }
|
||||||
.badge.nostock { background:#241214; color:#e74c3c; }
|
.badge.nostock { background:var(--err-soft); color:var(--err); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -272,7 +272,7 @@ class ProductsCrud extends HTMLElement {
|
|||||||
|
|
||||||
// Mostrar unidad actual si está definida
|
// Mostrar unidad actual si está definida
|
||||||
const unit = item.sell_unit || item.payload?._sell_unit_override;
|
const unit = item.sell_unit || item.payload?._sell_unit_override;
|
||||||
const unitBadge = unit ? `<span class="badge" style="background:#1a3a5c;color:#7eb8e7;">${unit === 'unit' ? 'Unidad' : 'Kg'}</span>` : '';
|
const unitBadge = unit ? `<span class="badge" style="background:var(--accent-soft);color:var(--accent-hover);">${unit === 'unit' ? 'Unidad' : 'Kg'}</span>` : '';
|
||||||
|
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge} ${unitBadge}</div>
|
<div class="item-name">${item.name || "Sin nombre"} ${stockBadge} ${unitBadge}</div>
|
||||||
@@ -328,7 +328,7 @@ class ProductsCrud extends HTMLElement {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[products-crud] Error in renderDetail:", err);
|
console.error("[products-crud] Error in renderDetail:", err);
|
||||||
const detail = this.shadowRoot.getElementById("detail");
|
const detail = this.shadowRoot.getElementById("detail");
|
||||||
detail.innerHTML = `<div class="detail-empty" style="color:#e74c3c;">Error: ${err.message}</div>`;
|
detail.innerHTML = `<div class="detail-empty" style="color:var(--err);">Error: ${err.message}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Scroll detail panel to top
|
// Scroll detail panel to top
|
||||||
@@ -449,12 +449,12 @@ class ProductsCrud extends HTMLElement {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Unidad de venta</label>
|
<label>Unidad de venta</label>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;">
|
<select id="sellUnit" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;">
|
||||||
<option value="kg" ${currentUnit === "kg" ? "selected" : ""}>Por peso (kg)</option>
|
<option value="kg" ${currentUnit === "kg" ? "selected" : ""}>Por peso (kg)</option>
|
||||||
<option value="unit" ${currentUnit === "unit" ? "selected" : ""}>Por unidad</option>
|
<option value="unit" ${currentUnit === "unit" ? "selected" : ""}>Por unidad</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
|
||||||
Define si este producto se vende por peso o por unidad
|
Define si este producto se vende por peso o por unidad
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -464,16 +464,16 @@ class ProductsCrud extends HTMLElement {
|
|||||||
${categoriesArray.length > 0
|
${categoriesArray.length > 0
|
||||||
? categoriesArray.map(cat => `
|
? categoriesArray.map(cat => `
|
||||||
<span class="category-tag" data-category="${this.escapeHtml(cat)}"
|
<span class="category-tag" data-category="${this.escapeHtml(cat)}"
|
||||||
style="display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;">
|
style="display:inline-flex;align-items:center;gap:4px;background:var(--accent-soft);color:var(--accent-hover);padding:4px 8px;border-radius:6px;font-size:12px;">
|
||||||
${this.escapeHtml(cat)}
|
${this.escapeHtml(cat)}
|
||||||
<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">×</span>
|
<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">×</span>
|
||||||
</span>
|
</span>
|
||||||
`).join("")
|
`).join("")
|
||||||
: '<span style="color:#8aa0b5;font-size:12px;">Sin categorías</span>'
|
: '<span style="color:var(--text-muted);font-size:12px;">Sin categorías</span>'
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
<select id="addCategorySelect" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||||
<option value="">-- Agregar categoría --</option>
|
<option value="">-- Agregar categoría --</option>
|
||||||
${this.getAllCategories().filter(c => !categoriesArray.includes(c)).map(cat =>
|
${this.getAllCategories().filter(c => !categoriesArray.includes(c)).map(cat =>
|
||||||
`<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`
|
`<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`
|
||||||
@@ -489,7 +489,7 @@ class ProductsCrud extends HTMLElement {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<button id="saveProduct" style="flex:1;padding:10px;">Guardar cambios</button>
|
<button id="saveProduct" style="flex:1;padding:10px;">Guardar cambios</button>
|
||||||
<span id="saveStatus" style="font-size:12px;color:#2ecc71;"></span>
|
<span id="saveStatus" style="font-size:12px;color:var(--ok);"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -533,7 +533,7 @@ class ProductsCrud extends HTMLElement {
|
|||||||
const tag = document.createElement("span");
|
const tag = document.createElement("span");
|
||||||
tag.className = "category-tag";
|
tag.className = "category-tag";
|
||||||
tag.dataset.category = categoryName;
|
tag.dataset.category = categoryName;
|
||||||
tag.style = "display:inline-flex;align-items:center;gap:4px;background:#1a3a5c;color:#7eb8e7;padding:4px 8px;border-radius:6px;font-size:12px;";
|
tag.style = "display:inline-flex;align-items:center;gap:4px;background:var(--accent-soft);color:var(--accent-hover);padding:4px 8px;border-radius:6px;font-size:12px;";
|
||||||
tag.innerHTML = `${this.escapeHtml(categoryName)}<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">×</span>`;
|
tag.innerHTML = `${this.escapeHtml(categoryName)}<span class="remove-cat" style="cursor:pointer;font-weight:bold;opacity:0.7;">×</span>`;
|
||||||
|
|
||||||
// Bind remove
|
// Bind remove
|
||||||
@@ -545,7 +545,7 @@ class ProductsCrud extends HTMLElement {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Remover el mensaje "Sin categorías" si existe
|
// Remover el mensaje "Sin categorías" si existe
|
||||||
const emptyMsg = container.querySelector('span[style*="color:#8aa0b5"]');
|
const emptyMsg = container.querySelector('span[style*="color:var(--text-muted)"]');
|
||||||
if (emptyMsg) emptyMsg.remove();
|
if (emptyMsg) emptyMsg.remove();
|
||||||
|
|
||||||
container.appendChild(tag);
|
container.appendChild(tag);
|
||||||
@@ -652,8 +652,8 @@ class ProductsCrud extends HTMLElement {
|
|||||||
detail.innerHTML = `
|
detail.innerHTML = `
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Productos seleccionados</label>
|
<label>Productos seleccionados</label>
|
||||||
<div class="field-value" style="color:#2ecc71;font-weight:600;">${count} productos</div>
|
<div class="field-value" style="color:var(--ok);font-weight:600;">${count} productos</div>
|
||||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">${names}${moreText}</div>
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">${names}${moreText}</div>
|
||||||
<div style="font-size:11px;margin-top:4px;">
|
<div style="font-size:11px;margin-top:4px;">
|
||||||
<span class="badge stock" style="margin-left:0;">${inStockCount} en stock</span>
|
<span class="badge stock" style="margin-left:0;">${inStockCount} en stock</span>
|
||||||
<span class="badge nostock">${count - inStockCount} sin stock</span>
|
<span class="badge nostock">${count - inStockCount} sin stock</span>
|
||||||
@@ -662,26 +662,26 @@ class ProductsCrud extends HTMLElement {
|
|||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Unidad de venta (para todos)</label>
|
<label>Unidad de venta (para todos)</label>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<select id="sellUnit" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
<select id="sellUnit" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||||
<option value="kg">Por peso (kg)</option>
|
<option value="kg">Por peso (kg)</option>
|
||||||
<option value="unit">Por unidad</option>
|
<option value="unit">Por unidad</option>
|
||||||
</select>
|
</select>
|
||||||
<button id="saveUnit" style="padding:8px 16px;">Aplicar</button>
|
<button id="saveUnit" style="padding:8px 16px;">Aplicar</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
|
||||||
Se aplicará a todos los productos seleccionados
|
Se aplicará a todos los productos seleccionados
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>Agregar categoría (para todos)</label>
|
<label>Agregar categoría (para todos)</label>
|
||||||
<div style="display:flex;gap:8px;align-items:center;">
|
<div style="display:flex;gap:8px;align-items:center;">
|
||||||
<select id="addCategorySelect" style="background:#0f1520;color:#e7eef7;border:1px solid #253245;border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
<select id="addCategorySelect" style="background:var(--panel-2);color:var(--text);border:1px solid var(--border-hi);border-radius:8px;padding:8px 12px;font-size:13px;flex:1;">
|
||||||
<option value="">-- Seleccionar categoría --</option>
|
<option value="">-- Seleccionar categoría --</option>
|
||||||
${this.getAllCategories().map(cat => `<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`).join("")}
|
${this.getAllCategories().map(cat => `<option value="${this.escapeHtml(cat)}">${this.escapeHtml(cat)}</option>`).join("")}
|
||||||
</select>
|
</select>
|
||||||
<button id="addCategory" style="padding:8px 16px;">Agregar</button>
|
<button id="addCategory" style="padding:8px 16px;">Agregar</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:11px;color:#8aa0b5;margin-top:4px;">
|
<div style="font-size:11px;color:var(--text-muted);margin-top:4px;">
|
||||||
Se agregará esta categoría a todos los productos seleccionados
|
Se agregará esta categoría a todos los productos seleccionados
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,470 +0,0 @@
|
|||||||
import { api } from "../lib/api.js";
|
|
||||||
import { on } from "../lib/bus.js";
|
|
||||||
import { modal } from "../lib/modal.js";
|
|
||||||
|
|
||||||
const PROMPT_LABELS = {
|
|
||||||
router: "Router (clasificador de dominio)",
|
|
||||||
greeting: "Saludos",
|
|
||||||
orders: "Pedidos",
|
|
||||||
shipping: "Envio/Retiro",
|
|
||||||
payment: "Pago",
|
|
||||||
browse: "Consultas de catalogo",
|
|
||||||
};
|
|
||||||
|
|
||||||
class PromptsCrud extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.attachShadow({ mode: "open" });
|
|
||||||
this.items = [];
|
|
||||||
this.selected = null;
|
|
||||||
this.loading = false;
|
|
||||||
this.versions = [];
|
|
||||||
this.availableVariables = [];
|
|
||||||
this.availableModels = [];
|
|
||||||
this.currentSettings = {}; // Valores actuales de las variables
|
|
||||||
this.testResult = null;
|
|
||||||
this.testLoading = false;
|
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
|
||||||
<style>
|
|
||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
|
||||||
.container { display:grid; grid-template-columns:280px 1fr; gap:16px; height:100%; }
|
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
|
||||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
|
||||||
|
|
||||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
|
||||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
|
||||||
textarea { resize:vertical; min-height:200px; font-family:monospace; font-size:12px; line-height:1.5; }
|
|
||||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
|
||||||
button:hover { background:#1a5fd0; }
|
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
|
||||||
button.secondary { background:#253245; }
|
|
||||||
button.secondary:hover { background:#2d3e52; }
|
|
||||||
button.danger { background:#e74c3c; }
|
|
||||||
button.danger:hover { background:#c0392b; }
|
|
||||||
button.small { padding:4px 8px; font-size:11px; }
|
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
|
||||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
|
||||||
.item:hover { border-color:#1f6feb; }
|
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
|
||||||
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; font-size:14px; }
|
|
||||||
.item-meta { font-size:11px; color:#8aa0b5; }
|
|
||||||
.item-meta .default { color:#2ecc71; }
|
|
||||||
.item-meta .custom { color:#f39c12; }
|
|
||||||
|
|
||||||
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
|
||||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
|
||||||
.field { }
|
|
||||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
|
||||||
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
|
|
||||||
|
|
||||||
.actions { display:flex; gap:8px; flex-wrap:wrap; }
|
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
|
||||||
|
|
||||||
.variables-list { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; }
|
|
||||||
.var-item { display:inline-flex; align-items:center; gap:4px; background:#0f1520; border:1px solid #253245; border-radius:4px; padding:2px 4px 2px 2px; }
|
|
||||||
.var-btn { background:#253245; border:none; color:#8aa0b5; padding:4px 8px; border-radius:3px; font-size:11px; cursor:pointer; font-family:monospace; }
|
|
||||||
.var-btn:hover { background:#1f6feb; color:#fff; }
|
|
||||||
.var-value { font-size:10px; color:#6c7a89; max-width:120px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
||||||
|
|
||||||
.versions-list { max-height:150px; overflow-y:auto; margin-top:8px; }
|
|
||||||
.version-item { display:flex; justify-content:space-between; align-items:center; padding:6px 8px; background:#0f1520; border-radius:4px; margin-bottom:4px; font-size:12px; }
|
|
||||||
.version-item.active { border-left:3px solid #2ecc71; }
|
|
||||||
.version-item .ver { color:#e7eef7; }
|
|
||||||
.version-item .date { color:#8aa0b5; font-size:10px; }
|
|
||||||
|
|
||||||
.test-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:16px; }
|
|
||||||
.test-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
|
|
||||||
.test-result { background:#0a0e14; border:1px solid #1e2a3a; border-radius:6px; padding:12px; margin-top:12px; font-family:monospace; font-size:11px; white-space:pre-wrap; max-height:200px; overflow-y:auto; }
|
|
||||||
.test-result.error { border-color:#e74c3c; color:#e74c3c; }
|
|
||||||
.test-result.success { border-color:#2ecc71; }
|
|
||||||
.test-meta { font-size:10px; color:#8aa0b5; margin-top:8px; }
|
|
||||||
|
|
||||||
.row { display:flex; gap:12px; align-items:flex-end; }
|
|
||||||
.row .field { flex:1; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-title">Prompts del Sistema</div>
|
|
||||||
<div class="list" id="list">
|
|
||||||
<div class="loading">Cargando...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-title" id="formTitle">Editor de Prompt</div>
|
|
||||||
<div class="form" id="form">
|
|
||||||
<div class="form-empty">Selecciona un prompt para editarlo</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.load();
|
|
||||||
|
|
||||||
// Refrescar settings cuando se vuelve a esta vista (por si cambiaron en Config)
|
|
||||||
this._unsubRouter = on("router:viewChanged", ({ view }) => {
|
|
||||||
if (view === "prompts") {
|
|
||||||
this.refreshSettings();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnectedCallback() {
|
|
||||||
this._unsubRouter?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
async refreshSettings() {
|
|
||||||
try {
|
|
||||||
const settings = await api.getSettings();
|
|
||||||
this.currentSettings = {
|
|
||||||
store_name: settings.store_name || "",
|
|
||||||
store_hours: this.formatStoreHours(settings),
|
|
||||||
store_address: settings.store_address || "",
|
|
||||||
store_phone: settings.store_phone || "",
|
|
||||||
bot_name: settings.bot_name || "",
|
|
||||||
current_date: new Date().toLocaleDateString("es-AR"),
|
|
||||||
customer_name: "(nombre del cliente)",
|
|
||||||
state: "(estado actual)",
|
|
||||||
};
|
|
||||||
// Re-renderizar el form si hay uno seleccionado
|
|
||||||
if (this.selected) {
|
|
||||||
this.renderForm();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.debug("Error refreshing settings:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
|
||||||
this.loading = true;
|
|
||||||
this.renderList();
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Cargar prompts y settings en paralelo
|
|
||||||
const [data, settings] = await Promise.all([
|
|
||||||
api.prompts(),
|
|
||||||
api.getSettings().catch(() => ({})),
|
|
||||||
]);
|
|
||||||
|
|
||||||
this.items = data.items || [];
|
|
||||||
this.availableVariables = data.available_variables || [];
|
|
||||||
this.availableModels = data.available_models || [];
|
|
||||||
|
|
||||||
// Mapear settings a variables
|
|
||||||
this.currentSettings = {
|
|
||||||
store_name: settings.store_name || "",
|
|
||||||
store_hours: this.formatStoreHours(settings),
|
|
||||||
store_address: settings.store_address || "",
|
|
||||||
store_phone: settings.store_phone || "",
|
|
||||||
bot_name: settings.bot_name || "",
|
|
||||||
current_date: new Date().toLocaleDateString("es-AR"),
|
|
||||||
customer_name: "(nombre del cliente)",
|
|
||||||
state: "(estado actual)",
|
|
||||||
};
|
|
||||||
|
|
||||||
this.loading = false;
|
|
||||||
this.renderList();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error loading prompts:", e);
|
|
||||||
this.items = [];
|
|
||||||
this.loading = false;
|
|
||||||
this.renderList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
formatStoreHours(settings) {
|
|
||||||
if (!settings.pickup_days) return "";
|
|
||||||
|
|
||||||
// Mapeo de días cortos a nombres legibles
|
|
||||||
const dayNames = {
|
|
||||||
lun: "Lun", mar: "Mar", mie: "Mié", jue: "Jue",
|
|
||||||
vie: "Vie", sab: "Sáb", dom: "Dom"
|
|
||||||
};
|
|
||||||
|
|
||||||
const days = settings.pickup_days.split(",").map(d => dayNames[d.trim()] || d).join(", ");
|
|
||||||
const start = (settings.pickup_hours_start || "08:00").slice(0, 5);
|
|
||||||
const end = (settings.pickup_hours_end || "20:00").slice(0, 5);
|
|
||||||
|
|
||||||
return `${days} de ${start} a ${end}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderList() {
|
|
||||||
const list = this.shadowRoot.getElementById("list");
|
|
||||||
|
|
||||||
if (this.loading) {
|
|
||||||
list.innerHTML = `<div class="loading">Cargando...</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.items.length) {
|
|
||||||
list.innerHTML = `<div class="loading">No se encontraron prompts</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
list.innerHTML = "";
|
|
||||||
for (const item of this.items) {
|
|
||||||
const el = document.createElement("div");
|
|
||||||
el.className = "item" + (this.selected?.prompt_key === item.prompt_key ? " active" : "");
|
|
||||||
|
|
||||||
const label = PROMPT_LABELS[item.prompt_key] || item.prompt_key;
|
|
||||||
const statusClass = item.is_default ? "default" : "custom";
|
|
||||||
const statusText = item.is_default ? "Default" : `v${item.version}`;
|
|
||||||
|
|
||||||
el.innerHTML = `
|
|
||||||
<div class="item-name">${label}</div>
|
|
||||||
<div class="item-meta">
|
|
||||||
<span class="${statusClass}">${statusText}</span>
|
|
||||||
${item.model ? ` | ${item.model}` : ""}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
el.onclick = () => this.selectPrompt(item);
|
|
||||||
list.appendChild(el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectPrompt(item) {
|
|
||||||
this.selected = item;
|
|
||||||
this.testResult = null;
|
|
||||||
this.renderList();
|
|
||||||
|
|
||||||
// Cargar detalles con versiones
|
|
||||||
try {
|
|
||||||
const details = await api.getPrompt(item.prompt_key);
|
|
||||||
this.selected = { ...item, ...details.current };
|
|
||||||
this.versions = details.versions || [];
|
|
||||||
this.availableVariables = details.available_variables || this.availableVariables;
|
|
||||||
this.availableModels = details.available_models || this.availableModels;
|
|
||||||
this.renderForm();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error loading prompt details:", e);
|
|
||||||
this.renderForm();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
renderForm() {
|
|
||||||
const form = this.shadowRoot.getElementById("form");
|
|
||||||
const title = this.shadowRoot.getElementById("formTitle");
|
|
||||||
|
|
||||||
if (!this.selected) {
|
|
||||||
title.textContent = "Editor de Prompt";
|
|
||||||
form.innerHTML = `<div class="form-empty">Selecciona un prompt para editarlo</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = PROMPT_LABELS[this.selected.prompt_key] || this.selected.prompt_key;
|
|
||||||
title.textContent = `Editar: ${label}`;
|
|
||||||
|
|
||||||
const content = this.selected.content || "";
|
|
||||||
const model = this.selected.model || "gpt-4-turbo";
|
|
||||||
|
|
||||||
form.innerHTML = `
|
|
||||||
<div class="row">
|
|
||||||
<div class="field">
|
|
||||||
<label>Modelo LLM</label>
|
|
||||||
<select id="modelSelect">
|
|
||||||
${this.availableModels.map(m => `<option value="${m}" ${m === model ? "selected" : ""}>${m}</option>`).join("")}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="field" style="flex:0">
|
|
||||||
<label> </label>
|
|
||||||
<button id="resetBtn" class="secondary">Reset a Default</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="field" style="flex:1; display:flex; flex-direction:column;">
|
|
||||||
<label>Contenido del Prompt</label>
|
|
||||||
<textarea id="contentInput" style="flex:1; min-height:250px;">${this.escapeHtml(content)}</textarea>
|
|
||||||
<div class="field-hint">
|
|
||||||
Variables disponibles (click para insertar):
|
|
||||||
<div class="variables-list" id="variablesList">
|
|
||||||
${this.availableVariables.map(v => {
|
|
||||||
const key = typeof v === 'string' ? v : v.key;
|
|
||||||
const desc = typeof v === 'string' ? '' : (v.description || '');
|
|
||||||
const value = this.currentSettings[key] || '';
|
|
||||||
const displayValue = value ? `= ${value}` : '(vacío)';
|
|
||||||
return `<span class="var-item">
|
|
||||||
<button class="var-btn" data-var="${key}" title="${desc}">{{${key}}}</button>
|
|
||||||
<span class="var-value" title="${this.escapeHtml(value)}">${this.escapeHtml(displayValue)}</span>
|
|
||||||
</span>`;
|
|
||||||
}).join("")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${this.versions.length > 0 ? `
|
|
||||||
<div class="field">
|
|
||||||
<label>Historial de Versiones</label>
|
|
||||||
<div class="versions-list" id="versionsList">
|
|
||||||
${this.versions.map(v => `
|
|
||||||
<div class="version-item ${v.is_active ? "active" : ""}">
|
|
||||||
<span class="ver">v${v.version} ${v.is_active ? "(activa)" : ""}</span>
|
|
||||||
<span class="date">${this.formatDate(v.created_at)}</span>
|
|
||||||
${!v.is_active ? `<button class="small secondary" data-version="${v.version}">Restaurar</button>` : ""}
|
|
||||||
</div>
|
|
||||||
`).join("")}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
` : ""}
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button id="saveBtn">Guardar Cambios</button>
|
|
||||||
<button id="testBtn" class="secondary">Probar Prompt</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="test-section" id="testSection" style="display:none;">
|
|
||||||
<h4>Probar Prompt</h4>
|
|
||||||
<div class="field">
|
|
||||||
<label>Mensaje de prueba</label>
|
|
||||||
<input type="text" id="testMessage" placeholder="Ej: Hola, quiero 2kg de asado" />
|
|
||||||
</div>
|
|
||||||
<button id="runTestBtn" style="margin-top:8px;">Ejecutar Prueba</button>
|
|
||||||
<div id="testResultContainer"></div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Event listeners
|
|
||||||
this.shadowRoot.getElementById("saveBtn").onclick = () => this.save();
|
|
||||||
this.shadowRoot.getElementById("resetBtn").onclick = () => this.reset();
|
|
||||||
this.shadowRoot.getElementById("testBtn").onclick = () => this.toggleTestSection();
|
|
||||||
this.shadowRoot.getElementById("runTestBtn").onclick = () => this.runTest();
|
|
||||||
|
|
||||||
// Variable buttons
|
|
||||||
this.shadowRoot.querySelectorAll(".var-btn").forEach(btn => {
|
|
||||||
btn.onclick = () => this.insertVariable(btn.dataset.var);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Version restore buttons
|
|
||||||
this.shadowRoot.querySelectorAll(".versions-list button").forEach(btn => {
|
|
||||||
btn.onclick = () => this.rollback(parseInt(btn.dataset.version, 10));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
escapeHtml(str) {
|
|
||||||
return (str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
||||||
}
|
|
||||||
|
|
||||||
formatDate(dateStr) {
|
|
||||||
if (!dateStr) return "";
|
|
||||||
const d = new Date(dateStr);
|
|
||||||
return d.toLocaleDateString("es-AR", { day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit" });
|
|
||||||
}
|
|
||||||
|
|
||||||
insertVariable(varName) {
|
|
||||||
const textarea = this.shadowRoot.getElementById("contentInput");
|
|
||||||
const start = textarea.selectionStart;
|
|
||||||
const end = textarea.selectionEnd;
|
|
||||||
const text = textarea.value;
|
|
||||||
const insertion = `{{${varName}}}`;
|
|
||||||
textarea.value = text.slice(0, start) + insertion + text.slice(end);
|
|
||||||
textarea.selectionStart = textarea.selectionEnd = start + insertion.length;
|
|
||||||
textarea.focus();
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleTestSection() {
|
|
||||||
const section = this.shadowRoot.getElementById("testSection");
|
|
||||||
section.style.display = section.style.display === "none" ? "block" : "none";
|
|
||||||
}
|
|
||||||
|
|
||||||
async save() {
|
|
||||||
const content = this.shadowRoot.getElementById("contentInput").value;
|
|
||||||
const model = this.shadowRoot.getElementById("modelSelect").value;
|
|
||||||
|
|
||||||
if (!content.trim()) {
|
|
||||||
modal.warn("El contenido no puede estar vacío");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.savePrompt(this.selected.prompt_key, { content, model });
|
|
||||||
modal.success("Prompt guardado correctamente");
|
|
||||||
await this.load();
|
|
||||||
// Re-seleccionar el prompt actual
|
|
||||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
|
||||||
if (updated) this.selectPrompt(updated);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error saving prompt:", e);
|
|
||||||
modal.error("Error guardando: " + (e.message || e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async reset() {
|
|
||||||
const confirmed = await modal.confirm("Esto desactivará todas las versiones custom y volverá al prompt por defecto. ¿Continuar?");
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.resetPrompt(this.selected.prompt_key);
|
|
||||||
modal.success("Prompt reseteado a default");
|
|
||||||
await this.load();
|
|
||||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
|
||||||
if (updated) this.selectPrompt(updated);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error resetting prompt:", e);
|
|
||||||
modal.error("Error: " + (e.message || e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async rollback(version) {
|
|
||||||
const confirmed = await modal.confirm(`¿Restaurar versión ${version}? Se creará una nueva versión con ese contenido.`);
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.rollbackPrompt(this.selected.prompt_key, version);
|
|
||||||
modal.success("Versión restaurada");
|
|
||||||
await this.load();
|
|
||||||
const updated = this.items.find(i => i.prompt_key === this.selected.prompt_key);
|
|
||||||
if (updated) this.selectPrompt(updated);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error rolling back:", e);
|
|
||||||
modal.error("Error: " + (e.message || e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async runTest() {
|
|
||||||
const testMessage = this.shadowRoot.getElementById("testMessage").value;
|
|
||||||
if (!testMessage.trim()) {
|
|
||||||
modal.warn("Ingresa un mensaje de prueba");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = this.shadowRoot.getElementById("contentInput").value;
|
|
||||||
const container = this.shadowRoot.getElementById("testResultContainer");
|
|
||||||
container.innerHTML = `<div class="test-result">Ejecutando prueba...</div>`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await api.testPrompt(this.selected.prompt_key, {
|
|
||||||
content,
|
|
||||||
test_message: testMessage,
|
|
||||||
store_config: { store_name: "Carniceria Demo", bot_name: "Piaf" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
let parsed = result.response;
|
|
||||||
try {
|
|
||||||
parsed = JSON.stringify(JSON.parse(result.response), null, 2);
|
|
||||||
} catch (e) { /* no es JSON */ }
|
|
||||||
|
|
||||||
container.innerHTML = `
|
|
||||||
<div class="test-result success">${this.escapeHtml(parsed)}</div>
|
|
||||||
<div class="test-meta">
|
|
||||||
Modelo: ${result.model} | Latencia: ${result.latency_ms}ms |
|
|
||||||
Tokens: ${result.usage?.total_tokens || "?"}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
} else {
|
|
||||||
container.innerHTML = `<div class="test-result error">Error: ${result.error || "Unknown"}</div>`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("Error testing prompt:", e);
|
|
||||||
container.innerHTML = `<div class="test-result error">Error: ${e.message || e}</div>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("prompts-crud", PromptsCrud);
|
|
||||||
@@ -28,47 +28,47 @@ class QuantitiesCrud extends HTMLElement {
|
|||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
input:focus, select:focus { outline:none; border-color:var(--accent); }
|
||||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
button:hover { background:#1a5fd0; }
|
button:hover { background:var(--accent-hover); }
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
button.secondary { background:#253245; }
|
button.secondary { background:var(--border-hi); }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
button.secondary:hover { background:var(--border-hi); }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.list { flex:1; overflow-y:auto; }
|
||||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; display:flex; justify-content:space-between; align-items:center; }
|
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; display:flex; justify-content:space-between; align-items:center; }
|
||||||
.item:hover { border-color:#1f6feb; }
|
.item:hover { border-color:var(--accent); }
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.item-name { font-weight:500; color:#e7eef7; }
|
.item-name { font-weight:500; color:var(--text); }
|
||||||
.item-price { font-size:12px; color:#8aa0b5; }
|
.item-price { font-size:12px; color:var(--text-muted); }
|
||||||
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; font-size:11px; font-weight:600; background:#1f6feb; color:#fff; }
|
.badge { display:inline-flex; align-items:center; justify-content:center; min-width:20px; height:20px; padding:0 6px; border-radius:999px; font-size:11px; font-weight:600; background:var(--accent); color:#fff; }
|
||||||
.badge.empty { background:#253245; color:#8aa0b5; }
|
.badge.empty { background:var(--border-hi); color:var(--text-muted); }
|
||||||
|
|
||||||
.form { flex:1; overflow-y:auto; }
|
.form { flex:1; overflow-y:auto; }
|
||||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||||
.product-header { margin-bottom:16px; }
|
.product-header { margin-bottom:16px; }
|
||||||
.product-name { font-size:18px; font-weight:600; color:#e7eef7; margin-bottom:4px; }
|
.product-name { font-size:18px; font-weight:600; color:var(--text); margin-bottom:4px; }
|
||||||
.product-price { font-size:14px; color:#8aa0b5; }
|
.product-price { font-size:14px; color:var(--text-muted); }
|
||||||
|
|
||||||
.qty-grid { width:100%; border-collapse:collapse; }
|
.qty-grid { width:100%; border-collapse:collapse; }
|
||||||
.qty-grid th { text-align:left; font-size:12px; color:#8aa0b5; padding:10px 8px; border-bottom:1px solid #253245; }
|
.qty-grid th { text-align:left; font-size:12px; color:var(--text-muted); padding:10px 8px; border-bottom:1px solid var(--border-hi); }
|
||||||
.qty-grid td { padding:8px; border-bottom:1px solid #1e2a3a; }
|
.qty-grid td { padding:8px; border-bottom:1px solid var(--border); }
|
||||||
.qty-grid .event-label { font-size:13px; color:#e7eef7; font-weight:500; }
|
.qty-grid .event-label { font-size:13px; color:var(--text); font-weight:500; }
|
||||||
.qty-grid input { width:70px; padding:6px 8px; font-size:12px; text-align:center; }
|
.qty-grid input { width:70px; padding:6px 8px; font-size:12px; text-align:center; }
|
||||||
.qty-grid select { width:70px; padding:6px 4px; font-size:11px; }
|
.qty-grid select { width:70px; padding:6px 4px; font-size:11px; }
|
||||||
|
|
||||||
.cell-group { display:flex; gap:4px; align-items:center; }
|
.cell-group { display:flex; gap:4px; align-items:center; }
|
||||||
|
|
||||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||||
|
|
||||||
.status { font-size:12px; color:#2ecc71; margin-left:auto; }
|
.status { font-size:12px; color:var(--ok); margin-left:auto; }
|
||||||
.status.error { color:#e74c3c; }
|
.status.error { color:var(--err); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
@@ -40,45 +40,45 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||||
textarea { min-height:60px; resize:vertical; font-size:13px; }
|
textarea { min-height:60px; resize:vertical; font-size:13px; }
|
||||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
button:hover { background:#1a5fd0; }
|
button:hover { background:var(--accent-hover); }
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
button.secondary { background:#253245; }
|
button.secondary { background:var(--border-hi); }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
button.secondary:hover { background:var(--border-hi); }
|
||||||
button.danger { background:#e74c3c; }
|
button.danger { background:var(--err); }
|
||||||
button.danger:hover { background:#c0392b; }
|
button.danger:hover { background:var(--err); }
|
||||||
button.small { padding:4px 8px; font-size:11px; }
|
button.small { padding:4px 8px; font-size:11px; }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.list { flex:1; overflow-y:auto; }
|
||||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||||
.item:hover { border-color:#1f6feb; }
|
.item:hover { border-color:var(--accent); }
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.item-key { font-weight:600; color:#e7eef7; margin-bottom:4px; display:flex; align-items:center; gap:8px; }
|
.item-key { font-weight:600; color:var(--text); margin-bottom:4px; display:flex; align-items:center; gap:8px; }
|
||||||
.item-trigger { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
|
.item-trigger { font-size:12px; color:var(--text-muted); margin-bottom:4px; }
|
||||||
.item-queries { font-size:11px; color:#2ecc71; }
|
.item-queries { font-size:11px; color:var(--ok); }
|
||||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; }
|
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:10px; }
|
||||||
.badge.active { background:#0f2a1a; color:#2ecc71; }
|
.badge.active { background:var(--ok-soft); color:var(--ok); }
|
||||||
.badge.inactive { background:#241214; color:#e74c3c; }
|
.badge.inactive { background:var(--err-soft); color:var(--err); }
|
||||||
.badge.priority { background:#253245; color:#8aa0b5; }
|
.badge.priority { background:var(--border-hi); color:var(--text-muted); }
|
||||||
.badge.type { background:#1a2a4a; color:#5dade2; }
|
.badge.type { background:var(--accent-soft); color:var(--accent-hover); }
|
||||||
|
|
||||||
.form { flex:1; overflow-y:auto; }
|
.form { flex:1; overflow-y:auto; }
|
||||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||||
.field { margin-bottom:16px; }
|
.field { margin-bottom:16px; }
|
||||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
.field-hint { font-size:11px; color:#8aa0b5; margin-top:4px; }
|
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
||||||
.field-row { display:flex; gap:12px; }
|
.field-row { display:flex; gap:12px; }
|
||||||
.field-row .field { flex:1; }
|
.field-row .field { flex:1; }
|
||||||
|
|
||||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||||
|
|
||||||
.toggle { display:flex; align-items:center; gap:8px; cursor:pointer; }
|
.toggle { display:flex; align-items:center; gap:8px; cursor:pointer; }
|
||||||
.toggle input { width:auto; }
|
.toggle input { width:auto; }
|
||||||
@@ -88,42 +88,42 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
.product-search { margin-bottom:8px; }
|
.product-search { margin-bottom:8px; }
|
||||||
.product-dropdown {
|
.product-dropdown {
|
||||||
position:absolute; top:100%; left:0; right:0; z-index:100;
|
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||||
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
|
||||||
max-height:200px; overflow-y:auto; display:none;
|
max-height:200px; overflow-y:auto; display:none;
|
||||||
}
|
}
|
||||||
.product-dropdown.open { display:block; }
|
.product-dropdown.open { display:block; }
|
||||||
.product-option {
|
.product-option {
|
||||||
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
|
||||||
display:flex; justify-content:space-between; align-items:center;
|
display:flex; justify-content:space-between; align-items:center;
|
||||||
}
|
}
|
||||||
.product-option:hover { background:#1a2535; }
|
.product-option:hover { background:var(--panel-2); }
|
||||||
.product-option.selected { background:#1a3a5c; }
|
.product-option.selected { background:var(--accent-soft); }
|
||||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
.product-option .price { font-size:11px; color:var(--text-muted); }
|
||||||
|
|
||||||
.selected-products { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; min-height:30px; }
|
.selected-products { display:flex; flex-wrap:wrap; gap:6px; margin-top:8px; min-height:30px; }
|
||||||
.product-chip {
|
.product-chip {
|
||||||
display:inline-flex; align-items:center; gap:4px;
|
display:inline-flex; align-items:center; gap:4px;
|
||||||
background:#253245; color:#e7eef7; padding:4px 8px 4px 12px;
|
background:var(--border-hi); color:var(--text); padding:4px 8px 4px 12px;
|
||||||
border-radius:999px; font-size:12px;
|
border-radius:999px; font-size:12px;
|
||||||
}
|
}
|
||||||
.product-chip .remove {
|
.product-chip .remove {
|
||||||
cursor:pointer; width:16px; height:16px; border-radius:50%;
|
cursor:pointer; width:16px; height:16px; border-radius:50%;
|
||||||
background:#e74c3c; color:#fff; font-size:10px;
|
background:var(--err); color:#fff; font-size:10px;
|
||||||
display:flex; align-items:center; justify-content:center;
|
display:flex; align-items:center; justify-content:center;
|
||||||
}
|
}
|
||||||
.product-chip .remove:hover { background:#c0392b; }
|
.product-chip .remove:hover { background:var(--err); }
|
||||||
|
|
||||||
.empty-hint { color:#8aa0b5; font-size:12px; font-style:italic; }
|
.empty-hint { color:var(--text-muted); font-size:12px; font-style:italic; }
|
||||||
|
|
||||||
/* Items table styles */
|
/* Items table styles */
|
||||||
.items-table { width:100%; border-collapse:collapse; margin-top:8px; }
|
.items-table { width:100%; border-collapse:collapse; margin-top:8px; }
|
||||||
.items-table th { text-align:left; font-size:11px; color:#8aa0b5; padding:8px 4px; border-bottom:1px solid #253245; }
|
.items-table th { text-align:left; font-size:11px; color:var(--text-muted); padding:8px 4px; border-bottom:1px solid var(--border-hi); }
|
||||||
.items-table td { padding:6px 4px; border-bottom:1px solid #1e2a3a; vertical-align:middle; }
|
.items-table td { padding:6px 4px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||||
.items-table input { padding:6px 8px; font-size:12px; }
|
.items-table input { padding:6px 8px; font-size:12px; }
|
||||||
.items-table input[type="number"] { width:70px; }
|
.items-table input[type="number"] { width:70px; }
|
||||||
.items-table select { padding:6px 8px; font-size:12px; width:80px; }
|
.items-table select { padding:6px 8px; font-size:12px; width:80px; }
|
||||||
.items-table .product-name { font-size:13px; color:#e7eef7; }
|
.items-table .product-name { font-size:13px; color:var(--text); }
|
||||||
.items-table .btn-remove { background:#e74c3c; padding:4px 8px; font-size:11px; }
|
.items-table .btn-remove { background:var(--err); padding:4px 8px; font-size:11px; }
|
||||||
|
|
||||||
.add-item-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
|
.add-item-row { display:flex; gap:8px; margin-top:12px; align-items:flex-end; }
|
||||||
.add-item-row .field { margin-bottom:0; }
|
.add-item-row .field { margin-bottom:0; }
|
||||||
@@ -131,12 +131,12 @@ class RecommendationsCrud extends HTMLElement {
|
|||||||
/* Rule type selector */
|
/* Rule type selector */
|
||||||
.rule-type-selector { display:flex; gap:8px; margin-bottom:16px; }
|
.rule-type-selector { display:flex; gap:8px; margin-bottom:16px; }
|
||||||
.rule-type-btn {
|
.rule-type-btn {
|
||||||
flex:1; padding:12px; border:2px solid #253245; border-radius:8px;
|
flex:1; padding:12px; border:2px solid var(--border-hi); border-radius:8px;
|
||||||
background:#0f1520; color:#8aa0b5; cursor:pointer; text-align:center;
|
background:var(--panel-2); color:var(--text-muted); cursor:pointer; text-align:center;
|
||||||
transition:all .15s;
|
transition:all .15s;
|
||||||
}
|
}
|
||||||
.rule-type-btn:hover { border-color:#1f6feb; }
|
.rule-type-btn:hover { border-color:var(--accent); }
|
||||||
.rule-type-btn.active { border-color:#1f6feb; background:#111b2a; color:#e7eef7; }
|
.rule-type-btn.active { border-color:var(--accent); background:var(--accent-soft); color:var(--text); }
|
||||||
.rule-type-btn .type-title { font-weight:600; margin-bottom:4px; }
|
.rule-type-btn .type-title { font-weight:600; margin-bottom:4px; }
|
||||||
.rule-type-btn .type-desc { font-size:11px; }
|
.rule-type-btn .type-desc { font-size:11px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,32 +7,74 @@ class RunTimeline extends HTMLElement {
|
|||||||
this.attachShadow({ mode: "open" });
|
this.attachShadow({ mode: "open" });
|
||||||
this.chatId = null;
|
this.chatId = null;
|
||||||
this.items = [];
|
this.items = [];
|
||||||
|
// Track if user scrolled away from the bottom — pause auto-scroll to not chase them
|
||||||
|
this._userScrolledUp = false;
|
||||||
|
this._scrollRaf = null;
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display:block; padding:12px; height:100%; overflow:hidden; }
|
:host { display:block; padding: var(--space-4); height:100%; overflow:hidden; font-family: var(--font-sans); }
|
||||||
.box { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:10px; height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box; }
|
.box {
|
||||||
.row { display:flex; gap:8px; align-items:center; }
|
background: var(--panel);
|
||||||
.muted { color:#8aa0b5; font-size:12px; }
|
border: 1px solid var(--border);
|
||||||
.title { font-weight:800; }
|
border-radius: var(--r-lg);
|
||||||
.chatlog { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:6px; margin-top:8px; flex:1; min-height:0; }
|
padding: var(--space-5);
|
||||||
/* WhatsApp-ish dark theme bubbles */
|
height:100%; display:flex; flex-direction:column; min-height:0; box-sizing:border-box;
|
||||||
.bubble { max-width:90%; margin-bottom:12px; padding:8px 10px; border-radius:14px; border:1px solid #253245; font-size:13px; line-height:1.35; white-space:pre-wrap; word-break:break-word; box-shadow: 0 1px 0 rgba(0,0,0,.35); box-sizing:border-box; }
|
box-shadow: var(--shadow-sm);
|
||||||
.bubble.user { align-self:flex-end; background:#0f2a1a; border-color:#1f6f43; color:#e7eef7; }
|
}
|
||||||
.bubble.bot { align-self:flex-start; background:#111b2a; border-color:#2a3a55; color:#e7eef7; }
|
.row { display:flex; gap: var(--space-2); align-items:center; }
|
||||||
.bubble.err { align-self:flex-start; background:#241214; border-color:#e74c3c; color:#ffe9ea; cursor:pointer; }
|
.muted { color: var(--text-muted); font-size: var(--fs-sm); }
|
||||||
.bubble.active { outline:2px solid #1f6feb; box-shadow: 0 0 0 2px rgba(31,111,235,.25); }
|
.title { font-weight: var(--fw-semibold); font-size: var(--fs-md); color: var(--text); }
|
||||||
.name { display:block; font-size:12px; font-weight:800; margin-bottom:4px; opacity:.95; }
|
.chatlog { display:flex; flex-direction:column; gap:0; overflow-y:auto; padding-right:8px; margin-top: var(--space-3); flex:1; min-height:0; }
|
||||||
.bubble.user .name { color:#cdebd8; text-align:right; }
|
|
||||||
.bubble.bot .name { color:#c7d8ee; }
|
/* WhatsApp-ish light pastel bubbles */
|
||||||
.bubble.err .name { color:#ffd0d4; }
|
.bubble {
|
||||||
.bubble .meta { display:block; margin-top:6px; font-size:11px; color:#8aa0b5; }
|
max-width: 88%; min-width:0;
|
||||||
.bubble.user .meta { color:#b9d9c6; opacity:.85; }
|
margin-bottom: var(--space-3);
|
||||||
.bubble.bot .meta { color:#a9bed6; opacity:.85; }
|
padding: 10px 14px;
|
||||||
.bubble.err .meta { color:#ffd0d4; opacity:.85; }
|
border-radius: var(--r-xl);
|
||||||
.toolbar { display:flex; gap:8px; margin-top:8px; align-items:center; }
|
border: 1px solid;
|
||||||
button { cursor:pointer; background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px; font-size:13px; }
|
font-size: var(--fs-base); line-height: var(--lh-base);
|
||||||
pre { white-space:pre-wrap; word-break:break-word; background:#0f1520; border:1px solid #253245; border-radius:10px; padding:10px; margin:0; font-size:12px; color:#d7e2ef; }
|
white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere;
|
||||||
|
box-shadow: var(--shadow-bubble);
|
||||||
|
box-sizing:border-box;
|
||||||
|
}
|
||||||
|
.bubble.user { align-self:flex-end; background: var(--user-bubble); border-color: var(--user-border); color: var(--user-text); }
|
||||||
|
.bubble.bot { align-self:flex-start; background: var(--bot-bubble); border-color: var(--bot-border); color: var(--bot-text); }
|
||||||
|
.bubble.err { align-self:flex-start; background: var(--err-bubble); border-color: var(--err-border); color: var(--err-text); cursor:pointer; }
|
||||||
|
.bubble.active { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.name { display:block; font-size: var(--fs-xs); font-weight: var(--fw-semibold); margin-bottom: 4px; letter-spacing: 0.02em; }
|
||||||
|
.bubble.user .name { color: var(--user-name); text-align:right; }
|
||||||
|
.bubble.bot .name { color: var(--bot-name); }
|
||||||
|
.bubble.err .name { color: var(--err-name); }
|
||||||
|
.bubble .meta { display:block; margin-top: 6px; font-size: var(--fs-xs); color: var(--text-muted); }
|
||||||
|
.bubble.user .meta { color: var(--user-meta); }
|
||||||
|
.bubble.bot .meta { color: var(--bot-meta); }
|
||||||
|
.bubble.err .meta { color: var(--err-meta); }
|
||||||
|
|
||||||
|
.toolbar { display:flex; gap: var(--space-2); margin-top: var(--space-3); align-items:center; }
|
||||||
|
button {
|
||||||
|
cursor:pointer;
|
||||||
|
background: var(--panel); color: var(--text);
|
||||||
|
border: 1px solid var(--border-hi);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 8px 14px;
|
||||||
|
font: var(--fw-medium) var(--fs-sm)/1 var(--font-sans);
|
||||||
|
transition: border-color .15s, background .15s;
|
||||||
|
}
|
||||||
|
button:hover { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-hover); }
|
||||||
|
button:focus-visible { outline:none; box-shadow: var(--focus-ring); }
|
||||||
|
pre {
|
||||||
|
white-space:pre-wrap; word-break:break-word; overflow-wrap:anywhere; overflow-x:auto; max-width:100%;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin:0;
|
||||||
|
font: 400 12px/1.5 var(--font-mono);
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
@@ -197,14 +239,14 @@ class RunTimeline extends HTMLElement {
|
|||||||
addedOptimistic = true;
|
addedOptimistic = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// auto-scroll al final cuando hay mensajes nuevos
|
// Auto-scroll al final cuando hay mensajes nuevos.
|
||||||
// Solo si el usuario estaba cerca del final (dentro de 150px) o si había optimistas
|
// Si el usuario scrolleó arriba (>150px del fondo), respetamos su posición
|
||||||
const wasNearBottom = this._lastScrollPosition === undefined ||
|
// a menos que él mismo haya disparado un optimistic bubble.
|
||||||
(log.scrollHeight - this._lastScrollPosition - log.clientHeight) < 150;
|
if (addedOptimistic || !this._userScrolledUp) {
|
||||||
if (addedOptimistic || wasNearBottom) {
|
|
||||||
log.scrollTop = log.scrollHeight;
|
log.scrollTop = log.scrollHeight;
|
||||||
|
// Una vez forzado al final, considerarlo "abajo" hasta que vuelva a scrollear arriba.
|
||||||
|
this._userScrolledUp = false;
|
||||||
}
|
}
|
||||||
this._lastScrollPosition = log.scrollTop;
|
|
||||||
|
|
||||||
requestAnimationFrame(() => this.emitLayout());
|
requestAnimationFrame(() => this.emitLayout());
|
||||||
this.bindScroll(log);
|
this.bindScroll(log);
|
||||||
@@ -215,8 +257,14 @@ class RunTimeline extends HTMLElement {
|
|||||||
if (this._scrollBound) return;
|
if (this._scrollBound) return;
|
||||||
this._scrollBound = true;
|
this._scrollBound = true;
|
||||||
log.addEventListener("scroll", () => {
|
log.addEventListener("scroll", () => {
|
||||||
this._lastScrollPosition = log.scrollTop;
|
// Throttle con rAF: el handler real corre 1x por frame.
|
||||||
emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop });
|
if (this._scrollRaf) return;
|
||||||
|
this._scrollRaf = requestAnimationFrame(() => {
|
||||||
|
this._scrollRaf = null;
|
||||||
|
const distFromBottom = log.scrollHeight - log.scrollTop - log.clientHeight;
|
||||||
|
this._userScrolledUp = distFromBottom > 150;
|
||||||
|
emit("ui:chatScroll", { chat_id: this.chatId, scrollTop: log.scrollTop, userScrolledUp: this._userScrolledUp });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,11 +342,10 @@ class RunTimeline extends HTMLElement {
|
|||||||
|
|
||||||
log.appendChild(bubble);
|
log.appendChild(bubble);
|
||||||
|
|
||||||
// Solo hacer scroll si el usuario ya estaba cerca del final (dentro de 100px)
|
// El usuario acaba de mandar un mensaje: forzamos al final y reseteamos
|
||||||
const wasNearBottom = (log.scrollHeight - log.scrollTop - log.clientHeight) < 100;
|
// el flag "scrolled-up" así los próximos mensajes del bot también auto-scrollean.
|
||||||
if (wasNearBottom) {
|
log.scrollTop = log.scrollHeight;
|
||||||
log.scrollTop = log.scrollHeight;
|
this._userScrolledUp = false;
|
||||||
}
|
|
||||||
|
|
||||||
// Emit layout update
|
// Emit layout update
|
||||||
requestAnimationFrame(() => this.emitLayout());
|
requestAnimationFrame(() => this.emitLayout());
|
||||||
|
|||||||
@@ -10,19 +10,12 @@ const DAYS = [
|
|||||||
{ id: "dom", label: "Domingo", short: "D" },
|
{ id: "dom", label: "Domingo", short: "D" },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Lista oficial de 48 barrios de CABA
|
function makeZoneId(name) {
|
||||||
const CABA_BARRIOS = [
|
return String(name || "").toLowerCase()
|
||||||
"Agronomía", "Almagro", "Balvanera", "Barracas", "Belgrano", "Boedo",
|
.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||||
"Caballito", "Chacarita", "Coghlan", "Colegiales", "Constitución",
|
.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
|
||||||
"Flores", "Floresta", "La Boca", "La Paternal", "Liniers",
|
.slice(0, 40) || `zona-${Date.now().toString(36)}`;
|
||||||
"Mataderos", "Monte Castro", "Montserrat", "Nueva Pompeya", "Núñez",
|
}
|
||||||
"Palermo", "Parque Avellaneda", "Parque Chacabuco", "Parque Chas",
|
|
||||||
"Parque Patricios", "Puerto Madero", "Recoleta", "Retiro", "Saavedra",
|
|
||||||
"San Cristóbal", "San Nicolás", "San Telmo", "Vélez Sársfield", "Versalles",
|
|
||||||
"Villa Crespo", "Villa del Parque", "Villa Devoto", "Villa General Mitre",
|
|
||||||
"Villa Lugano", "Villa Luro", "Villa Ortúzar", "Villa Pueyrredón",
|
|
||||||
"Villa Real", "Villa Riachuelo", "Villa Santa Rita", "Villa Soldati", "Villa Urquiza"
|
|
||||||
];
|
|
||||||
|
|
||||||
class SettingsCrud extends HTMLElement {
|
class SettingsCrud extends HTMLElement {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -31,53 +24,79 @@ class SettingsCrud extends HTMLElement {
|
|||||||
this.settings = null;
|
this.settings = null;
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
|
this.zones = [];
|
||||||
|
this.selectedZoneId = null;
|
||||||
|
this._mapEditor = null;
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
this.shadowRoot.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host { display:block; height:100%; padding:16px; overflow:auto; }
|
:host { display:block; height:100%; padding:16px 24px 24px; overflow:auto; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { box-sizing:border-box; font-family:var(--font-sans, system-ui); }
|
||||||
.container { max-width:800px; margin:0 auto; }
|
.container { max-width:1600px; margin:0 auto; }
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:20px; margin-bottom:16px; }
|
.settings-grid {
|
||||||
.panel-title { font-size:16px; font-weight:700; color:#e7eef7; margin-bottom:16px; display:flex; align-items:center; gap:8px; }
|
display:grid;
|
||||||
.panel-title svg { width:20px; height:20px; fill:#1f6feb; }
|
grid-template-columns:minmax(320px, 360px) minmax(0, 1fr);
|
||||||
|
gap:16px;
|
||||||
|
align-items:start;
|
||||||
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.settings-grid { grid-template-columns:1fr; }
|
||||||
|
}
|
||||||
|
.col { display:flex; flex-direction:column; gap:16px; }
|
||||||
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:20px; }
|
||||||
|
.panel-zones { padding:16px 16px 20px; display:flex; flex-direction:column; min-height:560px; }
|
||||||
|
.toolbar {
|
||||||
|
display:flex; align-items:center; justify-content:space-between;
|
||||||
|
gap:16px; padding:12px 4px 16px;
|
||||||
|
position:sticky; top:0; z-index:5; background:var(--bg, #f7fafc);
|
||||||
|
border-bottom:1px solid var(--border);
|
||||||
|
margin-bottom:16px;
|
||||||
|
}
|
||||||
|
.toolbar-title { margin:0; font-size:18px; font-weight:600; color:var(--text); }
|
||||||
|
.toolbar-actions { display:flex; gap:8px; }
|
||||||
|
.toolbar-actions button { padding:8px 16px; font-size:13px; }
|
||||||
|
.zones-map-wrap { display:flex; min-height:520px; min-width:0; }
|
||||||
|
.zones-map-wrap zone-map-editor { flex:1; height:auto; min-height:520px; }
|
||||||
|
.panel-title { font-size:16px; font-weight:700; color:var(--text); margin-bottom:16px; display:flex; align-items:center; gap:8px; }
|
||||||
|
.panel-title svg { width:20px; height:20px; fill:var(--accent); }
|
||||||
|
|
||||||
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
.form-row { display:grid; grid-template-columns:1fr 1fr; gap:16px; margin-bottom:16px; }
|
||||||
.form-row.full { grid-template-columns:1fr; }
|
.form-row.full { grid-template-columns:1fr; }
|
||||||
|
|
||||||
.field { }
|
.field { }
|
||||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
|
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
.field-hint { font-size:11px; color:#6c7a89; margin-top:4px; }
|
.field-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
||||||
|
|
||||||
input, select, textarea {
|
input, select, textarea {
|
||||||
background:#0f1520; color:#e7eef7; border:1px solid #253245;
|
background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi);
|
||||||
border-radius:8px; padding:10px 14px; font-size:14px; width:100%;
|
border-radius:8px; padding:10px 14px; font-size:14px; width:100%;
|
||||||
}
|
}
|
||||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||||
input:disabled { opacity:.6; cursor:not-allowed; }
|
input:disabled { opacity:.6; cursor:not-allowed; }
|
||||||
|
|
||||||
button {
|
button {
|
||||||
cursor:pointer; background:#1f6feb; color:#fff; border:none;
|
cursor:pointer; background:var(--accent); color:#fff; border:none;
|
||||||
border-radius:8px; padding:10px 20px; font-size:14px; font-weight:600;
|
border-radius:8px; padding:10px 20px; font-size:14px; font-weight:600;
|
||||||
}
|
}
|
||||||
button:hover { background:#1a5fd0; }
|
button:hover { background:var(--accent-hover); }
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
button.secondary { background:#253245; }
|
button.secondary { background:var(--border-hi); }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
button.secondary:hover { background:var(--border-hi); }
|
||||||
|
|
||||||
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
||||||
.toggle {
|
.toggle {
|
||||||
position:relative; width:48px; height:26px;
|
position:relative; width:48px; height:26px;
|
||||||
background:#253245; border-radius:13px; cursor:pointer;
|
background:var(--border-hi); border-radius:13px; cursor:pointer;
|
||||||
transition:background .2s; flex-shrink:0;
|
transition:background .2s; flex-shrink:0;
|
||||||
}
|
}
|
||||||
.toggle.active { background:#1f6feb; }
|
.toggle.active { background:var(--accent); }
|
||||||
.toggle::after {
|
.toggle::after {
|
||||||
content:''; position:absolute; top:3px; left:3px;
|
content:''; position:absolute; top:3px; left:3px;
|
||||||
width:20px; height:20px; background:#fff; border-radius:50%;
|
width:20px; height:20px; background:#fff; border-radius:50%;
|
||||||
transition:transform .2s;
|
transition:transform .2s;
|
||||||
}
|
}
|
||||||
.toggle.active::after { transform:translateX(22px); }
|
.toggle.active::after { transform:translateX(22px); }
|
||||||
.toggle-label { font-size:14px; color:#e7eef7; }
|
.toggle-label { font-size:14px; color:var(--text); }
|
||||||
|
|
||||||
/* Schedule grid */
|
/* Schedule grid */
|
||||||
.schedule-grid { display:flex; flex-direction:column; gap:8px; }
|
.schedule-grid { display:flex; flex-direction:column; gap:8px; }
|
||||||
@@ -87,17 +106,17 @@ class SettingsCrud extends HTMLElement {
|
|||||||
gap:12px;
|
gap:12px;
|
||||||
align-items:center;
|
align-items:center;
|
||||||
padding:8px 12px;
|
padding:8px 12px;
|
||||||
background:#0f1520;
|
background:var(--panel-2);
|
||||||
border-radius:8px;
|
border-radius:8px;
|
||||||
border:1px solid #1e2a3a;
|
border:1px solid var(--border);
|
||||||
}
|
}
|
||||||
.schedule-row.disabled { opacity:0.4; }
|
.schedule-row.disabled { opacity:0.4; }
|
||||||
.day-label { font-size:13px; color:#e7eef7; font-weight:500; }
|
.day-label { font-size:13px; color:var(--text); font-weight:500; }
|
||||||
.day-toggle {
|
.day-toggle {
|
||||||
width:32px; height:18px; background:#253245; border-radius:9px;
|
width:32px; height:18px; background:var(--border-hi); border-radius:9px;
|
||||||
cursor:pointer; position:relative; transition:background .2s;
|
cursor:pointer; position:relative; transition:background .2s;
|
||||||
}
|
}
|
||||||
.day-toggle.active { background:#2ecc71; }
|
.day-toggle.active { background:var(--ok); }
|
||||||
.day-toggle::after {
|
.day-toggle::after {
|
||||||
content:''; position:absolute; top:2px; left:2px;
|
content:''; position:absolute; top:2px; left:2px;
|
||||||
width:14px; height:14px; background:#fff; border-radius:50%;
|
width:14px; height:14px; background:#fff; border-radius:50%;
|
||||||
@@ -109,80 +128,61 @@ class SettingsCrud extends HTMLElement {
|
|||||||
width:70px; text-align:center; font-family:monospace;
|
width:70px; text-align:center; font-family:monospace;
|
||||||
font-size:13px; padding:6px 8px; letter-spacing:1px;
|
font-size:13px; padding:6px 8px; letter-spacing:1px;
|
||||||
}
|
}
|
||||||
.hours-inputs span { color:#6c7a89; font-size:12px; }
|
.hours-inputs span { color:var(--text-muted); font-size:12px; }
|
||||||
.hours-inputs.disabled input { opacity:0.4; pointer-events:none; }
|
.hours-inputs.disabled input { opacity:0.4; pointer-events:none; }
|
||||||
|
|
||||||
.actions { display:flex; gap:12px; margin-top:24px; }
|
.actions { display:flex; gap:12px; margin-top:24px; }
|
||||||
.loading { text-align:center; padding:60px; color:#8aa0b5; }
|
.loading { text-align:center; padding:60px; color:var(--text-muted); }
|
||||||
|
|
||||||
.success-msg {
|
.success-msg {
|
||||||
background:#2ecc7130; border:1px solid #2ecc71;
|
background:var(--ok)30; border:1px solid var(--ok);
|
||||||
color:#2ecc71; padding:12px 16px; border-radius:8px;
|
color:var(--ok); padding:12px 16px; border-radius:8px;
|
||||||
margin-bottom:16px; font-size:14px;
|
margin-bottom:16px; font-size:14px;
|
||||||
}
|
}
|
||||||
.error-msg {
|
.error-msg {
|
||||||
background:#e74c3c30; border:1px solid #e74c3c;
|
background:var(--err)30; border:1px solid var(--err);
|
||||||
color:#e74c3c; padding:12px 16px; border-radius:8px;
|
color:var(--err); padding:12px 16px; border-radius:8px;
|
||||||
margin-bottom:16px; font-size:14px;
|
margin-bottom:16px; font-size:14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid #1e2a3a; }
|
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); }
|
||||||
|
|
||||||
/* Zonas de entrega */
|
/* Zonas de entrega — editor con mapa */
|
||||||
.zones-search { margin-bottom:12px; }
|
.zones-layout { display:grid; grid-template-columns:300px minmax(0,1fr); gap:16px; height:calc(100vh - 220px); min-height:520px; }
|
||||||
.zones-search input {
|
@media (max-width: 1100px) { .zones-layout { grid-template-columns:1fr; height:auto; } }
|
||||||
width:100%; padding:10px 14px;
|
.zones-side { display:flex; flex-direction:column; gap:8px; min-width:0; min-height:0; }
|
||||||
background:#0f1520 url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%236c7a89'%3E%3Cpath d='M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z'/%3E%3C/svg%3E") no-repeat 12px center;
|
.zones-side-header { display:flex; align-items:center; justify-content:space-between; }
|
||||||
background-size:18px; padding-left:38px;
|
.zones-side-header h4 { margin:0; font-size:13px; color:var(--text); }
|
||||||
}
|
.zones-side-header button { padding:6px 10px; font-size:12px; }
|
||||||
.zones-list { max-height:400px; overflow-y:auto; display:flex; flex-direction:column; gap:4px; }
|
.zones-list { display:flex; flex-direction:column; gap:6px; flex:1; min-height:120px; max-height:50%; overflow-y:auto; padding-right:4px; }
|
||||||
.zone-row {
|
.zone-row {
|
||||||
display:grid;
|
display:flex; align-items:center; gap:10px;
|
||||||
grid-template-columns:32px 1fr;
|
padding:10px 12px; border-radius:var(--r-md, 10px);
|
||||||
gap:12px;
|
background:var(--panel-2); border:1px solid var(--border);
|
||||||
align-items:start;
|
cursor:pointer; transition:border-color .15s, background .15s;
|
||||||
padding:10px 12px;
|
|
||||||
background:#0f1520;
|
|
||||||
border-radius:8px;
|
|
||||||
border:1px solid #1e2a3a;
|
|
||||||
transition:border-color .2s;
|
|
||||||
}
|
}
|
||||||
.zone-row.active { border-color:#1f6feb; background:#0f1825; }
|
.zone-row:hover { border-color:var(--border-hi); }
|
||||||
.zone-row.hidden { display:none; }
|
.zone-row.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.zone-toggle {
|
.zone-row.disabled { opacity:.55; }
|
||||||
width:32px; height:18px; background:#253245; border-radius:9px;
|
.zone-swatch { width:14px; height:14px; border-radius:4px; flex-shrink:0; }
|
||||||
cursor:pointer; position:relative; transition:background .2s; margin-top:2px;
|
.zone-row-main { flex:1; min-width:0; display:flex; flex-direction:column; gap:2px; }
|
||||||
}
|
.zone-row-name { font-size:13px; font-weight:500; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
.zone-toggle.active { background:#2ecc71; }
|
.zone-row-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||||
.zone-toggle::after {
|
.zones-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
|
||||||
content:''; position:absolute; top:2px; left:2px;
|
.zone-form { padding:14px; background:var(--panel-2); border:1px solid var(--border); border-radius:var(--r-md, 10px); display:flex; flex-direction:column; gap:12px; }
|
||||||
width:14px; height:14px; background:#fff; border-radius:50%;
|
.zone-form .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||||
transition:transform .2s;
|
.zone-form .row.three { grid-template-columns:2fr 1fr 1fr; }
|
||||||
}
|
.zone-form label { font-size:11px; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; }
|
||||||
.zone-toggle.active::after { transform:translateX(14px); }
|
.zone-form input { padding:8px 10px; font-size:13px; }
|
||||||
.zone-content { display:flex; flex-direction:column; gap:8px; }
|
.zone-days-pick { display:flex; gap:4px; flex-wrap:wrap; }
|
||||||
.zone-name { font-size:14px; color:#e7eef7; font-weight:500; }
|
.zone-day-pick { padding:4px 8px; border-radius:6px; font-size:11px; font-weight:600; cursor:pointer;
|
||||||
.zone-config { display:none; gap:16px; flex-wrap:wrap; align-items:center; }
|
background:var(--border); color:var(--text-muted); border:1px solid transparent; }
|
||||||
.zone-row.active .zone-config { display:flex; }
|
.zone-day-pick.active { background:var(--accent); color:var(--text-on-acc, #fff); }
|
||||||
.zone-days { display:flex; gap:4px; }
|
.zone-row-actions { display:flex; gap:8px; justify-content:flex-end; }
|
||||||
.zone-day {
|
.zone-row-actions button { padding:6px 10px; font-size:12px; }
|
||||||
width:28px; height:28px; border-radius:6px;
|
.zone-row-actions .danger { background:var(--err); }
|
||||||
background:#253245; color:#8aa0b5;
|
.zones-summary { margin-top:8px; padding:10px 12px; background:var(--panel-2); border-radius:var(--r-md, 10px); font-size:12px; color:var(--text-muted); }
|
||||||
display:flex; align-items:center; justify-content:center;
|
.zones-summary strong { color:var(--text); }
|
||||||
font-size:11px; font-weight:600; cursor:pointer;
|
|
||||||
transition:all .15s;
|
|
||||||
}
|
|
||||||
.zone-day.active { background:#1f6feb; color:#fff; }
|
|
||||||
.zone-day:hover { background:#2d3e52; }
|
|
||||||
.zone-day.active:hover { background:#1a5fd0; }
|
|
||||||
.zone-cost { display:flex; align-items:center; gap:6px; }
|
|
||||||
.zone-cost label { font-size:12px; color:#8aa0b5; }
|
|
||||||
.zone-cost input { width:90px; padding:6px 10px; font-size:13px; text-align:right; }
|
|
||||||
.zones-summary {
|
|
||||||
margin-top:12px; padding:12px; background:#0f1520;
|
|
||||||
border-radius:8px; font-size:13px; color:#8aa0b5;
|
|
||||||
}
|
|
||||||
.zones-summary strong { color:#e7eef7; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -204,14 +204,12 @@ class SettingsCrud extends HTMLElement {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
this.settings = await api.getSettings();
|
this.settings = await api.getSettings();
|
||||||
// Asegurar que schedule existe
|
|
||||||
if (!this.settings.schedule) {
|
if (!this.settings.schedule) {
|
||||||
this.settings.schedule = { delivery: {}, pickup: {} };
|
this.settings.schedule = { delivery: {}, pickup: {} };
|
||||||
}
|
}
|
||||||
// Asegurar que delivery_zones existe
|
const dz = this.settings.delivery_zones || {};
|
||||||
if (!this.settings.delivery_zones) {
|
this.zones = Array.isArray(dz.zones) ? dz.zones.map((z) => ({ ...z })) : [];
|
||||||
this.settings.delivery_zones = {};
|
this.selectedZoneId = this.zones[0]?.id || null;
|
||||||
}
|
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
this.render();
|
this.render();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -275,70 +273,115 @@ class SettingsCrud extends HTMLElement {
|
|||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convierte nombre de barrio a key (ej: "Villa Crespo" -> "villa_crespo")
|
ZONE_PALETTE_VARS() {
|
||||||
barrioToKey(name) {
|
return ["--chart-blue", "--chart-green", "--chart-purple", "--chart-orange", "--chart-pink", "--chart-gray"];
|
||||||
return name.toLowerCase()
|
|
||||||
.normalize("NFD").replace(/[\u0300-\u036f]/g, "") // quitar acentos
|
|
||||||
.replace(/\s+/g, "_");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getZoneConfig(barrioKey) {
|
zoneSwatchColor(idx) {
|
||||||
return this.settings?.delivery_zones?.[barrioKey] || null;
|
const palette = this.ZONE_PALETTE_VARS();
|
||||||
|
return `var(${palette[idx % palette.length]})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
setZoneConfig(barrioKey, config) {
|
formatDaysShort(days) {
|
||||||
if (!this.settings.delivery_zones) {
|
if (!Array.isArray(days) || !days.length) return "\u2014";
|
||||||
this.settings.delivery_zones = {};
|
const order = ["lun","mar","mie","jue","vie","sab","dom"];
|
||||||
}
|
const idx = days.map((d) => order.indexOf(d)).filter((i) => i >= 0).sort((a, b) => a - b);
|
||||||
if (config === null) {
|
if (idx.length >= 3 && idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1)) {
|
||||||
delete this.settings.delivery_zones[barrioKey];
|
return `${order[idx[0]]}-${order[idx[idx.length - 1]]}`;
|
||||||
} else {
|
|
||||||
this.settings.delivery_zones[barrioKey] = config;
|
|
||||||
}
|
}
|
||||||
|
return idx.map((i) => order[i]).join("/");
|
||||||
}
|
}
|
||||||
|
|
||||||
renderZonesList() {
|
renderZonesList() {
|
||||||
return CABA_BARRIOS.map(barrio => {
|
if (!this.zones.length) {
|
||||||
const key = this.barrioToKey(barrio);
|
return `<div class="zones-empty">No hay zonas dibujadas todavía. Tocá <strong>Crear zona</strong> y dibujá un polígono en el mapa.</div>`;
|
||||||
const config = this.getZoneConfig(key);
|
}
|
||||||
const isActive = config?.enabled === true;
|
return this.zones.map((z, i) => {
|
||||||
const days = config?.days || [];
|
const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
|
||||||
const cost = config?.delivery_cost || 0;
|
const days = this.formatDaysShort(z.delivery_days);
|
||||||
|
const start = z.delivery_hours?.start?.slice(0, 5) || "";
|
||||||
|
const end = z.delivery_hours?.end?.slice(0, 5) || "";
|
||||||
|
const hours = start && end ? `${start}-${end}` : "";
|
||||||
|
const meta = [cost, days, hours].filter(Boolean).join(" · ");
|
||||||
|
const active = z.id === this.selectedZoneId ? "active" : "";
|
||||||
|
const disabled = z.enabled === false ? "disabled" : "";
|
||||||
return `
|
return `
|
||||||
<div class="zone-row ${isActive ? 'active' : ''}" data-barrio="${key}">
|
<div class="zone-row ${active} ${disabled}" data-zone-id="${z.id}">
|
||||||
<div class="zone-toggle ${isActive ? 'active' : ''}" data-barrio="${key}"></div>
|
<div class="zone-swatch" style="background:${this.zoneSwatchColor(i)}"></div>
|
||||||
<div class="zone-content">
|
<div class="zone-row-main">
|
||||||
<span class="zone-name">${barrio}</span>
|
<div class="zone-row-name">${this.escapeHtml(z.name || z.id)}</div>
|
||||||
<div class="zone-config">
|
<div class="zone-row-meta">${this.escapeHtml(meta || "sin configurar")}</div>
|
||||||
<div class="zone-days">
|
|
||||||
${DAYS.map(d => `
|
|
||||||
<div class="zone-day ${days.includes(d.id) ? 'active' : ''}"
|
|
||||||
data-barrio="${key}" data-day="${d.id}"
|
|
||||||
title="${d.label}">${d.short}</div>
|
|
||||||
`).join("")}
|
|
||||||
</div>
|
|
||||||
<div class="zone-cost">
|
|
||||||
<label>Costo:</label>
|
|
||||||
<input type="number" class="zone-cost-input" data-barrio="${key}"
|
|
||||||
value="${cost}" min="0" step="100" placeholder="0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join("");
|
}).join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
renderZonesSummary() {
|
renderZoneForm() {
|
||||||
const zones = this.settings?.delivery_zones || {};
|
const z = this.zones.find((x) => x.id === this.selectedZoneId);
|
||||||
const activeZones = Object.entries(zones).filter(([k, v]) => v?.enabled);
|
if (!z) {
|
||||||
|
return `<div class="zones-empty">Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.</div>`;
|
||||||
if (activeZones.length === 0) {
|
|
||||||
return `<div class="zones-summary">No hay zonas de entrega configuradas. Activá los barrios donde hacés delivery.</div>`;
|
|
||||||
}
|
}
|
||||||
|
const days = z.delivery_days || [];
|
||||||
|
const start = z.delivery_hours?.start?.slice(0, 5) || "10:00";
|
||||||
|
const end = z.delivery_hours?.end?.slice(0, 5) || "20:00";
|
||||||
|
return `
|
||||||
|
<div class="zone-form" data-zone-id="${z.id}">
|
||||||
|
<div class="row three">
|
||||||
|
<div class="field">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input type="text" id="zoneName" value="${this.escapeHtml(z.name || "")}" maxlength="60" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Costo de envío ($)</label>
|
||||||
|
<input type="number" id="zoneCost" value="${z.delivery_cost ?? 0}" min="0" step="100" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Mín. pedido ($)</label>
|
||||||
|
<input type="number" id="zoneMin" value="${z.min_order_amount ?? 0}" min="0" step="100" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Días de entrega</label>
|
||||||
|
<div class="zone-days-pick">
|
||||||
|
${DAYS.map((d) => `
|
||||||
|
<span class="zone-day-pick ${days.includes(d.id) ? "active" : ""}" data-day="${d.id}" title="${d.label}">${d.short}</span>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Hora de inicio</label>
|
||||||
|
<input type="time" id="zoneStart" value="${start}" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Hora de fin</label>
|
||||||
|
<input type="time" id="zoneEnd" value="${end}" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Estado</label>
|
||||||
|
<select id="zoneEnabled">
|
||||||
|
<option value="true" ${z.enabled !== false ? "selected" : ""}>Habilitada</option>
|
||||||
|
<option value="false" ${z.enabled === false ? "selected" : ""}>Deshabilitada (no recibe pedidos)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="zone-row-actions" style="align-self:end;">
|
||||||
|
<button class="secondary" id="zoneFitBtn">Centrar en mapa</button>
|
||||||
|
<button class="danger" id="zoneDeleteBtn">Eliminar zona</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return `<div class="zones-summary"><strong>${activeZones.length}</strong> zona${activeZones.length > 1 ? 's' : ''} de entrega activa${activeZones.length > 1 ? 's' : ''}</div>`;
|
renderZonesSummary() {
|
||||||
|
const enabled = this.zones.filter((z) => z.enabled !== false);
|
||||||
|
if (!this.zones.length) {
|
||||||
|
return `<div class="zones-summary">Dibujá al menos una zona en el mapa para que el bot pueda confirmar envíos. Sin zonas, todas las direcciones quedan sin validar.</div>`;
|
||||||
|
}
|
||||||
|
return `<div class="zones-summary"><strong>${enabled.length}</strong> de <strong>${this.zones.length}</strong> zona${this.zones.length > 1 ? "s" : ""} habilitada${enabled.length === 1 ? "" : "s"}.</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
@@ -357,100 +400,89 @@ class SettingsCrud extends HTMLElement {
|
|||||||
const s = this.settings;
|
const s = this.settings;
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<!-- Info del Negocio -->
|
<div class="toolbar">
|
||||||
<div class="panel">
|
<h2 class="toolbar-title">Configuración</h2>
|
||||||
<div class="panel-title">
|
<div class="toolbar-actions">
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
<button id="resetBtn" class="secondary" type="button">Restaurar</button>
|
||||||
Información del Negocio
|
<button id="saveBtn" type="button" ${this.saving ? "disabled" : ""}>
|
||||||
</div>
|
${this.saving ? "Guardando..." : "Guardar"}
|
||||||
|
</button>
|
||||||
<div class="form-row">
|
|
||||||
<div class="field">
|
|
||||||
<label>Nombre del negocio</label>
|
|
||||||
<input type="text" id="storeName" value="${this.escapeHtml(s.store_name || "")}" placeholder="Ej: Carnicería Don Pedro" />
|
|
||||||
<div class="field-hint">Se usa en los mensajes del bot</div>
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Nombre del bot</label>
|
|
||||||
<input type="text" id="botName" value="${this.escapeHtml(s.bot_name || "")}" placeholder="Ej: Piaf" />
|
|
||||||
<div class="field-hint">El asistente virtual</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-row">
|
|
||||||
<div class="field">
|
|
||||||
<label>Dirección</label>
|
|
||||||
<input type="text" id="storeAddress" value="${this.escapeHtml(s.store_address || "")}" placeholder="Ej: Av. Corrientes 1234, CABA" />
|
|
||||||
</div>
|
|
||||||
<div class="field">
|
|
||||||
<label>Teléfono</label>
|
|
||||||
<input type="text" id="storePhone" value="${this.escapeHtml(s.store_phone || "")}" placeholder="Ej: +54 11 1234-5678" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delivery -->
|
<div class="settings-grid">
|
||||||
<div class="panel">
|
<div class="col">
|
||||||
<div class="panel-title">
|
<div class="panel">
|
||||||
<svg viewBox="0 0 24 24"><path d="M18.92 6.01C18.72 5.42 18.16 5 17.5 5h-11c-.66 0-1.21.42-1.42 1.01L3 12v8c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-1h12v1c0 .55.45 1 1 1h1c.55 0 1-.45 1-1v-8l-2.08-5.99zM6.5 16c-.83 0-1.5-.67-1.5-1.5S5.67 13 6.5 13s1.5.67 1.5 1.5S7.33 16 6.5 16zm11 0c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zM5 11l1.5-4.5h11L19 11H5z"/></svg>
|
<div class="panel-title">
|
||||||
Delivery (Envío a domicilio)
|
<svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/></svg>
|
||||||
</div>
|
Información del Negocio
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom:12px;">
|
||||||
|
<label>Nombre del negocio</label>
|
||||||
|
<input type="text" id="storeName" value="${this.escapeHtml(s.store_name || "")}" placeholder="Ej: Carnicería Don Pedro" />
|
||||||
|
<div class="field-hint">Se usa en los mensajes del bot</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom:12px;">
|
||||||
|
<label>Nombre del bot</label>
|
||||||
|
<input type="text" id="botName" value="${this.escapeHtml(s.bot_name || "")}" placeholder="Ej: Piaf" />
|
||||||
|
<div class="field-hint">El asistente virtual</div>
|
||||||
|
</div>
|
||||||
|
<div class="field" style="margin-bottom:12px;">
|
||||||
|
<label>Dirección</label>
|
||||||
|
<input type="text" id="storeAddress" value="${this.escapeHtml(s.store_address || "")}" placeholder="Av. Corrientes 1234, CABA" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Teléfono</label>
|
||||||
|
<input type="text" id="storePhone" value="${this.escapeHtml(s.store_phone || "")}" placeholder="+54 11 1234-5678" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
<div class="panel">
|
||||||
<div class="toggle ${s.delivery_enabled ? "active" : ""}" id="deliveryToggle"></div>
|
<div class="panel-title">
|
||||||
<span class="toggle-label">Delivery habilitado</span>
|
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
|
||||||
</div>
|
Retiro en Tienda
|
||||||
|
</div>
|
||||||
<div class="schedule-grid" id="deliverySchedule">
|
<div class="toggle-row">
|
||||||
${this.renderScheduleGrid("delivery", s.delivery_enabled)}
|
<div class="toggle ${s.pickup_enabled ? "active" : ""}" id="pickupToggle"></div>
|
||||||
</div>
|
<span class="toggle-label">Retiro habilitado</span>
|
||||||
|
</div>
|
||||||
<div class="min-order-field">
|
<div class="schedule-grid" id="pickupSchedule">
|
||||||
<div class="field">
|
${this.renderScheduleGrid("pickup", s.pickup_enabled)}
|
||||||
<label>Pedido mínimo para delivery ($)</label>
|
</div>
|
||||||
<input type="number" id="deliveryMinOrder" value="${s.delivery_min_order || 0}" min="0" step="100" style="width:150px;" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Retiro en tienda -->
|
<div class="col">
|
||||||
<div class="panel">
|
<div class="panel panel-zones">
|
||||||
<div class="panel-title">
|
<div class="panel-title" style="margin-bottom:6px;">
|
||||||
<svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14l-5-5 1.41-1.41L12 14.17l4.59-4.58L18 11l-6 6z"/></svg>
|
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
|
||||||
Retiro en Tienda
|
Zonas de Entrega
|
||||||
|
</div>
|
||||||
|
<div class="field-hint" style="margin-bottom:12px;">
|
||||||
|
Dibujá los polígonos en el mapa. Cada zona tiene su costo, días y rango horario.
|
||||||
|
El bot matchea con la ubicación que el cliente comparta por WhatsApp.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="zones-layout">
|
||||||
|
<div class="zones-side">
|
||||||
|
<div class="zones-side-header">
|
||||||
|
<h4>Zonas</h4>
|
||||||
|
<button id="zoneCreateBtn" type="button">+ Crear zona</button>
|
||||||
|
</div>
|
||||||
|
<div class="zones-list" id="zonesList">
|
||||||
|
${this.renderZonesList()}
|
||||||
|
</div>
|
||||||
|
<div id="zoneFormSlot">
|
||||||
|
${this.renderZoneForm()}
|
||||||
|
</div>
|
||||||
|
${this.renderZonesSummary()}
|
||||||
|
</div>
|
||||||
|
<div class="zones-map-wrap">
|
||||||
|
<zone-map-editor id="zoneMapEditor"></zone-map-editor>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toggle-row">
|
|
||||||
<div class="toggle ${s.pickup_enabled ? "active" : ""}" id="pickupToggle"></div>
|
|
||||||
<span class="toggle-label">Retiro en tienda habilitado</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="schedule-grid" id="pickupSchedule">
|
|
||||||
${this.renderScheduleGrid("pickup", s.pickup_enabled)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Zonas de Entrega -->
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-title">
|
|
||||||
<svg viewBox="0 0 24 24"><path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z"/></svg>
|
|
||||||
Zonas de Entrega (Barrios CABA)
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="zones-search">
|
|
||||||
<input type="text" id="zoneSearch" placeholder="Buscar barrio..." />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="zones-list" id="zonesList">
|
|
||||||
${this.renderZonesList()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
${this.renderZonesSummary()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button id="saveBtn" ${this.saving ? "disabled" : ""}>${this.saving ? "Guardando..." : "Guardar Configuración"}</button>
|
|
||||||
<button id="resetBtn" class="secondary">Restaurar</button>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -458,13 +490,6 @@ class SettingsCrud extends HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupEventListeners() {
|
setupEventListeners() {
|
||||||
// Toggle delivery
|
|
||||||
const deliveryToggle = this.shadowRoot.getElementById("deliveryToggle");
|
|
||||||
deliveryToggle?.addEventListener("click", () => {
|
|
||||||
this.settings.delivery_enabled = !this.settings.delivery_enabled;
|
|
||||||
this.render();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Toggle pickup
|
// Toggle pickup
|
||||||
const pickupToggle = this.shadowRoot.getElementById("pickupToggle");
|
const pickupToggle = this.shadowRoot.getElementById("pickupToggle");
|
||||||
pickupToggle?.addEventListener("click", () => {
|
pickupToggle?.addEventListener("click", () => {
|
||||||
@@ -518,92 +543,142 @@ class SettingsCrud extends HTMLElement {
|
|||||||
// Reset button
|
// Reset button
|
||||||
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
|
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
|
||||||
|
|
||||||
// Zone search
|
this.setupZoneEditor();
|
||||||
this.shadowRoot.getElementById("zoneSearch")?.addEventListener("input", (e) => {
|
}
|
||||||
const query = e.target.value.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
|
||||||
this.shadowRoot.querySelectorAll(".zone-row").forEach(row => {
|
setupZoneEditor() {
|
||||||
const barrio = row.dataset.barrio;
|
const editor = this.shadowRoot.getElementById("zoneMapEditor");
|
||||||
const barrioName = CABA_BARRIOS.find(b => this.barrioToKey(b) === barrio) || "";
|
if (!editor) return;
|
||||||
const normalized = barrioName.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "");
|
this._mapEditor = editor;
|
||||||
row.classList.toggle("hidden", query && !normalized.includes(query));
|
|
||||||
|
editor.zones = this.zones;
|
||||||
|
if (this.selectedZoneId) editor.selectedId = this.selectedZoneId;
|
||||||
|
|
||||||
|
editor.addEventListener("change", (e) => {
|
||||||
|
const next = e.detail.zones || [];
|
||||||
|
const merged = [];
|
||||||
|
for (const z of next) {
|
||||||
|
const existing = this.zones.find((x) => x.id === z.id);
|
||||||
|
if (existing) merged.push({ ...existing, polygon: z.polygon });
|
||||||
|
else merged.push(z);
|
||||||
|
}
|
||||||
|
this.zones = merged;
|
||||||
|
this.refreshZonesPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
editor.addEventListener("select", (e) => {
|
||||||
|
this.selectedZoneId = e.detail.id || null;
|
||||||
|
this.refreshZonesPanel();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.shadowRoot.getElementById("zoneCreateBtn")?.addEventListener("click", () => {
|
||||||
|
this._mapEditor?.startDrawing();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.attachZoneSideListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
attachZoneSideListeners() {
|
||||||
|
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
const id = row.dataset.zoneId;
|
||||||
|
this.selectedZoneId = id;
|
||||||
|
if (this._mapEditor) this._mapEditor.selectedId = id;
|
||||||
|
this.refreshZonesPanel();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zone toggles
|
const z = this.zones.find((x) => x.id === this.selectedZoneId);
|
||||||
this.shadowRoot.querySelectorAll(".zone-toggle").forEach(toggle => {
|
if (!z) return;
|
||||||
toggle.addEventListener("click", () => {
|
|
||||||
const barrio = toggle.dataset.barrio;
|
|
||||||
const config = this.getZoneConfig(barrio);
|
|
||||||
|
|
||||||
if (config?.enabled) {
|
const onChange = () => this.refreshZonesList();
|
||||||
// Desactivar zona
|
|
||||||
this.setZoneConfig(barrio, null);
|
const nameEl = this.shadowRoot.getElementById("zoneName");
|
||||||
} else {
|
nameEl?.addEventListener("input", () => { z.name = nameEl.value; onChange(); });
|
||||||
// Activar zona con días default (lun-sab)
|
|
||||||
this.setZoneConfig(barrio, {
|
const costEl = this.shadowRoot.getElementById("zoneCost");
|
||||||
enabled: true,
|
costEl?.addEventListener("change", () => { z.delivery_cost = Number(costEl.value) || 0; onChange(); });
|
||||||
days: ["lun", "mar", "mie", "jue", "vie", "sab"],
|
|
||||||
delivery_cost: 0
|
const minEl = this.shadowRoot.getElementById("zoneMin");
|
||||||
});
|
minEl?.addEventListener("change", () => { z.min_order_amount = Number(minEl.value) || 0; });
|
||||||
}
|
|
||||||
this.render();
|
const startEl = this.shadowRoot.getElementById("zoneStart");
|
||||||
});
|
startEl?.addEventListener("change", () => {
|
||||||
|
z.delivery_hours = { ...(z.delivery_hours || {}), start: startEl.value };
|
||||||
|
onChange();
|
||||||
|
});
|
||||||
|
const endEl = this.shadowRoot.getElementById("zoneEnd");
|
||||||
|
endEl?.addEventListener("change", () => {
|
||||||
|
z.delivery_hours = { ...(z.delivery_hours || {}), end: endEl.value };
|
||||||
|
onChange();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zone day toggles
|
const enabledEl = this.shadowRoot.getElementById("zoneEnabled");
|
||||||
this.shadowRoot.querySelectorAll(".zone-day").forEach(dayBtn => {
|
enabledEl?.addEventListener("change", () => {
|
||||||
dayBtn.addEventListener("click", () => {
|
z.enabled = enabledEl.value === "true";
|
||||||
const barrio = dayBtn.dataset.barrio;
|
onChange();
|
||||||
const day = dayBtn.dataset.day;
|
});
|
||||||
const config = this.getZoneConfig(barrio);
|
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
const days = config.days || [];
|
this.shadowRoot.querySelectorAll(".zone-day-pick").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const day = btn.dataset.day;
|
||||||
|
const days = z.delivery_days || [];
|
||||||
const idx = days.indexOf(day);
|
const idx = days.indexOf(day);
|
||||||
if (idx >= 0) {
|
if (idx >= 0) days.splice(idx, 1);
|
||||||
days.splice(idx, 1);
|
else days.push(day);
|
||||||
} else {
|
z.delivery_days = days;
|
||||||
days.push(day);
|
btn.classList.toggle("active", days.includes(day));
|
||||||
}
|
onChange();
|
||||||
config.days = days;
|
|
||||||
this.setZoneConfig(barrio, config);
|
|
||||||
|
|
||||||
// Update UI without full re-render
|
|
||||||
dayBtn.classList.toggle("active", days.includes(day));
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Zone cost inputs
|
this.shadowRoot.getElementById("zoneFitBtn")?.addEventListener("click", () => {
|
||||||
this.shadowRoot.querySelectorAll(".zone-cost-input").forEach(input => {
|
if (this._mapEditor) this._mapEditor.selectedId = z.id;
|
||||||
input.addEventListener("change", () => {
|
});
|
||||||
const barrio = input.dataset.barrio;
|
|
||||||
const config = this.getZoneConfig(barrio);
|
|
||||||
if (!config) return;
|
|
||||||
|
|
||||||
config.delivery_cost = parseFloat(input.value) || 0;
|
this.shadowRoot.getElementById("zoneDeleteBtn")?.addEventListener("click", () => {
|
||||||
this.setZoneConfig(barrio, config);
|
if (this._mapEditor) this._mapEditor.removeZone(z.id);
|
||||||
|
this.zones = this.zones.filter((x) => x.id !== z.id);
|
||||||
|
if (this.selectedZoneId === z.id) this.selectedZoneId = this.zones[0]?.id || null;
|
||||||
|
this.refreshZonesPanel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshZonesPanel() {
|
||||||
|
const list = this.shadowRoot.getElementById("zonesList");
|
||||||
|
const formSlot = this.shadowRoot.getElementById("zoneFormSlot");
|
||||||
|
if (list) list.innerHTML = this.renderZonesList();
|
||||||
|
if (formSlot) formSlot.innerHTML = this.renderZoneForm();
|
||||||
|
this.attachZoneSideListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshZonesList() {
|
||||||
|
const list = this.shadowRoot.getElementById("zonesList");
|
||||||
|
if (list) list.innerHTML = this.renderZonesList();
|
||||||
|
this.shadowRoot.querySelectorAll(".zone-row[data-zone-id]").forEach((row) => {
|
||||||
|
row.addEventListener("click", () => {
|
||||||
|
const id = row.dataset.zoneId;
|
||||||
|
this.selectedZoneId = id;
|
||||||
|
if (this._mapEditor) this._mapEditor.selectedId = id;
|
||||||
|
this.refreshZonesPanel();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
collectScheduleFromInputs() {
|
collectScheduleFromInputs() {
|
||||||
const schedule = { delivery: {}, pickup: {} };
|
// Sólo pickup: el horario de delivery vive ahora dentro de cada zona.
|
||||||
|
const schedule = { pickup: {} };
|
||||||
for (const type of ["delivery", "pickup"]) {
|
this.shadowRoot.querySelectorAll(`.hour-start[data-type="pickup"]`).forEach((input) => {
|
||||||
this.shadowRoot.querySelectorAll(`.hour-start[data-type="${type}"]`).forEach(input => {
|
const day = input.dataset.day;
|
||||||
const day = input.dataset.day;
|
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="pickup"][data-day="${day}"]`);
|
||||||
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="${type}"][data-day="${day}"]`);
|
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="pickup"][data-day="${day}"]`);
|
||||||
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="${type}"][data-day="${day}"]`);
|
if (toggle?.classList.contains("active")) {
|
||||||
|
schedule.pickup[day] = {
|
||||||
if (toggle?.classList.contains("active")) {
|
start: input.value.trim() || "08:00",
|
||||||
schedule[type][day] = {
|
end: endInput?.value.trim() || "20:00",
|
||||||
start: input.value.trim() || (type === "delivery" ? "09:00" : "08:00"),
|
};
|
||||||
end: endInput?.value.trim() || (type === "delivery" ? "18:00" : "20:00"),
|
}
|
||||||
};
|
});
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -611,17 +686,37 @@ class SettingsCrud extends HTMLElement {
|
|||||||
// Collect schedule from inputs
|
// Collect schedule from inputs
|
||||||
const schedule = this.collectScheduleFromInputs();
|
const schedule = this.collectScheduleFromInputs();
|
||||||
|
|
||||||
// Collect delivery zones (already in settings from event handlers)
|
// Antes de serializar, refrescar polígonos desde el editor por si el
|
||||||
const delivery_zones = this.settings.delivery_zones || {};
|
// usuario editó vértices y no llegó a disparar otro evento change.
|
||||||
|
if (this._mapEditor) {
|
||||||
|
const live = this._mapEditor.zones;
|
||||||
|
this.zones = this.zones.map((z) => {
|
||||||
|
const fromMap = live.find((x) => x.id === z.id);
|
||||||
|
return fromMap ? { ...z, polygon: fromMap.polygon } : z;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanZones = this.zones
|
||||||
|
.filter((z) => z.polygon && Array.isArray(z.polygon.coordinates))
|
||||||
|
.map((z) => ({
|
||||||
|
id: z.id,
|
||||||
|
name: z.name || z.id,
|
||||||
|
polygon: z.polygon,
|
||||||
|
delivery_cost: Number(z.delivery_cost) || 0,
|
||||||
|
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : [],
|
||||||
|
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
|
||||||
|
min_order_amount: Number(z.min_order_amount) || 0,
|
||||||
|
enabled: z.enabled !== false,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const delivery_zones = { zones: cleanZones };
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
|
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
|
||||||
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
|
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
|
||||||
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
|
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
|
||||||
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
|
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
|
||||||
delivery_enabled: this.settings.delivery_enabled,
|
|
||||||
pickup_enabled: this.settings.pickup_enabled,
|
pickup_enabled: this.settings.pickup_enabled,
|
||||||
delivery_min_order: parseFloat(this.shadowRoot.getElementById("deliveryMinOrder")?.value) || 0,
|
|
||||||
schedule,
|
schedule,
|
||||||
delivery_zones,
|
delivery_zones,
|
||||||
};
|
};
|
||||||
|
|||||||
259
public/components/system-users-crud.js
Normal file
259
public/components/system-users-crud.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import { api } from "../lib/api.js";
|
||||||
|
import { modal } from "../lib/modal.js";
|
||||||
|
import { toast } from "../lib/toast.js";
|
||||||
|
|
||||||
|
class SystemUsersCrud extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.attachShadow({ mode: "open" });
|
||||||
|
this.users = [];
|
||||||
|
this.selected = null; // user seleccionado (objeto)
|
||||||
|
this.editing = null; // copia editable de selected
|
||||||
|
this.creating = false; // si true, editing es un user nuevo
|
||||||
|
this.loading = false;
|
||||||
|
this.saving = false;
|
||||||
|
|
||||||
|
this.shadowRoot.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host { display:block; height:100%; padding:16px 24px 24px; overflow:auto; }
|
||||||
|
* { box-sizing:border-box; font-family: var(--font-sans); }
|
||||||
|
.container { max-width:1200px; margin:0 auto; }
|
||||||
|
.toolbar { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:12px 4px 16px; position:sticky; top:0; background:var(--bg); border-bottom:1px solid var(--border); margin-bottom:16px; }
|
||||||
|
.toolbar-title { margin:0; font-size:18px; font-weight:600; }
|
||||||
|
.toolbar button { padding:8px 14px; font-size:13px; }
|
||||||
|
|
||||||
|
.grid { display:grid; grid-template-columns: minmax(280px,360px) minmax(0,1fr); gap:16px; align-items:start; }
|
||||||
|
@media (max-width: 900px) { .grid { grid-template-columns: 1fr; } }
|
||||||
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; }
|
||||||
|
.panel h3 { margin:0 0 12px; font-size:14px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; }
|
||||||
|
|
||||||
|
.list { display:flex; flex-direction:column; gap:6px; max-height:60vh; overflow-y:auto; }
|
||||||
|
.empty { padding:24px; color:var(--text-muted); text-align:center; font-size:13px; }
|
||||||
|
.item { padding:10px 12px; border:1px solid var(--border); border-radius:var(--r-md); cursor:pointer; transition:all .15s; background:var(--panel-2); }
|
||||||
|
.item:hover { border-color:var(--border-hi); }
|
||||||
|
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
|
.item.inactive { opacity:.55; }
|
||||||
|
.item-email { font-size:13px; font-weight:500; color:var(--text); }
|
||||||
|
.item-name { font-size:11px; color:var(--text-muted); margin-top:2px; }
|
||||||
|
.badge { display:inline-block; font-size:10px; padding:1px 6px; border-radius:6px; margin-left:6px; }
|
||||||
|
.badge.you { background:var(--accent-soft); color:var(--accent-hover); }
|
||||||
|
.badge.off { background:var(--panel-3); color:var(--text-muted); }
|
||||||
|
|
||||||
|
.form .field { margin-bottom:14px; }
|
||||||
|
.form label { display:block; font-size:11px; color:var(--text-muted); margin-bottom:6px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
|
.form input, .form select { width:100%; padding:9px 12px; font-size:13px; border:1px solid var(--border-hi); border-radius:var(--r-md); background:var(--panel-2); color:var(--text); }
|
||||||
|
.form input:focus, .form select:focus { outline:none; border-color:var(--accent); box-shadow:0 0 0 3px var(--accent-soft); }
|
||||||
|
.form-hint { font-size:11px; color:var(--text-muted); margin-top:4px; }
|
||||||
|
.form-actions { display:flex; gap:8px; flex-wrap:wrap; margin-top:16px; }
|
||||||
|
button { cursor:pointer; background:var(--accent); color:var(--text-on-acc, #fff); border:none; border-radius:var(--r-md); padding:9px 16px; font-size:13px; font-weight:500; }
|
||||||
|
button:hover { background:var(--accent-hover); }
|
||||||
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
|
button.secondary { background:var(--panel); color:var(--text); border:1px solid var(--border-hi); }
|
||||||
|
button.secondary:hover { border-color:var(--accent); color:var(--accent-hover); }
|
||||||
|
button.danger { background:var(--err); color:#fff; border:none; }
|
||||||
|
button.danger:hover { filter:brightness(0.95); }
|
||||||
|
|
||||||
|
.placeholder { padding:24px; color:var(--text-muted); text-align:center; font-size:13px; }
|
||||||
|
</style>
|
||||||
|
<div class="container">
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2 class="toolbar-title">Operadores</h2>
|
||||||
|
<div><button id="btnNew" type="button">+ Nuevo operador</button></div>
|
||||||
|
</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<h3>Lista</h3>
|
||||||
|
<div class="list" id="list"></div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<h3 id="formTitle">Detalle</h3>
|
||||||
|
<div id="formSlot"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.shadowRoot.getElementById("btnNew").addEventListener("click", () => this.startCreate());
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
meId() {
|
||||||
|
const u = window.__USER__;
|
||||||
|
return u ? Number(u.id) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
this.loading = true;
|
||||||
|
this.renderList();
|
||||||
|
try {
|
||||||
|
const data = await api.listSystemUsers();
|
||||||
|
this.users = data.items || [];
|
||||||
|
this.loading = false;
|
||||||
|
// Si hay seleccionado, refrescar referencia.
|
||||||
|
if (this.selected) {
|
||||||
|
this.selected = this.users.find((u) => Number(u.id) === Number(this.selected.id)) || null;
|
||||||
|
}
|
||||||
|
this.renderList();
|
||||||
|
this.renderForm();
|
||||||
|
} catch (e) {
|
||||||
|
this.loading = false;
|
||||||
|
this.renderList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startCreate() {
|
||||||
|
this.creating = true;
|
||||||
|
this.selected = null;
|
||||||
|
this.editing = { email: "", name: "", password: "", active: true };
|
||||||
|
this.renderList();
|
||||||
|
this.renderForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectUser(user) {
|
||||||
|
this.creating = false;
|
||||||
|
this.selected = user;
|
||||||
|
this.editing = { ...user, password: "" };
|
||||||
|
this.renderList();
|
||||||
|
this.renderForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList() {
|
||||||
|
const list = this.shadowRoot.getElementById("list");
|
||||||
|
if (this.loading) { list.innerHTML = `<div class="empty">Cargando...</div>`; return; }
|
||||||
|
if (!this.users.length) { list.innerHTML = `<div class="empty">No hay operadores</div>`; return; }
|
||||||
|
const meId = this.meId();
|
||||||
|
list.innerHTML = this.users.map((u) => {
|
||||||
|
const isMe = Number(u.id) === meId;
|
||||||
|
const active = !this.creating && this.selected?.id === u.id ? "active" : "";
|
||||||
|
const inactive = !u.active ? "inactive" : "";
|
||||||
|
return `
|
||||||
|
<div class="item ${active} ${inactive}" data-id="${u.id}">
|
||||||
|
<div class="item-email">${this.escapeHtml(u.email)}${isMe ? '<span class="badge you">Vos</span>' : ""}${!u.active ? '<span class="badge off">Inactivo</span>' : ""}</div>
|
||||||
|
<div class="item-name">${this.escapeHtml(u.name)}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
list.querySelectorAll(".item[data-id]").forEach((el) => {
|
||||||
|
el.addEventListener("click", () => {
|
||||||
|
const u = this.users.find((x) => String(x.id) === el.dataset.id);
|
||||||
|
if (u) this.selectUser(u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
renderForm() {
|
||||||
|
const title = this.shadowRoot.getElementById("formTitle");
|
||||||
|
const slot = this.shadowRoot.getElementById("formSlot");
|
||||||
|
if (!this.editing) {
|
||||||
|
title.textContent = "Detalle";
|
||||||
|
slot.innerHTML = `<div class="placeholder">Seleccioná un operador o creá uno nuevo.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const isCreate = this.creating;
|
||||||
|
const meId = this.meId();
|
||||||
|
const isSelf = !isCreate && Number(this.selected?.id) === meId;
|
||||||
|
title.textContent = isCreate ? "Nuevo operador" : `Editar — ${this.selected?.email || ""}`;
|
||||||
|
|
||||||
|
slot.innerHTML = `
|
||||||
|
<div class="form">
|
||||||
|
<div class="field">
|
||||||
|
<label>Email</label>
|
||||||
|
<input id="fEmail" type="email" value="${this.escapeHtml(this.editing.email || "")}" ${isCreate ? "" : "disabled"} placeholder="ej. juan@local" autocomplete="off" />
|
||||||
|
${!isCreate ? '<div class="form-hint">El email no se puede cambiar (volvete a crear el operador si hace falta).</div>' : ""}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input id="fName" type="text" value="${this.escapeHtml(this.editing.name || "")}" placeholder="Juan Pérez" />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>${isCreate ? "Contraseña" : "Cambiar contraseña (opcional)"}</label>
|
||||||
|
<input id="fPassword" type="password" value="" placeholder="${isCreate ? "Mínimo 8 caracteres" : "Dejar vacío para no cambiar"}" autocomplete="new-password" />
|
||||||
|
${isCreate ? '<div class="form-hint">Mínimo 8 caracteres.</div>' : ""}
|
||||||
|
</div>
|
||||||
|
${isCreate ? "" : `
|
||||||
|
<div class="field">
|
||||||
|
<label>Estado</label>
|
||||||
|
<select id="fActive" ${isSelf ? "disabled" : ""}>
|
||||||
|
<option value="true" ${this.editing.active ? "selected" : ""}>Activo</option>
|
||||||
|
<option value="false" ${!this.editing.active ? "selected" : ""}>Inactivo</option>
|
||||||
|
</select>
|
||||||
|
${isSelf ? '<div class="form-hint">No podés desactivarte a vos mismo.</div>' : ""}
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button id="btnSave" type="button">${this.saving ? "Guardando..." : isCreate ? "Crear" : "Guardar cambios"}</button>
|
||||||
|
<button id="btnCancel" type="button" class="secondary">Cancelar</button>
|
||||||
|
${isCreate || isSelf ? "" : '<button id="btnDelete" type="button" class="danger" style="margin-left:auto;">Eliminar</button>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
this.shadowRoot.getElementById("btnCancel").addEventListener("click", () => {
|
||||||
|
this.creating = false;
|
||||||
|
this.editing = this.selected ? { ...this.selected, password: "" } : null;
|
||||||
|
this.renderForm();
|
||||||
|
});
|
||||||
|
this.shadowRoot.getElementById("btnSave").addEventListener("click", () => this.save());
|
||||||
|
this.shadowRoot.getElementById("btnDelete")?.addEventListener("click", () => this.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
const f = this.shadowRoot;
|
||||||
|
const email = f.getElementById("fEmail").value.trim();
|
||||||
|
const name = f.getElementById("fName").value.trim();
|
||||||
|
const password = f.getElementById("fPassword").value;
|
||||||
|
const activeEl = f.getElementById("fActive");
|
||||||
|
const active = activeEl ? activeEl.value === "true" : true;
|
||||||
|
|
||||||
|
if (this.creating) {
|
||||||
|
if (!email) return toast({ kind: "error", text: "Email requerido" });
|
||||||
|
if (!name) return toast({ kind: "error", text: "Nombre requerido" });
|
||||||
|
if (!password || password.length < 8) return toast({ kind: "error", text: "Contraseña mínimo 8 caracteres" });
|
||||||
|
} else {
|
||||||
|
if (!name) return toast({ kind: "error", text: "Nombre requerido" });
|
||||||
|
if (password && password.length < 8) return toast({ kind: "error", text: "Contraseña mínimo 8 caracteres" });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saving = true;
|
||||||
|
this.renderForm();
|
||||||
|
try {
|
||||||
|
if (this.creating) {
|
||||||
|
await api.createSystemUser({ email, name, password, active });
|
||||||
|
toast({ kind: "ok", text: "Operador creado" });
|
||||||
|
} else {
|
||||||
|
const payload = { name, active };
|
||||||
|
if (password) payload.password = password;
|
||||||
|
await api.updateSystemUser(this.selected.id, payload);
|
||||||
|
toast({ kind: "ok", text: "Cambios guardados" });
|
||||||
|
}
|
||||||
|
this.creating = false;
|
||||||
|
await this.load();
|
||||||
|
} catch (e) {
|
||||||
|
// safeFetch ya muestra toast
|
||||||
|
} finally {
|
||||||
|
this.saving = false;
|
||||||
|
this.renderForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove() {
|
||||||
|
if (!this.selected) return;
|
||||||
|
const ok = await modal.confirm(`¿Eliminar al operador ${this.selected.email}? Pierde acceso al sistema.`, { confirmText: "Eliminar", cancelText: "Cancelar" });
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api.deleteSystemUser(this.selected.id);
|
||||||
|
toast({ kind: "ok", text: "Operador eliminado" });
|
||||||
|
this.selected = null;
|
||||||
|
this.editing = null;
|
||||||
|
await this.load();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
escapeHtml(s) {
|
||||||
|
return String(s ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("system-users-crud", SystemUsersCrud);
|
||||||
@@ -18,87 +18,87 @@ class TakeoversCrud extends HTMLElement {
|
|||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
.container { display:grid; grid-template-columns:350px 1fr; gap:16px; height:100%; }
|
.container { display:grid; grid-template-columns:350px 1fr; gap:16px; height:100%; }
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
|
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; display:flex; align-items:center; gap:8px; }
|
||||||
.panel-title .badge { background:#e74c3c; color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; }
|
.panel-title .badge { background:var(--err); color:#fff; padding:2px 8px; border-radius:10px; font-size:11px; }
|
||||||
|
|
||||||
input, select, textarea { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
input, select, textarea { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; width:100%; }
|
||||||
input:focus, select:focus, textarea:focus { outline:none; border-color:#1f6feb; }
|
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
||||||
textarea { resize:vertical; min-height:100px; }
|
textarea { resize:vertical; min-height:100px; }
|
||||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
button:hover { background:#1a5fd0; }
|
button:hover { background:var(--accent-hover); }
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
button.secondary { background:#253245; }
|
button.secondary { background:var(--border-hi); }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
button.secondary:hover { background:var(--border-hi); }
|
||||||
button.danger { background:#e74c3c; }
|
button.danger { background:var(--err); }
|
||||||
button.danger:hover { background:#c0392b; }
|
button.danger:hover { background:var(--err); }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.list { flex:1; overflow-y:auto; }
|
||||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||||
.item:hover { border-color:#1f6feb; }
|
.item:hover { border-color:var(--accent); }
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.item-query { font-weight:600; color:#f39c12; margin-bottom:4px; font-size:14px; }
|
.item-query { font-weight:600; color:var(--warn); margin-bottom:4px; font-size:14px; }
|
||||||
.item-reason { font-size:12px; color:#8aa0b5; margin-bottom:4px; }
|
.item-reason { font-size:12px; color:var(--text-muted); margin-bottom:4px; }
|
||||||
.item-time { font-size:11px; color:#6c7a89; }
|
.item-time { font-size:11px; color:var(--text-muted); }
|
||||||
.item-chat { font-size:11px; color:#1f6feb; }
|
.item-chat { font-size:11px; color:var(--accent); }
|
||||||
|
|
||||||
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
.form { flex:1; overflow-y:auto; display:flex; flex-direction:column; gap:16px; }
|
||||||
.form-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
.form-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||||
.field { }
|
.field { }
|
||||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
|
|
||||||
.actions { display:flex; gap:8px; margin-top:16px; }
|
.actions { display:flex; gap:8px; margin-top:16px; }
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||||
|
|
||||||
.conversation-history { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; }
|
.conversation-history { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; }
|
||||||
.msg { margin-bottom:8px; padding:8px; border-radius:6px; font-size:12px; }
|
.msg { margin-bottom:8px; padding:8px; border-radius:6px; font-size:12px; }
|
||||||
.msg.user { background:#1a2a3a; border-left:3px solid #1f6feb; }
|
.msg.user { background:var(--accent-soft); border-left:3px solid var(--accent); }
|
||||||
.msg.assistant { background:#1a2535; border-left:3px solid #2ecc71; }
|
.msg.assistant { background:var(--panel-2); border-left:3px solid var(--ok); }
|
||||||
.msg-role { font-size:10px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; }
|
.msg-role { font-size:10px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; }
|
||||||
.msg-content { color:#e7eef7; white-space:pre-wrap; }
|
.msg-content { color:var(--text); white-space:pre-wrap; }
|
||||||
|
|
||||||
.query-highlight { background:#f39c1230; border:1px solid #f39c12; border-radius:8px; padding:12px; margin-bottom:16px; }
|
.query-highlight { background:var(--warn)30; border:1px solid var(--warn); border-radius:8px; padding:12px; margin-bottom:16px; }
|
||||||
.query-highlight label { color:#f39c12; }
|
.query-highlight label { color:var(--warn); }
|
||||||
.query-highlight .query { font-size:16px; font-weight:600; color:#f39c12; margin-top:4px; }
|
.query-highlight .query { font-size:16px; font-weight:600; color:var(--warn); margin-top:4px; }
|
||||||
|
|
||||||
.alias-section { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-top:12px; }
|
.alias-section { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-top:12px; }
|
||||||
.alias-section h4 { margin:0 0 12px; font-size:13px; color:#8aa0b5; }
|
.alias-section h4 { margin:0 0 12px; font-size:13px; color:var(--text-muted); }
|
||||||
.checkbox-row { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
|
.checkbox-row { display:flex; align-items:center; gap:8px; margin-bottom:12px; }
|
||||||
.checkbox-row input[type="checkbox"] { width:auto; }
|
.checkbox-row input[type="checkbox"] { width:auto; }
|
||||||
.checkbox-row label { font-size:13px; color:#e7eef7; text-transform:none; }
|
.checkbox-row label { font-size:13px; color:var(--text); text-transform:none; }
|
||||||
|
|
||||||
.product-selector { position:relative; }
|
.product-selector { position:relative; }
|
||||||
.product-dropdown {
|
.product-dropdown {
|
||||||
position:absolute; top:100%; left:0; right:0; z-index:100;
|
position:absolute; top:100%; left:0; right:0; z-index:100;
|
||||||
background:#0f1520; border:1px solid #253245; border-radius:8px;
|
background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px;
|
||||||
max-height:200px; overflow-y:auto; display:none;
|
max-height:200px; overflow-y:auto; display:none;
|
||||||
}
|
}
|
||||||
.product-dropdown.open { display:block; }
|
.product-dropdown.open { display:block; }
|
||||||
.product-option {
|
.product-option {
|
||||||
padding:8px 12px; cursor:pointer; font-size:13px; color:#e7eef7;
|
padding:8px 12px; cursor:pointer; font-size:13px; color:var(--text);
|
||||||
display:flex; justify-content:space-between; align-items:center;
|
display:flex; justify-content:space-between; align-items:center;
|
||||||
}
|
}
|
||||||
.product-option:hover { background:#1a2535; }
|
.product-option:hover { background:var(--panel-2); }
|
||||||
.product-option .price { font-size:11px; color:#8aa0b5; }
|
.product-option .price { font-size:11px; color:var(--text-muted); }
|
||||||
|
|
||||||
.cart-section { background:#0d2818; border:1px solid #2ecc71; border-radius:8px; padding:12px; margin-bottom:12px; }
|
.cart-section { background:var(--ok-soft); border:1px solid var(--ok); border-radius:8px; padding:12px; margin-bottom:12px; }
|
||||||
.cart-section h4 { margin:0 0 12px; font-size:13px; color:#2ecc71; display:flex; align-items:center; gap:8px; }
|
.cart-section h4 { margin:0 0 12px; font-size:13px; color:var(--ok); display:flex; align-items:center; gap:8px; }
|
||||||
.cart-section h4 svg { width:16px; height:16px; fill:#2ecc71; }
|
.cart-section h4 svg { width:16px; height:16px; fill:var(--ok); }
|
||||||
.cart-items-list { margin-bottom:12px; }
|
.cart-items-list { margin-bottom:12px; }
|
||||||
.cart-item-row { display:flex; align-items:center; gap:8px; padding:8px; background:#0f1520; border-radius:6px; margin-bottom:6px; }
|
.cart-item-row { display:flex; align-items:center; gap:8px; padding:8px; background:var(--panel-2); border-radius:6px; margin-bottom:6px; }
|
||||||
.cart-item-row .name { flex:1; font-size:13px; color:#e7eef7; }
|
.cart-item-row .name { flex:1; font-size:13px; color:var(--text); }
|
||||||
.cart-item-row .qty { width:60px; text-align:center; }
|
.cart-item-row .qty { width:60px; text-align:center; }
|
||||||
.cart-item-row .unit-select { width:80px; }
|
.cart-item-row .unit-select { width:80px; }
|
||||||
.cart-item-row .remove-btn { background:#e74c3c; color:#fff; border:none; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:11px; }
|
.cart-item-row .remove-btn { background:var(--err); color:#fff; border:none; border-radius:4px; padding:4px 8px; cursor:pointer; font-size:11px; }
|
||||||
.cart-item-row .remove-btn:hover { background:#c0392b; }
|
.cart-item-row .remove-btn:hover { background:var(--err); }
|
||||||
.add-cart-row { display:flex; gap:8px; align-items:flex-end; }
|
.add-cart-row { display:flex; gap:8px; align-items:flex-end; }
|
||||||
.add-cart-row .product-selector { flex:1; }
|
.add-cart-row .product-selector { flex:1; }
|
||||||
.add-cart-row .qty-input { width:70px; }
|
.add-cart-row .qty-input { width:70px; }
|
||||||
.add-cart-row .unit-select { width:80px; }
|
.add-cart-row .unit-select { width:80px; }
|
||||||
.add-cart-row button { white-space:nowrap; }
|
.add-cart-row button { white-space:nowrap; }
|
||||||
|
|
||||||
.no-pending { text-align:center; padding:60px 20px; color:#2ecc71; }
|
.no-pending { text-align:center; padding:60px 20px; color:var(--ok); }
|
||||||
.no-pending svg { width:48px; height:48px; fill:#2ecc71; margin-bottom:16px; }
|
.no-pending svg { width:48px; height:48px; fill:var(--ok); margin-bottom:16px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -349,7 +349,7 @@ class TakeoversCrud extends HTMLElement {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (this.cartItemsToAdd.length === 0) {
|
if (this.cartItemsToAdd.length === 0) {
|
||||||
container.innerHTML = `<div style="font-size:12px;color:#8aa0b5;padding:8px;">Sin items agregados</div>`;
|
container.innerHTML = `<div style="font-size:12px;color:var(--text-muted);padding:8px;">Sin items agregados</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,394 +0,0 @@
|
|||||||
import { api } from "../lib/api.js";
|
|
||||||
import { modal } from "../lib/modal.js";
|
|
||||||
|
|
||||||
// Datos aleatorios para generar usuarios de prueba
|
|
||||||
const NOMBRES = ["Juan", "María", "Carlos", "Ana", "Pedro", "Laura", "Diego", "Sofía"];
|
|
||||||
const APELLIDOS = ["García", "Rodríguez", "Martínez", "López", "González", "Fernández", "Pérez"];
|
|
||||||
const CALLES = ["Av. Corrientes", "Av. Santa Fe", "Calle Florida", "Av. Rivadavia", "Av. Cabildo", "Av. Libertador"];
|
|
||||||
|
|
||||||
function randomItem(arr) {
|
|
||||||
return arr[Math.floor(Math.random() * arr.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
function randomInt(min, max) {
|
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateTestUser() {
|
|
||||||
const randomPhone = `549${randomInt(1000000000, 9999999999)}`;
|
|
||||||
const wa_chat_id = `${randomPhone}@s.whatsapp.net`;
|
|
||||||
const nombre = randomItem(NOMBRES);
|
|
||||||
const apellido = randomItem(APELLIDOS);
|
|
||||||
|
|
||||||
return {
|
|
||||||
wa_chat_id,
|
|
||||||
phone: randomPhone,
|
|
||||||
name: `${nombre} ${apellido}`,
|
|
||||||
address: {
|
|
||||||
first_name: nombre,
|
|
||||||
last_name: apellido,
|
|
||||||
address_1: `${randomItem(CALLES)} ${randomInt(100, 9000)}`,
|
|
||||||
city: "CABA",
|
|
||||||
state: "Buenos Aires",
|
|
||||||
postcode: `${randomInt(1000, 1999)}`,
|
|
||||||
country: "AR",
|
|
||||||
phone: randomPhone,
|
|
||||||
email: `${randomPhone}@no-email.local`,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class TestPanel extends HTMLElement {
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.attachShadow({ mode: "open" });
|
|
||||||
this.products = [];
|
|
||||||
this.selectedProducts = [];
|
|
||||||
this.testUser = null;
|
|
||||||
this.lastOrder = null;
|
|
||||||
this.loading = false;
|
|
||||||
|
|
||||||
this.shadowRoot.innerHTML = `
|
|
||||||
<style>
|
|
||||||
:host {
|
|
||||||
--bg: #0b0f14;
|
|
||||||
--panel: #121823;
|
|
||||||
--muted: #8aa0b5;
|
|
||||||
--text: #e7eef7;
|
|
||||||
--line: #1e2a3a;
|
|
||||||
--blue: #1f6feb;
|
|
||||||
--green: #238636;
|
|
||||||
--red: #da3633;
|
|
||||||
}
|
|
||||||
* { box-sizing: border-box; font-family: system-ui, Segoe UI, Roboto, Arial; }
|
|
||||||
.container {
|
|
||||||
height: 100%;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
padding: 16px;
|
|
||||||
overflow: auto;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
.panel {
|
|
||||||
background: var(--panel);
|
|
||||||
border-radius: 8px;
|
|
||||||
padding: 16px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
.panel-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
padding-bottom: 8px;
|
|
||||||
}
|
|
||||||
.section {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.section-title {
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--blue);
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
background: var(--blue);
|
|
||||||
border: none;
|
|
||||||
color: white;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: opacity 0.15s;
|
|
||||||
}
|
|
||||||
button:hover { opacity: 0.9; }
|
|
||||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
||||||
button.secondary {
|
|
||||||
background: transparent;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
button.secondary:hover { border-color: var(--blue); color: var(--text); }
|
|
||||||
button.success { background: var(--green); }
|
|
||||||
input, select {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
color: var(--text);
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
input:focus, select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--blue);
|
|
||||||
}
|
|
||||||
.product-list {
|
|
||||||
max-height: 200px;
|
|
||||||
overflow-y: auto;
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
.product-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 80px 60px 30px;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 8px;
|
|
||||||
border-bottom: 1px solid var(--line);
|
|
||||||
align-items: center;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.product-item:last-child { border-bottom: none; }
|
|
||||||
.product-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
||||||
.product-qty { text-align: right; }
|
|
||||||
.product-unit { color: var(--muted); }
|
|
||||||
.remove-btn {
|
|
||||||
background: var(--red);
|
|
||||||
padding: 4px 8px;
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.user-info {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.user-info div { margin-bottom: 4px; }
|
|
||||||
.user-info span { color: var(--muted); }
|
|
||||||
.result {
|
|
||||||
background: var(--bg);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.result.success { border-color: var(--green); }
|
|
||||||
.result.error { border-color: var(--red); }
|
|
||||||
.result-label { color: var(--muted); font-size: 10px; text-transform: uppercase; }
|
|
||||||
.result-value { font-weight: 600; margin-top: 4px; }
|
|
||||||
.result-value.big { font-size: 18px; }
|
|
||||||
.result-link {
|
|
||||||
color: var(--blue);
|
|
||||||
text-decoration: underline;
|
|
||||||
cursor: pointer;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
.row { display: flex; gap: 8px; align-items: center; }
|
|
||||||
.flex-1 { flex: 1; }
|
|
||||||
.loading { opacity: 0.5; pointer-events: none; }
|
|
||||||
.empty { color: var(--muted); font-size: 12px; text-align: center; padding: 20px; }
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="panel">
|
|
||||||
<div class="panel-title">1. Generar Orden de Prueba</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="row">
|
|
||||||
<button id="btnGenerate">Generar Orden Aleatoria</button>
|
|
||||||
<button id="btnClear" class="secondary">Limpiar</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">Productos seleccionados</div>
|
|
||||||
<div class="product-list" id="productList">
|
|
||||||
<div class="empty">Click "Generar Orden Aleatoria" para comenzar</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<div class="section-title">Datos del usuario</div>
|
|
||||||
<div class="user-info" id="userInfo">
|
|
||||||
<div class="empty">Se generarán automáticamente</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section">
|
|
||||||
<button id="btnCreateOrder" class="success" disabled>Crear Orden en WooCommerce</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="section" id="orderResult" style="display:none;">
|
|
||||||
<div class="result success">
|
|
||||||
<div class="result-label">Orden creada</div>
|
|
||||||
<div class="result-value big" id="orderIdValue">—</div>
|
|
||||||
<div style="margin-top:8px;">
|
|
||||||
<span class="result-label">Total:</span>
|
|
||||||
<span id="orderTotalValue">—</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
this.shadowRoot.getElementById("btnGenerate").onclick = () => this.generateRandomOrder();
|
|
||||||
this.shadowRoot.getElementById("btnClear").onclick = () => this.clearAll();
|
|
||||||
this.shadowRoot.getElementById("btnCreateOrder").onclick = () => this.createOrder();
|
|
||||||
|
|
||||||
this.loadProducts();
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadProducts() {
|
|
||||||
try {
|
|
||||||
const result = await api.getProductsWithStock();
|
|
||||||
this.products = result.items || [];
|
|
||||||
console.log(`[test-panel] Loaded ${this.products.length} products with stock`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[test-panel] Error loading products:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async generateRandomOrder() {
|
|
||||||
if (this.products.length === 0) {
|
|
||||||
await this.loadProducts();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.products.length === 0) {
|
|
||||||
modal.warn("No hay productos con stock disponible");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generar usuario aleatorio
|
|
||||||
this.testUser = generateTestUser();
|
|
||||||
|
|
||||||
// Seleccionar 1-3 productos aleatorios
|
|
||||||
const numProducts = randomInt(1, Math.min(3, this.products.length));
|
|
||||||
const shuffled = [...this.products].sort(() => Math.random() - 0.5);
|
|
||||||
this.selectedProducts = [];
|
|
||||||
|
|
||||||
for (let i = 0; i < numProducts; i++) {
|
|
||||||
const product = shuffled[i];
|
|
||||||
const isKg = product.sell_unit === "kg";
|
|
||||||
const quantity = isKg ? randomInt(200, 2000) : randomInt(1, 5);
|
|
||||||
|
|
||||||
this.selectedProducts.push({
|
|
||||||
product_id: product.woo_product_id,
|
|
||||||
name: product.name,
|
|
||||||
quantity,
|
|
||||||
unit: isKg ? "kg" : "unit",
|
|
||||||
price: product.price,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.renderProductList();
|
|
||||||
this.renderUserInfo();
|
|
||||||
this.updateButtonStates();
|
|
||||||
}
|
|
||||||
|
|
||||||
renderProductList() {
|
|
||||||
const container = this.shadowRoot.getElementById("productList");
|
|
||||||
|
|
||||||
if (this.selectedProducts.length === 0) {
|
|
||||||
container.innerHTML = `<div class="empty">Click "Generar Orden Aleatoria" para comenzar</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = this.selectedProducts.map((p, i) => `
|
|
||||||
<div class="product-item">
|
|
||||||
<div class="product-name" title="${p.name}">${p.name}</div>
|
|
||||||
<div class="product-qty">${p.unit === "kg" ? `${p.quantity}g` : `${p.quantity}u`}</div>
|
|
||||||
<div class="product-unit">$${Number(p.price || 0).toFixed(0)}</div>
|
|
||||||
<button class="remove-btn" data-index="${i}">X</button>
|
|
||||||
</div>
|
|
||||||
`).join("");
|
|
||||||
|
|
||||||
container.querySelectorAll(".remove-btn").forEach(btn => {
|
|
||||||
btn.onclick = (e) => {
|
|
||||||
const idx = parseInt(e.target.dataset.index);
|
|
||||||
this.selectedProducts.splice(idx, 1);
|
|
||||||
this.renderProductList();
|
|
||||||
this.updateButtonStates();
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderUserInfo() {
|
|
||||||
const container = this.shadowRoot.getElementById("userInfo");
|
|
||||||
|
|
||||||
if (!this.testUser) {
|
|
||||||
container.innerHTML = `<div class="empty">Se generarán automáticamente</div>`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const addr = this.testUser.address;
|
|
||||||
container.innerHTML = `
|
|
||||||
<div><span>Nombre:</span> ${addr.first_name} ${addr.last_name}</div>
|
|
||||||
<div><span>Dirección:</span> ${addr.address_1}</div>
|
|
||||||
<div><span>Ciudad:</span> ${addr.city}, ${addr.state}</div>
|
|
||||||
<div><span>Teléfono:</span> ${addr.phone}</div>
|
|
||||||
<div><span>Email:</span> ${addr.email}</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateButtonStates() {
|
|
||||||
const hasProducts = this.selectedProducts.length > 0;
|
|
||||||
this.shadowRoot.getElementById("btnCreateOrder").disabled = !hasProducts;
|
|
||||||
}
|
|
||||||
|
|
||||||
async createOrder() {
|
|
||||||
if (this.loading) return;
|
|
||||||
this.loading = true;
|
|
||||||
|
|
||||||
const btn = this.shadowRoot.getElementById("btnCreateOrder");
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = "Creando...";
|
|
||||||
|
|
||||||
try {
|
|
||||||
const basket = {
|
|
||||||
items: this.selectedProducts.map(p => ({
|
|
||||||
product_id: p.product_id,
|
|
||||||
quantity: p.quantity,
|
|
||||||
unit: p.unit,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await api.createTestOrder({
|
|
||||||
basket,
|
|
||||||
address: this.testUser?.address || null,
|
|
||||||
wa_chat_id: this.testUser?.wa_chat_id || null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.ok) {
|
|
||||||
this.lastOrder = result;
|
|
||||||
this.shadowRoot.getElementById("orderIdValue").textContent = `#${result.woo_order_id}`;
|
|
||||||
this.shadowRoot.getElementById("orderTotalValue").textContent = `$${Number(result.total || 0).toFixed(2)}`;
|
|
||||||
this.shadowRoot.getElementById("orderResult").style.display = "block";
|
|
||||||
this.shadowRoot.getElementById("inputAmount").value = result.total || "";
|
|
||||||
} else {
|
|
||||||
modal.error("Error: " + (result.error || "Error desconocido"));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error("[test-panel] createOrder error:", e);
|
|
||||||
modal.error("Error creando orden: " + e.message);
|
|
||||||
} finally {
|
|
||||||
this.loading = false;
|
|
||||||
btn.textContent = "Crear Orden en WooCommerce";
|
|
||||||
this.updateButtonStates();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAll() {
|
|
||||||
this.selectedProducts = [];
|
|
||||||
this.testUser = null;
|
|
||||||
this.lastOrder = null;
|
|
||||||
|
|
||||||
this.renderProductList();
|
|
||||||
this.renderUserInfo();
|
|
||||||
this.updateButtonStates();
|
|
||||||
|
|
||||||
this.shadowRoot.getElementById("orderResult").style.display = "none";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("test-panel", TestPanel);
|
|
||||||
@@ -18,45 +18,45 @@ class UsersCrud extends HTMLElement {
|
|||||||
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
:host { display:block; height:100%; padding:16px; overflow:hidden; }
|
||||||
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
||||||
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
.container { display:grid; grid-template-columns:1fr 1fr; gap:16px; height:100%; }
|
||||||
.panel { background:#121823; border:1px solid #1e2a3a; border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:16px; overflow:hidden; display:flex; flex-direction:column; }
|
||||||
.panel-title { font-size:14px; font-weight:700; color:#8aa0b5; text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
.panel-title { font-size:14px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:.4px; margin-bottom:12px; }
|
||||||
|
|
||||||
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
.toolbar { display:flex; gap:8px; margin-bottom:12px; }
|
||||||
input, select { background:#0f1520; color:#e7eef7; border:1px solid #253245; border-radius:8px; padding:8px 12px; font-size:13px; }
|
input, select { background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi); border-radius:8px; padding:8px 12px; font-size:13px; }
|
||||||
input:focus, select:focus { outline:none; border-color:#1f6feb; }
|
input:focus, select:focus { outline:none; border-color:var(--accent); }
|
||||||
input { flex:1; }
|
input { flex:1; }
|
||||||
button { cursor:pointer; background:#1f6feb; color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
button { cursor:pointer; background:var(--accent); color:#fff; border:none; border-radius:8px; padding:8px 16px; font-size:13px; }
|
||||||
button:hover { background:#1a5fd0; }
|
button:hover { background:var(--accent-hover); }
|
||||||
button:disabled { opacity:.5; cursor:not-allowed; }
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
||||||
button.secondary { background:#253245; }
|
button.secondary { background:var(--border-hi); }
|
||||||
button.secondary:hover { background:#2d3e52; }
|
button.secondary:hover { background:var(--border-hi); }
|
||||||
button.danger { background:#e74c3c; }
|
button.danger { background:var(--err); }
|
||||||
button.danger:hover { background:#c0392b; }
|
button.danger:hover { background:var(--err); }
|
||||||
|
|
||||||
.list { flex:1; overflow-y:auto; }
|
.list { flex:1; overflow-y:auto; }
|
||||||
.item { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
.item { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; margin-bottom:8px; cursor:pointer; transition:all .15s; }
|
||||||
.item:hover { border-color:#1f6feb; }
|
.item:hover { border-color:var(--accent); }
|
||||||
.item.active { border-color:#1f6feb; background:#111b2a; }
|
.item.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.item-name { font-weight:600; color:#e7eef7; margin-bottom:4px; }
|
.item-name { font-weight:600; color:var(--text); margin-bottom:4px; }
|
||||||
.item-meta { font-size:12px; color:#8aa0b5; }
|
.item-meta { font-size:12px; color:var(--text-muted); }
|
||||||
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:#253245; color:#8aa0b5; margin-left:8px; }
|
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; background:var(--border-hi); color:var(--text-muted); margin-left:8px; }
|
||||||
.badge.woo { background:#0f2a1a; color:#2ecc71; }
|
.badge.woo { background:var(--ok-soft); color:var(--ok); }
|
||||||
|
|
||||||
.detail { flex:1; overflow-y:auto; }
|
.detail { flex:1; overflow-y:auto; }
|
||||||
.detail-empty { color:#8aa0b5; text-align:center; padding:40px; }
|
.detail-empty { color:var(--text-muted); text-align:center; padding:40px; }
|
||||||
.field { margin-bottom:16px; }
|
.field { margin-bottom:16px; }
|
||||||
.field label { display:block; font-size:12px; color:#8aa0b5; margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
.field label { display:block; font-size:12px; color:var(--text-muted); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||||
.field-value { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:10px 12px; color:#e7eef7; font-size:13px; }
|
.field-value { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:10px 12px; color:var(--text); font-size:13px; }
|
||||||
|
|
||||||
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
|
.actions { display:flex; gap:8px; margin-top:16px; flex-wrap:wrap; }
|
||||||
.loading { text-align:center; padding:40px; color:#8aa0b5; }
|
.loading { text-align:center; padding:40px; color:var(--text-muted); }
|
||||||
|
|
||||||
.stats { display:flex; gap:16px; margin-bottom:16px; }
|
.stats { display:flex; gap:16px; margin-bottom:16px; }
|
||||||
.stat { background:#0f1520; border:1px solid #253245; border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
.stat { background:var(--panel-2); border:1px solid var(--border-hi); border-radius:8px; padding:12px; flex:1; text-align:center; cursor:pointer; transition:all .15s; }
|
||||||
.stat:hover { border-color:#1f6feb; }
|
.stat:hover { border-color:var(--accent); }
|
||||||
.stat.active { border-color:#1f6feb; background:#111b2a; }
|
.stat.active { border-color:var(--accent); background:var(--accent-soft); }
|
||||||
.stat-value { font-size:24px; font-weight:700; color:#1f6feb; }
|
.stat-value { font-size:24px; font-weight:700; color:var(--accent); }
|
||||||
.stat-label { font-size:11px; color:#8aa0b5; text-transform:uppercase; margin-top:4px; }
|
.stat-label { font-size:11px; color:var(--text-muted); text-transform:uppercase; margin-top:4px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container">
|
<div class="container">
|
||||||
|
|||||||
393
public/components/zone-map-editor.js
Normal file
393
public/components/zone-map-editor.js
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
/**
|
||||||
|
* <zone-map-editor> — editor de zonas de delivery sobre un mapa.
|
||||||
|
*
|
||||||
|
* Light DOM (sin shadow) para que Leaflet (que asume un DOM normal con CSS
|
||||||
|
* global) funcione bien dentro de paneles que sí usan shadow DOM (settings-crud).
|
||||||
|
*
|
||||||
|
* Carga Leaflet 1.9 + leaflet-geoman desde CDN al montar. Las primeras
|
||||||
|
* instancias inyectan los <script>/<link>; las siguientes esperan a que
|
||||||
|
* window.L y window.L.PM estén disponibles.
|
||||||
|
*
|
||||||
|
* API pública:
|
||||||
|
* set zones(arr) // [{id, name, polygon, delivery_cost, delivery_days, ...}]
|
||||||
|
* get zones() // serializado actual con coordenadas GeoJSON
|
||||||
|
* set selectedId(id)
|
||||||
|
* on("change", e => ...) // dispara cuando se crea/edita/borra/renombra
|
||||||
|
* on("select", e => ...) // dispara cuando se hace click en un polígono
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* const ed = document.createElement("zone-map-editor");
|
||||||
|
* ed.zones = [...];
|
||||||
|
* ed.addEventListener("change", e => console.log(e.detail.zones));
|
||||||
|
*/
|
||||||
|
|
||||||
|
const LEAFLET_VERSION = "1.9.4";
|
||||||
|
const GEOMAN_VERSION = "2.18.3";
|
||||||
|
|
||||||
|
const LEAFLET_CSS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.css`;
|
||||||
|
const LEAFLET_JS = `https://unpkg.com/leaflet@${LEAFLET_VERSION}/dist/leaflet.js`;
|
||||||
|
const GEOMAN_CSS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.css`;
|
||||||
|
const GEOMAN_JS = `https://unpkg.com/@geoman-io/leaflet-geoman-free@${GEOMAN_VERSION}/dist/leaflet-geoman.min.js`;
|
||||||
|
|
||||||
|
const DEFAULT_CENTER = [-34.6037, -58.3816]; // Obelisco (lat, lng — Leaflet usa [lat,lng])
|
||||||
|
const DEFAULT_ZOOM = 12;
|
||||||
|
|
||||||
|
const ZONE_PALETTE = [
|
||||||
|
"--chart-blue", "--chart-green", "--chart-purple",
|
||||||
|
"--chart-orange", "--chart-pink", "--chart-gray",
|
||||||
|
];
|
||||||
|
|
||||||
|
let _scriptsPromise = null;
|
||||||
|
function ensureLeafletScripts() {
|
||||||
|
if (window.L && window.L.PM) return Promise.resolve();
|
||||||
|
if (_scriptsPromise) return _scriptsPromise;
|
||||||
|
_scriptsPromise = (async () => {
|
||||||
|
if (!window.L) await loadScript(LEAFLET_JS);
|
||||||
|
if (!window.L.PM) await loadScript(GEOMAN_JS);
|
||||||
|
})();
|
||||||
|
return _scriptsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScript(src) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const existing = document.querySelector(`script[src="${src}"]`);
|
||||||
|
if (existing) {
|
||||||
|
if (existing.dataset.loaded === "1") { resolve(); return; }
|
||||||
|
existing.addEventListener("load", () => resolve());
|
||||||
|
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const s = document.createElement("script");
|
||||||
|
s.src = src;
|
||||||
|
s.async = true;
|
||||||
|
s.onload = () => { s.dataset.loaded = "1"; resolve(); };
|
||||||
|
s.onerror = () => reject(new Error(`Failed to load ${src}`));
|
||||||
|
document.head.appendChild(s);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForCSS(linkEl, timeoutMs = 4000) {
|
||||||
|
if (linkEl.sheet) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(resolve, timeoutMs);
|
||||||
|
linkEl.addEventListener("load", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||||
|
linkEl.addEventListener("error", () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function cssVar(name, fallback = "#0ea5e9") {
|
||||||
|
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return v || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(name) {
|
||||||
|
return String(name || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD").replace(/[̀-ͯ]/g, "")
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 40) || `zona-${Date.now().toString(36)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
class ZoneMapEditor extends HTMLElement {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this._zones = []; // estado interno (con polygon GeoJSON)
|
||||||
|
this._layersById = new Map(); // id -> leaflet layer
|
||||||
|
this._selectedId = null;
|
||||||
|
this._map = null;
|
||||||
|
this._mapDiv = null;
|
||||||
|
this._ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
this.style.display = "block";
|
||||||
|
this.style.position = "relative";
|
||||||
|
this.style.width = "100%";
|
||||||
|
this.style.height = this.getAttribute("height") || "480px";
|
||||||
|
|
||||||
|
// El web component vive en light DOM dentro del shadow root del padre.
|
||||||
|
// Los <link> en document.head NO cruzan shadow boundaries, así que los
|
||||||
|
// anclamos como hijos del propio elemento (sí cruzan, porque slot
|
||||||
|
// composition trae nuestros children al árbol del padre con sus assets).
|
||||||
|
const linkLeaflet = document.createElement("link");
|
||||||
|
linkLeaflet.rel = "stylesheet";
|
||||||
|
linkLeaflet.href = LEAFLET_CSS;
|
||||||
|
this.appendChild(linkLeaflet);
|
||||||
|
const linkGeoman = document.createElement("link");
|
||||||
|
linkGeoman.rel = "stylesheet";
|
||||||
|
linkGeoman.href = GEOMAN_CSS;
|
||||||
|
this.appendChild(linkGeoman);
|
||||||
|
|
||||||
|
this._mapDiv = document.createElement("div");
|
||||||
|
this._mapDiv.style.width = "100%";
|
||||||
|
this._mapDiv.style.height = "100%";
|
||||||
|
this._mapDiv.style.borderRadius = "var(--r-md, 10px)";
|
||||||
|
this._mapDiv.style.overflow = "hidden";
|
||||||
|
this._mapDiv.style.border = "1px solid var(--border, #e2e8f0)";
|
||||||
|
this._mapDiv.style.background = "#e2e8f0";
|
||||||
|
this.appendChild(this._mapDiv);
|
||||||
|
|
||||||
|
ensureLeafletScripts()
|
||||||
|
.then(() => waitForCSS(linkLeaflet))
|
||||||
|
.then(() => this._initMap())
|
||||||
|
.catch((err) => {
|
||||||
|
this._mapDiv.innerHTML = `<div style="padding:16px;color:var(--err);font-family:var(--font-sans);">No se pudo cargar el mapa: ${err.message}</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si el host estaba oculto al montar (router/visibility), Leaflet calcula
|
||||||
|
// 0×0 y los tiles no se piden. Observamos el resize para recalcular.
|
||||||
|
if (typeof ResizeObserver !== "undefined") {
|
||||||
|
this._ro = new ResizeObserver(() => {
|
||||||
|
if (this._map) this._map.invalidateSize();
|
||||||
|
});
|
||||||
|
this._ro.observe(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnectedCallback() {
|
||||||
|
if (this._ro) { this._ro.disconnect(); this._ro = null; }
|
||||||
|
if (this._map) {
|
||||||
|
this._map.remove();
|
||||||
|
this._map = null;
|
||||||
|
}
|
||||||
|
this._layersById.clear();
|
||||||
|
this._ready = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── API pública ─────────────
|
||||||
|
|
||||||
|
get zones() {
|
||||||
|
// Devolver el estado interno con polígonos sincronizados desde los layers.
|
||||||
|
return this._zones.map((z) => ({ ...z, polygon: this._serializePolygon(z.id) || z.polygon || null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
set zones(arr) {
|
||||||
|
const list = Array.isArray(arr) ? arr : [];
|
||||||
|
this._zones = list.map((z) => ({
|
||||||
|
id: z.id || slugify(z.name || ""),
|
||||||
|
name: z.name || "",
|
||||||
|
polygon: z.polygon || null,
|
||||||
|
delivery_cost: z.delivery_cost ?? 0,
|
||||||
|
delivery_days: Array.isArray(z.delivery_days) ? z.delivery_days : ["lun","mar","mie","jue","vie","sab"],
|
||||||
|
delivery_hours: z.delivery_hours || { start: "10:00", end: "20:00" },
|
||||||
|
min_order_amount: z.min_order_amount ?? 0,
|
||||||
|
enabled: z.enabled !== false,
|
||||||
|
}));
|
||||||
|
if (this._ready) this._renderLayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedId() { return this._selectedId; }
|
||||||
|
set selectedId(id) {
|
||||||
|
this._selectedId = id || null;
|
||||||
|
if (!this._ready) return;
|
||||||
|
for (const [zid, layer] of this._layersById) {
|
||||||
|
this._applyLayerStyle(zid, layer, zid === this._selectedId);
|
||||||
|
}
|
||||||
|
if (id && this._layersById.has(id)) {
|
||||||
|
const layer = this._layersById.get(id);
|
||||||
|
try { this._map.fitBounds(layer.getBounds().pad(0.25)); } catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upsertZone(zone) {
|
||||||
|
const idx = this._zones.findIndex((z) => z.id === zone.id);
|
||||||
|
if (idx >= 0) this._zones[idx] = { ...this._zones[idx], ...zone };
|
||||||
|
else this._zones.push(zone);
|
||||||
|
this._renderLayers();
|
||||||
|
this._emit("change");
|
||||||
|
}
|
||||||
|
|
||||||
|
removeZone(id) {
|
||||||
|
this._zones = this._zones.filter((z) => z.id !== id);
|
||||||
|
if (this._layersById.has(id)) {
|
||||||
|
this._map.removeLayer(this._layersById.get(id));
|
||||||
|
this._layersById.delete(id);
|
||||||
|
}
|
||||||
|
if (this._selectedId === id) this._selectedId = null;
|
||||||
|
this._emit("change");
|
||||||
|
}
|
||||||
|
|
||||||
|
startDrawing() {
|
||||||
|
if (!this._ready) return;
|
||||||
|
this._map.pm.enableDraw("Polygon", {
|
||||||
|
snappable: true,
|
||||||
|
templineStyle: { color: cssVar("--chart-blue") },
|
||||||
|
hintlineStyle: { color: cssVar("--chart-blue"), dashArray: [5, 5] },
|
||||||
|
pathOptions: this._defaultPathOptions(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ───────────── Internals ─────────────
|
||||||
|
|
||||||
|
_initMap() {
|
||||||
|
const L = window.L;
|
||||||
|
const center = DEFAULT_CENTER;
|
||||||
|
this._map = L.map(this._mapDiv, {
|
||||||
|
center, zoom: DEFAULT_ZOOM, zoomControl: true, attributionControl: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
||||||
|
}).addTo(this._map);
|
||||||
|
|
||||||
|
// Geoman: solo botón "draw polygon" + edit/delete contextual desde la UI nuestra.
|
||||||
|
this._map.pm.addControls({
|
||||||
|
position: "topright",
|
||||||
|
drawCircle: false, drawCircleMarker: false, drawMarker: false,
|
||||||
|
drawPolyline: false, drawRectangle: false, drawText: false,
|
||||||
|
cutPolygon: false, rotateMode: false,
|
||||||
|
drawPolygon: true, editMode: true, dragMode: false, removalMode: true,
|
||||||
|
});
|
||||||
|
this._map.pm.setLang("es");
|
||||||
|
this._map.pm.setGlobalOptions({
|
||||||
|
pathOptions: this._defaultPathOptions(),
|
||||||
|
snappable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listeners globales de geoman.
|
||||||
|
this._map.on("pm:create", (e) => this._handleCreate(e));
|
||||||
|
this._map.on("pm:remove", (e) => this._handleRemove(e));
|
||||||
|
|
||||||
|
this._ready = true;
|
||||||
|
this._renderLayers();
|
||||||
|
// Forzar un invalidateSize después del primer paint por si el contenedor
|
||||||
|
// recién obtuvo tamaño (panel oculto inicial / tabs / etc).
|
||||||
|
requestAnimationFrame(() => this._map && this._map.invalidateSize());
|
||||||
|
setTimeout(() => this._map && this._map.invalidateSize(), 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
_renderLayers() {
|
||||||
|
if (!this._ready) return;
|
||||||
|
const L = window.L;
|
||||||
|
// Borrar layers que ya no existen.
|
||||||
|
for (const [id, layer] of this._layersById) {
|
||||||
|
if (!this._zones.some((z) => z.id === id)) {
|
||||||
|
this._map.removeLayer(layer);
|
||||||
|
this._layersById.delete(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Crear/actualizar.
|
||||||
|
this._zones.forEach((z, i) => {
|
||||||
|
if (!z.polygon || !Array.isArray(z.polygon.coordinates)) return;
|
||||||
|
const latlngs = z.polygon.coordinates[0].map(([lng, lat]) => [lat, lng]);
|
||||||
|
const existing = this._layersById.get(z.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.setLatLngs(latlngs);
|
||||||
|
this._applyLayerStyle(z.id, existing, z.id === this._selectedId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
|
||||||
|
const layer = L.polygon(latlngs, this._pathOptions(color, z.id === this._selectedId)).addTo(this._map);
|
||||||
|
layer.bindTooltip(z.name || z.id, { sticky: true });
|
||||||
|
layer.on("click", () => this._select(z.id));
|
||||||
|
layer.on("pm:edit", () => this._handleEdit(z.id, layer));
|
||||||
|
this._layersById.set(z.id, layer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_select(id) {
|
||||||
|
this._selectedId = id;
|
||||||
|
for (const [zid, layer] of this._layersById) {
|
||||||
|
this._applyLayerStyle(zid, layer, zid === id);
|
||||||
|
}
|
||||||
|
this._emit("select", { id });
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleCreate(e) {
|
||||||
|
const layer = e.layer;
|
||||||
|
const id = `zona-${Date.now().toString(36)}`;
|
||||||
|
const i = this._zones.length;
|
||||||
|
const color = cssVar(ZONE_PALETTE[i % ZONE_PALETTE.length]);
|
||||||
|
layer.setStyle(this._pathOptions(color, true));
|
||||||
|
|
||||||
|
const polygon = this._toGeoJSONPolygon(layer.getLatLngs());
|
||||||
|
const zone = {
|
||||||
|
id,
|
||||||
|
name: `Zona ${i + 1}`,
|
||||||
|
polygon,
|
||||||
|
delivery_cost: 0,
|
||||||
|
delivery_days: ["lun","mar","mie","jue","vie","sab"],
|
||||||
|
delivery_hours: { start: "10:00", end: "20:00" },
|
||||||
|
min_order_amount: 0,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
this._zones.push(zone);
|
||||||
|
this._layersById.set(id, layer);
|
||||||
|
layer.bindTooltip(zone.name, { sticky: true });
|
||||||
|
layer.on("click", () => this._select(id));
|
||||||
|
layer.on("pm:edit", () => this._handleEdit(id, layer));
|
||||||
|
this._select(id);
|
||||||
|
this._emit("change");
|
||||||
|
this._emit("create", { id, zone });
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleEdit(id, layer) {
|
||||||
|
const z = this._zones.find((x) => x.id === id);
|
||||||
|
if (!z) return;
|
||||||
|
z.polygon = this._toGeoJSONPolygon(layer.getLatLngs());
|
||||||
|
this._emit("change");
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleRemove(e) {
|
||||||
|
const layer = e.layer;
|
||||||
|
let removedId = null;
|
||||||
|
for (const [id, l] of this._layersById) {
|
||||||
|
if (l === layer) { removedId = id; break; }
|
||||||
|
}
|
||||||
|
if (!removedId) return;
|
||||||
|
this._zones = this._zones.filter((z) => z.id !== removedId);
|
||||||
|
this._layersById.delete(removedId);
|
||||||
|
if (this._selectedId === removedId) this._selectedId = null;
|
||||||
|
this._emit("change");
|
||||||
|
}
|
||||||
|
|
||||||
|
_serializePolygon(id) {
|
||||||
|
const layer = this._layersById.get(id);
|
||||||
|
if (!layer) return null;
|
||||||
|
return this._toGeoJSONPolygon(layer.getLatLngs());
|
||||||
|
}
|
||||||
|
|
||||||
|
_toGeoJSONPolygon(latlngs) {
|
||||||
|
// Leaflet pasa anidado: [[{lat,lng}...]] para polígonos simples.
|
||||||
|
const ring = Array.isArray(latlngs[0]) ? latlngs[0] : latlngs;
|
||||||
|
const coords = ring.map((p) => [p.lng, p.lat]);
|
||||||
|
// Cerrar el anillo si no está cerrado (GeoJSON lo requiere).
|
||||||
|
const first = coords[0];
|
||||||
|
const last = coords[coords.length - 1];
|
||||||
|
if (!first || !last || first[0] !== last[0] || first[1] !== last[1]) {
|
||||||
|
if (first) coords.push([first[0], first[1]]);
|
||||||
|
}
|
||||||
|
return { type: "Polygon", coordinates: [coords] };
|
||||||
|
}
|
||||||
|
|
||||||
|
_defaultPathOptions() {
|
||||||
|
return this._pathOptions(cssVar("--chart-blue"), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
_pathOptions(color, selected) {
|
||||||
|
return {
|
||||||
|
color,
|
||||||
|
weight: selected ? 3 : 2,
|
||||||
|
opacity: 0.95,
|
||||||
|
fillColor: color,
|
||||||
|
fillOpacity: selected ? 0.28 : 0.18,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyLayerStyle(id, layer, selected) {
|
||||||
|
const i = this._zones.findIndex((z) => z.id === id);
|
||||||
|
const color = cssVar(ZONE_PALETTE[(i < 0 ? 0 : i) % ZONE_PALETTE.length]);
|
||||||
|
layer.setStyle(this._pathOptions(color, selected));
|
||||||
|
}
|
||||||
|
|
||||||
|
_emit(name, detail = {}) {
|
||||||
|
detail = { zones: this.zones, ...detail };
|
||||||
|
this.dispatchEvent(new CustomEvent(name, { detail, bubbles: true, composed: true }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!customElements.get("zone-map-editor")) {
|
||||||
|
customElements.define("zone-map-editor", ZoneMapEditor);
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
<title>Bot Ops Console</title>
|
<title>Piaf Console</title>
|
||||||
|
<link rel="stylesheet" href="/styles/theme.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<ops-shell></ops-shell>
|
<ops-shell></ops-shell>
|
||||||
|
|||||||
@@ -1,3 +1,35 @@
|
|||||||
|
import { toast } from "./toast.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* fetch wrapper que dispara toast en error de red o respuesta no-OK.
|
||||||
|
* Devuelve la respuesta parseada como JSON. Si la respuesta tiene
|
||||||
|
* `{ ok: false, error }`, también dispara toast.
|
||||||
|
*/
|
||||||
|
export async function safeFetch(url, opts = {}, { silent = false, label = null } = {}) {
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { credentials: "include", ...opts });
|
||||||
|
} catch (err) {
|
||||||
|
if (!silent) toast({ kind: "error", text: `${label || "Red"}: ${err?.message || "sin conexión"}` });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
let body = null;
|
||||||
|
try { body = await res.json(); } catch (_) {}
|
||||||
|
const msg = body?.error || body?.message || `${res.status} ${res.statusText}`;
|
||||||
|
if (!silent) toast({ kind: "error", text: `${label || "Error"}: ${msg}` });
|
||||||
|
const err = new Error(msg);
|
||||||
|
err.status = res.status;
|
||||||
|
err.body = body;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
async conversations({ q = "", status = "", state = "" } = {}) {
|
async conversations({ q = "", status = "", state = "" } = {}) {
|
||||||
const u = new URL("/conversations", location.origin);
|
const u = new URL("/conversations", location.origin);
|
||||||
@@ -46,16 +78,16 @@ export const api = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
async simEvolution(payload) {
|
async simEvolution(payload) {
|
||||||
return fetch("/webhook/evolution", {
|
return safeFetch("/webhook/evolution", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
}).then(r => r.json());
|
}, { label: "Sim Evolution" });
|
||||||
},
|
},
|
||||||
|
|
||||||
async retryLast(chat_id) {
|
async retryLast(chat_id) {
|
||||||
if (!chat_id) throw new Error("chat_id_required");
|
if (!chat_id) throw new Error("chat_id_required");
|
||||||
return fetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }).then(r => r.json());
|
return safeFetch(`/conversations/${encodeURIComponent(chat_id)}/retry-last`, { method: "POST" }, { label: "Retry" });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Products CRUD
|
// Products CRUD
|
||||||
@@ -202,18 +234,6 @@ export const api = {
|
|||||||
return this.listOrders({ page: 1, limit });
|
return this.listOrders({ page: 1, limit });
|
||||||
},
|
},
|
||||||
|
|
||||||
async getProductsWithStock() {
|
|
||||||
return fetch("/test/products-with-stock").then(r => r.json());
|
|
||||||
},
|
|
||||||
|
|
||||||
async createTestOrder({ basket, address, wa_chat_id }) {
|
|
||||||
return fetch("/test/order", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ basket, address, wa_chat_id }),
|
|
||||||
}).then(r => r.json());
|
|
||||||
},
|
|
||||||
|
|
||||||
// --- Prompts CRUD ---
|
// --- Prompts CRUD ---
|
||||||
async prompts() {
|
async prompts() {
|
||||||
return fetch("/prompts").then(r => r.json());
|
return fetch("/prompts").then(r => r.json());
|
||||||
@@ -293,4 +313,52 @@ export const api = {
|
|||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Auth ---
|
||||||
|
async me() {
|
||||||
|
const res = await fetch("/api/auth/me", { credentials: "include" });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = await res.json();
|
||||||
|
return data?.user || null;
|
||||||
|
},
|
||||||
|
async logout() {
|
||||||
|
return fetch("/api/auth/logout", { method: "POST", credentials: "include" }).then(r => r.json());
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- System users (operadores) ---
|
||||||
|
async listSystemUsers() {
|
||||||
|
return safeFetch("/api/system-users", {}, { label: "Operadores" });
|
||||||
|
},
|
||||||
|
async createSystemUser({ email, name, password, active = true }) {
|
||||||
|
return safeFetch("/api/system-users", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, name, password, active }),
|
||||||
|
}, { label: "Crear operador" });
|
||||||
|
},
|
||||||
|
async updateSystemUser(id, payload) {
|
||||||
|
return safeFetch(`/api/system-users/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
}, { label: "Editar operador" });
|
||||||
|
},
|
||||||
|
async deleteSystemUser(id) {
|
||||||
|
return safeFetch(`/api/system-users/${id}`, { method: "DELETE" }, { label: "Eliminar operador" });
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Audit log ---
|
||||||
|
async auditLog({ limit = 50, before, since, actor_id, entity_type, q } = {}) {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
qs.set("limit", String(limit));
|
||||||
|
if (before) qs.set("before", before);
|
||||||
|
if (since) qs.set("since", since);
|
||||||
|
if (actor_id) qs.set("actor_id", String(actor_id));
|
||||||
|
if (entity_type) qs.set("entity_type", entity_type);
|
||||||
|
if (q) qs.set("q", q);
|
||||||
|
return safeFetch(`/api/audit-log?${qs.toString()}`, {}, { silent: true });
|
||||||
|
},
|
||||||
|
async auditActors() {
|
||||||
|
return safeFetch("/api/audit-log/actors", {}, { silent: true });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,114 +11,81 @@
|
|||||||
|
|
||||||
const STYLES = `
|
const STYLES = `
|
||||||
.modal-overlay {
|
.modal-overlay {
|
||||||
position: fixed;
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
top: 0;
|
background: rgba(15, 23, 42, 0.45);
|
||||||
left: 0;
|
backdrop-filter: blur(2px);
|
||||||
right: 0;
|
display: flex; align-items: center; justify-content: center;
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.5);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
animation: fadeIn 0.15s ease-out;
|
animation: fadeIn 0.15s ease-out;
|
||||||
|
font-family: var(--font-sans, system-ui);
|
||||||
}
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; }
|
|
||||||
to { opacity: 1; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes slideIn {
|
@keyframes slideIn {
|
||||||
from { transform: translateY(-20px); opacity: 0; }
|
from { transform: translateY(-12px); opacity: 0; }
|
||||||
to { transform: translateY(0); opacity: 1; }
|
to { transform: translateY(0); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-box {
|
.modal-box {
|
||||||
background: #1e1e1e;
|
background: var(--panel, #ffffff);
|
||||||
border-radius: 8px;
|
border-radius: var(--r-lg, 12px);
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
max-width: 480px;
|
max-width: 480px;
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
box-shadow: var(--shadow-lg, 0 12px 28px rgba(15,23,42,.10));
|
||||||
|
border: 1px solid var(--border, #e2e8f0);
|
||||||
animation: slideIn 0.2s ease-out;
|
animation: slideIn 0.2s ease-out;
|
||||||
border: 1px solid #333;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-header {
|
.modal-header {
|
||||||
display: flex;
|
display: flex; align-items: center; gap: 12px;
|
||||||
align-items: center;
|
margin-bottom: 14px;
|
||||||
gap: 12px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-icon {
|
.modal-icon {
|
||||||
width: 28px;
|
width: 32px; height: 32px; border-radius: 50%;
|
||||||
height: 28px;
|
display: flex; align-items: center; justify-content: center;
|
||||||
border-radius: 50%;
|
font-size: 16px; font-weight: 700;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-size: 16px;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
.modal-icon.success { background: var(--ok-soft, #d1fae5); color: var(--ok, #10b981); }
|
||||||
.modal-icon.success { background: #22c55e20; color: #22c55e; }
|
.modal-icon.error { background: var(--err-soft, #fee2e2); color: var(--err, #ef4444); }
|
||||||
.modal-icon.error { background: #ef444420; color: #ef4444; }
|
.modal-icon.warn { background: var(--warn-soft, #fef3c7); color: var(--warn, #f59e0b); }
|
||||||
.modal-icon.warn { background: #f59e0b20; color: #f59e0b; }
|
.modal-icon.info { background: var(--accent-soft, #e0f2fe); color: var(--accent, #0ea5e9); }
|
||||||
.modal-icon.info { background: #3b82f620; color: #3b82f6; }
|
.modal-icon.confirm { background: var(--accent-soft, #e0f2fe); color: var(--accent-hover, #0284c7); }
|
||||||
.modal-icon.confirm { background: #8b5cf620; color: #8b5cf6; }
|
|
||||||
|
|
||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 16px;
|
font-size: 16px; font-weight: 600;
|
||||||
font-weight: 600;
|
color: var(--text, #0f172a);
|
||||||
color: #fff;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-message {
|
.modal-message {
|
||||||
color: #ccc;
|
color: var(--text-dim, #475569);
|
||||||
font-size: 14px;
|
font-size: 14px; line-height: 1.5;
|
||||||
line-height: 1.5;
|
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-buttons {
|
.modal-buttons {
|
||||||
display: flex;
|
display: flex; gap: 8px; justify-content: flex-end;
|
||||||
gap: 12px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-btn {
|
.modal-btn {
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: var(--r-md, 10px);
|
||||||
font-size: 14px;
|
font-size: 13px; font-weight: 500;
|
||||||
font-weight: 500;
|
cursor: pointer; border: 1px solid transparent;
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
.modal-btn:focus-visible { outline: none; box-shadow: var(--focus-ring, 0 0 0 3px rgba(14,165,233,.3)); }
|
||||||
.modal-btn:hover {
|
|
||||||
filter: brightness(1.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-btn.primary {
|
.modal-btn.primary {
|
||||||
background: #3b82f6;
|
background: var(--accent, #0ea5e9); color: var(--text-on-acc, #fff);
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
.modal-btn.primary:hover { background: var(--accent-hover, #0284c7); }
|
||||||
.modal-btn.secondary {
|
.modal-btn.secondary {
|
||||||
background: #333;
|
background: var(--panel, #fff); color: var(--text, #0f172a);
|
||||||
color: #ccc;
|
border-color: var(--border-hi, #cbd5e1);
|
||||||
border: 1px solid #444;
|
|
||||||
}
|
}
|
||||||
|
.modal-btn.secondary:hover { border-color: var(--accent, #0ea5e9); color: var(--accent-hover, #0284c7); }
|
||||||
.modal-btn.danger {
|
.modal-btn.danger {
|
||||||
background: #ef4444;
|
background: var(--err, #ef4444); color: #fff;
|
||||||
color: white;
|
|
||||||
}
|
}
|
||||||
|
.modal-btn.danger:hover { filter: brightness(0.95); }
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Inyectar estilos una sola vez
|
// Inyectar estilos una sola vez
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ const ROUTES = [
|
|||||||
{ pattern: /^\/cantidades$/, view: "quantities", params: [] },
|
{ pattern: /^\/cantidades$/, view: "quantities", params: [] },
|
||||||
{ pattern: /^\/pedidos$/, view: "orders", params: [] },
|
{ pattern: /^\/pedidos$/, view: "orders", params: [] },
|
||||||
{ pattern: /^\/pedidos\/([^/]+)$/, view: "orders", params: ["id"] },
|
{ pattern: /^\/pedidos\/([^/]+)$/, view: "orders", params: ["id"] },
|
||||||
{ pattern: /^\/test$/, view: "test", params: [] },
|
|
||||||
{ pattern: /^\/config-prompts$/, view: "prompts", params: [] },
|
{ pattern: /^\/config-prompts$/, view: "prompts", params: [] },
|
||||||
{ pattern: /^\/atencion-humana$/, view: "takeovers", params: [] },
|
{ pattern: /^\/atencion-humana$/, view: "takeovers", params: [] },
|
||||||
{ pattern: /^\/configuracion$/, view: "settings", params: [] },
|
{ pattern: /^\/configuracion$/, view: "settings", params: [] },
|
||||||
|
{ pattern: /^\/operadores$/, view: "operadores", params: [] },
|
||||||
|
{ pattern: /^\/actividad$/, view: "actividad", params: [] },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mapeo de vistas a rutas base (para navegación sin parámetros)
|
// Mapeo de vistas a rutas base (para navegación sin parámetros)
|
||||||
@@ -33,10 +34,11 @@ const VIEW_TO_PATH = {
|
|||||||
crosssell: "/crosssell",
|
crosssell: "/crosssell",
|
||||||
quantities: "/cantidades",
|
quantities: "/cantidades",
|
||||||
orders: "/pedidos",
|
orders: "/pedidos",
|
||||||
test: "/test",
|
|
||||||
prompts: "/config-prompts",
|
prompts: "/config-prompts",
|
||||||
takeovers: "/atencion-humana",
|
takeovers: "/atencion-humana",
|
||||||
settings: "/configuracion",
|
settings: "/configuracion",
|
||||||
|
operadores: "/operadores",
|
||||||
|
actividad: "/actividad",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,15 +1,71 @@
|
|||||||
import { emit } from "./bus.js";
|
import { emit } from "./bus.js";
|
||||||
|
|
||||||
export function connectSSE() {
|
/**
|
||||||
const es = new EventSource("/stream");
|
* SSE client con reconnect exponencial y try-catch en parseo.
|
||||||
|
* Si el server reinicia o un evento viene malformado, no rompe la app.
|
||||||
|
*/
|
||||||
|
|
||||||
es.addEventListener("hello", () => emit("sse:status", { ok: true }));
|
let _es = null;
|
||||||
es.addEventListener("conversation.upsert", (e) => emit("conversation:upsert", JSON.parse(e.data)));
|
let _retryDelay = 1000;
|
||||||
es.addEventListener("run.created", (e) => emit("run:created", JSON.parse(e.data)));
|
let _retryTimer = null;
|
||||||
es.addEventListener("takeover.created", (e) => emit("takeover:created", JSON.parse(e.data)));
|
const MAX_RETRY = 30_000;
|
||||||
es.addEventListener("order.created", (e) => emit("order:created", JSON.parse(e.data)));
|
|
||||||
|
|
||||||
es.onerror = () => emit("sse:status", { ok: false });
|
const EVENTS = [
|
||||||
|
["conversation.upsert", "conversation:upsert"],
|
||||||
|
["run.created", "run:created"],
|
||||||
|
["takeover.created", "takeover:created"],
|
||||||
|
["order.created", "order:created"],
|
||||||
|
];
|
||||||
|
|
||||||
return es;
|
function safeParse(rawData, evName) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(rawData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[sse] bad payload for ${evName}:`, err?.message || err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(es) {
|
||||||
|
es.addEventListener("hello", () => {
|
||||||
|
_retryDelay = 1000; // reset on success
|
||||||
|
emit("sse:status", { ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const [serverName, busName] of EVENTS) {
|
||||||
|
es.addEventListener(serverName, (e) => {
|
||||||
|
const data = safeParse(e.data, serverName);
|
||||||
|
if (data !== null) emit(busName, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
emit("sse:status", { ok: false });
|
||||||
|
try { es.close(); } catch (_) {}
|
||||||
|
if (_es === es) _es = null;
|
||||||
|
scheduleReconnect();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReconnect() {
|
||||||
|
if (_retryTimer) return;
|
||||||
|
const delay = _retryDelay;
|
||||||
|
_retryTimer = setTimeout(() => {
|
||||||
|
_retryTimer = null;
|
||||||
|
connectSSE();
|
||||||
|
}, delay);
|
||||||
|
_retryDelay = Math.min(_retryDelay * 2, MAX_RETRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectSSE() {
|
||||||
|
if (_retryTimer) {
|
||||||
|
clearTimeout(_retryTimer);
|
||||||
|
_retryTimer = null;
|
||||||
|
}
|
||||||
|
if (_es) {
|
||||||
|
try { _es.close(); } catch (_) {}
|
||||||
|
}
|
||||||
|
_es = new EventSource("/stream");
|
||||||
|
attach(_es);
|
||||||
|
return _es;
|
||||||
}
|
}
|
||||||
|
|||||||
95
public/lib/toast.js
Normal file
95
public/lib/toast.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
/**
|
||||||
|
* Toast service global. Sin dependencias.
|
||||||
|
* Inyecta un container en <body> y empuja toasts apilados.
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* import { toast } from "./toast.js";
|
||||||
|
* toast({ kind: "error", text: "No se pudo guardar" });
|
||||||
|
* toast({ kind: "ok", text: "Listo", ms: 2000 });
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Lee var del :root con fallback. Permite que la paleta de toasts se adapte
|
||||||
|
// al tema sin que el archivo conozca los hex.
|
||||||
|
function v(name, fallback) {
|
||||||
|
try {
|
||||||
|
const c = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||||
|
return c || fallback;
|
||||||
|
} catch { return fallback; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function kindColors() {
|
||||||
|
return {
|
||||||
|
error: { bg: v("--err-soft", "#fee2e2"), border: v("--err", "#ef4444"), text: v("--err-text", "#7f1d1d") },
|
||||||
|
ok: { bg: v("--ok-soft", "#d1fae5"), border: v("--ok", "#10b981"), text: v("--user-text", "#064e3b") },
|
||||||
|
warn: { bg: v("--warn-soft", "#fef3c7"), border: v("--warn", "#f59e0b"), text: v("--text", "#0f172a") },
|
||||||
|
info: { bg: v("--accent-soft","#e0f2fe"),border: v("--accent","#0ea5e9"),text: v("--bot-text", "#1e3a8a") },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let _container = null;
|
||||||
|
|
||||||
|
function ensureContainer() {
|
||||||
|
if (_container) return _container;
|
||||||
|
_container = document.createElement("div");
|
||||||
|
_container.id = "toast-stack";
|
||||||
|
Object.assign(_container.style, {
|
||||||
|
position: "fixed",
|
||||||
|
right: "16px",
|
||||||
|
bottom: "16px",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "8px",
|
||||||
|
zIndex: "9999",
|
||||||
|
pointerEvents: "none",
|
||||||
|
maxWidth: "420px",
|
||||||
|
});
|
||||||
|
document.body.appendChild(_container);
|
||||||
|
return _container;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toast({ kind = "error", text = "", ms = 4000 } = {}) {
|
||||||
|
if (!text) return;
|
||||||
|
const COLORS = kindColors();
|
||||||
|
const colors = COLORS[kind] || COLORS.info;
|
||||||
|
const el = document.createElement("div");
|
||||||
|
Object.assign(el.style, {
|
||||||
|
background: colors.bg,
|
||||||
|
border: `1px solid ${colors.border}`,
|
||||||
|
color: colors.text,
|
||||||
|
padding: "12px 16px",
|
||||||
|
borderRadius: "12px",
|
||||||
|
fontSize: "13px",
|
||||||
|
fontWeight: "500",
|
||||||
|
fontFamily: "var(--font-sans, system-ui)",
|
||||||
|
boxShadow: "var(--shadow-md, 0 4px 12px rgba(15,23,42,.06))",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
cursor: "pointer",
|
||||||
|
transform: "translateX(120%)",
|
||||||
|
transition: "transform .25s ease, opacity .25s ease",
|
||||||
|
opacity: "0",
|
||||||
|
wordBreak: "break-word",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
});
|
||||||
|
el.textContent = String(text);
|
||||||
|
|
||||||
|
const c = ensureContainer();
|
||||||
|
c.appendChild(el);
|
||||||
|
|
||||||
|
// Animar entrada
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el.style.transform = "translateX(0)";
|
||||||
|
el.style.opacity = "1";
|
||||||
|
});
|
||||||
|
|
||||||
|
const dismiss = () => {
|
||||||
|
el.style.transform = "translateX(120%)";
|
||||||
|
el.style.opacity = "0";
|
||||||
|
setTimeout(() => el.remove(), 280);
|
||||||
|
};
|
||||||
|
el.addEventListener("click", dismiss);
|
||||||
|
setTimeout(dismiss, Math.max(800, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toastError(text) { toast({ kind: "error", text }); }
|
||||||
|
export function toastOk(text) { toast({ kind: "ok", text, ms: 2500 }); }
|
||||||
|
export function toastWarn(text) { toast({ kind: "warn", text }); }
|
||||||
107
public/login.html
Normal file
107
public/login.html
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>Piaf Console — Login</title>
|
||||||
|
<link rel="stylesheet" href="/styles/theme.css" />
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--r-lg);
|
||||||
|
padding: 32px;
|
||||||
|
box-shadow: var(--shadow-md);
|
||||||
|
}
|
||||||
|
.login-card h1 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.login-card .subtitle {
|
||||||
|
margin: 0 0 24px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.field { margin-bottom: 16px; }
|
||||||
|
.field label {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
}
|
||||||
|
.field input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: var(--panel-2);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border-hi);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 10px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.field input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--text-on-acc, #fff);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
padding: 11px 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
button:hover { background: var(--accent-hover); }
|
||||||
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.error {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--err-soft);
|
||||||
|
border: 1px solid var(--err);
|
||||||
|
color: var(--err-text, #7f1d1d);
|
||||||
|
border-radius: var(--r-md);
|
||||||
|
font-size: 13px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.error.visible { display: block; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form class="login-card" id="loginForm" novalidate>
|
||||||
|
<h1>Piaf Console</h1>
|
||||||
|
<p class="subtitle">Iniciá sesión para continuar</p>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input id="email" name="email" type="email" autocomplete="username" required autofocus />
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Contraseña</label>
|
||||||
|
<input id="password" name="password" type="password" autocomplete="current-password" required />
|
||||||
|
</div>
|
||||||
|
<button type="submit" id="submitBtn">Entrar</button>
|
||||||
|
<div class="error" id="errorBox"></div>
|
||||||
|
</form>
|
||||||
|
<script type="module" src="/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
58
public/login.js
Normal file
58
public/login.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
const form = document.getElementById("loginForm");
|
||||||
|
const errorBox = document.getElementById("errorBox");
|
||||||
|
const submitBtn = document.getElementById("submitBtn");
|
||||||
|
|
||||||
|
const ERROR_MESSAGES = {
|
||||||
|
missing_credentials: "Completá email y contraseña.",
|
||||||
|
invalid_credentials: "Email o contraseña incorrectos.",
|
||||||
|
user_inactive: "El usuario está deshabilitado.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function showError(code) {
|
||||||
|
errorBox.textContent = ERROR_MESSAGES[code] || "No pudimos iniciarte sesión.";
|
||||||
|
errorBox.classList.add("visible");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearError() {
|
||||||
|
errorBox.classList.remove("visible");
|
||||||
|
errorBox.textContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
clearError();
|
||||||
|
const email = form.email.value.trim();
|
||||||
|
const password = form.password.value;
|
||||||
|
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.textContent = "Entrando...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/auth/login", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: "include",
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok || !data.ok) {
|
||||||
|
showError(data.error || "login_failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = new URLSearchParams(window.location.search).get("next") || "/home";
|
||||||
|
window.location.replace(next);
|
||||||
|
} catch (err) {
|
||||||
|
showError("network");
|
||||||
|
} finally {
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.textContent = "Entrar";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si ya hay sesión activa, ir directo al home.
|
||||||
|
fetch("/api/auth/me", { credentials: "include" })
|
||||||
|
.then((r) => (r.ok ? r.json() : null))
|
||||||
|
.then((d) => {
|
||||||
|
if (d?.ok) window.location.replace("/home");
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
@@ -81,7 +81,7 @@ async function processMessage({ chat_id, from, text }) {
|
|||||||
// Minimal simulated LLM output (replace later)
|
// Minimal simulated LLM output (replace later)
|
||||||
const plan = {
|
const plan = {
|
||||||
reply: `Recibido: "${text}". ¿Querés retiro o envío?`,
|
reply: `Recibido: "${text}". ¿Querés retiro o envío?`,
|
||||||
next_state: "BUILDING_ORDER",
|
next_state: "CART",
|
||||||
intent: "create_order",
|
intent: "create_order",
|
||||||
missing_fields: ["delivery_or_pickup"],
|
missing_fields: ["delivery_or_pickup"],
|
||||||
order_action: "none",
|
order_action: "none",
|
||||||
@@ -92,7 +92,6 @@ async function processMessage({ chat_id, from, text }) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
checks: [
|
checks: [
|
||||||
{ name: "required_keys_present", ok: true },
|
{ name: "required_keys_present", ok: true },
|
||||||
{ name: "no_checkout_without_payment_link", ok: true },
|
|
||||||
{ name: "no_order_action_without_items", ok: true },
|
{ name: "no_order_action_without_items", ok: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -110,7 +109,6 @@ async function processMessage({ chat_id, from, text }) {
|
|||||||
invariants,
|
invariants,
|
||||||
final_reply: plan.reply,
|
final_reply: plan.reply,
|
||||||
order_id: null,
|
order_id: null,
|
||||||
payment_link: null,
|
|
||||||
latency_ms: Date.now() - started_at,
|
latency_ms: Date.now() - started_at,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
BIN
public/styles/fonts/Inter-Variable.woff2
Normal file
BIN
public/styles/fonts/Inter-Variable.woff2
Normal file
Binary file not shown.
1483
public/styles/fonts/JetBrainsMono-Variable.woff2
Normal file
1483
public/styles/fonts/JetBrainsMono-Variable.woff2
Normal file
File diff suppressed because one or more lines are too long
156
public/styles/theme.css
Normal file
156
public/styles/theme.css
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* Tema global Botino — light pastel frío (azul/verde).
|
||||||
|
*
|
||||||
|
* CSS custom properties heredan a través del shadow DOM, así que cualquier
|
||||||
|
* componente puede usar `var(--bg)` sin re-declarar nada.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────
|
||||||
|
* Tipografía: Inter + JetBrains Mono (variable, self-hosted)
|
||||||
|
* ──────────────────────────────────────────────────────────── */
|
||||||
|
@font-face {
|
||||||
|
font-family: "Inter";
|
||||||
|
src: url("/styles/fonts/Inter-Variable.woff2") format("woff2");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "JetBrains Mono";
|
||||||
|
src: url("/styles/fonts/JetBrainsMono-Variable.woff2") format("woff2");
|
||||||
|
font-weight: 100 900;
|
||||||
|
font-style: normal;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────
|
||||||
|
* Tokens de diseño
|
||||||
|
* ──────────────────────────────────────────────────────────── */
|
||||||
|
:root {
|
||||||
|
/* Surfaces */
|
||||||
|
--bg: #f7fafc; /* casi blanco con tinte azul muy sutil */
|
||||||
|
--panel: #ffffff; /* tarjetas / paneles principales */
|
||||||
|
--panel-2: #f1f5f9; /* slate-100 */
|
||||||
|
--panel-3: #e2e8f0; /* slate-200 */
|
||||||
|
|
||||||
|
/* Borders */
|
||||||
|
--border: #e2e8f0; /* slate-200 */
|
||||||
|
--border-hi: #cbd5e1; /* slate-300 */
|
||||||
|
--border-active:#0ea5e9; /* sky-500 */
|
||||||
|
|
||||||
|
/* Text */
|
||||||
|
--text: #0f172a; /* slate-900 */
|
||||||
|
--text-dim: #475569; /* slate-600 */
|
||||||
|
--text-muted: #64748b; /* slate-500 */
|
||||||
|
--text-on-acc: #ffffff;
|
||||||
|
--muted: var(--text-muted);
|
||||||
|
|
||||||
|
/* Accents — azul/verde fríos como protagonistas */
|
||||||
|
--accent: #0ea5e9; /* sky-500 */
|
||||||
|
--accent-hover: #0284c7; /* sky-600 */
|
||||||
|
--accent-soft: #e0f2fe; /* sky-100 */
|
||||||
|
--ok: #10b981; /* emerald-500 */
|
||||||
|
--ok-soft: #d1fae5; /* emerald-100 */
|
||||||
|
--warn: #f59e0b; /* amber-500 */
|
||||||
|
--warn-soft: #fef3c7;
|
||||||
|
--err: #ef4444; /* red-500 */
|
||||||
|
--err-soft: #fee2e2;
|
||||||
|
|
||||||
|
/* Bubbles — light pastel */
|
||||||
|
--user-bubble: #d1fae5; /* emerald-100 (cliente) */
|
||||||
|
--user-border: #6ee7b7; /* emerald-300 */
|
||||||
|
--user-text: #064e3b; /* emerald-900 */
|
||||||
|
--user-name: #047857; /* emerald-700 */
|
||||||
|
--user-meta: #059669; /* emerald-600 */
|
||||||
|
|
||||||
|
--bot-bubble: #dbeafe; /* blue-100 (bot) */
|
||||||
|
--bot-border: #93c5fd; /* blue-300 */
|
||||||
|
--bot-text: #1e3a8a; /* blue-900 */
|
||||||
|
--bot-name: #1d4ed8; /* blue-700 */
|
||||||
|
--bot-meta: #2563eb; /* blue-600 */
|
||||||
|
|
||||||
|
--err-bubble: #fee2e2;
|
||||||
|
--err-border: #fca5a5;
|
||||||
|
--err-text: #7f1d1d;
|
||||||
|
--err-name: #b91c1c;
|
||||||
|
--err-meta: #dc2626;
|
||||||
|
|
||||||
|
/* Charts — paleta pastel coordinada (~400 saturación) */
|
||||||
|
--chart-blue: #38bdf8; /* sky-400 */
|
||||||
|
--chart-green: #34d399; /* emerald-400 */
|
||||||
|
--chart-purple: #a78bfa; /* violet-400 */
|
||||||
|
--chart-orange: #fb923c; /* orange-400 */
|
||||||
|
--chart-pink: #f472b6; /* pink-400 */
|
||||||
|
--chart-gray: #94a3b8; /* slate-400 */
|
||||||
|
--chart-blue-soft: rgba(56, 189, 248, 0.20);
|
||||||
|
--chart-green-soft: rgba(52, 211, 153, 0.20);
|
||||||
|
|
||||||
|
/* Spacing scale */
|
||||||
|
--space-1: 4px;
|
||||||
|
--space-2: 8px;
|
||||||
|
--space-3: 12px;
|
||||||
|
--space-4: 16px;
|
||||||
|
--space-5: 20px;
|
||||||
|
--space-6: 24px;
|
||||||
|
--space-8: 32px;
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
|
--r-sm: 8px;
|
||||||
|
--r-md: 10px;
|
||||||
|
--r-lg: 12px;
|
||||||
|
--r-xl: 16px;
|
||||||
|
|
||||||
|
/* Shadows */
|
||||||
|
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
--shadow-md: 0 4px 12px rgba(15, 23, 42, 0.06);
|
||||||
|
--shadow-lg: 0 12px 28px rgba(15, 23, 42, 0.10);
|
||||||
|
--shadow-bubble: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||||
|
|
||||||
|
/* Type scale */
|
||||||
|
--font-sans: "Inter", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||||
|
--font-mono: "JetBrains Mono", ui-monospace, "Fira Mono", monospace;
|
||||||
|
--fs-xs: 11px;
|
||||||
|
--fs-sm: 12px;
|
||||||
|
--fs-base: 14px;
|
||||||
|
--fs-md: 15px;
|
||||||
|
--fs-lg: 18px;
|
||||||
|
--fs-xl: 24px;
|
||||||
|
--fw-regular: 400;
|
||||||
|
--fw-medium: 500;
|
||||||
|
--fw-semibold: 600;
|
||||||
|
--fw-bold: 700;
|
||||||
|
--lh-base: 1.5;
|
||||||
|
--lh-tight: 1.3;
|
||||||
|
|
||||||
|
/* Focus ring (accesibilidad) */
|
||||||
|
--focus-ring: 0 0 0 3px var(--accent-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ────────────────────────────────────────────────────────────
|
||||||
|
* Reset / defaults
|
||||||
|
* ──────────────────────────────────────────────────────────── */
|
||||||
|
html, body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
line-height: var(--lh-base);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* Scrollbars finos / pastel */
|
||||||
|
* {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--border-hi) transparent;
|
||||||
|
}
|
||||||
|
*::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
*::-webkit-scrollbar-thumb { background: var(--border-hi); border-radius: 4px; }
|
||||||
|
*::-webkit-scrollbar-thumb:hover { background: var(--text-muted); }
|
||||||
|
*::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
|
||||||
|
/* Selection con tinte pastel */
|
||||||
|
::selection { background: var(--accent-soft); color: var(--text); }
|
||||||
64
src/app.js
64
src/app.js
@@ -1,57 +1,71 @@
|
|||||||
import express from "express";
|
import express from "express";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
import cookieParser from "cookie-parser";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
||||||
import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js";
|
import { createSimulatorRouter } from "./modules/1-intake/routes/simulator.js";
|
||||||
import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js";
|
import { createEvolutionRouter } from "./modules/1-intake/routes/evolution.js";
|
||||||
import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js";
|
import { createWooWebhooksRouter } from "./modules/2-identity/routes/wooWebhooks.js";
|
||||||
|
import { createAuthRouter } from "./modules/auth/controllers/authRoutes.js";
|
||||||
|
import { createSystemUsersRouter } from "./modules/auth/controllers/usersRoutes.js";
|
||||||
|
import { createAuditLogRouter } from "./modules/auth/controllers/auditRoutes.js";
|
||||||
|
import { requireAuth } from "./modules/auth/middleware/requireAuth.js";
|
||||||
|
import { auditWriter } from "./modules/auth/middleware/auditWriter.js";
|
||||||
|
|
||||||
export function createApp({ tenantId }) {
|
export function createApp({ tenantId }) {
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set("trust proxy", true);
|
||||||
|
|
||||||
app.use(cors());
|
app.use(cors({ origin: true, credentials: true }));
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
|
||||||
// Serve /public as static (UI + webcomponents)
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
const publicDir = path.join(__dirname, "..", "public");
|
const publicDir = path.join(__dirname, "..", "public");
|
||||||
app.use(express.static(publicDir));
|
|
||||||
|
|
||||||
// --- Integraciones / UI ---
|
// Webhooks externos (Evolution, Woo) NO llevan auth ni se trazan en el log
|
||||||
app.use(createSimulatorRouter({ tenantId }));
|
// de operadores: se montan antes del requireAuth.
|
||||||
app.use(createEvolutionRouter());
|
app.use(createEvolutionRouter());
|
||||||
app.use(createWooWebhooksRouter());
|
app.use(createWooWebhooksRouter());
|
||||||
|
|
||||||
// Home (UI)
|
// Auth endpoints (login/logout/me) van también antes del requireAuth.
|
||||||
|
app.use(createAuthRouter());
|
||||||
|
|
||||||
|
// Login HTML (sin auth).
|
||||||
|
app.get("/login", (req, res) => {
|
||||||
|
res.sendFile(path.join(publicDir, "login.html"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Static assets — SIN auth (assets del shell, login, fonts, etc.)
|
||||||
|
app.use(express.static(publicDir));
|
||||||
|
|
||||||
|
// SPA shell HTML — sin auth en el HTML; el JS gatea con /api/auth/me.
|
||||||
app.get("/", (req, res) => {
|
app.get("/", (req, res) => {
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
res.sendFile(path.join(publicDir, "index.html"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// SPA catch-all - sirve index.html para todas las rutas del frontend
|
|
||||||
const spaRoutes = [
|
const spaRoutes = [
|
||||||
'/chat', '/conversaciones', '/usuarios', '/productos',
|
"/home", "/chat", "/conversaciones", "/usuarios", "/productos",
|
||||||
'/equivalencias', '/crosssell', '/cantidades', '/pedidos', '/test',
|
"/equivalencias", "/crosssell", "/cantidades", "/pedidos",
|
||||||
'/config-prompts', '/atencion-humana', '/configuracion'
|
"/config-prompts", "/atencion-humana", "/configuracion",
|
||||||
|
"/operadores", "/actividad",
|
||||||
];
|
];
|
||||||
app.get(spaRoutes, (req, res) => {
|
app.get(spaRoutes, (req, res) => {
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
res.sendFile(path.join(publicDir, "index.html"));
|
||||||
});
|
});
|
||||||
// Rutas con parámetros
|
app.get("/usuarios/:id", (req, res) => res.sendFile(path.join(publicDir, "index.html")));
|
||||||
app.get('/usuarios/:id', (req, res) => {
|
app.get("/productos/:id", (req, res) => res.sendFile(path.join(publicDir, "index.html")));
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
app.get("/crosssell/:id", (req, res) => res.sendFile(path.join(publicDir, "index.html")));
|
||||||
});
|
app.get("/pedidos/:id", (req, res) => res.sendFile(path.join(publicDir, "index.html")));
|
||||||
app.get('/productos/:id', (req, res) => {
|
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
// Todas las rutas de admin (data API) requieren login + se trazan.
|
||||||
});
|
app.use(requireAuth);
|
||||||
app.get('/crosssell/:id', (req, res) => {
|
app.use(auditWriter);
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
|
||||||
});
|
app.use(createSimulatorRouter({ tenantId }));
|
||||||
app.get('/pedidos/:id', (req, res) => {
|
app.use(createSystemUsersRouter());
|
||||||
res.sendFile(path.join(publicDir, "index.html"));
|
app.use(createAuditLogRouter());
|
||||||
});
|
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
import {
|
|
||||||
handleListPrompts,
|
|
||||||
handleGetPrompt,
|
|
||||||
handleSavePrompt,
|
|
||||||
handleRollbackPrompt,
|
|
||||||
handleResetPrompt,
|
|
||||||
handleGetPromptVersion,
|
|
||||||
handleTestPrompt,
|
|
||||||
} from "../handlers/prompts.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /prompts - Lista todos los prompts del tenant
|
|
||||||
*/
|
|
||||||
export const makeListPrompts = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const result = await handleListPrompts({ tenantId });
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[prompts] List error:", err);
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /prompts/:key - Obtiene un prompt específico con versiones
|
|
||||||
*/
|
|
||||||
export const makeGetPrompt = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const promptKey = req.params.key;
|
|
||||||
const result = await handleGetPrompt({ tenantId, promptKey });
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[prompts] Get error:", err);
|
|
||||||
if (err.message.includes("Invalid prompt_key")) {
|
|
||||||
return res.status(400).json({ ok: false, error: "invalid_prompt_key" });
|
|
||||||
}
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /prompts/:key - Crea/actualiza un prompt (nueva versión)
|
|
||||||
*/
|
|
||||||
export const makeSavePrompt = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const promptKey = req.params.key;
|
|
||||||
const { content, model, created_by } = req.body || {};
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
return res.status(400).json({ ok: false, error: "content_required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await handleSavePrompt({
|
|
||||||
tenantId,
|
|
||||||
promptKey,
|
|
||||||
content,
|
|
||||||
model,
|
|
||||||
createdBy: created_by || null,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[prompts] Save error:", err);
|
|
||||||
if (err.message.includes("Invalid prompt_key")) {
|
|
||||||
return res.status(400).json({ ok: false, error: "invalid_prompt_key" });
|
|
||||||
}
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /prompts/:key/rollback/:version - Restaura una versión anterior
|
|
||||||
*/
|
|
||||||
export const makeRollbackPrompt = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const { key, version } = req.params;
|
|
||||||
const { created_by } = req.body || {};
|
|
||||||
|
|
||||||
const result = await handleRollbackPrompt({
|
|
||||||
tenantId,
|
|
||||||
promptKey: key,
|
|
||||||
toVersion: version,
|
|
||||||
createdBy: created_by || null,
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[prompts] Rollback error:", err);
|
|
||||||
if (err.message.includes("not found")) {
|
|
||||||
return res.status(404).json({ ok: false, error: "version_not_found" });
|
|
||||||
}
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /prompts/:key/reset - Resetea al default
|
|
||||||
*/
|
|
||||||
export const makeResetPrompt = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const promptKey = req.params.key;
|
|
||||||
|
|
||||||
const result = await handleResetPrompt({ tenantId, promptKey });
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[prompts] Reset error:", err);
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /prompts/:key/versions/:version - Obtiene contenido de una versión específica
|
|
||||||
*/
|
|
||||||
export const makeGetPromptVersion = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const { key, version } = req.params;
|
|
||||||
|
|
||||||
const result = await handleGetPromptVersion({ tenantId, promptKey: key, version });
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[prompts] GetVersion error:", err);
|
|
||||||
if (err.message.includes("not found")) {
|
|
||||||
return res.status(404).json({ ok: false, error: "version_not_found" });
|
|
||||||
}
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /prompts/:key/test - Prueba un prompt con un mensaje
|
|
||||||
*/
|
|
||||||
export const makeTestPrompt = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const promptKey = req.params.key;
|
|
||||||
const { content, test_message, store_config } = req.body || {};
|
|
||||||
|
|
||||||
if (!test_message) {
|
|
||||||
return res.status(400).json({ ok: false, error: "test_message_required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await handleTestPrompt({
|
|
||||||
tenantId,
|
|
||||||
promptKey,
|
|
||||||
content,
|
|
||||||
testMessage: test_message,
|
|
||||||
storeConfig: store_config || {},
|
|
||||||
});
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[prompts] Test error:", err);
|
|
||||||
res.status(500).json({ ok: false, error: "internal_error", message: err.message });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import {
|
import { handleListOrders } from "../handlers/testing.js";
|
||||||
handleListOrders,
|
|
||||||
handleGetProductsWithStock,
|
|
||||||
handleCreateTestOrder,
|
|
||||||
} from "../handlers/testing.js";
|
|
||||||
import { handleGetOrderStats } from "../handlers/stats.js";
|
import { handleGetOrderStats } from "../handlers/stats.js";
|
||||||
|
|
||||||
export const makeListOrders = (tenantIdOrFn) => async (req, res) => {
|
export const makeListOrders = (tenantIdOrFn) => async (req, res) => {
|
||||||
@@ -29,31 +25,3 @@ export const makeGetOrderStats = (tenantIdOrFn) => async (req, res) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const makeGetProductsWithStock = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const result = await handleGetProductsWithStock({ tenantId });
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[testing] getProductsWithStock error:", err);
|
|
||||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const makeCreateTestOrder = (tenantIdOrFn) => async (req, res) => {
|
|
||||||
try {
|
|
||||||
const tenantId = typeof tenantIdOrFn === "function" ? tenantIdOrFn() : tenantIdOrFn;
|
|
||||||
const { basket, address, wa_chat_id } = req.body || {};
|
|
||||||
|
|
||||||
if (!basket?.items?.length) {
|
|
||||||
return res.status(400).json({ ok: false, error: "basket_required" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await handleCreateTestOrder({ tenantId, basket, address, wa_chat_id });
|
|
||||||
res.json(result);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[testing] createTestOrder error:", err);
|
|
||||||
res.status(500).json({ ok: false, error: err.message || "internal_error" });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
import { pool } from "../../shared/db/pool.js";
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// Prompt Templates - CRUD con versionado
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// Prompt keys válidos
|
|
||||||
export const PROMPT_KEYS = ["router", "greeting", "orders", "shipping", "payment", "browse"];
|
|
||||||
|
|
||||||
// Modelos por defecto para cada prompt
|
|
||||||
export const DEFAULT_MODELS = {
|
|
||||||
router: "gpt-4o-mini",
|
|
||||||
greeting: "gpt-4-turbo",
|
|
||||||
orders: "gpt-4-turbo",
|
|
||||||
shipping: "gpt-4o-mini",
|
|
||||||
payment: "gpt-4o-mini",
|
|
||||||
browse: "gpt-4-turbo",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene el prompt activo para un tenant y key
|
|
||||||
* @returns {Object|null} { id, prompt_key, content, model, version, is_active, created_at, created_by }
|
|
||||||
*/
|
|
||||||
export async function getActivePrompt({ tenantId, promptKey }) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
|
|
||||||
FROM prompt_templates
|
|
||||||
WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [tenantId, promptKey]);
|
|
||||||
return rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lista todos los prompts activos de un tenant
|
|
||||||
*/
|
|
||||||
export async function listActivePrompts({ tenantId }) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
|
|
||||||
FROM prompt_templates
|
|
||||||
WHERE tenant_id = $1 AND is_active = true
|
|
||||||
ORDER BY prompt_key
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [tenantId]);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene todas las versiones de un prompt
|
|
||||||
*/
|
|
||||||
export async function getPromptVersions({ tenantId, promptKey, limit = 20 }) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
|
|
||||||
FROM prompt_templates
|
|
||||||
WHERE tenant_id = $1 AND prompt_key = $2
|
|
||||||
ORDER BY version DESC
|
|
||||||
LIMIT $3
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [tenantId, promptKey, limit]);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene una versión específica de un prompt
|
|
||||||
*/
|
|
||||||
export async function getPromptVersion({ tenantId, promptKey, version }) {
|
|
||||||
const sql = `
|
|
||||||
SELECT id, prompt_key, content, model, version, is_active, created_at, created_by
|
|
||||||
FROM prompt_templates
|
|
||||||
WHERE tenant_id = $1 AND prompt_key = $2 AND version = $3
|
|
||||||
LIMIT 1
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [tenantId, promptKey, version]);
|
|
||||||
return rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Desactiva el prompt activo actual (para crear nueva versión)
|
|
||||||
*/
|
|
||||||
export async function deactivatePrompt({ tenantId, promptKey }) {
|
|
||||||
const sql = `
|
|
||||||
UPDATE prompt_templates
|
|
||||||
SET is_active = false
|
|
||||||
WHERE tenant_id = $1 AND prompt_key = $2 AND is_active = true
|
|
||||||
`;
|
|
||||||
await pool.query(sql, [tenantId, promptKey]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea una nueva versión del prompt (automáticamente desactiva la anterior)
|
|
||||||
* @returns {Object} El prompt creado con su versión
|
|
||||||
*/
|
|
||||||
export async function createPrompt({ tenantId, promptKey, content, model, createdBy = null }) {
|
|
||||||
// Validar prompt_key
|
|
||||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
|
||||||
throw new Error(`Invalid prompt_key: ${promptKey}. Valid keys: ${PROMPT_KEYS.join(", ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desactivar versión anterior
|
|
||||||
await deactivatePrompt({ tenantId, promptKey });
|
|
||||||
|
|
||||||
// Insertar nueva versión (el trigger calcula la versión automáticamente)
|
|
||||||
const sql = `
|
|
||||||
INSERT INTO prompt_templates (tenant_id, prompt_key, content, model, is_active, created_by)
|
|
||||||
VALUES ($1, $2, $3, $4, true, $5)
|
|
||||||
RETURNING id, prompt_key, content, model, version, is_active, created_at, created_by
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [
|
|
||||||
tenantId,
|
|
||||||
promptKey,
|
|
||||||
content,
|
|
||||||
model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
|
|
||||||
createdBy,
|
|
||||||
]);
|
|
||||||
return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restaura una versión anterior del prompt (crea nueva versión con el contenido antiguo)
|
|
||||||
*/
|
|
||||||
export async function rollbackPrompt({ tenantId, promptKey, toVersion, createdBy = null }) {
|
|
||||||
// Obtener la versión a restaurar
|
|
||||||
const oldVersion = await getPromptVersion({ tenantId, promptKey, version: toVersion });
|
|
||||||
if (!oldVersion) {
|
|
||||||
throw new Error(`Version ${toVersion} not found for prompt ${promptKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Crear nueva versión con el contenido antiguo
|
|
||||||
return createPrompt({
|
|
||||||
tenantId,
|
|
||||||
promptKey,
|
|
||||||
content: oldVersion.content,
|
|
||||||
model: oldVersion.model,
|
|
||||||
createdBy,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resetea un prompt a su default (desactiva todas las versiones custom)
|
|
||||||
*/
|
|
||||||
export async function resetPromptToDefault({ tenantId, promptKey }) {
|
|
||||||
const sql = `
|
|
||||||
UPDATE prompt_templates
|
|
||||||
SET is_active = false
|
|
||||||
WHERE tenant_id = $1 AND prompt_key = $2
|
|
||||||
`;
|
|
||||||
await pool.query(sql, [tenantId, promptKey]);
|
|
||||||
return { success: true, message: `Prompt ${promptKey} reset to default` };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Elimina todas las versiones de un prompt (usar con cuidado)
|
|
||||||
*/
|
|
||||||
export async function deleteAllPromptVersions({ tenantId, promptKey }) {
|
|
||||||
const sql = `
|
|
||||||
DELETE FROM prompt_templates
|
|
||||||
WHERE tenant_id = $1 AND prompt_key = $2
|
|
||||||
RETURNING id
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [tenantId, promptKey]);
|
|
||||||
return { deleted: rows.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene estadísticas de prompts de un tenant
|
|
||||||
*/
|
|
||||||
export async function getPromptStats({ tenantId }) {
|
|
||||||
const sql = `
|
|
||||||
SELECT
|
|
||||||
prompt_key,
|
|
||||||
COUNT(*) as total_versions,
|
|
||||||
MAX(version) as latest_version,
|
|
||||||
MAX(CASE WHEN is_active THEN version END) as active_version,
|
|
||||||
MAX(created_at) as last_updated
|
|
||||||
FROM prompt_templates
|
|
||||||
WHERE tenant_id = $1
|
|
||||||
GROUP BY prompt_key
|
|
||||||
ORDER BY prompt_key
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(sql, [tenantId]);
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
@@ -3,19 +3,20 @@ import { pool } from "../../shared/db/pool.js";
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Tenant Settings - CRUD
|
// Tenant Settings - CRUD
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Modelo actual:
|
||||||
|
// - Datos del comercio: store_name, bot_name, store_address, store_phone.
|
||||||
|
// - Pickup (retiro en tienda): pickup_enabled + pickup_days CSV +
|
||||||
|
// pickup_hours_start/end TIME, y `schedule.pickup` JSONB para horario por día.
|
||||||
|
// - Delivery: TODO vive en `delivery_zones.zones[]` (polígonos GeoJSON con
|
||||||
|
// costo, días y rango horario por zona). El bot valida zona usando la
|
||||||
|
// ubicación que el cliente comparte por WhatsApp.
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene la configuración del tenant
|
|
||||||
*/
|
|
||||||
export async function getSettings({ tenantId }) {
|
export async function getSettings({ tenantId }) {
|
||||||
const sql = `
|
const sql = `
|
||||||
SELECT
|
SELECT
|
||||||
id, tenant_id,
|
id, tenant_id,
|
||||||
store_name, bot_name, store_address, store_phone,
|
store_name, bot_name, store_address, store_phone,
|
||||||
delivery_enabled, delivery_days,
|
|
||||||
delivery_hours_start::text as delivery_hours_start,
|
|
||||||
delivery_hours_end::text as delivery_hours_end,
|
|
||||||
delivery_min_order,
|
|
||||||
pickup_enabled, pickup_days,
|
pickup_enabled, pickup_days,
|
||||||
pickup_hours_start::text as pickup_hours_start,
|
pickup_hours_start::text as pickup_hours_start,
|
||||||
pickup_hours_end::text as pickup_hours_end,
|
pickup_hours_end::text as pickup_hours_end,
|
||||||
@@ -30,20 +31,12 @@ export async function getSettings({ tenantId }) {
|
|||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea o actualiza la configuración del tenant (upsert)
|
|
||||||
*/
|
|
||||||
export async function upsertSettings({ tenantId, settings }) {
|
export async function upsertSettings({ tenantId, settings }) {
|
||||||
const {
|
const {
|
||||||
store_name,
|
store_name,
|
||||||
bot_name,
|
bot_name,
|
||||||
store_address,
|
store_address,
|
||||||
store_phone,
|
store_phone,
|
||||||
delivery_enabled,
|
|
||||||
delivery_days,
|
|
||||||
delivery_hours_start,
|
|
||||||
delivery_hours_end,
|
|
||||||
delivery_min_order,
|
|
||||||
pickup_enabled,
|
pickup_enabled,
|
||||||
pickup_days,
|
pickup_days,
|
||||||
pickup_hours_start,
|
pickup_hours_start,
|
||||||
@@ -55,21 +48,15 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO tenant_settings (
|
INSERT INTO tenant_settings (
|
||||||
tenant_id, store_name, bot_name, store_address, store_phone,
|
tenant_id, store_name, bot_name, store_address, store_phone,
|
||||||
delivery_enabled, delivery_days, delivery_hours_start, delivery_hours_end, delivery_min_order,
|
|
||||||
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end,
|
pickup_enabled, pickup_days, pickup_hours_start, pickup_hours_end,
|
||||||
schedule, delivery_zones
|
schedule, delivery_zones
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
ON CONFLICT (tenant_id) DO UPDATE SET
|
ON CONFLICT (tenant_id) DO UPDATE SET
|
||||||
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
|
store_name = COALESCE(EXCLUDED.store_name, tenant_settings.store_name),
|
||||||
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
|
bot_name = COALESCE(EXCLUDED.bot_name, tenant_settings.bot_name),
|
||||||
store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address),
|
store_address = COALESCE(EXCLUDED.store_address, tenant_settings.store_address),
|
||||||
store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone),
|
store_phone = COALESCE(EXCLUDED.store_phone, tenant_settings.store_phone),
|
||||||
delivery_enabled = COALESCE(EXCLUDED.delivery_enabled, tenant_settings.delivery_enabled),
|
|
||||||
delivery_days = COALESCE(EXCLUDED.delivery_days, tenant_settings.delivery_days),
|
|
||||||
delivery_hours_start = COALESCE(EXCLUDED.delivery_hours_start, tenant_settings.delivery_hours_start),
|
|
||||||
delivery_hours_end = COALESCE(EXCLUDED.delivery_hours_end, tenant_settings.delivery_hours_end),
|
|
||||||
delivery_min_order = COALESCE(EXCLUDED.delivery_min_order, tenant_settings.delivery_min_order),
|
|
||||||
pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled),
|
pickup_enabled = COALESCE(EXCLUDED.pickup_enabled, tenant_settings.pickup_enabled),
|
||||||
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
|
pickup_days = COALESCE(EXCLUDED.pickup_days, tenant_settings.pickup_days),
|
||||||
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
|
pickup_hours_start = COALESCE(EXCLUDED.pickup_hours_start, tenant_settings.pickup_hours_start),
|
||||||
@@ -80,10 +67,6 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
RETURNING
|
RETURNING
|
||||||
id, tenant_id,
|
id, tenant_id,
|
||||||
store_name, bot_name, store_address, store_phone,
|
store_name, bot_name, store_address, store_phone,
|
||||||
delivery_enabled, delivery_days,
|
|
||||||
delivery_hours_start::text as delivery_hours_start,
|
|
||||||
delivery_hours_end::text as delivery_hours_end,
|
|
||||||
delivery_min_order,
|
|
||||||
pickup_enabled, pickup_days,
|
pickup_enabled, pickup_days,
|
||||||
pickup_hours_start::text as pickup_hours_start,
|
pickup_hours_start::text as pickup_hours_start,
|
||||||
pickup_hours_end::text as pickup_hours_end,
|
pickup_hours_end::text as pickup_hours_end,
|
||||||
@@ -98,11 +81,6 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
bot_name || null,
|
bot_name || null,
|
||||||
store_address || null,
|
store_address || null,
|
||||||
store_phone || null,
|
store_phone || null,
|
||||||
delivery_enabled ?? null,
|
|
||||||
delivery_days || null,
|
|
||||||
delivery_hours_start || null,
|
|
||||||
delivery_hours_end || null,
|
|
||||||
delivery_min_order ?? null,
|
|
||||||
pickup_enabled ?? null,
|
pickup_enabled ?? null,
|
||||||
pickup_days || null,
|
pickup_days || null,
|
||||||
pickup_hours_start || null,
|
pickup_hours_start || null,
|
||||||
@@ -112,151 +90,101 @@ export async function upsertSettings({ tenantId, settings }) {
|
|||||||
];
|
];
|
||||||
|
|
||||||
const { rows } = await pool.query(sql, params);
|
const { rows } = await pool.query(sql, params);
|
||||||
|
|
||||||
return rows[0];
|
return rows[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formatea horarios desde schedule JSONB para mostrar de forma natural
|
* Formatea schedule.pickup ({ lun: { start, end } }) en prosa para mostrar al
|
||||||
* Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs, Sáb de 9 a 13hs"
|
* cliente. Agrupa días con mismos horarios: "Lun a Vie de 9 a 14hs".
|
||||||
*/
|
*/
|
||||||
function formatScheduleHours(scheduleType, enabled) {
|
function formatScheduleHours(scheduleObj, enabled) {
|
||||||
if (!enabled || !scheduleType || typeof scheduleType !== "object") {
|
if (!enabled || !scheduleObj || typeof scheduleObj !== "object") {
|
||||||
return enabled === false ? "No disponible" : "";
|
return enabled === false ? "No disponible" : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
const dayOrder = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
||||||
const dayNames = {
|
const dayNames = {
|
||||||
lun: "Lunes", mar: "Martes", mie: "Miércoles",
|
lun: "Lunes", mar: "Martes", mie: "Miércoles",
|
||||||
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo"
|
jue: "Jueves", vie: "Viernes", sab: "Sábado", dom: "Domingo",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Agrupar días por horario
|
|
||||||
const groups = {};
|
const groups = {};
|
||||||
for (const day of dayOrder) {
|
for (const day of dayOrder) {
|
||||||
const slot = scheduleType[day];
|
const slot = scheduleObj[day];
|
||||||
if (!slot || !slot.start || !slot.end) continue;
|
if (!slot || !slot.start || !slot.end) continue;
|
||||||
|
|
||||||
const key = `${slot.start}-${slot.end}`;
|
const key = `${slot.start}-${slot.end}`;
|
||||||
if (!groups[key]) {
|
if (!groups[key]) groups[key] = { start: slot.start, end: slot.end, days: [] };
|
||||||
groups[key] = { start: slot.start, end: slot.end, days: [] };
|
|
||||||
}
|
|
||||||
groups[key].days.push(day);
|
groups[key].days.push(day);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(groups).length === 0) return "";
|
if (!Object.keys(groups).length) return "";
|
||||||
|
|
||||||
// Formatear cada grupo
|
const parts = Object.values(groups).map((g) => {
|
||||||
const parts = Object.values(groups).map(g => {
|
|
||||||
const days = g.days;
|
const days = g.days;
|
||||||
let dayStr;
|
let dayStr;
|
||||||
|
|
||||||
// Detectar rangos consecutivos
|
|
||||||
if (days.length >= 3) {
|
if (days.length >= 3) {
|
||||||
const indices = days.map(d => dayOrder.indexOf(d));
|
const indices = days.map((d) => dayOrder.indexOf(d));
|
||||||
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i-1] + 1);
|
const isConsecutive = indices.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1);
|
||||||
if (isConsecutive) {
|
dayStr = isConsecutive
|
||||||
dayStr = `${dayNames[days[0]]} a ${dayNames[days[days.length-1]]}`;
|
? `${dayNames[days[0]]} a ${dayNames[days[days.length - 1]]}`
|
||||||
} else {
|
: days.map((d) => dayNames[d]).join(", ");
|
||||||
dayStr = days.map(d => dayNames[d]).join(", ");
|
|
||||||
}
|
|
||||||
} else if (days.length === 2) {
|
} else if (days.length === 2) {
|
||||||
dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`;
|
dayStr = `${dayNames[days[0]]} y ${dayNames[days[1]]}`;
|
||||||
} else {
|
} else {
|
||||||
dayStr = dayNames[days[0]];
|
dayStr = dayNames[days[0]];
|
||||||
}
|
}
|
||||||
|
return `${dayStr} de ${g.start.slice(0, 5)} a ${g.end.slice(0, 5)}`;
|
||||||
const startH = g.start.slice(0, 5);
|
|
||||||
const endH = g.end.slice(0, 5);
|
|
||||||
return `${dayStr} de ${startH} a ${endH}`;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return parts.join(", ");
|
return parts.join(", ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatLegacyPickupHours(enabled, days, start, end) {
|
||||||
|
if (!enabled) return "No disponible";
|
||||||
|
if (!days || !start || !end) return "";
|
||||||
|
const daysFormatted = days.split(",").map((d) => d.trim()).join(", ");
|
||||||
|
return `${daysFormatted} de ${start.slice(0, 5)} a ${end.slice(0, 5)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Obtiene la configuración formateada para usar en prompts (storeConfig)
|
* Forma de la storeConfig que consume el agente y su workingMemory.
|
||||||
*/
|
*/
|
||||||
export async function getStoreConfig({ tenantId }) {
|
export async function getStoreConfig({ tenantId }) {
|
||||||
const settings = await getSettings({ tenantId });
|
const settings = await getSettings({ tenantId });
|
||||||
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
// Valores por defecto si no hay configuración
|
|
||||||
return {
|
return {
|
||||||
name: "la carnicería",
|
name: "la carnicería",
|
||||||
botName: "Piaf",
|
botName: "Piaf",
|
||||||
hours: "",
|
hours: "",
|
||||||
address: "",
|
address: "",
|
||||||
phone: "",
|
phone: "",
|
||||||
deliveryHours: "",
|
|
||||||
pickupHours: "",
|
pickupHours: "",
|
||||||
schedule: null,
|
schedule: null,
|
||||||
|
delivery_zones: {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedule = settings.schedule || {};
|
const schedule = settings.schedule || {};
|
||||||
|
|
||||||
// Usar nuevo formato schedule si existe, sino legacy
|
const pickupHours = schedule.pickup && Object.keys(schedule.pickup).length
|
||||||
let deliveryHours, pickupHours;
|
? formatScheduleHours(schedule.pickup, settings.pickup_enabled)
|
||||||
|
: formatLegacyPickupHours(
|
||||||
if (schedule.delivery && Object.keys(schedule.delivery).length > 0) {
|
settings.pickup_enabled,
|
||||||
deliveryHours = formatScheduleHours(schedule.delivery, settings.delivery_enabled);
|
settings.pickup_days,
|
||||||
} else {
|
settings.pickup_hours_start,
|
||||||
// Legacy format
|
settings.pickup_hours_end,
|
||||||
deliveryHours = formatLegacyHours(
|
);
|
||||||
settings.delivery_enabled,
|
|
||||||
settings.delivery_days,
|
|
||||||
settings.delivery_hours_start,
|
|
||||||
settings.delivery_hours_end
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (schedule.pickup && Object.keys(schedule.pickup).length > 0) {
|
|
||||||
pickupHours = formatScheduleHours(schedule.pickup, settings.pickup_enabled);
|
|
||||||
} else {
|
|
||||||
// Legacy format
|
|
||||||
pickupHours = formatLegacyHours(
|
|
||||||
settings.pickup_enabled,
|
|
||||||
settings.pickup_days,
|
|
||||||
settings.pickup_hours_start,
|
|
||||||
settings.pickup_hours_end
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Combinar horarios para store_hours (usa pickup como horario de tienda)
|
|
||||||
let storeHours = "";
|
|
||||||
if (settings.pickup_enabled) {
|
|
||||||
storeHours = pickupHours;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: settings.store_name || "la carnicería",
|
name: settings.store_name || "la carnicería",
|
||||||
botName: settings.bot_name || "Piaf",
|
botName: settings.bot_name || "Piaf",
|
||||||
hours: storeHours,
|
hours: settings.pickup_enabled ? pickupHours : "",
|
||||||
address: settings.store_address || "",
|
address: settings.store_address || "",
|
||||||
phone: settings.store_phone || "",
|
phone: settings.store_phone || "",
|
||||||
deliveryHours,
|
|
||||||
pickupHours,
|
pickupHours,
|
||||||
deliveryEnabled: settings.delivery_enabled,
|
|
||||||
pickupEnabled: settings.pickup_enabled,
|
pickupEnabled: settings.pickup_enabled,
|
||||||
schedule,
|
schedule,
|
||||||
// Campos legacy para compatibilidad
|
delivery_zones: settings.delivery_zones || {},
|
||||||
delivery_days: settings.delivery_days,
|
|
||||||
delivery_hours_start: settings.delivery_hours_start,
|
|
||||||
delivery_hours_end: settings.delivery_hours_end,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Formatear horarios en formato legacy (días + rango único)
|
|
||||||
*/
|
|
||||||
function formatLegacyHours(enabled, days, start, end) {
|
|
||||||
if (!enabled) return "No disponible";
|
|
||||||
if (!days || !start || !end) return "";
|
|
||||||
|
|
||||||
const daysFormatted = days.split(",").map(d => d.trim()).join(", ");
|
|
||||||
const startFormatted = start?.slice(0, 5) || "";
|
|
||||||
const endFormatted = end?.slice(0, 5) || "";
|
|
||||||
|
|
||||||
return `${daysFormatted} de ${startFormatted} a ${endFormatted}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,258 +0,0 @@
|
|||||||
import {
|
|
||||||
getActivePrompt,
|
|
||||||
listActivePrompts,
|
|
||||||
getPromptVersions,
|
|
||||||
getPromptVersion,
|
|
||||||
createPrompt,
|
|
||||||
rollbackPrompt,
|
|
||||||
resetPromptToDefault,
|
|
||||||
getPromptStats,
|
|
||||||
PROMPT_KEYS,
|
|
||||||
DEFAULT_MODELS,
|
|
||||||
} from "../db/promptsRepo.js";
|
|
||||||
import { loadDefaultPrompt, AVAILABLE_VARIABLES, invalidatePromptCache } from "../../3-turn-engine/nlu/promptLoader.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lista todos los prompts del tenant (activos + defaults para los que no tienen custom)
|
|
||||||
*/
|
|
||||||
export async function handleListPrompts({ tenantId }) {
|
|
||||||
const activePrompts = await listActivePrompts({ tenantId });
|
|
||||||
const stats = await getPromptStats({ tenantId });
|
|
||||||
|
|
||||||
// Construir lista completa con defaults para los que no tienen custom
|
|
||||||
const promptsMap = new Map(activePrompts.map(p => [p.prompt_key, p]));
|
|
||||||
|
|
||||||
const items = PROMPT_KEYS.map(key => {
|
|
||||||
const custom = promptsMap.get(key);
|
|
||||||
const stat = stats.find(s => s.prompt_key === key);
|
|
||||||
|
|
||||||
if (custom) {
|
|
||||||
return {
|
|
||||||
prompt_key: key,
|
|
||||||
content: custom.content,
|
|
||||||
model: custom.model,
|
|
||||||
version: custom.version,
|
|
||||||
is_default: false,
|
|
||||||
total_versions: stat?.total_versions || 1,
|
|
||||||
last_updated: custom.created_at,
|
|
||||||
created_by: custom.created_by,
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// Cargar default
|
|
||||||
let defaultContent = "";
|
|
||||||
try {
|
|
||||||
defaultContent = loadDefaultPrompt(key);
|
|
||||||
} catch (e) {
|
|
||||||
defaultContent = `[Error loading default: ${e.message}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
prompt_key: key,
|
|
||||||
content: defaultContent,
|
|
||||||
model: DEFAULT_MODELS[key] || "gpt-4-turbo",
|
|
||||||
version: null,
|
|
||||||
is_default: true,
|
|
||||||
total_versions: stat?.total_versions || 0,
|
|
||||||
last_updated: null,
|
|
||||||
created_by: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
available_variables: AVAILABLE_VARIABLES,
|
|
||||||
available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene un prompt específico con su historial de versiones
|
|
||||||
*/
|
|
||||||
export async function handleGetPrompt({ tenantId, promptKey }) {
|
|
||||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
|
||||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = await getActivePrompt({ tenantId, promptKey });
|
|
||||||
const versions = await getPromptVersions({ tenantId, promptKey, limit: 20 });
|
|
||||||
|
|
||||||
let defaultContent = "";
|
|
||||||
try {
|
|
||||||
defaultContent = loadDefaultPrompt(promptKey);
|
|
||||||
} catch (e) {
|
|
||||||
defaultContent = `[Error: ${e.message}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
prompt_key: promptKey,
|
|
||||||
current: current || {
|
|
||||||
content: defaultContent,
|
|
||||||
model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
|
|
||||||
version: null,
|
|
||||||
is_default: true,
|
|
||||||
},
|
|
||||||
default_content: defaultContent,
|
|
||||||
default_model: DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
|
|
||||||
versions: versions.map(v => ({
|
|
||||||
version: v.version,
|
|
||||||
is_active: v.is_active,
|
|
||||||
created_at: v.created_at,
|
|
||||||
created_by: v.created_by,
|
|
||||||
content_preview: v.content.slice(0, 100) + (v.content.length > 100 ? "..." : ""),
|
|
||||||
})),
|
|
||||||
available_variables: AVAILABLE_VARIABLES,
|
|
||||||
available_models: ["gpt-4-turbo", "gpt-4o-mini", "gpt-4o", "gpt-3.5-turbo"],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea o actualiza un prompt (crea nueva versión)
|
|
||||||
*/
|
|
||||||
export async function handleSavePrompt({ tenantId, promptKey, content, model, createdBy }) {
|
|
||||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
|
||||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!content || content.trim().length === 0) {
|
|
||||||
throw new Error("Content is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await createPrompt({
|
|
||||||
tenantId,
|
|
||||||
promptKey,
|
|
||||||
content: content.trim(),
|
|
||||||
model: model || DEFAULT_MODELS[promptKey] || "gpt-4-turbo",
|
|
||||||
createdBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalidar cache
|
|
||||||
invalidatePromptCache(tenantId, promptKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
item: result,
|
|
||||||
message: `Prompt ${promptKey} saved as version ${result.version}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Restaura una versión anterior del prompt
|
|
||||||
*/
|
|
||||||
export async function handleRollbackPrompt({ tenantId, promptKey, toVersion, createdBy }) {
|
|
||||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
|
||||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await rollbackPrompt({
|
|
||||||
tenantId,
|
|
||||||
promptKey,
|
|
||||||
toVersion: parseInt(toVersion, 10),
|
|
||||||
createdBy,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalidar cache
|
|
||||||
invalidatePromptCache(tenantId, promptKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
item: result,
|
|
||||||
message: `Prompt ${promptKey} rolled back to version ${toVersion}, new version is ${result.version}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resetea un prompt al default (desactiva todas las versiones custom)
|
|
||||||
*/
|
|
||||||
export async function handleResetPrompt({ tenantId, promptKey }) {
|
|
||||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
|
||||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await resetPromptToDefault({ tenantId, promptKey });
|
|
||||||
|
|
||||||
// Invalidar cache
|
|
||||||
invalidatePromptCache(tenantId, promptKey);
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
message: `Prompt ${promptKey} reset to default`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene el contenido de una versión específica
|
|
||||||
*/
|
|
||||||
export async function handleGetPromptVersion({ tenantId, promptKey, version }) {
|
|
||||||
if (!PROMPT_KEYS.includes(promptKey)) {
|
|
||||||
throw new Error(`Invalid prompt_key: ${promptKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const versionData = await getPromptVersion({
|
|
||||||
tenantId,
|
|
||||||
promptKey,
|
|
||||||
version: parseInt(version, 10)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!versionData) {
|
|
||||||
throw new Error(`Version ${version} not found for prompt ${promptKey}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { item: versionData };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prueba un prompt con un mensaje de ejemplo
|
|
||||||
*/
|
|
||||||
export async function handleTestPrompt({ tenantId, promptKey, content, testMessage, storeConfig = {} }) {
|
|
||||||
// Importar dinámicamente para evitar dependencias circulares
|
|
||||||
const { loadPrompt } = await import("../../3-turn-engine/nlu/promptLoader.js");
|
|
||||||
|
|
||||||
// Si se proporciona content, usarlo directamente
|
|
||||||
// Si no, cargar el prompt actual
|
|
||||||
let promptContent = content;
|
|
||||||
let model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo";
|
|
||||||
|
|
||||||
if (!promptContent) {
|
|
||||||
const loaded = await loadPrompt({ tenantId, promptKey, variables: storeConfig });
|
|
||||||
promptContent = loaded.content;
|
|
||||||
model = loaded.model;
|
|
||||||
} else {
|
|
||||||
// Aplicar variables al content proporcionado
|
|
||||||
for (const [key, value] of Object.entries(storeConfig)) {
|
|
||||||
promptContent = promptContent.replace(new RegExp(`{{${key}}}`, "g"), value || "");
|
|
||||||
}
|
|
||||||
promptContent = promptContent.replace(/\{\{[^}]+\}\}/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Importar OpenAI
|
|
||||||
const OpenAI = (await import("openai")).default;
|
|
||||||
const apiKey = process.env.OPENAI_API_KEY || process.env.OPENAI_APIKEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
throw new Error("OPENAI_API_KEY not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
const openai = new OpenAI({ apiKey });
|
|
||||||
|
|
||||||
// Hacer la llamada de prueba
|
|
||||||
const startTime = Date.now();
|
|
||||||
const response = await openai.chat.completions.create({
|
|
||||||
model,
|
|
||||||
temperature: 0.2,
|
|
||||||
max_tokens: 500,
|
|
||||||
response_format: { type: "json_object" },
|
|
||||||
messages: [
|
|
||||||
{ role: "system", content: promptContent },
|
|
||||||
{ role: "user", content: testMessage },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const endTime = Date.now();
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
response: response?.choices?.[0]?.message?.content || "",
|
|
||||||
model,
|
|
||||||
usage: response?.usage,
|
|
||||||
latency_ms: endTime - startTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,63 +1,42 @@
|
|||||||
import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js";
|
import { getSettings, upsertSettings, getStoreConfig } from "../db/settingsRepo.js";
|
||||||
|
|
||||||
// Días de la semana para validación
|
|
||||||
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
const VALID_DAYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
||||||
|
|
||||||
/**
|
function defaultPickupSchedule() {
|
||||||
* Genera schedule por defecto con horarios uniformes
|
const days = ["lun", "mar", "mie", "jue", "vie", "sab"];
|
||||||
*/
|
|
||||||
function createDefaultSchedule() {
|
|
||||||
const defaultDays = ["lun", "mar", "mie", "jue", "vie", "sab"];
|
|
||||||
const delivery = {};
|
|
||||||
const pickup = {};
|
const pickup = {};
|
||||||
|
for (const d of days) pickup[d] = { start: "08:00", end: "20:00" };
|
||||||
for (const day of defaultDays) {
|
return { pickup };
|
||||||
delivery[day] = { start: "09:00", end: "18:00" };
|
|
||||||
pickup[day] = { start: "08:00", end: "20:00" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { delivery, pickup };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene la configuración actual del tenant
|
|
||||||
*/
|
|
||||||
export async function handleGetSettings({ tenantId }) {
|
export async function handleGetSettings({ tenantId }) {
|
||||||
const settings = await getSettings({ tenantId });
|
const settings = await getSettings({ tenantId });
|
||||||
|
|
||||||
// Si no hay configuración, devolver defaults
|
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
return {
|
return {
|
||||||
store_name: "Mi Negocio",
|
store_name: "Mi Negocio",
|
||||||
bot_name: "Piaf",
|
bot_name: "Piaf",
|
||||||
store_address: "",
|
store_address: "",
|
||||||
store_phone: "",
|
store_phone: "",
|
||||||
delivery_enabled: true,
|
|
||||||
delivery_days: "lun,mar,mie,jue,vie,sab",
|
|
||||||
delivery_hours_start: "09:00",
|
|
||||||
delivery_hours_end: "18:00",
|
|
||||||
delivery_min_order: 0,
|
|
||||||
pickup_enabled: true,
|
pickup_enabled: true,
|
||||||
pickup_days: "lun,mar,mie,jue,vie,sab",
|
pickup_days: "lun,mar,mie,jue,vie,sab",
|
||||||
pickup_hours_start: "08:00",
|
pickup_hours_start: "08:00",
|
||||||
pickup_hours_end: "20:00",
|
pickup_hours_end: "20:00",
|
||||||
schedule: createDefaultSchedule(),
|
schedule: defaultPickupSchedule(),
|
||||||
delivery_zones: {},
|
delivery_zones: {},
|
||||||
is_default: true,
|
is_default: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si no tiene schedule, generar desde datos legacy
|
// Si schedule está vacío pero tenemos los campos legacy de pickup, generar
|
||||||
|
// schedule.pickup para que la UI pueda editar el grid por día.
|
||||||
let schedule = settings.schedule;
|
let schedule = settings.schedule;
|
||||||
if (!schedule || Object.keys(schedule).length === 0) {
|
if (!schedule || !schedule.pickup || !Object.keys(schedule.pickup).length) {
|
||||||
schedule = buildScheduleFromLegacy(settings);
|
schedule = buildPickupScheduleFromLegacy(settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
// Formatear horarios TIME a HH:MM
|
|
||||||
delivery_hours_start: settings.delivery_hours_start?.slice(0, 5) || "09:00",
|
|
||||||
delivery_hours_end: settings.delivery_hours_end?.slice(0, 5) || "18:00",
|
|
||||||
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
|
pickup_hours_start: settings.pickup_hours_start?.slice(0, 5) || "08:00",
|
||||||
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
|
pickup_hours_end: settings.pickup_hours_end?.slice(0, 5) || "20:00",
|
||||||
schedule,
|
schedule,
|
||||||
@@ -66,171 +45,95 @@ export async function handleGetSettings({ tenantId }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function buildPickupScheduleFromLegacy(settings) {
|
||||||
* Construye schedule desde datos legacy
|
const out = { pickup: {} };
|
||||||
*/
|
if (settings?.pickup_enabled && settings?.pickup_days) {
|
||||||
function buildScheduleFromLegacy(settings) {
|
const days = settings.pickup_days.split(",").map((d) => d.trim());
|
||||||
const schedule = { delivery: {}, pickup: {} };
|
|
||||||
|
|
||||||
// Delivery
|
|
||||||
if (settings.delivery_enabled && settings.delivery_days) {
|
|
||||||
const days = settings.delivery_days.split(",").map(d => d.trim());
|
|
||||||
const start = settings.delivery_hours_start?.slice(0, 5) || "09:00";
|
|
||||||
const end = settings.delivery_hours_end?.slice(0, 5) || "18:00";
|
|
||||||
for (const day of days) {
|
|
||||||
if (VALID_DAYS.includes(day)) {
|
|
||||||
schedule.delivery[day] = { start, end };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pickup
|
|
||||||
if (settings.pickup_enabled && settings.pickup_days) {
|
|
||||||
const days = settings.pickup_days.split(",").map(d => d.trim());
|
|
||||||
const start = settings.pickup_hours_start?.slice(0, 5) || "08:00";
|
const start = settings.pickup_hours_start?.slice(0, 5) || "08:00";
|
||||||
const end = settings.pickup_hours_end?.slice(0, 5) || "20:00";
|
const end = settings.pickup_hours_end?.slice(0, 5) || "20:00";
|
||||||
for (const day of days) {
|
for (const day of days) {
|
||||||
if (VALID_DAYS.includes(day)) {
|
if (VALID_DAYS.includes(day)) out.pickup[day] = { start, end };
|
||||||
schedule.pickup[day] = { start, end };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return out;
|
||||||
return schedule;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Valida la estructura del schedule
|
|
||||||
*/
|
|
||||||
function validateSchedule(schedule) {
|
function validateSchedule(schedule) {
|
||||||
if (!schedule || typeof schedule !== "object") return;
|
if (!schedule || typeof schedule !== "object") return;
|
||||||
|
|
||||||
// Acepta HH:MM o HH:MM:SS (la BD puede devolver con segundos)
|
|
||||||
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
|
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9](:[0-5][0-9])?$/;
|
||||||
|
const pickup = schedule.pickup;
|
||||||
|
if (!pickup || typeof pickup !== "object") return;
|
||||||
|
for (const [day, slot] of Object.entries(pickup)) {
|
||||||
|
if (!VALID_DAYS.includes(day)) throw new Error(`Invalid day in schedule.pickup: ${day}`);
|
||||||
|
if (slot === null) continue;
|
||||||
|
if (typeof slot !== "object" || !slot.start || !slot.end) {
|
||||||
|
throw new Error(`Invalid slot format for pickup.${day}`);
|
||||||
|
}
|
||||||
|
if (!timeRegex.test(slot.start)) throw new Error(`Invalid start time for pickup.${day}: ${slot.start}`);
|
||||||
|
if (!timeRegex.test(slot.end)) throw new Error(`Invalid end time for pickup.${day}: ${slot.end}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const type of ["delivery", "pickup"]) {
|
function syncPickupLegacyFromSchedule(settings) {
|
||||||
const typeSchedule = schedule[type];
|
const pickup = settings?.schedule?.pickup;
|
||||||
if (!typeSchedule || typeof typeSchedule !== "object") continue;
|
if (!pickup) return;
|
||||||
|
const days = Object.keys(pickup).filter((d) => pickup[d] && pickup[d].start && pickup[d].end);
|
||||||
|
days.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
|
||||||
|
if (days.length) {
|
||||||
|
settings.pickup_days = days.join(",");
|
||||||
|
const first = pickup[days[0]];
|
||||||
|
settings.pickup_hours_start = first.start;
|
||||||
|
settings.pickup_hours_end = first.end;
|
||||||
|
} else {
|
||||||
|
settings.pickup_days = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const [day, slot] of Object.entries(typeSchedule)) {
|
function validateDeliveryZones(dz) {
|
||||||
if (!VALID_DAYS.includes(day)) {
|
if (!dz || typeof dz !== "object") return;
|
||||||
throw new Error(`Invalid day in schedule.${type}: ${day}`);
|
if (dz.zones && !Array.isArray(dz.zones)) {
|
||||||
}
|
throw new Error("delivery_zones.zones must be an array");
|
||||||
|
}
|
||||||
if (slot === null) continue; // null = no disponible
|
for (const z of dz.zones || []) {
|
||||||
|
if (!z?.id || !z?.name) throw new Error("Each zone needs id + name");
|
||||||
if (typeof slot !== "object" || !slot.start || !slot.end) {
|
if (z.polygon && (z.polygon.type !== "Polygon" || !Array.isArray(z.polygon.coordinates))) {
|
||||||
throw new Error(`Invalid slot format for ${type}.${day}`);
|
throw new Error(`Invalid polygon GeoJSON for zone ${z.id}`);
|
||||||
}
|
}
|
||||||
|
if (Array.isArray(z.delivery_days)) {
|
||||||
if (!timeRegex.test(slot.start)) {
|
for (const d of z.delivery_days) {
|
||||||
throw new Error(`Invalid start time for ${type}.${day}: ${slot.start}`);
|
if (!VALID_DAYS.includes(d)) throw new Error(`Invalid delivery day in zone ${z.id}: ${d}`);
|
||||||
}
|
|
||||||
if (!timeRegex.test(slot.end)) {
|
|
||||||
throw new Error(`Invalid end time for ${type}.${day}: ${slot.end}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sincroniza campos legacy desde schedule
|
|
||||||
*/
|
|
||||||
function syncLegacyFromSchedule(settings) {
|
|
||||||
const schedule = settings.schedule;
|
|
||||||
if (!schedule) return;
|
|
||||||
|
|
||||||
// Sincronizar delivery
|
|
||||||
if (schedule.delivery) {
|
|
||||||
const deliveryDays = Object.keys(schedule.delivery).filter(d => schedule.delivery[d] !== null);
|
|
||||||
if (deliveryDays.length > 0) {
|
|
||||||
// Ordenar días
|
|
||||||
deliveryDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
|
|
||||||
settings.delivery_days = deliveryDays.join(",");
|
|
||||||
|
|
||||||
// Usar primer horario como legacy
|
|
||||||
const firstSlot = schedule.delivery[deliveryDays[0]];
|
|
||||||
if (firstSlot) {
|
|
||||||
settings.delivery_hours_start = firstSlot.start;
|
|
||||||
settings.delivery_hours_end = firstSlot.end;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
settings.delivery_days = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sincronizar pickup
|
|
||||||
if (schedule.pickup) {
|
|
||||||
const pickupDays = Object.keys(schedule.pickup).filter(d => schedule.pickup[d] !== null);
|
|
||||||
if (pickupDays.length > 0) {
|
|
||||||
pickupDays.sort((a, b) => VALID_DAYS.indexOf(a) - VALID_DAYS.indexOf(b));
|
|
||||||
settings.pickup_days = pickupDays.join(",");
|
|
||||||
|
|
||||||
const firstSlot = schedule.pickup[pickupDays[0]];
|
|
||||||
if (firstSlot) {
|
|
||||||
settings.pickup_hours_start = firstSlot.start;
|
|
||||||
settings.pickup_hours_end = firstSlot.end;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
settings.pickup_days = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Guarda la configuración del tenant
|
|
||||||
*/
|
|
||||||
export async function handleSaveSettings({ tenantId, settings }) {
|
export async function handleSaveSettings({ tenantId, settings }) {
|
||||||
// Validaciones básicas
|
if (!settings.store_name?.trim()) throw new Error("store_name is required");
|
||||||
if (!settings.store_name?.trim()) {
|
if (!settings.bot_name?.trim()) throw new Error("bot_name is required");
|
||||||
throw new Error("store_name is required");
|
|
||||||
}
|
|
||||||
if (!settings.bot_name?.trim()) {
|
|
||||||
throw new Error("bot_name is required");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validar schedule si viene
|
|
||||||
if (settings.schedule) {
|
if (settings.schedule) {
|
||||||
validateSchedule(settings.schedule);
|
validateSchedule(settings.schedule);
|
||||||
// Sincronizar campos legacy desde schedule
|
syncPickupLegacyFromSchedule(settings);
|
||||||
syncLegacyFromSchedule(settings);
|
}
|
||||||
} else {
|
|
||||||
// Legacy: validar días individuales
|
|
||||||
if (settings.delivery_days) {
|
|
||||||
const days = settings.delivery_days.split(",").map(d => d.trim().toLowerCase());
|
|
||||||
for (const day of days) {
|
|
||||||
if (!VALID_DAYS.includes(day)) {
|
|
||||||
throw new Error(`Invalid delivery day: ${day}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
settings.delivery_days = days.join(",");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (settings.pickup_days) {
|
if (settings.delivery_zones) {
|
||||||
const days = settings.pickup_days.split(",").map(d => d.trim().toLowerCase());
|
validateDeliveryZones(settings.delivery_zones);
|
||||||
for (const day of days) {
|
}
|
||||||
if (!VALID_DAYS.includes(day)) {
|
|
||||||
throw new Error(`Invalid pickup day: ${day}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
settings.pickup_days = days.join(",");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validar horarios legacy
|
if (settings.pickup_days) {
|
||||||
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
const days = settings.pickup_days.split(",").map((d) => d.trim().toLowerCase());
|
||||||
|
for (const d of days) {
|
||||||
|
if (!VALID_DAYS.includes(d)) throw new Error(`Invalid pickup day: ${d}`);
|
||||||
|
}
|
||||||
|
settings.pickup_days = days.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.delivery_hours_start && !timeRegex.test(settings.delivery_hours_start)) {
|
const timeRegex = /^([01]?[0-9]|2[0-3]):[0-5][0-9]$/;
|
||||||
throw new Error("Invalid delivery_hours_start format (use HH:MM)");
|
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
|
||||||
}
|
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
|
||||||
if (settings.delivery_hours_end && !timeRegex.test(settings.delivery_hours_end)) {
|
}
|
||||||
throw new Error("Invalid delivery_hours_end format (use HH:MM)");
|
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
|
||||||
}
|
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
|
||||||
if (settings.pickup_hours_start && !timeRegex.test(settings.pickup_hours_start)) {
|
|
||||||
throw new Error("Invalid pickup_hours_start format (use HH:MM)");
|
|
||||||
}
|
|
||||||
if (settings.pickup_hours_end && !timeRegex.test(settings.pickup_hours_end)) {
|
|
||||||
throw new Error("Invalid pickup_hours_end format (use HH:MM)");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await upsertSettings({ tenantId, settings });
|
const result = await upsertSettings({ tenantId, settings });
|
||||||
@@ -239,8 +142,6 @@ export async function handleSaveSettings({ tenantId, settings }) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
settings: {
|
settings: {
|
||||||
...result,
|
...result,
|
||||||
delivery_hours_start: result.delivery_hours_start?.slice(0, 5),
|
|
||||||
delivery_hours_end: result.delivery_hours_end?.slice(0, 5),
|
|
||||||
pickup_hours_start: result.pickup_hours_start?.slice(0, 5),
|
pickup_hours_start: result.pickup_hours_start?.slice(0, 5),
|
||||||
pickup_hours_end: result.pickup_hours_end?.slice(0, 5),
|
pickup_hours_end: result.pickup_hours_end?.slice(0, 5),
|
||||||
delivery_zones: result.delivery_zones || {},
|
delivery_zones: result.delivery_zones || {},
|
||||||
@@ -249,9 +150,6 @@ export async function handleSaveSettings({ tenantId, settings }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene el storeConfig formateado para prompts
|
|
||||||
*/
|
|
||||||
export async function handleGetStoreConfig({ tenantId }) {
|
export async function handleGetStoreConfig({ tenantId }) {
|
||||||
return await getStoreConfig({ tenantId });
|
return await getStoreConfig({ tenantId });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
|
|||||||
* Obtiene estadísticas de pedidos para el dashboard
|
* Obtiene estadísticas de pedidos para el dashboard
|
||||||
*/
|
*/
|
||||||
export async function handleGetOrderStats({ tenantId }) {
|
export async function handleGetOrderStats({ tenantId }) {
|
||||||
// 1. Sincronizar pedidos nuevos de Woo
|
// Sync en background — no bloqueamos el request
|
||||||
const syncResult = await syncOrdersIncremental({ tenantId });
|
const syncPromise = syncOrdersIncremental({ tenantId }).catch(err =>
|
||||||
|
console.error("[stats] sync error:", err)
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Obtener todas las estadísticas en paralelo
|
// Respondemos con lo que hay en DB mientras sincroniza
|
||||||
const [monthlyStats, productStats, yoyStats, totals] = await Promise.all([
|
const [monthlyStats, productStats, yoyStats, totals] = await Promise.all([
|
||||||
ordersRepo.getMonthlyStats({ tenantId }),
|
ordersRepo.getMonthlyStats({ tenantId }),
|
||||||
ordersRepo.getProductStats({ tenantId }),
|
ordersRepo.getProductStats({ tenantId }),
|
||||||
@@ -23,7 +25,6 @@ export async function handleGetOrderStats({ tenantId }) {
|
|||||||
order_counts: monthlyStats.order_counts,
|
order_counts: monthlyStats.order_counts,
|
||||||
by_source: monthlyStats.by_source,
|
by_source: monthlyStats.by_source,
|
||||||
by_shipping: monthlyStats.by_shipping,
|
by_shipping: monthlyStats.by_shipping,
|
||||||
by_payment: monthlyStats.by_payment,
|
|
||||||
|
|
||||||
// Totales agregados (para donuts)
|
// Totales agregados (para donuts)
|
||||||
totals_aggregated: totals,
|
totals_aggregated: totals,
|
||||||
@@ -36,8 +37,8 @@ export async function handleGetOrderStats({ tenantId }) {
|
|||||||
// YoY
|
// YoY
|
||||||
yoy: yoyStats,
|
yoy: yoyStats,
|
||||||
|
|
||||||
// Info de sync
|
// Info de sync (sincronizando en background)
|
||||||
synced: syncResult.synced,
|
synced: 0,
|
||||||
total_in_cache: syncResult.total,
|
total_in_cache: totals.total_orders ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ export async function handleRespondToTakeover({
|
|||||||
invariants: { ok: true, checks: [] },
|
invariants: { ok: true, checks: [] },
|
||||||
final_reply: response,
|
final_reply: response,
|
||||||
order_id: null,
|
order_id: null,
|
||||||
payment_link: null,
|
|
||||||
latency_ms: 0,
|
latency_ms: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -348,13 +347,10 @@ function summarizeContext(contextSnapshot) {
|
|||||||
summary.push(`Pendiente: ${pendingItems}`);
|
summary.push(`Pendiente: ${pendingItems}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shipping/Payment
|
// Shipping (sin payment — el bot no maneja pagos)
|
||||||
if (ctx.order?.is_delivery !== null) {
|
if (ctx.order?.is_delivery !== null) {
|
||||||
summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro");
|
summary.push(ctx.order.is_delivery ? "Delivery" : "Retiro");
|
||||||
}
|
}
|
||||||
if (ctx.order?.payment_type) {
|
|
||||||
summary.push(`Pago: ${ctx.order.payment_type}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary.join(" | ") || "Sin contexto";
|
return summary.join(" | ") || "Sin contexto";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createOrder, syncOrdersIncremental } from "../../4-woo-orders/wooOrders.js";
|
import { syncOrdersIncremental } from "../../4-woo-orders/wooOrders.js";
|
||||||
import { listProducts } from "../db/repo.js";
|
|
||||||
import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
|
import * as ordersRepo from "../../4-woo-orders/ordersRepo.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,51 +23,3 @@ export async function handleListOrders({ tenantId, page = 1, limit = 50 }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene productos con stock para testing
|
|
||||||
*/
|
|
||||||
export async function handleGetProductsWithStock({ tenantId }) {
|
|
||||||
const allProducts = await listProducts({ tenantId, limit: 500 });
|
|
||||||
const withStock = allProducts.filter(p =>
|
|
||||||
p.stock_status === "instock" &&
|
|
||||||
p.price &&
|
|
||||||
Number(p.price) > 0
|
|
||||||
);
|
|
||||||
return { items: withStock };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea una orden de prueba en WooCommerce
|
|
||||||
*/
|
|
||||||
export async function handleCreateTestOrder({ tenantId, basket, address, wa_chat_id }) {
|
|
||||||
if (!basket?.items?.length) {
|
|
||||||
throw new Error("basket_empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
const order = await createOrder({
|
|
||||||
tenantId,
|
|
||||||
wooCustomerId: null, // Sin customer de Woo para testing
|
|
||||||
basket,
|
|
||||||
address,
|
|
||||||
run_id: `test-${Date.now()}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calcular total desde line_items
|
|
||||||
let total = 0;
|
|
||||||
if (order?.raw?.line_items) {
|
|
||||||
for (const item of order.raw.line_items) {
|
|
||||||
total += Number(item.total) || 0;
|
|
||||||
}
|
|
||||||
} else if (order?.raw?.total) {
|
|
||||||
total = Number(order.raw.total) || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
ok: true,
|
|
||||||
woo_order_id: order?.id || null,
|
|
||||||
total,
|
|
||||||
line_items: order?.line_items || [],
|
|
||||||
raw: order?.raw || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,25 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
|
import { parseEvolutionWebhook } from "../services/evolutionParser.js";
|
||||||
import { resolveTenantId, processMessage } from "../../2-identity/services/pipeline.js";
|
import { processMessage } from "../../2-identity/services/pipeline.js";
|
||||||
|
import { getTenantId } from "../../shared/tenant.js";
|
||||||
import { debug as dbg } from "../../shared/debug.js";
|
import { debug as dbg } from "../../shared/debug.js";
|
||||||
|
|
||||||
export async function handleEvolutionWebhook(body) {
|
export async function handleEvolutionWebhook(body) {
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const parsed = parseEvolutionWebhook(body);
|
const parsed = parseEvolutionWebhook(body);
|
||||||
if (!parsed.ok) {
|
if (!parsed.ok) {
|
||||||
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
|
return { status: 200, payload: { ok: true, ignored: parsed.reason } };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dbg.perf || dbg.evolution) {
|
if (dbg.perf || dbg.evolution) {
|
||||||
console.log("[perf] evolution.webhook.start", {
|
console.log("[perf] evolution.webhook.start", {
|
||||||
tenant_key: parsed.tenant_key || null,
|
|
||||||
chat_id: parsed.chat_id,
|
chat_id: parsed.chat_id,
|
||||||
message_id: parsed.message_id || null,
|
message_id: parsed.message_id || null,
|
||||||
ts: parsed.ts || null,
|
ts: parsed.ts || null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenantId = await resolveTenantId({
|
const tenantId = getTenantId();
|
||||||
chat_id: parsed.chat_id,
|
|
||||||
tenant_key: parsed.tenant_key,
|
|
||||||
to_phone: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pm = await processMessage({
|
const pm = await processMessage({
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -31,9 +27,10 @@ if (!parsed.ok) {
|
|||||||
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
|
from: parsed.chat_id.replace("@s.whatsapp.net", ""),
|
||||||
displayName: parsed.from_name || null,
|
displayName: parsed.from_name || null,
|
||||||
text: parsed.text,
|
text: parsed.text,
|
||||||
|
inboundLocation: parsed.location || null,
|
||||||
provider: "evolution",
|
provider: "evolution",
|
||||||
message_id: parsed.message_id || crypto.randomUUID(),
|
message_id: parsed.message_id || crypto.randomUUID(),
|
||||||
meta: { pushName: parsed.from_name, ts: parsed.ts, instance: parsed.tenant_key, source: parsed.source },
|
meta: { pushName: parsed.from_name, ts: parsed.ts, source: parsed.source },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (dbg.perf || dbg.evolution) {
|
if (dbg.perf || dbg.evolution) {
|
||||||
@@ -48,4 +45,3 @@ if (!parsed.ok) {
|
|||||||
|
|
||||||
return { status: 200, payload: { ok: true } };
|
return { status: 200, payload: { ok: true } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,33 @@
|
|||||||
import crypto from "crypto";
|
import crypto from "crypto";
|
||||||
import { resolveTenantId } from "../../2-identity/services/pipeline.js";
|
|
||||||
import { processMessage } from "../../2-identity/services/pipeline.js";
|
import { processMessage } from "../../2-identity/services/pipeline.js";
|
||||||
|
import { getTenantId } from "../../shared/tenant.js";
|
||||||
|
|
||||||
export async function handleSimSend(body) {
|
export async function handleSimSend(body) {
|
||||||
const { chat_id, from_phone, text } = body || {};
|
const { chat_id, from_phone, text, location } = body || {};
|
||||||
if (!chat_id || !from_phone || !text) {
|
if (!chat_id || !from_phone || (!text && !location)) {
|
||||||
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, text are required" } };
|
return { status: 400, payload: { ok: false, error: "chat_id, from_phone, and text or location are required" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aceptar location share desde el simulator. Mismo formato que el parser de
|
||||||
|
// Evolution: { lat, lng, label? }.
|
||||||
|
const inboundLocation =
|
||||||
|
location && typeof location.lat === "number" && typeof location.lng === "number"
|
||||||
|
? { lat: location.lat, lng: location.lng, label: location.label || null }
|
||||||
|
: null;
|
||||||
|
|
||||||
const provider = "sim";
|
const provider = "sim";
|
||||||
const message_id = crypto.randomUUID();
|
const message_id = crypto.randomUUID();
|
||||||
const tenantId = await resolveTenantId({
|
const tenantId = getTenantId();
|
||||||
chat_id,
|
|
||||||
tenant_key: body?.tenant_key,
|
|
||||||
to_phone: body?.to_phone,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await processMessage({
|
const result = await processMessage({
|
||||||
tenantId,
|
tenantId,
|
||||||
chat_id,
|
chat_id,
|
||||||
from: from_phone,
|
from: from_phone,
|
||||||
text,
|
text: text || "",
|
||||||
|
inboundLocation,
|
||||||
provider,
|
provider,
|
||||||
message_id,
|
message_id,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { status: 200, payload: { ok: true, run_id: result.run_id, reply: result.reply } };
|
return { status: 200, payload: { ok: true, run_id: result.run_id, reply: result.reply } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import { makeSearchProducts, makeListProducts, makeGetProduct, makeSyncProducts,
|
|||||||
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
|
import { makeListAliases, makeCreateAlias, makeUpdateAlias, makeDeleteAlias } from "../../0-ui/controllers/aliases.js";
|
||||||
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
|
import { makeListRecommendations, makeGetRecommendation, makeCreateRecommendation, makeUpdateRecommendation, makeDeleteRecommendation } from "../../0-ui/controllers/recommendations.js";
|
||||||
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
|
import { makeListProductQtyRules, makeGetProductQtyRules, makeSaveProductQtyRules } from "../../0-ui/controllers/quantities.js";
|
||||||
import { makeListPrompts, makeGetPrompt, makeSavePrompt, makeRollbackPrompt, makeResetPrompt, makeGetPromptVersion, makeTestPrompt } from "../../0-ui/controllers/prompts.js";
|
// Prompts CRUD removido: el agente nuevo usa un system prompt único hardcoded.
|
||||||
import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js";
|
import { makeListPendingTakeovers, makeListAllTakeovers, makeGetTakeover, makeRespondToTakeover, makeCancelTakeover, makeCheckPendingTakeover } from "../../0-ui/controllers/takeovers.js";
|
||||||
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
|
import { makeGetSettings, makeSaveSettings } from "../../0-ui/controllers/settings.js";
|
||||||
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
|
import { makeDeleteConversation, makeDeleteUser, makeListUsers, makeRetryLast } from "../../0-ui/controllers/admin.js";
|
||||||
import { makeListOrders, makeGetOrderStats, makeGetProductsWithStock, makeCreateTestOrder } from "../../0-ui/controllers/testing.js";
|
import { makeListOrders, makeGetOrderStats } from "../../0-ui/controllers/testing.js";
|
||||||
|
import { getAgentMetrics } from "../../3-turn-engine/agent/runTurn.js";
|
||||||
|
|
||||||
function nowIso() {
|
function nowIso() {
|
||||||
return new Date().toISOString();
|
return new Date().toISOString();
|
||||||
@@ -50,6 +51,7 @@ export function createSimulatorRouter({ tenantId }) {
|
|||||||
* --- UI data endpoints ---
|
* --- UI data endpoints ---
|
||||||
*/
|
*/
|
||||||
router.post("/sim/send", makeSimSend());
|
router.post("/sim/send", makeSimSend());
|
||||||
|
router.get("/api/metrics/agent", (req, res) => res.json(getAgentMetrics()));
|
||||||
|
|
||||||
router.get("/conversations", makeGetConversations(getTenantId));
|
router.get("/conversations", makeGetConversations(getTenantId));
|
||||||
router.get("/conversations/state", makeGetConversationState(getTenantId));
|
router.get("/conversations/state", makeGetConversationState(getTenantId));
|
||||||
@@ -81,13 +83,8 @@ export function createSimulatorRouter({ tenantId }) {
|
|||||||
router.put("/quantities/:wooProductId", makeSaveProductQtyRules(getTenantId));
|
router.put("/quantities/:wooProductId", makeSaveProductQtyRules(getTenantId));
|
||||||
|
|
||||||
// --- Prompts routes ---
|
// --- Prompts routes ---
|
||||||
router.get("/prompts", makeListPrompts(getTenantId));
|
// /prompts/* removido tras flip a agente tool-calling. El system prompt
|
||||||
router.get("/prompts/:key", makeGetPrompt(getTenantId));
|
// único vive en src/modules/3-turn-engine/agent/systemPrompt.js.
|
||||||
router.post("/prompts/:key", makeSavePrompt(getTenantId));
|
|
||||||
router.post("/prompts/:key/rollback/:version", makeRollbackPrompt(getTenantId));
|
|
||||||
router.post("/prompts/:key/reset", makeResetPrompt(getTenantId));
|
|
||||||
router.get("/prompts/:key/versions/:version", makeGetPromptVersion(getTenantId));
|
|
||||||
router.post("/prompts/:key/test", makeTestPrompt(getTenantId));
|
|
||||||
|
|
||||||
// --- Human Takeovers routes ---
|
// --- Human Takeovers routes ---
|
||||||
router.get("/takeovers", makeListPendingTakeovers(getTenantId));
|
router.get("/takeovers", makeListPendingTakeovers(getTenantId));
|
||||||
@@ -111,10 +108,6 @@ export function createSimulatorRouter({ tenantId }) {
|
|||||||
router.get("/api/orders", makeListOrders(getTenantId));
|
router.get("/api/orders", makeListOrders(getTenantId));
|
||||||
router.get("/api/stats/orders", makeGetOrderStats(getTenantId));
|
router.get("/api/stats/orders", makeGetOrderStats(getTenantId));
|
||||||
|
|
||||||
// --- Testing routes ---
|
|
||||||
router.get("/test/products-with-stock", makeGetProductsWithStock(getTenantId));
|
|
||||||
router.post("/test/order", makeCreateTestOrder(getTenantId));
|
|
||||||
|
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,19 @@ export function parseEvolutionWebhook(reqBody) {
|
|||||||
(typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) ||
|
(typeof msg.extendedTextMessage?.text === "string" && msg.extendedTextMessage.text) ||
|
||||||
"";
|
"";
|
||||||
|
|
||||||
|
// extract location share (WhatsApp pin). Evolution wraps Baileys formats:
|
||||||
|
// - locationMessage: { degreesLatitude, degreesLongitude, name?, address? }
|
||||||
|
// - liveLocationMessage: { degreesLatitude, degreesLongitude }
|
||||||
|
const loc = msg.locationMessage || msg.liveLocationMessage || null;
|
||||||
|
const lat = loc?.degreesLatitude;
|
||||||
|
const lng = loc?.degreesLongitude;
|
||||||
|
const location =
|
||||||
|
typeof lat === "number" && typeof lng === "number"
|
||||||
|
? { lat, lng, label: loc?.name || loc?.address || null }
|
||||||
|
: null;
|
||||||
|
|
||||||
const cleanText = String(text).trim();
|
const cleanText = String(text).trim();
|
||||||
if (!cleanText) return { ok: false, reason: "empty_text" };
|
if (!cleanText && !location) return { ok: false, reason: "empty_message" };
|
||||||
|
|
||||||
// metadata
|
// metadata
|
||||||
const pushName = data.pushName || null;
|
const pushName = data.pushName || null;
|
||||||
@@ -48,6 +59,7 @@ export function parseEvolutionWebhook(reqBody) {
|
|||||||
chat_id: remoteJid,
|
chat_id: remoteJid,
|
||||||
message_id: messageId || null,
|
message_id: messageId || null,
|
||||||
text: cleanText,
|
text: cleanText,
|
||||||
|
location,
|
||||||
from_name: pushName,
|
from_name: pushName,
|
||||||
message_type: messageType || null,
|
message_type: messageType || null,
|
||||||
ts,
|
ts,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
|
import { refreshProductByWooId } from "../../shared/wooSnapshot.js";
|
||||||
import { getTenantByKey } from "../db/repo.js";
|
import { getTenantId } from "../../shared/tenant.js";
|
||||||
import { insertAuditLog } from "../../0-ui/db/repo.js";
|
import { insertAuditLog } from "../../0-ui/db/repo.js";
|
||||||
|
|
||||||
function unauthorized(res) {
|
function unauthorized(res) {
|
||||||
@@ -48,11 +48,8 @@ export function makeWooProductWebhook() {
|
|||||||
const { id, parentId, resource, action, changes } = parseWooPayload(req.body || {});
|
const { id, parentId, resource, action, changes } = parseWooPayload(req.body || {});
|
||||||
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
|
if (!id) return res.status(400).json({ ok: false, error: "missing_id" });
|
||||||
|
|
||||||
// Determinar tenant por query ?tenant_key=...
|
// Mono-tenant: el tenant es el único cargado al boot.
|
||||||
const tenantKey = req.query?.tenant_key || process.env.TENANT_KEY || null;
|
const tenant = { id: getTenantId() };
|
||||||
if (!tenantKey) return res.status(400).json({ ok: false, error: "missing_tenant_key" });
|
|
||||||
const tenant = await getTenantByKey(String(tenantKey).toLowerCase());
|
|
||||||
if (!tenant?.id) return res.status(404).json({ ok: false, error: "tenant_not_found" });
|
|
||||||
|
|
||||||
const parentForVariation =
|
const parentForVariation =
|
||||||
resource && String(resource).includes("variation") ? parentId || null : null;
|
resource && String(resource).includes("variation") ? parentId || null : null;
|
||||||
|
|||||||
@@ -461,21 +461,6 @@ export async function deleteIdentityMapByChat({ tenant_id, wa_chat_id, provider
|
|||||||
return rowCount || 0;
|
return rowCount || 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTenantByKey(key) {
|
|
||||||
const { rows } = await pool.query(`select id, key, name from tenants where key=$1`, [key]);
|
|
||||||
return rows[0] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getTenantIdByChannel({ channel_type, channel_key }) {
|
|
||||||
const q = `
|
|
||||||
select tenant_id
|
|
||||||
from tenant_channels
|
|
||||||
where channel_type=$1 and channel_key=$2
|
|
||||||
`;
|
|
||||||
const { rows } = await pool.query(q, [channel_type, channel_key]);
|
|
||||||
return rows[0]?.tenant_id || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getExternalCustomerIdByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
|
export async function getExternalCustomerIdByChat({ tenant_id, wa_chat_id, provider = "woo" }) {
|
||||||
const q = `
|
const q = `
|
||||||
select external_customer_id
|
select external_customer_id
|
||||||
@@ -535,32 +520,57 @@ export async function getDecryptedTenantEcommerceConfig({
|
|||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchProductAliases({ tenant_id, q = "", limit = 20 }) {
|
export async function searchProductAliases({ tenant_id, q = "", limit = 20, threshold = 0.3 }) {
|
||||||
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
|
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 20));
|
||||||
const query = String(q || "").trim();
|
const query = String(q || "").trim();
|
||||||
if (!query) return [];
|
if (!query) return [];
|
||||||
const normalized = query.toLowerCase();
|
const normalized = query.toLowerCase();
|
||||||
const like = `%${query}%`;
|
|
||||||
const nlike = `%${normalized}%`;
|
|
||||||
const sql = `
|
const sql = `
|
||||||
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at
|
select tenant_id, alias, normalized_alias, woo_product_id, category_hint, boost, metadata, updated_at,
|
||||||
|
greatest(similarity(alias, $2), similarity(normalized_alias, $3)) as sim
|
||||||
from product_aliases
|
from product_aliases
|
||||||
where tenant_id=$1
|
where tenant_id = $1
|
||||||
and (alias ilike $2 or normalized_alias ilike $3)
|
and (alias % $2 or normalized_alias % $3)
|
||||||
order by boost desc, updated_at desc
|
order by sim desc, boost desc, updated_at desc
|
||||||
limit $4
|
limit $4
|
||||||
`;
|
`;
|
||||||
const { rows } = await pool.query(sql, [tenant_id, like, nlike, lim]);
|
const { rows } = await pool.query(sql, [tenant_id, query, normalized, lim]);
|
||||||
return rows.map((r) => ({
|
return rows
|
||||||
tenant_id: r.tenant_id,
|
.filter((r) => Number(r.sim) >= threshold)
|
||||||
alias: r.alias,
|
.map((r) => ({
|
||||||
normalized_alias: r.normalized_alias,
|
tenant_id: r.tenant_id,
|
||||||
woo_product_id: r.woo_product_id,
|
alias: r.alias,
|
||||||
category_hint: r.category_hint,
|
normalized_alias: r.normalized_alias,
|
||||||
boost: r.boost,
|
woo_product_id: r.woo_product_id,
|
||||||
metadata: r.metadata,
|
category_hint: r.category_hint,
|
||||||
updated_at: r.updated_at,
|
boost: r.boost,
|
||||||
}));
|
metadata: r.metadata,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
similarity: Number(r.sim),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchAliasProductMappings({ tenant_id, q = "", limit = 50, threshold = 0.3 }) {
|
||||||
|
const lim = Math.max(1, Math.min(200, parseInt(limit, 10) || 50));
|
||||||
|
const query = String(q || "").trim();
|
||||||
|
if (!query) return [];
|
||||||
|
const normalized = query.toLowerCase();
|
||||||
|
const sql = `
|
||||||
|
select alias, woo_product_id, score, similarity(alias, $2) as sim
|
||||||
|
from alias_product_mappings
|
||||||
|
where tenant_id = $1 and alias % $2
|
||||||
|
order by sim desc, score desc
|
||||||
|
limit $3
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(sql, [tenant_id, normalized, lim]);
|
||||||
|
return rows
|
||||||
|
.filter((r) => Number(r.sim) >= threshold)
|
||||||
|
.map((r) => ({
|
||||||
|
alias: r.alias,
|
||||||
|
woo_product_id: Number(r.woo_product_id),
|
||||||
|
score: Number(r.score || 1),
|
||||||
|
similarity: Number(r.sim),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecoRules({ tenant_id }) {
|
export async function getRecoRules({ tenant_id }) {
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import {
|
|||||||
getExternalCustomerIdByChat,
|
getExternalCustomerIdByChat,
|
||||||
upsertExternalCustomerMap,
|
upsertExternalCustomerMap,
|
||||||
updateRunLatency,
|
updateRunLatency,
|
||||||
getTenantByKey,
|
|
||||||
getTenantIdByChannel,
|
|
||||||
} from "../db/repo.js";
|
} from "../db/repo.js";
|
||||||
|
import { getTenantId } from "../../shared/tenant.js";
|
||||||
import { sseSend } from "../../shared/sse.js";
|
import { sseSend } from "../../shared/sse.js";
|
||||||
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
import { createWooCustomer, getWooCustomerById } from "./woo.js";
|
||||||
import { debug as dbg } from "../../shared/debug.js";
|
import { debug as dbg } from "../../shared/debug.js";
|
||||||
@@ -122,6 +121,7 @@ export async function processMessage({
|
|||||||
message_id,
|
message_id,
|
||||||
displayName = null,
|
displayName = null,
|
||||||
meta = null,
|
meta = null,
|
||||||
|
inboundLocation = null,
|
||||||
}) {
|
}) {
|
||||||
const { started_at, mark, msBetween } = makePerf();
|
const { started_at, mark, msBetween } = makePerf();
|
||||||
const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
|
const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: chat_id });
|
||||||
@@ -129,12 +129,15 @@ const prev = await touchConversationState({ tenant_id: tenantId, wa_chat_id: cha
|
|||||||
mark("start");
|
mark("start");
|
||||||
const stageDebug = dbg.perf;
|
const stageDebug = dbg.perf;
|
||||||
mark("after_touchConversationState");
|
mark("after_touchConversationState");
|
||||||
// Detectar conversación nueva (más de 24 horas sin actividad)
|
// TTL stale: 24h general, 7d si la conversación quedó PAUSED, sin TTL si AWAITING_HUMAN.
|
||||||
const staleThresholdMs = 24 * 60 * 60 * 1000; // 24 horas
|
const stateNow = prev?.state || "IDLE";
|
||||||
|
let staleThresholdMs = 24 * 60 * 60 * 1000;
|
||||||
|
if (stateNow === "PAUSED") staleThresholdMs = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
if (stateNow === "AWAITING_HUMAN") staleThresholdMs = Infinity;
|
||||||
const isStale =
|
const isStale =
|
||||||
prev?.state_updated_at &&
|
prev?.state_updated_at &&
|
||||||
Date.now() - new Date(prev.state_updated_at).getTime() > staleThresholdMs;
|
Date.now() - new Date(prev.state_updated_at).getTime() > staleThresholdMs;
|
||||||
const prev_state = isStale ? "IDLE" : prev?.state || "IDLE";
|
const prev_state = isStale ? "IDLE" : stateNow;
|
||||||
let externalCustomerId = await getExternalCustomerIdByChat({
|
let externalCustomerId = await getExternalCustomerIdByChat({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
wa_chat_id: chat_id,
|
wa_chat_id: chat_id,
|
||||||
@@ -142,7 +145,7 @@ let externalCustomerId = await getExternalCustomerIdByChat({
|
|||||||
});
|
});
|
||||||
mark("after_getExternalCustomerIdByChat");
|
mark("after_getExternalCustomerIdByChat");
|
||||||
|
|
||||||
await insertMessage({
|
const inserted = await insertMessage({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
wa_chat_id: chat_id,
|
wa_chat_id: chat_id,
|
||||||
provider,
|
provider,
|
||||||
@@ -154,6 +157,15 @@ let externalCustomerId = await getExternalCustomerIdByChat({
|
|||||||
});
|
});
|
||||||
mark("after_insertMessage_in");
|
mark("after_insertMessage_in");
|
||||||
|
|
||||||
|
// Idempotency: si el message_id ya estaba insertado (Evolution suele
|
||||||
|
// reentregar webhooks), evitamos volver a procesar el turn entero.
|
||||||
|
if (!inserted) {
|
||||||
|
if (dbg.perf || dbg.evolution) {
|
||||||
|
console.log("[pipeline] duplicate message ignored", { message_id, chat_id });
|
||||||
|
}
|
||||||
|
return { run_id: null, reply: null, duplicate: true };
|
||||||
|
}
|
||||||
|
|
||||||
mark("before_getRecentMessagesForLLM_for_plan");
|
mark("before_getRecentMessagesForLLM_for_plan");
|
||||||
const history = await getRecentMessagesForLLM({
|
const history = await getRecentMessagesForLLM({
|
||||||
tenant_id: tenantId,
|
tenant_id: tenantId,
|
||||||
@@ -168,17 +180,34 @@ let externalCustomerId = await getExternalCustomerIdByChat({
|
|||||||
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
let reducedContext = prev?.context && typeof prev?.context === "object" ? { ...prev.context } : {};
|
||||||
if (isStale) {
|
if (isStale) {
|
||||||
// Conversación nueva: resetear carrito pero mantener datos del cliente
|
// Conversación nueva: resetear carrito pero mantener datos del cliente
|
||||||
|
// (external_customer_id) y la "última entrega" para que el bot pueda
|
||||||
|
// ofrecer "te lo mando al mismo lugar que la otra vez?".
|
||||||
reducedContext = {
|
reducedContext = {
|
||||||
external_customer_id: reducedContext.external_customer_id,
|
external_customer_id: reducedContext.external_customer_id,
|
||||||
// Resetear order y pending
|
last_delivery: reducedContext.last_delivery || null,
|
||||||
order: null,
|
order: null,
|
||||||
order_basket: null,
|
order_basket: null,
|
||||||
pending_items: null,
|
pending_items: null,
|
||||||
// Marcar que fue reseteado
|
|
||||||
_reset_reason: "stale",
|
_reset_reason: "stale",
|
||||||
_reset_at: new Date().toISOString(),
|
_reset_at: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Si llegó una ubicación compartida (WhatsApp pin), guardarla en pending
|
||||||
|
// para que el agente la lea via working_memory y matchee zona en set_address.
|
||||||
|
if (inboundLocation && typeof inboundLocation.lat === "number" && typeof inboundLocation.lng === "number") {
|
||||||
|
const baseOrder = reducedContext.order && typeof reducedContext.order === "object" ? reducedContext.order : {};
|
||||||
|
const merged = { ...baseOrder };
|
||||||
|
if (!Array.isArray(merged.cart)) merged.cart = [];
|
||||||
|
if (!Array.isArray(merged.pending)) merged.pending = [];
|
||||||
|
merged.pending_location = {
|
||||||
|
lat: inboundLocation.lat,
|
||||||
|
lng: inboundLocation.lng,
|
||||||
|
label: inboundLocation.label || null,
|
||||||
|
received_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
reducedContext.order = merged;
|
||||||
|
}
|
||||||
let decision;
|
let decision;
|
||||||
let plan;
|
let plan;
|
||||||
let llmMeta;
|
let llmMeta;
|
||||||
@@ -205,7 +234,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
ok: true,
|
ok: true,
|
||||||
checks: [
|
checks: [
|
||||||
{ name: "required_keys_present", ok: true },
|
{ name: "required_keys_present", ok: true },
|
||||||
{ name: "no_checkout_without_payment_link", ok: true },
|
|
||||||
{ name: "no_order_action_without_items", ok: true },
|
{ name: "no_order_action_without_items", ok: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -257,16 +285,14 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
...baseAddress,
|
...baseAddress,
|
||||||
phone: baseAddress.phone || phoneFromWa,
|
phone: baseAddress.phone || phoneFromWa,
|
||||||
};
|
};
|
||||||
// Obtener shipping_method y payment_method del contexto (preferir decision que es el resultado del turn)
|
// shipping_method del contexto (delivery|pickup). El cobro se gestiona offline.
|
||||||
const shippingMethod = decision?.context_patch?.shipping_method || reducedContext?.shipping_method || null;
|
const shippingMethod = decision?.context_patch?.shipping_method || reducedContext?.shipping_method || null;
|
||||||
const paymentMethod = decision?.context_patch?.payment_method || reducedContext?.payment_method || null;
|
|
||||||
const order = await createOrder({
|
const order = await createOrder({
|
||||||
tenantId,
|
tenantId,
|
||||||
wooCustomerId: externalCustomerId,
|
wooCustomerId: externalCustomerId,
|
||||||
basket: basketToUse,
|
basket: basketToUse,
|
||||||
address: addressWithPhone,
|
address: addressWithPhone,
|
||||||
shippingMethod,
|
shippingMethod,
|
||||||
paymentMethod,
|
|
||||||
run_id: null,
|
run_id: null,
|
||||||
});
|
});
|
||||||
actionPatch.woo_order_id = order?.id || null;
|
actionPatch.woo_order_id = order?.id || null;
|
||||||
@@ -391,7 +417,7 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
const orderForFsm = context?.order || context?.order_basket || {};
|
const orderForFsm = context?.order || context?.order_basket || {};
|
||||||
const signals = {
|
const signals = {
|
||||||
confirm_order: plan.intent === "confirm_order",
|
confirm_order: plan.intent === "confirm_order",
|
||||||
payment_selected: plan.intent === "select_payment",
|
shipping_completed: plan.order_action === "create_order",
|
||||||
};
|
};
|
||||||
const nextState = safeNextState(prev_state, orderForFsm, signals).next_state;
|
const nextState = safeNextState(prev_state, orderForFsm, signals).next_state;
|
||||||
plan.next_state = nextState;
|
plan.next_state = nextState;
|
||||||
@@ -448,7 +474,6 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
invariants,
|
invariants,
|
||||||
final_reply: plan.reply,
|
final_reply: plan.reply,
|
||||||
order_id: actionPatch.woo_order_id || null,
|
order_id: actionPatch.woo_order_id || null,
|
||||||
payment_link: actionPatch.payment_link || null,
|
|
||||||
latency_ms: end_to_end_ms,
|
latency_ms: end_to_end_ms,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -473,28 +498,11 @@ const runStatus = llmMeta?.error ? "warn" : "ok";
|
|||||||
return { run_id, reply: plan.reply };
|
return { run_id, reply: plan.reply };
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTenantFromChatId(chat_id) {
|
/**
|
||||||
const m = /^([a-z0-9_-]+):/.exec(chat_id);
|
* Mono-tenant: devuelve el id resuelto al boot. No hace queries por turno.
|
||||||
return m?.[1]?.toLowerCase() || null;
|
* Se mantiene como async para no romper callers existentes.
|
||||||
}
|
*/
|
||||||
|
export async function resolveTenantId() {
|
||||||
export async function resolveTenantId({ chat_id, to_phone = null, tenant_key = null }) {
|
return getTenantId();
|
||||||
const explicit = (tenant_key || parseTenantFromChatId(chat_id) || "").toLowerCase();
|
|
||||||
|
|
||||||
if (explicit) {
|
|
||||||
const t = await getTenantByKey(explicit);
|
|
||||||
if (t) return t.id;
|
|
||||||
throw new Error(`tenant_not_found: ${explicit}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (to_phone) {
|
|
||||||
const id = await getTenantIdByChannel({ channel_type: "whatsapp", channel_key: to_phone });
|
|
||||||
if (id) return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackKey = (process.env.TENANT_KEY || "piaf").toLowerCase();
|
|
||||||
const t = await getTenantByKey(fallbackKey);
|
|
||||||
if (t) return t.id;
|
|
||||||
throw new Error(`tenant_not_found: ${fallbackKey}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
112
src/modules/3-turn-engine/agent/customerProfile.js
Normal file
112
src/modules/3-turn-engine/agent/customerProfile.js
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
/**
|
||||||
|
* customerProfile — perfil del cliente para "lo de siempre".
|
||||||
|
*
|
||||||
|
* Lookup por teléfono (extraído del chat_id WhatsApp) en woo_orders_cache.
|
||||||
|
* Agrupa items por woo_product_id en los últimos 6 meses, top 5 frequent_items.
|
||||||
|
*
|
||||||
|
* Cache 10 min por chat_id.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { pool } from "../../shared/db/pool.js";
|
||||||
|
|
||||||
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
||||||
|
const _cache = new Map();
|
||||||
|
|
||||||
|
function phoneFromChatId(chatId) {
|
||||||
|
if (!chatId) return null;
|
||||||
|
// chat_id típico: "5491133230322@s.whatsapp.net"
|
||||||
|
const m = /^(\d+)/.exec(String(chatId));
|
||||||
|
return m ? m[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePhone(p) {
|
||||||
|
return String(p || "").replace(/[^\d]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Devuelve perfil del cliente o null si no hay datos.
|
||||||
|
*/
|
||||||
|
export async function getCustomerProfile({ tenantId, chat_id }) {
|
||||||
|
if (!tenantId || !chat_id) return null;
|
||||||
|
const cacheKey = `${tenantId}:${chat_id}`;
|
||||||
|
const cached = _cache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.t < CACHE_TTL_MS) return cached.value;
|
||||||
|
|
||||||
|
const phone = phoneFromChatId(chat_id);
|
||||||
|
if (!phone) {
|
||||||
|
_cache.set(cacheKey, { value: null, t: Date.now() });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await fetchProfile({ tenantId, phone });
|
||||||
|
_cache.set(cacheKey, { value: profile, t: Date.now() });
|
||||||
|
return profile;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[customerProfile] error:", err?.message || err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProfile({ tenantId, phone }) {
|
||||||
|
const phoneClean = normalizePhone(phone);
|
||||||
|
if (!phoneClean) return null;
|
||||||
|
|
||||||
|
// Match phones que terminen igual (los Woo a veces vienen con +54 o sin)
|
||||||
|
const phoneSuffix = phoneClean.slice(-8);
|
||||||
|
|
||||||
|
const orderSql = `
|
||||||
|
SELECT id, woo_order_id, total, date_created,
|
||||||
|
shipping_address_1, shipping_address_2, shipping_city, customer_name
|
||||||
|
FROM woo_orders_cache
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND regexp_replace(coalesce(customer_phone,''), '\\D', '', 'g') LIKE '%' || $2
|
||||||
|
AND date_created > NOW() - INTERVAL '6 months'
|
||||||
|
ORDER BY date_created DESC
|
||||||
|
LIMIT 30
|
||||||
|
`;
|
||||||
|
const { rows: orders } = await pool.query(orderSql, [tenantId, phoneSuffix]);
|
||||||
|
if (orders.length === 0) {
|
||||||
|
return { is_returning: false, last_order_at: null, frequent_items: [], preferred_address: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderIds = orders.map((o) => o.woo_order_id);
|
||||||
|
const itemsSql = `
|
||||||
|
SELECT woo_product_id, product_name, sell_unit,
|
||||||
|
COUNT(*) AS times,
|
||||||
|
SUM(quantity) AS total_qty,
|
||||||
|
AVG(quantity) AS avg_qty
|
||||||
|
FROM woo_order_items
|
||||||
|
WHERE tenant_id = $1 AND woo_order_id = ANY($2::bigint[]) AND woo_product_id IS NOT NULL
|
||||||
|
GROUP BY woo_product_id, product_name, sell_unit
|
||||||
|
ORDER BY times DESC, total_qty DESC
|
||||||
|
LIMIT 5
|
||||||
|
`;
|
||||||
|
const { rows: items } = await pool.query(itemsSql, [tenantId, orderIds]);
|
||||||
|
|
||||||
|
const frequent_items = items.map((it) => ({
|
||||||
|
woo_id: Number(it.woo_product_id),
|
||||||
|
name: it.product_name,
|
||||||
|
times_ordered: Number(it.times),
|
||||||
|
avg_qty: Number(it.avg_qty),
|
||||||
|
avg_unit: it.sell_unit || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lastOrder = orders[0];
|
||||||
|
const preferredAddress = [lastOrder.shipping_address_1, lastOrder.shipping_address_2, lastOrder.shipping_city]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return {
|
||||||
|
is_returning: true,
|
||||||
|
last_order_at: lastOrder.date_created,
|
||||||
|
customer_name: lastOrder.customer_name || null,
|
||||||
|
frequent_items,
|
||||||
|
preferred_address: preferredAddress || null,
|
||||||
|
total_orders_last_6m: orders.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateCustomerProfileCache(chat_id) {
|
||||||
|
for (const k of _cache.keys()) if (k.endsWith(`:${chat_id}`)) _cache.delete(k);
|
||||||
|
}
|
||||||
148
src/modules/3-turn-engine/agent/quantityParser.js
Normal file
148
src/modules/3-turn-engine/agent/quantityParser.js
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
/**
|
||||||
|
* Quantity parser determinista (es-AR).
|
||||||
|
*
|
||||||
|
* Pre-procesa el texto del usuario para extraer cantidad+unidad ANTES del LLM.
|
||||||
|
* El resultado se pasa al agente como side-channel (`working_memory.preparsed`)
|
||||||
|
* — el agente lo ve pero puede sobreescribirlo si el contexto lo amerita.
|
||||||
|
*
|
||||||
|
* Cubre los patrones AR-es más comunes:
|
||||||
|
* - Fracciones: "1/4 kg", "1/2 kilo"
|
||||||
|
* - Frases compuestas: "media docena", "cuarto kilo", "cuarto de kilo",
|
||||||
|
* "tres cuartos", "medio kilo", "par"
|
||||||
|
* - Numerales con unidad: "300 gramos", "0.5kg", "2 botellas"
|
||||||
|
* - Numerales solos: "300", "0.5" (unit=null para que el contexto decida)
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NUMBER_WORDS = {
|
||||||
|
un: 1, uno: 1, una: 1,
|
||||||
|
dos: 2, tres: 3, cuatro: 4, cinco: 5,
|
||||||
|
seis: 6, siete: 7, ocho: 8, nueve: 9, diez: 10,
|
||||||
|
once: 11, doce: 12, trece: 13, catorce: 14, quince: 15,
|
||||||
|
dieciseis: 16, diecisiete: 17, dieciocho: 18, diecinueve: 19,
|
||||||
|
veinte: 20, veintidos: 22, veinticinco: 25, treinta: 30,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Frases compuestas. Orden importa: las más largas primero.
|
||||||
|
const PHRASES = [
|
||||||
|
["tres cuartos de kilo", { qty: 0.75, unit: "kg" }],
|
||||||
|
["tres cuartos kilo", { qty: 0.75, unit: "kg" }],
|
||||||
|
["tres cuartos", { qty: 0.75, unit: "kg" }],
|
||||||
|
["cuarto de kilo", { qty: 0.25, unit: "kg" }],
|
||||||
|
["cuarto kilo", { qty: 0.25, unit: "kg" }],
|
||||||
|
["un cuarto", { qty: 0.25, unit: "kg" }],
|
||||||
|
["media docena", { qty: 6, unit: "unit" }],
|
||||||
|
["medio kilo", { qty: 0.5, unit: "kg" }],
|
||||||
|
["media kilo", { qty: 0.5, unit: "kg" }],
|
||||||
|
["dos kilos", { qty: 2, unit: "kg" }],
|
||||||
|
["tres kilos", { qty: 3, unit: "kg" }],
|
||||||
|
["cinco kilos", { qty: 5, unit: "kg" }],
|
||||||
|
["un kilo", { qty: 1, unit: "kg" }],
|
||||||
|
["docena", { qty: 12, unit: "unit" }],
|
||||||
|
["par", { qty: 2, unit: "unit" }],
|
||||||
|
["pareja", { qty: 2, unit: "unit" }],
|
||||||
|
];
|
||||||
|
|
||||||
|
// Fracción: respeta separación opcional con espacios. NO depende de \b.
|
||||||
|
const FRACTION = /(\d+)\s*\/\s*(\d+)/;
|
||||||
|
// Decimal con punto o coma. Capta "2.5", "0,3", "300", "0.5"
|
||||||
|
const NUMERIC = /(\d+(?:[.,]\d+)?)/;
|
||||||
|
|
||||||
|
// Unidades: pueden venir pegadas al número ("2kg") o separadas ("2 kg").
|
||||||
|
// Lookbehind opcional para dígito o non-letter; lookahead obligatorio para
|
||||||
|
// non-letter o fin de string. Probamos kilos antes que gramos para evitar
|
||||||
|
// que "kg" matchee "g" (ambos requieren post boundary, pero kg es 2 chars).
|
||||||
|
const UNIT_KG_RE = /(?:kgs?|kilos?|kilogramos?)(?![a-z])/i;
|
||||||
|
const UNIT_G_RE = /(?:^|[^a-z])(?:g|gr|grs|gramos?)(?![a-z])/i;
|
||||||
|
const UNIT_UNIT_RE = /(?:unidad(?:es)?|botellas?|frascos?|paquetes?|atados?|piezas?)(?![a-z])|(?:^|\s)u(?:\s|$)/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lower + sin diacríticos. Conserva dígitos y separadores numéricos.
|
||||||
|
*/
|
||||||
|
function lowerNoDiacritics(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD").replace(/[̀-ͯ]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detecta la unidad explícita en el texto. Devuelve null si no hay.
|
||||||
|
*/
|
||||||
|
export function detectUnit(text) {
|
||||||
|
const t = lowerNoDiacritics(text);
|
||||||
|
if (!t) return null;
|
||||||
|
if (UNIT_KG_RE.test(t)) return "kg";
|
||||||
|
if (UNIT_G_RE.test(t)) return "g";
|
||||||
|
if (UNIT_UNIT_RE.test(t)) return "unit";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function postProcess(qty, unit) {
|
||||||
|
if (!Number.isFinite(qty) || qty <= 0) return null;
|
||||||
|
return { qty, unit };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrae cantidad+unidad del texto. Devuelve `null` si no encuentra nada confiable.
|
||||||
|
* Confidence:
|
||||||
|
* - 0.95 fracción explícita
|
||||||
|
* - 0.9 frase compuesta o numérico+unit
|
||||||
|
* - 0.85 palabra + unit
|
||||||
|
* - 0.7 numérico solo
|
||||||
|
*/
|
||||||
|
export function parseQuantity(text) {
|
||||||
|
const raw = String(text || "").trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
const t = lowerNoDiacritics(raw);
|
||||||
|
|
||||||
|
// 1) Fracción explícita
|
||||||
|
const fracMatch = FRACTION.exec(t);
|
||||||
|
if (fracMatch) {
|
||||||
|
const num = Number(fracMatch[1]);
|
||||||
|
const den = Number(fracMatch[2]);
|
||||||
|
if (den > 0 && Number.isFinite(num) && num > 0) {
|
||||||
|
const value = num / den;
|
||||||
|
const unit = detectUnit(t);
|
||||||
|
// Si no hay unit explícita pero menciona "kilo"/"kg" o similar, igual cae.
|
||||||
|
// detectUnit lo cubre; si t contiene "kilo" después de "1/2", lo agarra.
|
||||||
|
const result = postProcess(value, unit);
|
||||||
|
if (result) return { ...result, confidence: 0.95, source: "fraction" };
|
||||||
|
} else {
|
||||||
|
return null; // div por cero / fracción inválida
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2) Frases compuestas (las más largas primero)
|
||||||
|
for (const [phrase, payload] of PHRASES) {
|
||||||
|
if (t.includes(phrase)) {
|
||||||
|
const explicitUnit = detectUnit(t);
|
||||||
|
// Si hay unit explícita en el texto, gana sobre la default de la frase
|
||||||
|
const finalUnit = explicitUnit || payload.unit;
|
||||||
|
return { qty: payload.qty, unit: finalUnit, confidence: 0.9, source: "phrase", phrase };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) Numérico con unit
|
||||||
|
const numMatch = NUMERIC.exec(t);
|
||||||
|
if (numMatch) {
|
||||||
|
const value = parseFloat(numMatch[1].replace(",", "."));
|
||||||
|
const unit = detectUnit(t);
|
||||||
|
if (unit) {
|
||||||
|
const result = postProcess(value, unit);
|
||||||
|
if (result) return { ...result, confidence: 0.9, source: "numeric_with_unit" };
|
||||||
|
}
|
||||||
|
// Numérico solo (sin unit)
|
||||||
|
const result = postProcess(value, null);
|
||||||
|
if (result) return { ...result, confidence: 0.7, source: "numeric_alone" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4) Palabra (ej: "dos botellas")
|
||||||
|
for (const [word, value] of Object.entries(NUMBER_WORDS)) {
|
||||||
|
const re = new RegExp(`(?:^|\\s)${word}(?:\\s|$)`);
|
||||||
|
if (re.test(t)) {
|
||||||
|
const unit = detectUnit(t);
|
||||||
|
if (unit) return { qty: value, unit, confidence: 0.85, source: "word_with_unit" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
193
src/modules/3-turn-engine/agent/quantityParser.test.js
Normal file
193
src/modules/3-turn-engine/agent/quantityParser.test.js
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { parseQuantity, detectUnit } from "./quantityParser.js";
|
||||||
|
|
||||||
|
describe("parseQuantity — fracciones", () => {
|
||||||
|
it("'1/4 kg' → 0.25 kg", () => {
|
||||||
|
const r = parseQuantity("1/4 kg");
|
||||||
|
expect(r).toMatchObject({ qty: 0.25, unit: "kg", source: "fraction" });
|
||||||
|
});
|
||||||
|
it("'1/2 kilo' → 0.5 kg", () => {
|
||||||
|
const r = parseQuantity("1/2 kilo");
|
||||||
|
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'3/4 kilos de matambre' → 0.75 kg", () => {
|
||||||
|
const r = parseQuantity("3/4 kilos de matambre");
|
||||||
|
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'1/2' sin unidad → 0.5 sin unit", () => {
|
||||||
|
const r = parseQuantity("1/2");
|
||||||
|
expect(r).toMatchObject({ qty: 0.5, unit: null });
|
||||||
|
});
|
||||||
|
it("'1/0' division por cero → null", () => {
|
||||||
|
expect(parseQuantity("1/0")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseQuantity — frases compuestas", () => {
|
||||||
|
it("'media docena' → 6 unit", () => {
|
||||||
|
const r = parseQuantity("media docena");
|
||||||
|
expect(r).toMatchObject({ qty: 6, unit: "unit", source: "phrase" });
|
||||||
|
});
|
||||||
|
it("'media docena de chorizos' → 6 unit", () => {
|
||||||
|
const r = parseQuantity("media docena de chorizos");
|
||||||
|
expect(r).toMatchObject({ qty: 6, unit: "unit" });
|
||||||
|
});
|
||||||
|
it("'cuarto de kilo' → 0.25 kg", () => {
|
||||||
|
const r = parseQuantity("cuarto de kilo");
|
||||||
|
expect(r).toMatchObject({ qty: 0.25, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'cuarto kilo' → 0.25 kg", () => {
|
||||||
|
const r = parseQuantity("cuarto kilo");
|
||||||
|
expect(r).toMatchObject({ qty: 0.25, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'tres cuartos' → 0.75 kg", () => {
|
||||||
|
const r = parseQuantity("tres cuartos");
|
||||||
|
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'tres cuartos de kilo' → 0.75 kg", () => {
|
||||||
|
const r = parseQuantity("tres cuartos de kilo");
|
||||||
|
expect(r).toMatchObject({ qty: 0.75, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'medio kilo' → 0.5 kg", () => {
|
||||||
|
const r = parseQuantity("medio kilo");
|
||||||
|
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'media kilo' → 0.5 kg (typo común)", () => {
|
||||||
|
const r = parseQuantity("media kilo");
|
||||||
|
expect(r).toMatchObject({ qty: 0.5, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'docena' → 12 unit", () => {
|
||||||
|
expect(parseQuantity("una docena")).toMatchObject({ qty: 12, unit: "unit" });
|
||||||
|
});
|
||||||
|
it("'par' → 2 unit", () => {
|
||||||
|
expect(parseQuantity("un par de chorizos")).toMatchObject({ qty: 2, unit: "unit" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseQuantity — numéricos con unidad", () => {
|
||||||
|
it("'300 gramos' → 300 g", () => {
|
||||||
|
expect(parseQuantity("300 gramos")).toMatchObject({ qty: 300, unit: "g" });
|
||||||
|
});
|
||||||
|
it("'500g' → 500 g", () => {
|
||||||
|
expect(parseQuantity("500g")).toMatchObject({ qty: 500, unit: "g" });
|
||||||
|
});
|
||||||
|
it("'2kg' → 2 kg", () => {
|
||||||
|
expect(parseQuantity("2kg")).toMatchObject({ qty: 2, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'2.5 kilos' → 2.5 kg", () => {
|
||||||
|
expect(parseQuantity("2.5 kilos")).toMatchObject({ qty: 2.5, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'2,5 kilos' (coma decimal) → 2.5 kg", () => {
|
||||||
|
expect(parseQuantity("2,5 kilos")).toMatchObject({ qty: 2.5, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'0.5kg' → 0.5 kg", () => {
|
||||||
|
expect(parseQuantity("0.5kg")).toMatchObject({ qty: 0.5, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'3 botellas' → 3 unit", () => {
|
||||||
|
expect(parseQuantity("3 botellas")).toMatchObject({ qty: 3, unit: "unit" });
|
||||||
|
});
|
||||||
|
it("'2 unidades' → 2 unit", () => {
|
||||||
|
expect(parseQuantity("2 unidades")).toMatchObject({ qty: 2, unit: "unit" });
|
||||||
|
});
|
||||||
|
it("'1 atado' → 1 unit", () => {
|
||||||
|
expect(parseQuantity("1 atado")).toMatchObject({ qty: 1, unit: "unit" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseQuantity — numéricos solos", () => {
|
||||||
|
it("'300' → 300 (sin unit)", () => {
|
||||||
|
expect(parseQuantity("300")).toMatchObject({ qty: 300, unit: null });
|
||||||
|
});
|
||||||
|
it("'2.5' → 2.5 (sin unit)", () => {
|
||||||
|
expect(parseQuantity("2.5")).toMatchObject({ qty: 2.5, unit: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseQuantity — palabras + unidad", () => {
|
||||||
|
it("'dos botellas' → 2 unit", () => {
|
||||||
|
expect(parseQuantity("dos botellas")).toMatchObject({ qty: 2, unit: "unit" });
|
||||||
|
});
|
||||||
|
it("'tres kilos' → 3 kg (frase compuesta)", () => {
|
||||||
|
expect(parseQuantity("tres kilos")).toMatchObject({ qty: 3, unit: "kg" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseQuantity — casos negativos", () => {
|
||||||
|
it("texto sin números retorna null", () => {
|
||||||
|
expect(parseQuantity("hola que tal")).toBeNull();
|
||||||
|
});
|
||||||
|
it("string vacío retorna null", () => {
|
||||||
|
expect(parseQuantity("")).toBeNull();
|
||||||
|
});
|
||||||
|
it("null/undefined retorna null", () => {
|
||||||
|
expect(parseQuantity(null)).toBeNull();
|
||||||
|
expect(parseQuantity(undefined)).toBeNull();
|
||||||
|
});
|
||||||
|
it("cantidad cero retorna null", () => {
|
||||||
|
expect(parseQuantity("0 kg")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("detectUnit", () => {
|
||||||
|
it("detecta kg/kilo/kilos", () => {
|
||||||
|
expect(detectUnit("2 kg")).toBe("kg");
|
||||||
|
expect(detectUnit("dos kilos")).toBe("kg");
|
||||||
|
expect(detectUnit("medio kilogramo")).toBe("kg");
|
||||||
|
});
|
||||||
|
it("detecta g/gr/gramos", () => {
|
||||||
|
expect(detectUnit("300 g")).toBe("g");
|
||||||
|
expect(detectUnit("500 gr")).toBe("g");
|
||||||
|
expect(detectUnit("100 gramos")).toBe("g");
|
||||||
|
});
|
||||||
|
it("detecta unidades múltiples", () => {
|
||||||
|
expect(detectUnit("3 unidades")).toBe("unit");
|
||||||
|
expect(detectUnit("una botella")).toBe("unit");
|
||||||
|
expect(detectUnit("2 frascos")).toBe("unit");
|
||||||
|
expect(detectUnit("1 atado")).toBe("unit");
|
||||||
|
});
|
||||||
|
it("retorna null sin unidad explícita", () => {
|
||||||
|
expect(detectUnit("dame 3")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseQuantity — confidence", () => {
|
||||||
|
it("fraction: confidence 0.95", () => {
|
||||||
|
expect(parseQuantity("1/4 kg").confidence).toBe(0.95);
|
||||||
|
});
|
||||||
|
it("phrase: confidence 0.9", () => {
|
||||||
|
expect(parseQuantity("media docena").confidence).toBe(0.9);
|
||||||
|
});
|
||||||
|
it("numeric_with_unit: confidence 0.9", () => {
|
||||||
|
expect(parseQuantity("300 gramos").confidence).toBe(0.9);
|
||||||
|
});
|
||||||
|
it("numeric_alone: confidence 0.7", () => {
|
||||||
|
expect(parseQuantity("300").confidence).toBe(0.7);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseQuantity — casos de WhatsApp real", () => {
|
||||||
|
it("'dame 1/4 de matambre' → 0.25 sin unit (el contexto resuelve)", () => {
|
||||||
|
// sin "kg"/"kilo" explícito, el parser deja unit=null. El agente
|
||||||
|
// infiere "kg" porque matambre vende por peso.
|
||||||
|
expect(parseQuantity("dame 1/4 de matambre")).toMatchObject({ qty: 0.25, unit: null });
|
||||||
|
});
|
||||||
|
it("'media docena de chorizos por favor' → 6 unit", () => {
|
||||||
|
expect(parseQuantity("media docena de chorizos por favor")).toMatchObject({ qty: 6, unit: "unit" });
|
||||||
|
});
|
||||||
|
it("'2.5kg de asado' → 2.5 kg", () => {
|
||||||
|
expect(parseQuantity("2.5kg de asado")).toMatchObject({ qty: 2.5, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'cuarto kilo de fuet' → 0.25 kg", () => {
|
||||||
|
expect(parseQuantity("cuarto kilo de fuet")).toMatchObject({ qty: 0.25, unit: "kg" });
|
||||||
|
});
|
||||||
|
it("'un kilo y medio' → ambiguo, por ahora cae a phrase 'un kilo' → 1 kg", () => {
|
||||||
|
// limitación conocida — el LLM puede sobreescribir
|
||||||
|
const r = parseQuantity("un kilo y medio");
|
||||||
|
expect(r.qty).toBe(1);
|
||||||
|
});
|
||||||
|
it("'mandame 3 chorizos' → 3 unit (vía word + unit detection)", () => {
|
||||||
|
const r = parseQuantity("mandame 3 chorizos");
|
||||||
|
// chorizos no es una unit reconocida, así que queda numeric_alone
|
||||||
|
expect(r.qty).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
377
src/modules/3-turn-engine/agent/runTurn.js
Normal file
377
src/modules/3-turn-engine/agent/runTurn.js
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
/**
|
||||||
|
* runTurn — Punto de entrada del agente tool-calling.
|
||||||
|
*
|
||||||
|
* Reemplaza turnEngineV3 cuando AGENT_TURN_ENGINE=1.
|
||||||
|
* Mantiene la firma compatible con pipeline.js:
|
||||||
|
* runTurnAgent({ tenantId, chat_id, text, prev_state, prev_context, conversation_history })
|
||||||
|
* → { plan, decision }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import OpenAI from "openai";
|
||||||
|
import { migrateOldContext, createEmptyOrder } from "../orderModel.js";
|
||||||
|
import { getStoreConfig } from "../../0-ui/db/settingsRepo.js";
|
||||||
|
import { ConversationState, safeNextState } from "../fsm.js";
|
||||||
|
import { buildWorkingMemory } from "./workingMemory.js";
|
||||||
|
import { SYSTEM_PROMPT } from "./systemPrompt.js";
|
||||||
|
import { TOOL_SCHEMAS } from "./tools/schemas.js";
|
||||||
|
import { executeToolCall } from "./tools/executor.js";
|
||||||
|
import { getCustomerProfile } from "./customerProfile.js";
|
||||||
|
import { debug as dbg } from "../../shared/debug.js";
|
||||||
|
|
||||||
|
const MAX_TOOL_CALLS = parseInt(process.env.AGENT_MAX_TOOL_CALLS || "10", 10);
|
||||||
|
const TURN_TIMEOUT_MS = parseInt(process.env.AGENT_TURN_TIMEOUT_MS || "20000", 10);
|
||||||
|
|
||||||
|
// Métricas in-memory: turns/calls, fallback rate, escalation rate, avg duration,
|
||||||
|
// prompt-cache hit ratio (DeepSeek devuelve prompt_cache_hit_tokens / prompt_cache_miss_tokens
|
||||||
|
// en usage; campos OpenAI-style equivalentes "prompt_tokens_details.cached_tokens").
|
||||||
|
const _metrics = {
|
||||||
|
turns: 0,
|
||||||
|
total_tool_calls: 0,
|
||||||
|
total_llm_calls: 0,
|
||||||
|
total_duration_ms: 0,
|
||||||
|
fallback_used: 0,
|
||||||
|
llm_errors: 0,
|
||||||
|
escalations: 0,
|
||||||
|
pauses: 0,
|
||||||
|
orders_confirmed: 0,
|
||||||
|
prompt_tokens_total: 0,
|
||||||
|
prompt_cache_hit_tokens: 0,
|
||||||
|
completion_tokens_total: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAgentMetrics() {
|
||||||
|
const t = _metrics.turns;
|
||||||
|
const promptTotal = _metrics.prompt_tokens_total;
|
||||||
|
return {
|
||||||
|
turns: t,
|
||||||
|
avg_tool_calls_per_turn: t ? +(_metrics.total_tool_calls / t).toFixed(2) : 0,
|
||||||
|
avg_llm_calls_per_turn: t ? +(_metrics.total_llm_calls / t).toFixed(2) : 0,
|
||||||
|
avg_duration_ms: t ? Math.round(_metrics.total_duration_ms / t) : 0,
|
||||||
|
fallback_rate: t ? +(_metrics.fallback_used / t).toFixed(3) : 0,
|
||||||
|
error_rate: t ? +(_metrics.llm_errors / t).toFixed(3) : 0,
|
||||||
|
escalations: _metrics.escalations,
|
||||||
|
pauses: _metrics.pauses,
|
||||||
|
orders_confirmed: _metrics.orders_confirmed,
|
||||||
|
prompt_tokens_total: promptTotal,
|
||||||
|
completion_tokens_total: _metrics.completion_tokens_total,
|
||||||
|
prompt_cache_hit_tokens: _metrics.prompt_cache_hit_tokens,
|
||||||
|
cache_hit_ratio: promptTotal ? +(_metrics.prompt_cache_hit_tokens / promptTotal).toFixed(3) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetAgentMetrics() {
|
||||||
|
for (const k of Object.keys(_metrics)) _metrics[k] = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _client = null;
|
||||||
|
function getClient() {
|
||||||
|
if (_client) return _client;
|
||||||
|
const apiKey = process.env.OPENAI_API_KEY;
|
||||||
|
if (!apiKey) throw new Error("OPENAI_API_KEY not set");
|
||||||
|
const baseURL = process.env.OPENAI_BASE_URL || undefined;
|
||||||
|
_client = new OpenAI({ apiKey, ...(baseURL ? { baseURL } : {}) });
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModel() {
|
||||||
|
return process.env.OPENAI_MODEL || "deepseek-chat";
|
||||||
|
}
|
||||||
|
|
||||||
|
function withTimeout(promise, ms, label) {
|
||||||
|
return Promise.race([
|
||||||
|
promise,
|
||||||
|
new Promise((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error(`${label}_timeout_${ms}ms`)), ms)
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Punto de entrada principal. Mismo signature que runTurnV3.
|
||||||
|
*/
|
||||||
|
export async function runTurnAgent({
|
||||||
|
tenantId,
|
||||||
|
chat_id,
|
||||||
|
text,
|
||||||
|
prev_state,
|
||||||
|
prev_context,
|
||||||
|
conversation_history,
|
||||||
|
}) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const audit = {
|
||||||
|
trace: { tenantId, chat_id, text_preview: String(text || "").slice(0, 50), prev_state, engine: "agent" },
|
||||||
|
tool_calls: [],
|
||||||
|
llm_calls: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cargar order, store, last_shown_options, customer_profile
|
||||||
|
const order = migrateOldContext(prev_context);
|
||||||
|
const storeConfig = await getStoreConfig({ tenantId });
|
||||||
|
const lastShownOptions = Array.isArray(prev_context?.last_shown_options)
|
||||||
|
? prev_context.last_shown_options
|
||||||
|
: [];
|
||||||
|
const customerProfile = await getCustomerProfile({ tenantId, chat_id }).catch((err) => {
|
||||||
|
audit.customer_profile_error = String(err?.message || err);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// last_delivery del cliente: snapshot de la última orden confirmada
|
||||||
|
// (dirección + zona + day/time). El bot puede ofrecérsela proactivamente
|
||||||
|
// o el cliente puede pedir "lo mismo de la última vez".
|
||||||
|
const lastDelivery = (prev_context && typeof prev_context === "object" && prev_context.last_delivery) || null;
|
||||||
|
|
||||||
|
// Construir working memory
|
||||||
|
const wm = buildWorkingMemory({
|
||||||
|
text,
|
||||||
|
order,
|
||||||
|
prev_state: prev_state || "IDLE",
|
||||||
|
conversation_history,
|
||||||
|
storeConfig,
|
||||||
|
customerProfile,
|
||||||
|
lastShownOptions,
|
||||||
|
lastDelivery,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Estado mutable que los tools mutan
|
||||||
|
const ctx = {
|
||||||
|
tenantId,
|
||||||
|
chat_id,
|
||||||
|
order: { ...order, last_shown_options: lastShownOptions },
|
||||||
|
pending_actions: [],
|
||||||
|
last_shown_options: [...lastShownOptions],
|
||||||
|
storeConfig,
|
||||||
|
last_delivery: lastDelivery,
|
||||||
|
say_text: null,
|
||||||
|
paused: false,
|
||||||
|
paused_until: order.paused_until ?? null,
|
||||||
|
awaiting_human: false,
|
||||||
|
awaiting_human_reason: null,
|
||||||
|
fsm_state: prev_state || "IDLE",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mensajes para el LLM. system prompt PRIMERO siempre + estático
|
||||||
|
// (clave para hit del prompt cache de DeepSeek/OpenAI).
|
||||||
|
const messages = [
|
||||||
|
{ role: "system", content: SYSTEM_PROMPT },
|
||||||
|
{ role: "user", content: JSON.stringify({ working_memory: wm }) },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Loop tool-calling
|
||||||
|
const client = getClient();
|
||||||
|
const model = getModel();
|
||||||
|
let turnDone = false;
|
||||||
|
let llmError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < MAX_TOOL_CALLS && !turnDone; i++) {
|
||||||
|
audit.llm_calls++;
|
||||||
|
const elapsed = Date.now() - t0;
|
||||||
|
const remaining = Math.max(2000, TURN_TIMEOUT_MS - elapsed);
|
||||||
|
|
||||||
|
if (dbg.llm) console.log("[agent] llm.request", { model, iteration: i, remaining_ms: remaining });
|
||||||
|
|
||||||
|
const resp = await withTimeout(
|
||||||
|
client.chat.completions.create({
|
||||||
|
model,
|
||||||
|
temperature: 0.4,
|
||||||
|
max_tokens: 600,
|
||||||
|
tools: TOOL_SCHEMAS,
|
||||||
|
tool_choice: "required",
|
||||||
|
messages,
|
||||||
|
}),
|
||||||
|
remaining,
|
||||||
|
"agent_llm"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Capturar usage para métricas de prompt caching.
|
||||||
|
// DeepSeek: usage.prompt_cache_hit_tokens / prompt_cache_miss_tokens.
|
||||||
|
// OpenAI compat: usage.prompt_tokens_details.cached_tokens.
|
||||||
|
const usage = resp?.usage || {};
|
||||||
|
const promptTokens = usage.prompt_tokens || 0;
|
||||||
|
const completionTokens = usage.completion_tokens || 0;
|
||||||
|
const cachedTokens =
|
||||||
|
usage.prompt_cache_hit_tokens ||
|
||||||
|
usage.prompt_tokens_details?.cached_tokens ||
|
||||||
|
0;
|
||||||
|
_metrics.prompt_tokens_total += promptTokens;
|
||||||
|
_metrics.completion_tokens_total += completionTokens;
|
||||||
|
_metrics.prompt_cache_hit_tokens += cachedTokens;
|
||||||
|
if (dbg.llm) {
|
||||||
|
console.log("[agent] llm.usage", {
|
||||||
|
prompt_tokens: promptTokens,
|
||||||
|
cached_tokens: cachedTokens,
|
||||||
|
completion_tokens: completionTokens,
|
||||||
|
cache_hit_ratio: promptTokens ? +(cachedTokens / promptTokens).toFixed(2) : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = resp?.choices?.[0]?.message || {};
|
||||||
|
messages.push({ role: "assistant", content: msg.content || "", tool_calls: msg.tool_calls || [] });
|
||||||
|
|
||||||
|
const calls = msg.tool_calls || [];
|
||||||
|
if (!calls.length) {
|
||||||
|
// Sin tool calls → forzar say con fallback y salir
|
||||||
|
audit.no_tool_calls = true;
|
||||||
|
ctx.say_text = ctx.say_text || msg.content || "Disculpame, no te entendí. ¿Me lo decís de otra forma?";
|
||||||
|
turnDone = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
const obs = await executeToolCall(call, ctx);
|
||||||
|
audit.tool_calls.push({
|
||||||
|
name: call.function?.name,
|
||||||
|
ok: obs.ok !== false,
|
||||||
|
error: obs.error || null,
|
||||||
|
duration_ms: obs.duration_ms || null,
|
||||||
|
});
|
||||||
|
messages.push({
|
||||||
|
role: "tool",
|
||||||
|
tool_call_id: call.id,
|
||||||
|
content: JSON.stringify(obs),
|
||||||
|
});
|
||||||
|
if (obs.terminal) {
|
||||||
|
turnDone = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
llmError = String(err?.message || err);
|
||||||
|
audit.llm_error = llmError;
|
||||||
|
if (dbg.llm) console.error("[agent] error", llmError);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no hay say, fallback determinista
|
||||||
|
if (!ctx.say_text) {
|
||||||
|
ctx.say_text = pickFallbackReply(ctx, llmError);
|
||||||
|
audit.fallback_used = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
audit.duration_ms = Date.now() - t0;
|
||||||
|
|
||||||
|
// Actualizar métricas
|
||||||
|
_metrics.turns++;
|
||||||
|
_metrics.total_tool_calls += audit.tool_calls.length;
|
||||||
|
_metrics.total_llm_calls += audit.llm_calls;
|
||||||
|
_metrics.total_duration_ms += audit.duration_ms;
|
||||||
|
if (audit.fallback_used) _metrics.fallback_used++;
|
||||||
|
if (audit.llm_error) _metrics.llm_errors++;
|
||||||
|
if (ctx.awaiting_human) _metrics.escalations++;
|
||||||
|
if (ctx.paused) _metrics.pauses++;
|
||||||
|
if (ctx.pending_actions.some((a) => a.type === "create_order")) _metrics.orders_confirmed++;
|
||||||
|
|
||||||
|
// Derivar nextState desde el order resultante
|
||||||
|
const signals = {
|
||||||
|
confirm_order: ctx.pending_actions.some((a) => a.type === "create_order"),
|
||||||
|
shipping_completed: ctx.pending_actions.some((a) => a.type === "create_order"),
|
||||||
|
return_to_cart: false,
|
||||||
|
};
|
||||||
|
// Si el agente pausó la conversación, mantenemos el order pero el next_state
|
||||||
|
// queda guardado en ctx.fsm_state ("PAUSED") para que pipeline lo persista.
|
||||||
|
let nextState;
|
||||||
|
if (ctx.awaiting_human) {
|
||||||
|
nextState = ConversationState.AWAITING_HUMAN;
|
||||||
|
} else if (ctx.paused) {
|
||||||
|
nextState = "PAUSED"; // estado nuevo, fsm.js lo va a permitir tras D7
|
||||||
|
} else {
|
||||||
|
nextState = safeNextState(prev_state, ctx.order, signals).next_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
plan: {
|
||||||
|
reply: ctx.say_text,
|
||||||
|
next_state: nextState,
|
||||||
|
intent: detectIntent(audit.tool_calls),
|
||||||
|
missing_fields: [],
|
||||||
|
order_action: ctx.pending_actions[0]?.type || "none",
|
||||||
|
basket_resolved: { items: (ctx.order.cart || []).map(toBasketItem) },
|
||||||
|
},
|
||||||
|
decision: {
|
||||||
|
actions: ctx.pending_actions,
|
||||||
|
context_patch: buildContextPatch(ctx),
|
||||||
|
audit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFallbackReply(ctx, err) {
|
||||||
|
if (ctx.awaiting_human) return "Te paso con un humano que pueda ayudarte.";
|
||||||
|
if (ctx.paused) return "Dale, cuando quieras seguimos.";
|
||||||
|
if (err) return "Disculpame, tuve un problema. ¿Lo intentás de nuevo?";
|
||||||
|
return "No te seguí, ¿me lo decís de otra forma?";
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectIntent(toolCalls = []) {
|
||||||
|
const names = toolCalls.map((c) => c.name);
|
||||||
|
if (names.includes("confirm_order")) return "confirm_order";
|
||||||
|
if (names.includes("set_address") || names.includes("set_shipping")) return "select_shipping";
|
||||||
|
if (names.includes("escalate_to_human")) return "escalate";
|
||||||
|
if (names.includes("pause")) return "pause";
|
||||||
|
if (names.includes("add_to_cart") || names.includes("set_quantity") || names.includes("select_candidate")) return "add_to_cart";
|
||||||
|
if (names.includes("remove_from_cart")) return "remove_from_cart";
|
||||||
|
if (names.includes("search_catalog")) return "browse";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBasketItem(item) {
|
||||||
|
return {
|
||||||
|
product_id: item.woo_id,
|
||||||
|
woo_product_id: item.woo_id,
|
||||||
|
quantity: item.qty,
|
||||||
|
unit: item.unit,
|
||||||
|
label: item.name,
|
||||||
|
name: item.name,
|
||||||
|
price: item.price,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContextPatch(ctx) {
|
||||||
|
const order = ctx.order || createEmptyOrder();
|
||||||
|
|
||||||
|
// Si hay create_order encolado y la orden está completa, snapshot del
|
||||||
|
// "último envío" para reusar en próximas conversaciones.
|
||||||
|
let last_delivery = ctx.last_delivery || null;
|
||||||
|
const isClosing = ctx.pending_actions.some((a) => a.type === "create_order");
|
||||||
|
if (isClosing && (order.shipping_address || order.matched_zone || order.is_delivery === false)) {
|
||||||
|
last_delivery = {
|
||||||
|
is_delivery: !!order.is_delivery,
|
||||||
|
shipping_address: order.shipping_address || null,
|
||||||
|
matched_zone: order.matched_zone || null,
|
||||||
|
pending_location: order.pending_location || null,
|
||||||
|
delivery_window: order.delivery_window || null,
|
||||||
|
saved_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Persist the full order object so pending_location/matched_zone/delivery_window
|
||||||
|
// sobrevivan turno a turno (migrateOldContext devuelve ctx.order tal cual).
|
||||||
|
order,
|
||||||
|
last_delivery,
|
||||||
|
order_basket: { items: (order.cart || []).map(toBasketItem) },
|
||||||
|
pending_items: (order.pending || []).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
query: p.query,
|
||||||
|
candidates: p.candidates,
|
||||||
|
resolved_product: p.selected_woo_id
|
||||||
|
? {
|
||||||
|
woo_product_id: p.selected_woo_id,
|
||||||
|
name: p.selected_name,
|
||||||
|
price: p.selected_price,
|
||||||
|
display_unit: p.selected_unit,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
quantity: p.qty,
|
||||||
|
unit: p.unit,
|
||||||
|
status: p.status?.toLowerCase() || "needs_type",
|
||||||
|
})),
|
||||||
|
shipping_method:
|
||||||
|
order.is_delivery === true ? "delivery" : order.is_delivery === false ? "pickup" : null,
|
||||||
|
delivery_address: order.shipping_address ? { text: order.shipping_address } : null,
|
||||||
|
woo_order_id: order.woo_order_id,
|
||||||
|
last_shown_options: ctx.last_shown_options || [],
|
||||||
|
paused_until: ctx.paused_until || null,
|
||||||
|
awaiting_human: ctx.awaiting_human || false,
|
||||||
|
awaiting_human_reason: ctx.awaiting_human_reason || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
117
src/modules/3-turn-engine/agent/systemPrompt.js
Normal file
117
src/modules/3-turn-engine/agent/systemPrompt.js
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* System prompt del agente conversacional.
|
||||||
|
*
|
||||||
|
* 100% estático para que DeepSeek (y cualquier proveedor con prefix-cache)
|
||||||
|
* lo cachee turn a turn. La parte dinámica — incluyendo el nombre de la
|
||||||
|
* tienda — va SIEMPRE en el primer user message vía working_memory.store.name.
|
||||||
|
*
|
||||||
|
* Reglas para mantener el cache caliente:
|
||||||
|
* - NO interpolar variables aquí. Si querés cambiar tono por tenant,
|
||||||
|
* hacelo agregando una sección al user message, no al system.
|
||||||
|
* - Cualquier cambio al texto invalida el cache para todos los turnos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SYSTEM_PROMPT = `Sos Botino, el empleado virtual de la carnicería que te contrata.
|
||||||
|
Hablás como vendedor argentino: cálido, breve, "vos", sin emojis, sin marketing.
|
||||||
|
|
||||||
|
TU TRABAJO ES UNO SOLO: tomar pedidos por WhatsApp.
|
||||||
|
1. Entendés lo que pide el cliente y lo anotás en el carrito.
|
||||||
|
2. Coordinás envío (delivery o retiro) y dirección si corresponde.
|
||||||
|
3. Cerrás el pedido. NO cobrás — el pago lo coordina el comercio aparte.
|
||||||
|
|
||||||
|
REGLAS DURAS:
|
||||||
|
- NUNCA inventes productos ni precios. Para CUALQUIER producto que el cliente
|
||||||
|
mencione, llamá primero a search_catalog. Si la lista viene vacía, decilo.
|
||||||
|
- NUNCA pidas datos que ya están en el contexto (cart, dirección, método).
|
||||||
|
- NO ofrezcas promociones, métodos de pago, ni info que no esté en store.
|
||||||
|
- Si el cliente mezcla pedido + duda + cambio de tema, resolvé con tools y
|
||||||
|
aclarás lo que falte en say.
|
||||||
|
- Si te pide algo fuera de tomar pedidos (queja, cambio, factura, otro idioma),
|
||||||
|
llamá escalate_to_human.
|
||||||
|
|
||||||
|
CÓMO PROCESAS UN MENSAJE (user message viene como JSON con working_memory):
|
||||||
|
- Releé order.cart, order.pending, last_shown_options, fsm_state, paused_until,
|
||||||
|
order.pending_location, order.matched_zone, order.delivery_window.
|
||||||
|
- preparsed: tiene cantidades parseadas (ej: "media docena" → 6 unit; "1/4 kg"
|
||||||
|
→ 0.25 kg). Confiá en eso si su confidence ≥ 0.85.
|
||||||
|
- Si user dice "el segundo", "ese", "el primero", "el de arriba", resolvé
|
||||||
|
contra last_shown_options. Si está vacío, pedí que aclare.
|
||||||
|
- Si user da SOLO una cantidad ("300 gramos", "media docena") y hay un pending
|
||||||
|
con status=NEEDS_QUANTITY, asumí que es para ese producto y llamá set_quantity.
|
||||||
|
- Si user dice "lo de siempre" / "lo mismo de la otra vez", mirá
|
||||||
|
customer_profile.frequent_items. Si hay 1-2 items, ofrecelos con
|
||||||
|
search_catalog para confirmar y un say preguntando si va eso.
|
||||||
|
- Si menciona producto genérico ("asado") y el catálogo tiene varios,
|
||||||
|
mostrá top 3-5 con say (numerados). El sistema guarda last_shown_options
|
||||||
|
automáticamente.
|
||||||
|
- Si dice "después te digo" / "más tarde" / "ahora no puedo", llamá pause
|
||||||
|
con reason="user_paused" y un say corto tipo "Dale, cuando quieras seguimos".
|
||||||
|
|
||||||
|
CÓMO ESCRIBÍS EL say:
|
||||||
|
- 1-2 oraciones máximo. Concreto. Sin emojis.
|
||||||
|
- Confirmá lo anotado con cantidad y producto: "Va, anoté 500g de Vacío.
|
||||||
|
¿Algo más?"
|
||||||
|
- Cuando preguntás cantidad, mencioná el producto: "¿Cuántas botellas de
|
||||||
|
Chimichurri querés?" — NUNCA "¿cuántas?" pelado.
|
||||||
|
- Si no encontraste el producto, sugerí 1-2 alternativas concretas que
|
||||||
|
vinieron del catálogo: "No tengo Chinchulín, pero tengo Mollejas y
|
||||||
|
Riñones. ¿Te sirve alguno?"
|
||||||
|
- Cuando cerrás el pedido, listá items y pasás a shipping.
|
||||||
|
|
||||||
|
ORDEN DE TOOLS EN UN TURNO TÍPICO:
|
||||||
|
1. (opcional) search_catalog si hay producto sin resolver. Llamala UNA VEZ por
|
||||||
|
producto distinto. NO la llames de nuevo con la misma query si ya devolvió
|
||||||
|
resultados — usá los que tenés.
|
||||||
|
2. Si search_catalog devolvió 1 candidato fuerte → add_to_cart con qty/unit.
|
||||||
|
3. Si devolvió varios candidatos → NO sigas buscando: usá say para pedir que
|
||||||
|
elija entre los top 3-5 (numerados). El sistema guarda last_shown_options.
|
||||||
|
4. (opcional) add_to_cart / set_quantity / select_candidate / set_shipping /
|
||||||
|
set_address / set_delivery_window / confirm_order / remove_from_cart /
|
||||||
|
pause / escalate_to_human.
|
||||||
|
5. say SIEMPRE como último tool del turno. Sin say no hay respuesta.
|
||||||
|
|
||||||
|
ENVÍO Y ZONAS:
|
||||||
|
- Si store.delivery.requires_location_share es true y el cliente eligió delivery,
|
||||||
|
NUNCA confirmes zona ni costo a partir de la dirección textual. Necesitamos
|
||||||
|
la ubicación compartida (pin/location share) por WhatsApp.
|
||||||
|
- Cuando el cliente pide envío: llamá set_shipping(method="delivery"). Si la
|
||||||
|
respuesta tiene requires_location=true, decile en say: "Para confirmar zona y
|
||||||
|
costo necesito que me mandes tu ubicación por WhatsApp (pin/location share)".
|
||||||
|
- Cuando llegue la ubicación, working_memory.order.pending_location va a tener
|
||||||
|
lat/lng. Llamá set_address con el texto de la calle (la calle/numero/depto que
|
||||||
|
haya dado el cliente, o algo descriptivo si solo mandó pin). Si match → te
|
||||||
|
devuelve matched_zone con costo/días/horas. Comunicá eso y pedí día y hora.
|
||||||
|
- Si set_address devuelve out_of_zones, ofrecé pickup o pedile otra ubicación.
|
||||||
|
- Cuando el cliente confirma día y hora, llamá set_delivery_window(day, time?).
|
||||||
|
Días: lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM 24h). Confirmá lo
|
||||||
|
registrado en say antes de llamar a confirm_order.
|
||||||
|
- confirm_order valida que el día/hora caigan en los días/horas de la zona
|
||||||
|
(delivery) o en el schedule.pickup (pickup). Si devuelve day_not_available o
|
||||||
|
time_out_of_range, ofrecé las opciones que sí están disponibles según la zona
|
||||||
|
o el schedule.
|
||||||
|
|
||||||
|
CLIENTES QUE VUELVEN (last_delivery):
|
||||||
|
- Si working_memory.last_delivery existe (no null), el cliente ya hizo un pedido
|
||||||
|
antes y tenemos guardado: shipping_address, matched_zone, delivery_window.
|
||||||
|
- Cuando el cliente está armando un pedido nuevo y todavía no eligió método de
|
||||||
|
envío ni dio dirección, ofrecele proactivamente la opción de repetir:
|
||||||
|
"¿Te lo mandamos al mismo lugar que la última vez (Av. Corrientes 1234,
|
||||||
|
zona Centro, $1.500)? También podés pedirme otra dirección o retiro."
|
||||||
|
- Si confirma ("sí", "dale", "el mismo lugar", "como siempre"), llamá
|
||||||
|
reuse_last_delivery — copia dirección + zona y se salta el pedido del pin.
|
||||||
|
Después confirmá el día/horario (puede ser distinto al de la última).
|
||||||
|
- Si dice que no, o pide otro lugar, seguí el flujo normal (set_shipping →
|
||||||
|
pedir pin → set_address → set_delivery_window).
|
||||||
|
- last_delivery.delivery_window es sólo referencia, NO lo asumas como elegido
|
||||||
|
para esta orden. Preguntá día/hora aunque vayas a reutilizar el lugar.
|
||||||
|
|
||||||
|
LIMITES TÉCNICOS:
|
||||||
|
- Tenés un máximo de 10 tool calls por turno. No los gastes en búsquedas
|
||||||
|
redundantes — si una query ya devolvió X candidatos, NO la repitas.
|
||||||
|
- Si después de 2 búsquedas no encontrás nada útil, llamá say pidiendo que el
|
||||||
|
cliente reformule ("no te encontré X, ¿lo decís de otra forma?").
|
||||||
|
|
||||||
|
LIMITES OPERATIVOS DEL COMERCIO (NO LOS NEGOCIES):
|
||||||
|
- Lo que diga el bloque store del working_memory.
|
||||||
|
- Si el cliente pide algo fuera de eso, decí qué es lo que sí podemos.`;
|
||||||
|
|
||||||
66
src/modules/3-turn-engine/agent/tools/addToCart.js
Normal file
66
src/modules/3-turn-engine/agent/tools/addToCart.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* add_to_cart — agrega un producto resuelto al carrito.
|
||||||
|
*
|
||||||
|
* Valida woo_id contra el snapshot (anti-halucinación). Si no existe,
|
||||||
|
* devuelve error obligando re-search.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
||||||
|
import { createCartItem } from "../../orderModel.js";
|
||||||
|
|
||||||
|
export async function addToCartTool(args, ctx) {
|
||||||
|
const { woo_id, qty, unit } = args;
|
||||||
|
|
||||||
|
// Validar que el producto existe en el snapshot
|
||||||
|
const lookup = await getSnapshotItemsByIds({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
wooProductIds: [woo_id],
|
||||||
|
});
|
||||||
|
const found = (lookup?.items || [])[0];
|
||||||
|
if (!found) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "woo_id_unknown",
|
||||||
|
hint: "Volvé a llamar search_catalog para obtener un woo_id válido.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear item de carrito
|
||||||
|
const newItem = createCartItem({
|
||||||
|
woo_id,
|
||||||
|
qty,
|
||||||
|
unit,
|
||||||
|
name: found.name,
|
||||||
|
price: found.price ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Si ya existe el woo_id en el cart, sumamos cantidad
|
||||||
|
const cart = ctx.order.cart || [];
|
||||||
|
const existingIdx = cart.findIndex((c) => Number(c.woo_id) === Number(woo_id));
|
||||||
|
let nextCart;
|
||||||
|
if (existingIdx >= 0) {
|
||||||
|
nextCart = cart.map((c, i) =>
|
||||||
|
i === existingIdx ? { ...c, qty: (c.qty || 0) + qty, unit: c.unit || unit } : c
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
nextCart = [...cart, newItem];
|
||||||
|
}
|
||||||
|
ctx.order = { ...ctx.order, cart: nextCart };
|
||||||
|
|
||||||
|
// Enqueue add_to_cart action para SSE/UI (reutiliza shape existente)
|
||||||
|
ctx.pending_actions.push({ type: "add_to_cart", payload: newItem });
|
||||||
|
|
||||||
|
// Reset failed_searches si hubiera
|
||||||
|
if (ctx.order.failed_searches) {
|
||||||
|
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpiar last_shown_options — ya resolvió la elección
|
||||||
|
ctx.last_shown_options = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
cart_size: nextCart.length,
|
||||||
|
added: { woo_id, name: found.name, qty, unit },
|
||||||
|
};
|
||||||
|
}
|
||||||
134
src/modules/3-turn-engine/agent/tools/confirmOrder.js
Normal file
134
src/modules/3-turn-engine/agent/tools/confirmOrder.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* confirm_order — emite create_order si hay cart + shipping completo.
|
||||||
|
*
|
||||||
|
* Validaciones extra (cuando hay schedule/zonas configuradas):
|
||||||
|
* - delivery: día y hora del delivery_window deben caer en zone.delivery_days
|
||||||
|
* y zone.delivery_hours.
|
||||||
|
* - pickup: ídem contra schedule.pickup[day].
|
||||||
|
*
|
||||||
|
* Si no hay delivery_window seteado, confirmamos igual y dejamos que el
|
||||||
|
* comercio coordine día/hora aparte.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { hasCartItems, hasShippingInfo } from "../../fsm.js";
|
||||||
|
|
||||||
|
function isHHMMInRange(time, start, end) {
|
||||||
|
if (!time || !start || !end) return true;
|
||||||
|
const t = String(time).slice(0, 5);
|
||||||
|
const a = String(start).slice(0, 5);
|
||||||
|
const b = String(end).slice(0, 5);
|
||||||
|
return t >= a && t <= b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDayInList(day, list) {
|
||||||
|
if (!Array.isArray(list) || !list.length) return true;
|
||||||
|
return list.includes(day);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPickupDays(schedule) {
|
||||||
|
if (!schedule || typeof schedule !== "object") return "";
|
||||||
|
const entries = Object.entries(schedule).filter(
|
||||||
|
([, v]) => v && v.enabled !== false && v.start && v.end
|
||||||
|
);
|
||||||
|
if (!entries.length) return "";
|
||||||
|
return entries.map(([k, v]) => `${k} ${v.start.slice(0, 5)}-${v.end.slice(0, 5)}`).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmOrderTool(_args, ctx) {
|
||||||
|
if (!hasCartItems(ctx.order)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "empty_cart",
|
||||||
|
hint: "Pedile al cliente que agregue productos antes de confirmar.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!hasShippingInfo(ctx.order)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "shipping_missing",
|
||||||
|
hint:
|
||||||
|
ctx.order.is_delivery == null
|
||||||
|
? "Falta saber si es delivery o pickup. Llamá set_shipping."
|
||||||
|
: "Falta dirección. Llamá set_address.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const win = ctx.order.delivery_window || null;
|
||||||
|
|
||||||
|
if (ctx.order.is_delivery) {
|
||||||
|
const z = ctx.order.matched_zone;
|
||||||
|
// Si hay zonas configuradas pero no se matcheó zona aún, bloquear.
|
||||||
|
const zonesConfigured = (ctx.storeConfig?.delivery_zones?.zones || []).some(
|
||||||
|
(zo) => zo?.enabled !== false
|
||||||
|
);
|
||||||
|
if (zonesConfigured && !z) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "zone_unverified",
|
||||||
|
hint:
|
||||||
|
"Falta verificar zona. Pedile la ubicación por WhatsApp y llamá set_address antes de confirmar.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (z && win) {
|
||||||
|
if (!isDayInList(win.day, z.delivery_days)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "day_not_available",
|
||||||
|
hint: `La zona ${z.name} entrega ${(z.delivery_days || []).join("/")}. Pedile otro día.`,
|
||||||
|
allowed_days: z.delivery_days || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (z.delivery_hours && !isHHMMInRange(win.time, z.delivery_hours.start, z.delivery_hours.end)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "time_out_of_range",
|
||||||
|
hint: `La zona ${z.name} entrega entre ${z.delivery_hours.start.slice(0, 5)} y ${z.delivery_hours.end.slice(0, 5)}. Pedile otro horario.`,
|
||||||
|
allowed_range: z.delivery_hours,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Pickup
|
||||||
|
const schedule = ctx.storeConfig?.schedule?.pickup || null;
|
||||||
|
if (schedule && win) {
|
||||||
|
const slot = schedule[win.day];
|
||||||
|
if (!slot || slot.enabled === false || !slot.start || !slot.end) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "day_not_available_pickup",
|
||||||
|
hint: `Ese día la tienda no abre. ${formatPickupDays(schedule)}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (win.time && !isHHMMInRange(win.time, slot.start, slot.end)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "time_out_of_range_pickup",
|
||||||
|
hint: `Ese día abrimos ${slot.start.slice(0, 5)}-${slot.end.slice(0, 5)}. Pedile otro horario.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idempotencia: si ya existe create_order encolado, no duplicar
|
||||||
|
const already = ctx.pending_actions.some((a) => a.type === "create_order");
|
||||||
|
if (!already) {
|
||||||
|
ctx.pending_actions.push({
|
||||||
|
type: "create_order",
|
||||||
|
payload: {
|
||||||
|
source: "wa_bot",
|
||||||
|
delivery_window: win,
|
||||||
|
matched_zone: ctx.order.matched_zone || null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
cart_size: (ctx.order.cart || []).length,
|
||||||
|
is_delivery: !!ctx.order.is_delivery,
|
||||||
|
address: ctx.order.shipping_address || null,
|
||||||
|
delivery_window: win,
|
||||||
|
matched_zone: ctx.order.matched_zone || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
16
src/modules/3-turn-engine/agent/tools/escalateToHuman.js
Normal file
16
src/modules/3-turn-engine/agent/tools/escalateToHuman.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* escalate_to_human — pasa la conversación a awaiting_human.
|
||||||
|
* El registro real en human_takeovers se crea downstream en pipeline.js
|
||||||
|
* vía la action "request_human_takeover".
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function escalateToHumanTool(args, ctx) {
|
||||||
|
const { reason } = args;
|
||||||
|
ctx.awaiting_human = true;
|
||||||
|
ctx.awaiting_human_reason = String(reason || "unspecified");
|
||||||
|
ctx.pending_actions.push({
|
||||||
|
type: "request_human_takeover",
|
||||||
|
payload: { reason: ctx.awaiting_human_reason, source: "agent" },
|
||||||
|
});
|
||||||
|
return { ok: true, reason: ctx.awaiting_human_reason };
|
||||||
|
}
|
||||||
106
src/modules/3-turn-engine/agent/tools/executor.js
Normal file
106
src/modules/3-turn-engine/agent/tools/executor.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Executor: parsea + valida + ejecuta tool calls del agente.
|
||||||
|
* Devuelve `obs` (objeto serializable) que se pushea como `tool` message.
|
||||||
|
*
|
||||||
|
* Convenciones:
|
||||||
|
* - obs.ok: true|false
|
||||||
|
* - obs.error: string si !ok
|
||||||
|
* - obs.terminal: true si esta tool finaliza el turno (say, pause, escalate)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import Ajv from "ajv";
|
||||||
|
import { TOOL_SCHEMAS } from "./schemas.js";
|
||||||
|
import { searchCatalogTool } from "./searchCatalog.js";
|
||||||
|
import { addToCartTool } from "./addToCart.js";
|
||||||
|
import { setQuantityTool } from "./setQuantity.js";
|
||||||
|
import { selectCandidateTool } from "./selectCandidate.js";
|
||||||
|
import { removeFromCartTool } from "./removeFromCart.js";
|
||||||
|
import { setShippingTool } from "./setShipping.js";
|
||||||
|
import { setAddressTool } from "./setAddress.js";
|
||||||
|
import { setDeliveryWindowTool } from "./setDeliveryWindow.js";
|
||||||
|
import { reuseLastDeliveryTool } from "./reuseLastDelivery.js";
|
||||||
|
import { confirmOrderTool } from "./confirmOrder.js";
|
||||||
|
import { pauseTool } from "./pause.js";
|
||||||
|
import { escalateToHumanTool } from "./escalateToHuman.js";
|
||||||
|
|
||||||
|
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||||
|
|
||||||
|
// Compilar validators una vez
|
||||||
|
const VALIDATORS = {};
|
||||||
|
for (const t of TOOL_SCHEMAS) {
|
||||||
|
VALIDATORS[t.function.name] = ajv.compile(t.function.parameters);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOOLS = {
|
||||||
|
search_catalog: searchCatalogTool,
|
||||||
|
add_to_cart: addToCartTool,
|
||||||
|
set_quantity: setQuantityTool,
|
||||||
|
select_candidate: selectCandidateTool,
|
||||||
|
remove_from_cart: removeFromCartTool,
|
||||||
|
set_shipping: setShippingTool,
|
||||||
|
set_address: setAddressTool,
|
||||||
|
set_delivery_window: setDeliveryWindowTool,
|
||||||
|
reuse_last_delivery: reuseLastDeliveryTool,
|
||||||
|
confirm_order: confirmOrderTool,
|
||||||
|
pause: pauseTool,
|
||||||
|
escalate_to_human: escalateToHumanTool,
|
||||||
|
// `say` se maneja inline (asigna ctx.say_text y termina el turno)
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function executeToolCall(call, ctx) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const name = call?.function?.name;
|
||||||
|
const argsRaw = call?.function?.arguments || "{}";
|
||||||
|
|
||||||
|
let args;
|
||||||
|
try {
|
||||||
|
args = typeof argsRaw === "string" ? JSON.parse(argsRaw) : argsRaw;
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `invalid_json_args: ${String(e?.message || e)}`,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// `say` es especial: termina el turno
|
||||||
|
if (name === "say") {
|
||||||
|
const validator = VALIDATORS.say;
|
||||||
|
if (!validator(args)) {
|
||||||
|
return { ok: false, error: "say_args_invalid", details: validator.errors, duration_ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
ctx.say_text = args.text;
|
||||||
|
return { ok: true, terminal: true, duration_ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = VALIDATORS[name];
|
||||||
|
if (!validator) {
|
||||||
|
return { ok: false, error: `unknown_tool: ${name}`, duration_ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
if (!validator(args)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "args_schema_invalid",
|
||||||
|
details: validator.errors,
|
||||||
|
tool: name,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const handler = TOOLS[name];
|
||||||
|
if (!handler) {
|
||||||
|
return { ok: false, error: `tool_not_implemented: ${name}`, duration_ms: Date.now() - t0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await handler(args, ctx);
|
||||||
|
return { ...result, duration_ms: Date.now() - t0 };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `tool_error: ${String(err?.message || err)}`,
|
||||||
|
tool: name,
|
||||||
|
duration_ms: Date.now() - t0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/modules/3-turn-engine/agent/tools/pause.js
Normal file
14
src/modules/3-turn-engine/agent/tools/pause.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* pause — marca la conversación como pausada (TTL 7d).
|
||||||
|
*
|
||||||
|
* El cart NO se limpia. Cuando el cliente vuelva, sale de paused y sigue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PAUSE_TTL_MS = 7 * 24 * 3600 * 1000;
|
||||||
|
|
||||||
|
export async function pauseTool(args, ctx) {
|
||||||
|
const { reason = "user_paused" } = args;
|
||||||
|
ctx.paused = true;
|
||||||
|
ctx.paused_until = new Date(Date.now() + PAUSE_TTL_MS).toISOString();
|
||||||
|
return { ok: true, paused_until: ctx.paused_until, reason, terminal: false };
|
||||||
|
}
|
||||||
43
src/modules/3-turn-engine/agent/tools/removeFromCart.js
Normal file
43
src/modules/3-turn-engine/agent/tools/removeFromCart.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* remove_from_cart — quita un producto por woo_id (string numérico) o por
|
||||||
|
* substring del nombre.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { removeCartItem } from "../../orderModel.js";
|
||||||
|
|
||||||
|
export async function removeFromCartTool(args, ctx) {
|
||||||
|
const { target } = args;
|
||||||
|
const t = String(target || "").trim();
|
||||||
|
if (!t) return { ok: false, error: "empty_target" };
|
||||||
|
|
||||||
|
// Si es un número puro, intentar match por woo_id directo
|
||||||
|
const asNumber = /^\d+$/.test(t) ? Number(t) : null;
|
||||||
|
let removed = null;
|
||||||
|
let nextOrder = ctx.order;
|
||||||
|
|
||||||
|
if (asNumber != null) {
|
||||||
|
const cart = ctx.order.cart || [];
|
||||||
|
const idx = cart.findIndex((c) => Number(c.woo_id) === asNumber);
|
||||||
|
if (idx >= 0) {
|
||||||
|
removed = cart[idx];
|
||||||
|
nextOrder = { ...ctx.order, cart: cart.filter((_, i) => i !== idx) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match por nombre
|
||||||
|
if (!removed) {
|
||||||
|
const result = removeCartItem(ctx.order, t);
|
||||||
|
if (result?.removed) {
|
||||||
|
removed = result.removed;
|
||||||
|
nextOrder = result.order;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!removed) {
|
||||||
|
return { ok: false, error: "not_found_in_cart", target: t };
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.order = nextOrder;
|
||||||
|
ctx.pending_actions.push({ type: "remove_from_cart", payload: { removed } });
|
||||||
|
return { ok: true, removed: { woo_id: removed.woo_id, name: removed.name }, cart_size: (nextOrder.cart || []).length };
|
||||||
|
}
|
||||||
52
src/modules/3-turn-engine/agent/tools/reuseLastDelivery.js
Normal file
52
src/modules/3-turn-engine/agent/tools/reuseLastDelivery.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* reuse_last_delivery — copia los datos de envío de la última orden del cliente
|
||||||
|
* (working_memory.last_delivery) al order actual: shipping_address, matched_zone,
|
||||||
|
* is_delivery (y opcionalmente pending_location + delivery_window).
|
||||||
|
*
|
||||||
|
* El agente lo usa cuando proactivamente ofrece "te lo mandamos al mismo lugar
|
||||||
|
* que la última vez?" y el cliente confirma. Si no hay last_delivery, devuelve
|
||||||
|
* error y el agente pide la dirección/ubicación de cero.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function reuseLastDeliveryTool(_args, ctx) {
|
||||||
|
const last = ctx.last_delivery;
|
||||||
|
if (!last) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "no_last_delivery",
|
||||||
|
hint: "El cliente no tiene una entrega previa registrada. Pedile la ubicación de cero.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pickup-only no necesita copiar nada de envío.
|
||||||
|
if (last.is_delivery === false) {
|
||||||
|
ctx.order = { ...ctx.order, is_delivery: false };
|
||||||
|
return { ok: true, is_delivery: false, hint: "El cliente la última vez retiró por el local." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery: si no hay matched_zone tampoco podemos reusar (no se cerró bien).
|
||||||
|
if (!last.matched_zone) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "no_zone_in_last_delivery",
|
||||||
|
hint: "La última entrega no tenía zona registrada. Pedí la ubicación al cliente.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.order = {
|
||||||
|
...ctx.order,
|
||||||
|
is_delivery: true,
|
||||||
|
shipping_address: last.shipping_address || ctx.order.shipping_address || null,
|
||||||
|
matched_zone: last.matched_zone,
|
||||||
|
// Reusar también la ubicación si la tenemos (evita re-pedir el pin).
|
||||||
|
pending_location: last.pending_location || ctx.order.pending_location || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
is_delivery: true,
|
||||||
|
shipping_address: ctx.order.shipping_address,
|
||||||
|
matched_zone: last.matched_zone,
|
||||||
|
last_delivery_window: last.delivery_window || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
213
src/modules/3-turn-engine/agent/tools/schemas.js
Normal file
213
src/modules/3-turn-engine/agent/tools/schemas.js
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
/**
|
||||||
|
* JSON Schemas de los tools que el agente puede invocar. Formato
|
||||||
|
* OpenAI/DeepSeek (function calling).
|
||||||
|
*
|
||||||
|
* El executor valida los args con Ajv y devuelve error obligando re-llamada
|
||||||
|
* si falla.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TOOL_SCHEMAS = [
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "search_catalog",
|
||||||
|
description:
|
||||||
|
"Busca productos en el catálogo. Devuelve top candidatos con woo_id, nombre, precio, unidad de venta. Llamala SIEMPRE antes de cualquier add_to_cart si no tenés el woo_id.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["query"],
|
||||||
|
properties: {
|
||||||
|
query: { type: "string", minLength: 2, description: "Término de búsqueda libre, lo que dijo el cliente." },
|
||||||
|
hint_category: { type: "string", description: "Categoría heurística para fallback (ej. 'parrilla', 'embutidos')." },
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: 10 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "add_to_cart",
|
||||||
|
description: "Agrega un producto resuelto al carrito.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["woo_id", "qty", "unit"],
|
||||||
|
properties: {
|
||||||
|
woo_id: { type: "integer", description: "Woo product ID exacto del producto." },
|
||||||
|
qty: { type: "number", exclusiveMinimum: 0 },
|
||||||
|
unit: { type: "string", enum: ["kg", "g", "unit"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "set_quantity",
|
||||||
|
description: "Setea la cantidad de un producto pendiente que ya está resuelto pero faltaba qty/unit.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["pending_id", "qty", "unit"],
|
||||||
|
properties: {
|
||||||
|
pending_id: { type: "string" },
|
||||||
|
qty: { type: "number", exclusiveMinimum: 0 },
|
||||||
|
unit: { type: "string", enum: ["kg", "g", "unit"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "select_candidate",
|
||||||
|
description: "Resuelve un pending NEEDS_TYPE eligiendo uno de los candidatos mostrados (last_shown_options).",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["pending_id", "woo_id"],
|
||||||
|
properties: {
|
||||||
|
pending_id: { type: "string" },
|
||||||
|
woo_id: { type: "integer" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "remove_from_cart",
|
||||||
|
description: "Quita un producto del carrito por woo_id o nombre/query.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["target"],
|
||||||
|
properties: {
|
||||||
|
target: { type: "string", description: "woo_id como string o nombre del producto a quitar." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "set_shipping",
|
||||||
|
description: "Setea el método de envío (delivery o pickup).",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["method"],
|
||||||
|
properties: {
|
||||||
|
method: { type: "string", enum: ["delivery", "pickup"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "set_address",
|
||||||
|
description:
|
||||||
|
"Registra la dirección de entrega (texto, como label) y matchea zona usando la ubicación compartida " +
|
||||||
|
"por WhatsApp (working_memory.order.pending_location). Si no hay ubicación compartida, devuelve " +
|
||||||
|
"need_location: tenés que pedirle al cliente que mande el pin por WhatsApp.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["text"],
|
||||||
|
properties: {
|
||||||
|
text: {
|
||||||
|
type: "string",
|
||||||
|
minLength: 3,
|
||||||
|
description: "Dirección textual (calle, número, depto, referencias). Sirve como label para el repartidor.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "reuse_last_delivery",
|
||||||
|
description:
|
||||||
|
"Reusa los datos de envío de la última orden del cliente (working_memory.last_delivery): " +
|
||||||
|
"dirección, zona y ubicación. Llamala cuando ofrezcas 'te lo mandamos al mismo lugar que la otra vez?' " +
|
||||||
|
"y el cliente confirme. Si no hay last_delivery devuelve error y tenés que pedir la ubicación de cero.",
|
||||||
|
parameters: { type: "object", additionalProperties: false, properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "set_delivery_window",
|
||||||
|
description:
|
||||||
|
"Registra el día/horario que pidió el cliente para entrega o retiro. " +
|
||||||
|
"El día debe ser uno de lun/mar/mie/jue/vie/sab/dom. Hora opcional (HH:MM). " +
|
||||||
|
"confirm_order va a validar después contra la zona o el horario de pickup.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["day"],
|
||||||
|
properties: {
|
||||||
|
day: { type: "string", enum: ["lun", "mar", "mie", "jue", "vie", "sab", "dom"] },
|
||||||
|
time: { type: "string", pattern: "^\\d{2}:\\d{2}$", description: "HH:MM (24h). Opcional." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "confirm_order",
|
||||||
|
description: "Confirma el pedido. Requiere cart no vacío y shipping completo (pickup o delivery+address). Emite acción create_order.",
|
||||||
|
parameters: { type: "object", additionalProperties: false, properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "pause",
|
||||||
|
description: "Pausa la conversación cuando el cliente dice 'después te digo' o equivalente. Mantiene el cart 7 días.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["reason"],
|
||||||
|
properties: {
|
||||||
|
reason: { type: "string", enum: ["user_paused", "user_busy", "needs_to_check"] },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "escalate_to_human",
|
||||||
|
description: "Escala a un humano (quejas, dudas de pago/factura, urgencias, no podemos resolver).",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["reason"],
|
||||||
|
properties: {
|
||||||
|
reason: { type: "string", minLength: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: "say",
|
||||||
|
description: "Texto final que se envía al cliente. ÚLTIMO tool del turno SIEMPRE.",
|
||||||
|
parameters: {
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: false,
|
||||||
|
required: ["text"],
|
||||||
|
properties: {
|
||||||
|
text: { type: "string", minLength: 1, maxLength: 600 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
106
src/modules/3-turn-engine/agent/tools/searchCatalog.js
Normal file
106
src/modules/3-turn-engine/agent/tools/searchCatalog.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* search_catalog tool — wrappea retrieveCandidates con fallback por categoría.
|
||||||
|
*
|
||||||
|
* Side effects: muta `ctx.last_shown_options` con el top de candidatos para
|
||||||
|
* que select_candidate pueda resolver "el segundo" en turnos posteriores.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { retrieveCandidates } from "../../catalogRetrieval.js";
|
||||||
|
import { searchSnapshotItems } from "../../../shared/wooSnapshot.js";
|
||||||
|
import { pool } from "../../../shared/db/pool.js";
|
||||||
|
|
||||||
|
const MIN_GOOD_SCORE = 0.4;
|
||||||
|
|
||||||
|
function summarizeCandidate(c, idx) {
|
||||||
|
return {
|
||||||
|
index: idx + 1,
|
||||||
|
woo_id: c.woo_product_id,
|
||||||
|
name: c.name,
|
||||||
|
price: c.price ?? null,
|
||||||
|
sell_unit: c.sell_unit || null,
|
||||||
|
score: typeof c._score === "number" ? Number(c._score.toFixed(2)) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCatalogTool(args, ctx) {
|
||||||
|
const { query, hint_category = null, limit = 5 } = args;
|
||||||
|
|
||||||
|
// 1) Búsqueda directa con catalogRetrieval (pg_trgm + alias + snapshot)
|
||||||
|
const result = await retrieveCandidates({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
query,
|
||||||
|
limit: Math.max(limit, 5),
|
||||||
|
});
|
||||||
|
let candidates = result?.candidates || [];
|
||||||
|
|
||||||
|
// 2) Fallback por categoría si la búsqueda directa rinde poco
|
||||||
|
let usedFallback = null;
|
||||||
|
if (
|
||||||
|
(candidates.length === 0 ||
|
||||||
|
(candidates[0]?._score || 0) < MIN_GOOD_SCORE) &&
|
||||||
|
hint_category
|
||||||
|
) {
|
||||||
|
const byCategory = await searchByCategory({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
category: hint_category,
|
||||||
|
limit,
|
||||||
|
});
|
||||||
|
if (byCategory.length > 0) {
|
||||||
|
usedFallback = "category";
|
||||||
|
candidates = byCategory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recortar al limit final
|
||||||
|
const top = candidates.slice(0, limit).map(summarizeCandidate);
|
||||||
|
|
||||||
|
// Guardar last_shown_options si hay >1 (para select_candidate posterior)
|
||||||
|
if (top.length > 1) {
|
||||||
|
ctx.last_shown_options = top.map((t) => ({
|
||||||
|
index: t.index,
|
||||||
|
woo_id: t.woo_id,
|
||||||
|
name: t.name,
|
||||||
|
price: t.price,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
query,
|
||||||
|
candidates: top,
|
||||||
|
used_fallback: usedFallback,
|
||||||
|
count: top.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchByCategory({ tenantId, category, limit }) {
|
||||||
|
// Buscar productos cuya categories JSONB contenga el name/slug indicado.
|
||||||
|
// Sin nuevas tablas — explota lo que ya está en woo_products_snapshot.categories.
|
||||||
|
const sql = `
|
||||||
|
SELECT woo_product_id, name, sku, slug, price, stock_status, stock_qty,
|
||||||
|
categories, sell_unit, payload
|
||||||
|
FROM woo_products_snapshot
|
||||||
|
WHERE tenant_id = $1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM jsonb_array_elements_text(categories) cat
|
||||||
|
WHERE LOWER(cat) LIKE '%' || LOWER($2) || '%'
|
||||||
|
)
|
||||||
|
AND COALESCE(stock_status, 'instock') != 'outofstock'
|
||||||
|
ORDER BY name ASC
|
||||||
|
LIMIT $3
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
const { rows } = await pool.query(sql, [tenantId, category, limit]);
|
||||||
|
return rows.map((r) => ({
|
||||||
|
woo_product_id: r.woo_product_id,
|
||||||
|
name: r.name,
|
||||||
|
price: r.price,
|
||||||
|
sell_unit: r.sell_unit,
|
||||||
|
_score: 0.5, // score sintético para que pase MIN_GOOD_SCORE en el caller
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
// Fallback: snapshot search por texto plano
|
||||||
|
const r = await searchSnapshotItems({ tenantId, q: category, limit });
|
||||||
|
return r?.items || [];
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/modules/3-turn-engine/agent/tools/selectCandidate.js
Normal file
72
src/modules/3-turn-engine/agent/tools/selectCandidate.js
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* select_candidate — resuelve un pending NEEDS_TYPE eligiendo woo_id.
|
||||||
|
*
|
||||||
|
* Si el pending tenía `requested_qty` ya, lo promueve directo a READY → cart.
|
||||||
|
* Sino lo deja en NEEDS_QUANTITY esperando set_quantity.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
updatePendingItem,
|
||||||
|
moveReadyToCart,
|
||||||
|
PendingStatus,
|
||||||
|
} from "../../orderModel.js";
|
||||||
|
import { getSnapshotItemsByIds } from "../../../shared/wooSnapshot.js";
|
||||||
|
|
||||||
|
export async function selectCandidateTool(args, ctx) {
|
||||||
|
const { pending_id, woo_id } = args;
|
||||||
|
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
|
||||||
|
if (!pending) {
|
||||||
|
return { ok: false, error: "pending_not_found" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buscar el producto seleccionado en el snapshot para nombre/unit/precio
|
||||||
|
const lookup = await getSnapshotItemsByIds({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
wooProductIds: [woo_id],
|
||||||
|
});
|
||||||
|
const found = (lookup?.items || [])[0];
|
||||||
|
if (!found) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "woo_id_unknown",
|
||||||
|
hint: "El woo_id no existe en el catálogo. Probá con search_catalog.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sellsByWeight =
|
||||||
|
!found.sell_unit || !["unit", "unidad"].includes(found.sell_unit);
|
||||||
|
const displayUnit = found.sell_unit === "unit" ? "unit" : sellsByWeight ? "kg" : "unit";
|
||||||
|
|
||||||
|
const hasRequestedQty =
|
||||||
|
pending.requested_qty != null && Number.isFinite(pending.requested_qty) && pending.requested_qty > 0;
|
||||||
|
const finalQty = hasRequestedQty ? pending.requested_qty : null;
|
||||||
|
const finalUnit = pending.requested_unit || displayUnit;
|
||||||
|
const needsQty = sellsByWeight && !hasRequestedQty;
|
||||||
|
|
||||||
|
const updated = updatePendingItem(ctx.order, pending_id, {
|
||||||
|
selected_woo_id: woo_id,
|
||||||
|
selected_name: found.name,
|
||||||
|
selected_price: found.price ?? null,
|
||||||
|
selected_unit: displayUnit,
|
||||||
|
candidates: [],
|
||||||
|
qty: needsQty ? null : finalQty,
|
||||||
|
unit: finalUnit,
|
||||||
|
status: needsQty ? PendingStatus.NEEDS_QUANTITY : PendingStatus.READY,
|
||||||
|
});
|
||||||
|
ctx.order = moveReadyToCart(updated);
|
||||||
|
|
||||||
|
if (!needsQty) {
|
||||||
|
ctx.pending_actions.push({
|
||||||
|
type: "add_to_cart",
|
||||||
|
payload: { woo_id, qty: finalQty, unit: finalUnit },
|
||||||
|
});
|
||||||
|
ctx.last_shown_options = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
selected: { woo_id, name: found.name, unit: displayUnit, sells_by_weight: sellsByWeight },
|
||||||
|
needs_quantity: needsQty,
|
||||||
|
cart_size: (ctx.order.cart || []).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
80
src/modules/3-turn-engine/agent/tools/setAddress.js
Normal file
80
src/modules/3-turn-engine/agent/tools/setAddress.js
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* set_address — fija dirección (texto) y matchea zona usando la ubicación
|
||||||
|
* compartida por WhatsApp (pending_location). Sin location, no se valida
|
||||||
|
* zona y se pide al cliente que mande el pin.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { findZoneForPoint } from "../../lib/geo.js";
|
||||||
|
|
||||||
|
export async function setAddressTool(args, ctx) {
|
||||||
|
const { text } = args || {};
|
||||||
|
const address = String(text || "").trim();
|
||||||
|
if (address.length < 3) return { ok: false, error: "address_too_short" };
|
||||||
|
|
||||||
|
const allZones = ctx.storeConfig?.delivery_zones?.zones || [];
|
||||||
|
const enabled = allZones.filter((z) => z?.enabled !== false);
|
||||||
|
|
||||||
|
// Sin zonas configuradas, aceptamos la dirección sin validar zona.
|
||||||
|
if (!enabled.length) {
|
||||||
|
ctx.order = {
|
||||||
|
...ctx.order,
|
||||||
|
shipping_address: address,
|
||||||
|
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
|
||||||
|
matched_zone: null,
|
||||||
|
};
|
||||||
|
return { ok: true, address, in_zone: true, reason: "no_zones_configured" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const loc = ctx.order?.pending_location;
|
||||||
|
if (!loc || typeof loc.lat !== "number" || typeof loc.lng !== "number") {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "need_location",
|
||||||
|
hint:
|
||||||
|
"Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) " +
|
||||||
|
"para validar zona y costo. Sin ubicación no podemos confirmar envío.",
|
||||||
|
available_zones: enabled.map((z) => ({
|
||||||
|
name: z.name,
|
||||||
|
delivery_cost: z.delivery_cost ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = findZoneForPoint(loc.lng, loc.lat, enabled);
|
||||||
|
if (!matched) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "out_of_zones",
|
||||||
|
hint:
|
||||||
|
"La ubicación que mandó está fuera de las zonas que cubre la carnicería. " +
|
||||||
|
"Ofrecé pickup o pedile otra ubicación dentro de las zonas habilitadas.",
|
||||||
|
available_zones: enabled.map((z) => ({
|
||||||
|
name: z.name,
|
||||||
|
delivery_cost: z.delivery_cost ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoneSummary = {
|
||||||
|
id: matched.id,
|
||||||
|
name: matched.name,
|
||||||
|
delivery_cost: matched.delivery_cost ?? null,
|
||||||
|
delivery_days: Array.isArray(matched.delivery_days) ? matched.delivery_days : [],
|
||||||
|
delivery_hours: matched.delivery_hours || null,
|
||||||
|
min_order_amount: matched.min_order_amount ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.order = {
|
||||||
|
...ctx.order,
|
||||||
|
shipping_address: address,
|
||||||
|
is_delivery: ctx.order.is_delivery == null ? true : ctx.order.is_delivery,
|
||||||
|
matched_zone: zoneSummary,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
address,
|
||||||
|
in_zone: true,
|
||||||
|
matched_zone: zoneSummary,
|
||||||
|
};
|
||||||
|
}
|
||||||
26
src/modules/3-turn-engine/agent/tools/setDeliveryWindow.js
Normal file
26
src/modules/3-turn-engine/agent/tools/setDeliveryWindow.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* set_delivery_window — registra el día/horario que pidió el cliente.
|
||||||
|
*
|
||||||
|
* El LLM lo llama cuando el cliente confirma "el martes a las 11" o similar.
|
||||||
|
* confirm_order valida después contra zone.delivery_days/delivery_hours
|
||||||
|
* (delivery) o schedule.pickup (pickup).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DAY_KEYS = ["lun", "mar", "mie", "jue", "vie", "sab", "dom"];
|
||||||
|
|
||||||
|
export async function setDeliveryWindowTool(args, ctx) {
|
||||||
|
const { day, time } = args || {};
|
||||||
|
if (!DAY_KEYS.includes(day)) {
|
||||||
|
return { ok: false, error: "invalid_day", allowed: DAY_KEYS };
|
||||||
|
}
|
||||||
|
if (time != null && !/^\d{2}:\d{2}$/.test(String(time))) {
|
||||||
|
return { ok: false, error: "invalid_time", hint: "Formato HH:MM (24h)." };
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.order = {
|
||||||
|
...ctx.order,
|
||||||
|
delivery_window: { day, time: time || null },
|
||||||
|
};
|
||||||
|
|
||||||
|
return { ok: true, day, time: time || null };
|
||||||
|
}
|
||||||
36
src/modules/3-turn-engine/agent/tools/setQuantity.js
Normal file
36
src/modules/3-turn-engine/agent/tools/setQuantity.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* set_quantity — completa la cantidad de un pending NEEDS_QUANTITY y lo
|
||||||
|
* promueve a READY → cart.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { updatePendingItem, moveReadyToCart, PendingStatus } from "../../orderModel.js";
|
||||||
|
|
||||||
|
export async function setQuantityTool(args, ctx) {
|
||||||
|
const { pending_id, qty, unit } = args;
|
||||||
|
const pending = (ctx.order.pending || []).find((p) => p.id === pending_id);
|
||||||
|
if (!pending) {
|
||||||
|
return { ok: false, error: "pending_not_found", hint: "Verificá el pending_id contra working_memory.order.pending." };
|
||||||
|
}
|
||||||
|
if (!pending.selected_woo_id) {
|
||||||
|
return { ok: false, error: "pending_not_resolved", hint: "Llamá select_candidate primero para resolver el producto." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = updatePendingItem(ctx.order, pending_id, {
|
||||||
|
qty,
|
||||||
|
unit,
|
||||||
|
status: PendingStatus.READY,
|
||||||
|
});
|
||||||
|
ctx.order = moveReadyToCart(updated);
|
||||||
|
|
||||||
|
ctx.pending_actions.push({ type: "add_to_cart", payload: { woo_id: pending.selected_woo_id, qty, unit } });
|
||||||
|
ctx.last_shown_options = [];
|
||||||
|
if (ctx.order.failed_searches) {
|
||||||
|
ctx.order = { ...ctx.order, failed_searches: { count: 0 } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
promoted: { name: pending.selected_name, qty, unit },
|
||||||
|
cart_size: (ctx.order.cart || []).length,
|
||||||
|
};
|
||||||
|
}
|
||||||
50
src/modules/3-turn-engine/agent/tools/setShipping.js
Normal file
50
src/modules/3-turn-engine/agent/tools/setShipping.js
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* set_shipping — fija el método de envío (delivery o pickup).
|
||||||
|
*
|
||||||
|
* Cuando method=delivery y hay zonas configuradas, devuelve hints para que
|
||||||
|
* el LLM le pida al cliente que comparta ubicación si no la tenemos.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export async function setShippingTool(args, ctx) {
|
||||||
|
const { method } = args || {};
|
||||||
|
if (method !== "delivery" && method !== "pickup") {
|
||||||
|
return { ok: false, error: "invalid_method" };
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.order = { ...ctx.order, is_delivery: method === "delivery" };
|
||||||
|
|
||||||
|
if (method === "pickup") {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
method,
|
||||||
|
requires_address: false,
|
||||||
|
requires_location: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delivery
|
||||||
|
const zones = ctx.storeConfig?.delivery_zones?.zones || [];
|
||||||
|
const enabledZones = zones.filter((z) => z?.enabled !== false);
|
||||||
|
const hasZones = enabledZones.length > 0;
|
||||||
|
const hasLocation = !!ctx.order?.pending_location?.lat;
|
||||||
|
const hasMatchedZone = !!ctx.order?.matched_zone;
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
method,
|
||||||
|
requires_address: !ctx.order.shipping_address,
|
||||||
|
requires_location: hasZones && !hasLocation && !hasMatchedZone,
|
||||||
|
available_zones: hasZones
|
||||||
|
? enabledZones.map((z) => ({
|
||||||
|
name: z.name,
|
||||||
|
delivery_cost: z.delivery_cost ?? null,
|
||||||
|
delivery_days: z.delivery_days || [],
|
||||||
|
delivery_hours: z.delivery_hours || null,
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
hint:
|
||||||
|
hasZones && !hasLocation
|
||||||
|
? "Pedile al cliente que te mande su ubicación por WhatsApp (pin/location share) para validar zona y costo de envío."
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
131
src/modules/3-turn-engine/agent/workingMemory.js
Normal file
131
src/modules/3-turn-engine/agent/workingMemory.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* WorkingMemory — payload contextual que recibe el agente cada turno.
|
||||||
|
*
|
||||||
|
* Se serializa como JSON y se inyecta en el primer USER message. NO va en
|
||||||
|
* system (eso permite cachear el system prompt entre turnos).
|
||||||
|
*
|
||||||
|
* Reglas de poda:
|
||||||
|
* - history: últimos 8 mensajes, content truncado a 200 chars.
|
||||||
|
* - customer_profile: top 5 frequent_items.
|
||||||
|
* - last_shown_options: top 8.
|
||||||
|
* - cart/pending: enteros, sin truncar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseQuantity } from "./quantityParser.js";
|
||||||
|
import { buildStoreContextVars, buildZonesForLLM } from "../storeContext.js";
|
||||||
|
|
||||||
|
const HISTORY_MAX = 8;
|
||||||
|
const HISTORY_CHAR_CAP = 200;
|
||||||
|
const LAST_SHOWN_MAX = 8;
|
||||||
|
|
||||||
|
function nowIso() {
|
||||||
|
return new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(s, n) {
|
||||||
|
if (s == null) return "";
|
||||||
|
const str = String(s);
|
||||||
|
if (str.length <= n) return str;
|
||||||
|
return str.slice(0, n - 1) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Object} params
|
||||||
|
* @param {string} params.text - Mensaje crudo del usuario en este turno.
|
||||||
|
* @param {Object} params.order - order del context (cart, pending, etc.)
|
||||||
|
* @param {string} params.prev_state - estado FSM previo
|
||||||
|
* @param {Array} params.conversation_history
|
||||||
|
* @param {Object} params.storeConfig - resultado de getStoreConfig
|
||||||
|
* @param {Object} params.customerProfile - perfil del cliente (puede ser null)
|
||||||
|
* @param {Array} params.lastShownOptions - opciones del turno previo
|
||||||
|
*/
|
||||||
|
export function buildWorkingMemory({
|
||||||
|
text,
|
||||||
|
order = {},
|
||||||
|
prev_state = "IDLE",
|
||||||
|
conversation_history = [],
|
||||||
|
storeConfig = {},
|
||||||
|
customerProfile = null,
|
||||||
|
lastShownOptions = [],
|
||||||
|
lastDelivery = null,
|
||||||
|
}) {
|
||||||
|
const storeVars = buildStoreContextVars(storeConfig);
|
||||||
|
|
||||||
|
const cart = (order.cart || []).map((it) => ({
|
||||||
|
woo_id: it.woo_id,
|
||||||
|
name: it.name,
|
||||||
|
qty: it.qty,
|
||||||
|
unit: it.unit,
|
||||||
|
price: it.price ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const pending = (order.pending || []).map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
query: p.query,
|
||||||
|
status: p.status,
|
||||||
|
selected_woo_id: p.selected_woo_id ?? null,
|
||||||
|
selected_name: p.selected_name ?? null,
|
||||||
|
selected_unit: p.selected_unit ?? null,
|
||||||
|
requested_qty: p.requested_qty ?? null,
|
||||||
|
requested_unit: p.requested_unit ?? null,
|
||||||
|
candidates: (p.candidates || []).slice(0, 8).map((c, i) => ({
|
||||||
|
index: i + 1,
|
||||||
|
woo_id: c.woo_id ?? c.woo_product_id,
|
||||||
|
name: c.name,
|
||||||
|
price: c.price ?? null,
|
||||||
|
display_unit: c.display_unit ?? null,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const history = (conversation_history || []).slice(-HISTORY_MAX).map((m) => ({
|
||||||
|
role: m.role === "user" ? "user" : "assistant",
|
||||||
|
text: truncate(m.content || m.text || "", HISTORY_CHAR_CAP),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const last_shown_options = (lastShownOptions || []).slice(0, LAST_SHOWN_MAX).map((o, i) => ({
|
||||||
|
index: o.index ?? i + 1,
|
||||||
|
woo_id: o.woo_id,
|
||||||
|
name: o.name,
|
||||||
|
price: o.price ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const preparsed = parseQuantity(text || "");
|
||||||
|
|
||||||
|
const zones = buildZonesForLLM(storeConfig.delivery_zones);
|
||||||
|
const pickupSchedule = storeConfig.schedule?.pickup || null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
now: nowIso(),
|
||||||
|
store: {
|
||||||
|
name: storeVars.store_name || "la carnicería",
|
||||||
|
hours_today: storeVars.store_hours_today || "consultar",
|
||||||
|
delivery: {
|
||||||
|
zones, // [{id,name,delivery_cost,delivery_days,delivery_hours,min_order_amount}]
|
||||||
|
zones_summary: storeVars.delivery_zones_summary || "",
|
||||||
|
requires_location_share: zones.length > 0, // hint para el LLM
|
||||||
|
},
|
||||||
|
pickup: {
|
||||||
|
schedule: pickupSchedule, // { lun:{start,end,enabled}, mar:..., ... } o null
|
||||||
|
hours_today: storeVars.pickup_hours_today || "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fsm_state: prev_state || "IDLE",
|
||||||
|
order: {
|
||||||
|
cart,
|
||||||
|
pending,
|
||||||
|
is_delivery: order.is_delivery ?? null,
|
||||||
|
shipping_address: order.shipping_address ?? null,
|
||||||
|
woo_order_id: order.woo_order_id ?? null,
|
||||||
|
pending_location: order.pending_location || null, // { lat, lng, label?, received_at }
|
||||||
|
matched_zone: order.matched_zone || null, // resumen de zona matched (set por set_address)
|
||||||
|
delivery_window: order.delivery_window || null, // { day, time } elegido por cliente
|
||||||
|
},
|
||||||
|
last_shown_options,
|
||||||
|
paused_until: order.paused_until ?? null,
|
||||||
|
customer_profile: customerProfile,
|
||||||
|
last_delivery: lastDelivery, // null si es 1er pedido
|
||||||
|
history,
|
||||||
|
user_message: text || "",
|
||||||
|
preparsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
searchProductAliases,
|
searchProductAliases,
|
||||||
getProductEmbedding,
|
getProductEmbedding,
|
||||||
upsertProductEmbedding,
|
upsertProductEmbedding,
|
||||||
getAllAliasProductMappings,
|
searchAliasProductMappings,
|
||||||
} from "../2-identity/db/repo.js";
|
} from "../2-identity/db/repo.js";
|
||||||
|
|
||||||
function getOpenAiKey() {
|
function getOpenAiKey() {
|
||||||
@@ -138,60 +138,62 @@ export async function retrieveCandidates({
|
|||||||
|
|
||||||
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
const audit = { query: q, sources: {}, boosts: {}, embeddings: {} };
|
||||||
|
|
||||||
// 1) Buscar aliases que matcheen la query
|
// 1) Buscar aliases con fuzzy matching (pg_trgm).
|
||||||
const aliases = await searchProductAliases({ tenant_id: tenantId, q, limit: 20 });
|
// Captura plurales, diminutivos y typos sin reglas escritas.
|
||||||
|
const [aliases, mappings] = await Promise.all([
|
||||||
|
searchProductAliases({ tenant_id: tenantId, q, limit: 20 }),
|
||||||
|
searchAliasProductMappings({ tenant_id: tenantId, q, limit: 50 }),
|
||||||
|
]);
|
||||||
|
|
||||||
const aliasBoostByProduct = new Map();
|
const aliasBoostByProduct = new Map();
|
||||||
const aliasProductIds = new Set();
|
const aliasProductIds = new Set();
|
||||||
|
|
||||||
// También buscar en alias_product_mappings (multi-producto)
|
// alias_product_mappings: score * similarity (premia tanto reglas explícitas como fuzziness)
|
||||||
const allMappings = await getAllAliasProductMappings({ tenant_id: tenantId });
|
for (const m of mappings) {
|
||||||
const normalizedQuery = normalizeText(q);
|
const id = m.woo_product_id;
|
||||||
const queryWords = new Set(normalizedQuery.split(" ").filter(Boolean));
|
const boost = m.score * m.similarity;
|
||||||
|
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
|
||||||
// Buscar mappings cuyos aliases matcheen la query
|
aliasProductIds.add(id);
|
||||||
for (const mapping of allMappings) {
|
|
||||||
const aliasNorm = normalizeText(mapping.alias);
|
|
||||||
// Match exacto o parcial del alias
|
|
||||||
if (aliasNorm === normalizedQuery || normalizedQuery.includes(aliasNorm) || aliasNorm.includes(normalizedQuery)) {
|
|
||||||
const id = Number(mapping.woo_product_id);
|
|
||||||
const score = Number(mapping.score || 1);
|
|
||||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
|
|
||||||
aliasProductIds.add(id);
|
|
||||||
} else {
|
|
||||||
// Check word overlap
|
|
||||||
const aliasWords = new Set(aliasNorm.split(" ").filter(Boolean));
|
|
||||||
for (const word of queryWords) {
|
|
||||||
if (aliasWords.has(word)) {
|
|
||||||
const id = Number(mapping.woo_product_id);
|
|
||||||
const score = Number(mapping.score || 1) * 0.7; // Partial match gets lower score
|
|
||||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, score));
|
|
||||||
aliasProductIds.add(id);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// También incluir aliases legacy (product_aliases.woo_product_id)
|
// product_aliases legacy (1 alias → 1 producto)
|
||||||
for (const a of aliases) {
|
for (const a of aliases) {
|
||||||
if (a?.woo_product_id) {
|
if (a?.woo_product_id) {
|
||||||
const id = Number(a.woo_product_id);
|
const id = Number(a.woo_product_id);
|
||||||
const boost = Number(a.boost || 0);
|
const boost = Number(a.boost || 0) * (a.similarity || 1);
|
||||||
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost || 0));
|
aliasBoostByProduct.set(id, Math.max(aliasBoostByProduct.get(id) || 0, boost));
|
||||||
aliasProductIds.add(id);
|
aliasProductIds.add(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
audit.sources.aliases = aliases.length;
|
audit.sources.aliases = aliases.length;
|
||||||
audit.sources.alias_mappings = aliasProductIds.size;
|
audit.sources.alias_mappings = mappings.length;
|
||||||
|
|
||||||
// 2) Buscar productos por nombre/slug (búsqueda literal)
|
// 2) Buscar productos por nombre/slug (búsqueda literal con query original)
|
||||||
const { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
let { items: wooItems, source: wooSource } = await searchSnapshotItems({
|
||||||
tenantId,
|
tenantId,
|
||||||
q,
|
q,
|
||||||
limit: lim,
|
limit: lim,
|
||||||
});
|
});
|
||||||
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
audit.sources.snapshot = { source: wooSource, count: wooItems?.length || 0 };
|
||||||
|
|
||||||
|
// 2b) Si el query literal no rinde pero un alias matcheó (typo/plural/diminutivo),
|
||||||
|
// re-buscar el snapshot con el normalized_alias del mejor match.
|
||||||
|
// Esto cierra el loop: "vasio" → alias "vacio" → buscar "vacio" en productos.
|
||||||
|
if ((!wooItems || wooItems.length === 0) && aliases.length > 0) {
|
||||||
|
const refined = aliases[0]?.normalized_alias;
|
||||||
|
if (refined && refined.toLowerCase() !== q.toLowerCase()) {
|
||||||
|
const { items: aliasRefined, source: refSource } = await searchSnapshotItems({
|
||||||
|
tenantId,
|
||||||
|
q: refined,
|
||||||
|
limit: lim,
|
||||||
|
});
|
||||||
|
if (aliasRefined?.length) {
|
||||||
|
wooItems = aliasRefined;
|
||||||
|
audit.sources.snapshot_refined = { query: refined, source: refSource, count: aliasRefined.length };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3) Traer productos que matchearon por alias pero no por búsqueda literal
|
// 3) Traer productos que matchearon por alias pero no por búsqueda literal
|
||||||
const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id)));
|
const foundIds = new Set(wooItems.map(w => Number(w.woo_product_id)));
|
||||||
const missingAliasIds = [...aliasProductIds].filter(id => !foundIds.has(id));
|
const missingAliasIds = [...aliasProductIds].filter(id => !foundIds.has(id));
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* FSM simplificada para el flujo conversacional.
|
* FSM simplificada para el flujo conversacional.
|
||||||
*
|
*
|
||||||
* Estados lineales: IDLE → CART → SHIPPING → PAYMENT → WAITING_WEBHOOKS
|
* Estados lineales: IDLE → CART → SHIPPING → IDLE (post-confirmación).
|
||||||
|
* El bot toma pedidos y datos de entrega; el cobro se gestiona offline.
|
||||||
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
|
* Regla universal: add_to_cart SIEMPRE vuelve a CART desde cualquier estado.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -9,9 +10,8 @@ export const ConversationState = Object.freeze({
|
|||||||
IDLE: "IDLE",
|
IDLE: "IDLE",
|
||||||
CART: "CART",
|
CART: "CART",
|
||||||
SHIPPING: "SHIPPING",
|
SHIPPING: "SHIPPING",
|
||||||
PAYMENT: "PAYMENT",
|
PAUSED: "PAUSED", // Cliente dijo "después te digo" - cart preservado, TTL 7d
|
||||||
WAITING_WEBHOOKS: "WAITING_WEBHOOKS",
|
AWAITING_HUMAN: "AWAITING_HUMAN",
|
||||||
AWAITING_HUMAN: "AWAITING_HUMAN", // Esperando respuesta de un humano
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
||||||
@@ -19,23 +19,22 @@ export const ALL_STATES = Object.freeze(Object.values(ConversationState));
|
|||||||
// Intents válidos por estado
|
// Intents válidos por estado
|
||||||
export const INTENTS_BY_STATE = Object.freeze({
|
export const INTENTS_BY_STATE = Object.freeze({
|
||||||
[ConversationState.IDLE]: [
|
[ConversationState.IDLE]: [
|
||||||
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other"
|
"greeting", "add_to_cart", "browse", "price_query", "recommend", "other",
|
||||||
],
|
],
|
||||||
[ConversationState.CART]: [
|
[ConversationState.CART]: [
|
||||||
"add_to_cart", "remove_from_cart", "browse", "price_query",
|
"add_to_cart", "remove_from_cart", "browse", "price_query",
|
||||||
"recommend", "view_cart", "confirm_order", "other"
|
"recommend", "view_cart", "confirm_order", "other",
|
||||||
],
|
],
|
||||||
[ConversationState.SHIPPING]: [
|
[ConversationState.SHIPPING]: [
|
||||||
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other"
|
"provide_address", "select_shipping", "add_to_cart", "view_cart", "other",
|
||||||
],
|
],
|
||||||
[ConversationState.PAYMENT]: [
|
[ConversationState.PAUSED]: [
|
||||||
"select_payment", "add_to_cart", "view_cart", "other"
|
// Cualquier intent del cliente lo reactiva; el agente decide el destino.
|
||||||
],
|
"greeting", "add_to_cart", "browse", "view_cart", "confirm_order",
|
||||||
[ConversationState.WAITING_WEBHOOKS]: [
|
"select_shipping", "provide_address", "remove_from_cart", "other",
|
||||||
"add_to_cart", "view_cart", "other"
|
|
||||||
],
|
],
|
||||||
[ConversationState.AWAITING_HUMAN]: [
|
[ConversationState.AWAITING_HUMAN]: [
|
||||||
"other" // En este estado, el bot no procesa - espera respuesta humana
|
"other",
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -44,21 +43,18 @@ export const INTENTS_BY_STATE = Object.freeze({
|
|||||||
*/
|
*/
|
||||||
export function shouldReturnToCart(state, nlu, text = "") {
|
export function shouldReturnToCart(state, nlu, text = "") {
|
||||||
if (state === ConversationState.CART || state === ConversationState.IDLE) {
|
if (state === ConversationState.CART || state === ConversationState.IDLE) {
|
||||||
return false; // Ya está en CART o IDLE (IDLE irá a CART naturalmente)
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// En SHIPPING/PAYMENT, números solos son selecciones de opción, no productos
|
// En SHIPPING, números solos son selecciones de opción, no productos
|
||||||
const isCheckoutState = state === ConversationState.SHIPPING || state === ConversationState.PAYMENT;
|
const isCheckoutState = state === ConversationState.SHIPPING;
|
||||||
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
|
const isJustNumber = /^\s*\d+([.,]\d+)?\s*$/.test(text || "");
|
||||||
if (isCheckoutState && isJustNumber) {
|
if (isCheckoutState && isJustNumber) {
|
||||||
return false; // No redirigir, es una selección de opción
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const intent = nlu?.intent;
|
const intent = nlu?.intent;
|
||||||
// Si el intent es add_to_cart, browse, price_query, o recommend → volver a CART
|
|
||||||
// Pero solo si hay una query de producto real (no vacía)
|
|
||||||
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
|
if (["add_to_cart", "browse", "price_query", "recommend"].includes(intent)) {
|
||||||
// Verificar que hay un producto real mencionado
|
|
||||||
const hasRealProduct = nlu?.entities?.product_query &&
|
const hasRealProduct = nlu?.entities?.product_query &&
|
||||||
String(nlu.entities.product_query).trim().length > 2;
|
String(nlu.entities.product_query).trim().length > 2;
|
||||||
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
|
const hasRealItems = Array.isArray(nlu?.entities?.items) &&
|
||||||
@@ -66,11 +62,9 @@ export function shouldReturnToCart(state, nlu, text = "") {
|
|||||||
if (hasRealProduct || hasRealItems) {
|
if (hasRealProduct || hasRealItems) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Si no hay producto real, no redirigir (probablemente es una selección numérica mal interpretada)
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si hay menciones de producto en entities (con contenido real)
|
|
||||||
if (nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2) return true;
|
if (nlu?.entities?.product_query && String(nlu.entities.product_query).trim().length > 2) return true;
|
||||||
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true;
|
if (Array.isArray(nlu?.entities?.items) && nlu.entities.items.some(i => i?.product_query?.trim().length > 2)) return true;
|
||||||
|
|
||||||
@@ -100,43 +94,27 @@ export function hasShippingInfo(order) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasPaymentInfo(order) {
|
|
||||||
return order?.payment_type === "cash" || order?.payment_type === "link";
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isPaid(order) {
|
|
||||||
return order?.is_paid === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deriva el siguiente estado basado en el contexto y signals.
|
* Deriva el siguiente estado basado en el contexto y signals.
|
||||||
*
|
*
|
||||||
* signals: {
|
* signals: {
|
||||||
* confirm_order: boolean, // Usuario quiere cerrar pedido
|
* confirm_order: boolean, // Usuario quiere cerrar pedido
|
||||||
* shipping_selected: boolean, // Usuario seleccionó delivery/pickup
|
* shipping_completed: boolean, // Shipping info quedó completa (gatilla create_order + IDLE)
|
||||||
* payment_selected: boolean, // Usuario seleccionó método de pago
|
|
||||||
* return_to_cart: boolean, // Forzar volver a CART
|
* return_to_cart: boolean, // Forzar volver a CART
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
export function deriveNextState(prevState, order = {}, signals = {}) {
|
export function deriveNextState(prevState, order = {}, signals = {}) {
|
||||||
// Regla 0: Si se fuerza volver a CART
|
|
||||||
if (signals.return_to_cart) {
|
if (signals.return_to_cart) {
|
||||||
return ConversationState.CART;
|
return ConversationState.CART;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regla 1: Si está pagado, completado (volver a IDLE para nueva conversación)
|
// Si la orden ya fue creada en Woo, volvemos a IDLE para nueva conversación.
|
||||||
if (isPaid(order)) {
|
if (order?.woo_order_id) {
|
||||||
return ConversationState.IDLE;
|
return ConversationState.IDLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regla 2: Si tiene woo_order_id y espera pago
|
|
||||||
if (order?.woo_order_id && !isPaid(order)) {
|
|
||||||
return ConversationState.WAITING_WEBHOOKS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desde IDLE
|
// Desde IDLE
|
||||||
if (prevState === ConversationState.IDLE) {
|
if (prevState === ConversationState.IDLE) {
|
||||||
// Si hay cart o pending items, ir a CART
|
|
||||||
if (hasCartItems(order) || hasPendingItems(order)) {
|
if (hasCartItems(order) || hasPendingItems(order)) {
|
||||||
return ConversationState.CART;
|
return ConversationState.CART;
|
||||||
}
|
}
|
||||||
@@ -145,11 +123,9 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
|
|||||||
|
|
||||||
// Desde CART
|
// Desde CART
|
||||||
if (prevState === ConversationState.CART) {
|
if (prevState === ConversationState.CART) {
|
||||||
// Si hay pending items sin resolver, quedarse en CART
|
|
||||||
if (hasPendingItems(order)) {
|
if (hasPendingItems(order)) {
|
||||||
return ConversationState.CART;
|
return ConversationState.CART;
|
||||||
}
|
}
|
||||||
// Si usuario confirma orden y hay items en cart, ir a SHIPPING
|
|
||||||
if (signals.confirm_order && hasCartItems(order)) {
|
if (signals.confirm_order && hasCartItems(order)) {
|
||||||
return ConversationState.SHIPPING;
|
return ConversationState.SHIPPING;
|
||||||
}
|
}
|
||||||
@@ -158,69 +134,49 @@ export function deriveNextState(prevState, order = {}, signals = {}) {
|
|||||||
|
|
||||||
// Desde SHIPPING
|
// Desde SHIPPING
|
||||||
if (prevState === ConversationState.SHIPPING) {
|
if (prevState === ConversationState.SHIPPING) {
|
||||||
// Si ya tiene shipping info completa, ir a PAYMENT
|
// Una vez completado el shipping, la orden se crea y vuelve a IDLE.
|
||||||
if (hasShippingInfo(order)) {
|
if (signals.shipping_completed || hasShippingInfo(order)) {
|
||||||
return ConversationState.PAYMENT;
|
return ConversationState.IDLE;
|
||||||
}
|
}
|
||||||
return ConversationState.SHIPPING;
|
return ConversationState.SHIPPING;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Desde PAYMENT
|
|
||||||
if (prevState === ConversationState.PAYMENT) {
|
|
||||||
// Si ya tiene payment info, ir a WAITING_WEBHOOKS
|
|
||||||
if (signals.payment_selected || hasPaymentInfo(order)) {
|
|
||||||
return ConversationState.WAITING_WEBHOOKS;
|
|
||||||
}
|
|
||||||
return ConversationState.PAYMENT;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Desde WAITING_WEBHOOKS
|
|
||||||
if (prevState === ConversationState.WAITING_WEBHOOKS) {
|
|
||||||
if (isPaid(order)) {
|
|
||||||
return ConversationState.IDLE;
|
|
||||||
}
|
|
||||||
return ConversationState.WAITING_WEBHOOKS;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default
|
|
||||||
return prevState || ConversationState.IDLE;
|
return prevState || ConversationState.IDLE;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transiciones permitidas (para validación)
|
|
||||||
const ALLOWED = Object.freeze({
|
const ALLOWED = Object.freeze({
|
||||||
[ConversationState.IDLE]: [
|
[ConversationState.IDLE]: [
|
||||||
ConversationState.IDLE,
|
ConversationState.IDLE,
|
||||||
ConversationState.CART,
|
ConversationState.CART,
|
||||||
ConversationState.AWAITING_HUMAN, // Puede ir a esperar humano
|
ConversationState.PAUSED,
|
||||||
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.CART]: [
|
[ConversationState.CART]: [
|
||||||
ConversationState.CART,
|
ConversationState.CART,
|
||||||
ConversationState.SHIPPING,
|
ConversationState.SHIPPING,
|
||||||
ConversationState.IDLE, // Si vacía el carrito
|
ConversationState.IDLE,
|
||||||
ConversationState.AWAITING_HUMAN, // Producto no encontrado
|
ConversationState.PAUSED,
|
||||||
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.SHIPPING]: [
|
[ConversationState.SHIPPING]: [
|
||||||
ConversationState.SHIPPING,
|
ConversationState.SHIPPING,
|
||||||
ConversationState.PAYMENT,
|
ConversationState.IDLE,
|
||||||
ConversationState.CART, // Volver a agregar productos
|
ConversationState.CART,
|
||||||
|
ConversationState.PAUSED,
|
||||||
ConversationState.AWAITING_HUMAN,
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.PAYMENT]: [
|
[ConversationState.PAUSED]: [
|
||||||
ConversationState.PAYMENT,
|
// Cualquier mensaje saca de paused
|
||||||
ConversationState.WAITING_WEBHOOKS,
|
ConversationState.PAUSED,
|
||||||
ConversationState.CART, // Volver a agregar productos
|
ConversationState.CART,
|
||||||
ConversationState.AWAITING_HUMAN,
|
ConversationState.SHIPPING,
|
||||||
],
|
ConversationState.IDLE,
|
||||||
[ConversationState.WAITING_WEBHOOKS]: [
|
|
||||||
ConversationState.WAITING_WEBHOOKS,
|
|
||||||
ConversationState.IDLE, // Pago completado
|
|
||||||
ConversationState.CART, // Agregar más productos
|
|
||||||
ConversationState.AWAITING_HUMAN,
|
ConversationState.AWAITING_HUMAN,
|
||||||
],
|
],
|
||||||
[ConversationState.AWAITING_HUMAN]: [
|
[ConversationState.AWAITING_HUMAN]: [
|
||||||
ConversationState.AWAITING_HUMAN, // Sigue esperando
|
ConversationState.AWAITING_HUMAN,
|
||||||
ConversationState.CART, // Humano respondió, volver a procesar
|
ConversationState.CART,
|
||||||
ConversationState.IDLE, // Humano canceló
|
ConversationState.IDLE,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,7 +193,5 @@ export function safeNextState(prevState, order, signals) {
|
|||||||
const desired = deriveNextState(prevState, order, signals);
|
const desired = deriveNextState(prevState, order, signals);
|
||||||
const v = validateTransition(prevState, desired);
|
const v = validateTransition(prevState, desired);
|
||||||
if (v.ok) return { next_state: desired, validation: v };
|
if (v.ok) return { next_state: desired, validation: v };
|
||||||
// Si la transición no es válida, forzar a un estado seguro
|
|
||||||
// En el nuevo modelo, siempre podemos ir a CART
|
|
||||||
return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } };
|
return { next_state: ConversationState.CART, validation: { ...v, forced_to_cart: true } };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Tests para fsm.js
|
* Tests para fsm.js (sin payment / waiting — el bot no maneja pagos).
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import {
|
import {
|
||||||
@@ -11,555 +11,211 @@ import {
|
|||||||
hasPendingItems,
|
hasPendingItems,
|
||||||
hasReadyPendingItems,
|
hasReadyPendingItems,
|
||||||
hasShippingInfo,
|
hasShippingInfo,
|
||||||
hasPaymentInfo,
|
|
||||||
isPaid,
|
|
||||||
deriveNextState,
|
deriveNextState,
|
||||||
validateTransition,
|
validateTransition,
|
||||||
safeNextState,
|
safeNextState,
|
||||||
} from './fsm.js';
|
} from './fsm.js';
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// Constants
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('ConversationState', () => {
|
describe('ConversationState', () => {
|
||||||
it('tiene todos los estados definidos', () => {
|
it('tiene los estados del flujo (incluye PAUSED)', () => {
|
||||||
expect(ConversationState.IDLE).toBe('IDLE');
|
expect(ConversationState.IDLE).toBe('IDLE');
|
||||||
expect(ConversationState.CART).toBe('CART');
|
expect(ConversationState.CART).toBe('CART');
|
||||||
expect(ConversationState.SHIPPING).toBe('SHIPPING');
|
expect(ConversationState.SHIPPING).toBe('SHIPPING');
|
||||||
expect(ConversationState.PAYMENT).toBe('PAYMENT');
|
expect(ConversationState.PAUSED).toBe('PAUSED');
|
||||||
expect(ConversationState.WAITING_WEBHOOKS).toBe('WAITING_WEBHOOKS');
|
|
||||||
expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
|
expect(ConversationState.AWAITING_HUMAN).toBe('AWAITING_HUMAN');
|
||||||
|
expect(ConversationState.PAYMENT).toBeUndefined();
|
||||||
|
expect(ConversationState.WAITING_WEBHOOKS).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('ALL_STATES contiene todos', () => {
|
it('ALL_STATES contiene 5 estados', () => {
|
||||||
expect(ALL_STATES).toContain('IDLE');
|
expect(ALL_STATES).toEqual(expect.arrayContaining(['IDLE', 'CART', 'SHIPPING', 'PAUSED', 'AWAITING_HUMAN']));
|
||||||
expect(ALL_STATES).toContain('CART');
|
expect(ALL_STATES).toHaveLength(5);
|
||||||
expect(ALL_STATES).toContain('SHIPPING');
|
|
||||||
expect(ALL_STATES).toContain('PAYMENT');
|
|
||||||
expect(ALL_STATES).toContain('WAITING_WEBHOOKS');
|
|
||||||
expect(ALL_STATES).toContain('AWAITING_HUMAN');
|
|
||||||
expect(ALL_STATES).toHaveLength(6);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('INTENTS_BY_STATE define intents para cada estado', () => {
|
it('INTENTS_BY_STATE define intents por estado', () => {
|
||||||
expect(INTENTS_BY_STATE[ConversationState.IDLE]).toContain('greeting');
|
expect(INTENTS_BY_STATE[ConversationState.IDLE]).toContain('greeting');
|
||||||
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
|
expect(INTENTS_BY_STATE[ConversationState.CART]).toContain('add_to_cart');
|
||||||
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
|
expect(INTENTS_BY_STATE[ConversationState.SHIPPING]).toContain('provide_address');
|
||||||
expect(INTENTS_BY_STATE[ConversationState.PAYMENT]).toContain('select_payment');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasCartItems
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasCartItems', () => {
|
describe('hasCartItems', () => {
|
||||||
it('retorna true si cart tiene items', () => {
|
it('retorna true si cart tiene items', () => {
|
||||||
const order = { cart: [{ woo_id: 1, qty: 1 }] };
|
expect(hasCartItems({ cart: [{ woo_id: 1, qty: 1 }] })).toBe(true);
|
||||||
expect(hasCartItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si cart está vacío', () => {
|
it('retorna false si cart está vacío', () => {
|
||||||
const order = { cart: [] };
|
expect(hasCartItems({ cart: [] })).toBe(false);
|
||||||
expect(hasCartItems(order)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si cart es undefined', () => {
|
it('retorna false si cart es undefined', () => {
|
||||||
const order = {};
|
expect(hasCartItems({})).toBe(false);
|
||||||
expect(hasCartItems(order)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
it('retorna false si order es null/undefined', () => {
|
||||||
it('retorna false si order es null', () => {
|
|
||||||
expect(hasCartItems(null)).toBe(false);
|
expect(hasCartItems(null)).toBe(false);
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false si order es undefined', () => {
|
|
||||||
expect(hasCartItems(undefined)).toBe(false);
|
expect(hasCartItems(undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasPendingItems
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasPendingItems', () => {
|
describe('hasPendingItems', () => {
|
||||||
it('retorna true si hay NEEDS_TYPE', () => {
|
it('retorna true si hay NEEDS_TYPE', () => {
|
||||||
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
|
expect(hasPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(true);
|
||||||
expect(hasPendingItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna true si hay NEEDS_QUANTITY', () => {
|
it('retorna true si hay NEEDS_QUANTITY', () => {
|
||||||
const order = { pending: [{ status: 'NEEDS_QUANTITY' }] };
|
expect(hasPendingItems({ pending: [{ status: 'NEEDS_QUANTITY' }] })).toBe(true);
|
||||||
expect(hasPendingItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si solo hay READY', () => {
|
it('retorna false si solo hay READY', () => {
|
||||||
const order = { pending: [{ status: 'READY' }] };
|
expect(hasPendingItems({ pending: [{ status: 'READY' }] })).toBe(false);
|
||||||
expect(hasPendingItems(order)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si pending está vacío', () => {
|
|
||||||
const order = { pending: [] };
|
|
||||||
expect(hasPendingItems(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false si order es null', () => {
|
|
||||||
expect(hasPendingItems(null)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('detecta entre múltiples items', () => {
|
it('detecta entre múltiples items', () => {
|
||||||
const order = {
|
expect(hasPendingItems({ pending: [{ status: 'READY' }, { status: 'NEEDS_TYPE' }] })).toBe(true);
|
||||||
pending: [
|
|
||||||
{ status: 'READY' },
|
|
||||||
{ status: 'NEEDS_TYPE' },
|
|
||||||
]
|
|
||||||
};
|
|
||||||
expect(hasPendingItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasReadyPendingItems
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasReadyPendingItems', () => {
|
describe('hasReadyPendingItems', () => {
|
||||||
it('retorna true si hay READY', () => {
|
it('retorna true si hay READY', () => {
|
||||||
const order = { pending: [{ status: 'READY' }] };
|
expect(hasReadyPendingItems({ pending: [{ status: 'READY' }] })).toBe(true);
|
||||||
expect(hasReadyPendingItems(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si no hay READY', () => {
|
it('retorna false si no hay READY', () => {
|
||||||
const order = { pending: [{ status: 'NEEDS_TYPE' }] };
|
expect(hasReadyPendingItems({ pending: [{ status: 'NEEDS_TYPE' }] })).toBe(false);
|
||||||
expect(hasReadyPendingItems(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false si pending vacío', () => {
|
|
||||||
const order = { pending: [] };
|
|
||||||
expect(hasReadyPendingItems(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false si order es null', () => {
|
|
||||||
expect(hasReadyPendingItems(null)).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasShippingInfo
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasShippingInfo', () => {
|
describe('hasShippingInfo', () => {
|
||||||
it('retorna true para pickup (no necesita dirección)', () => {
|
it('retorna true para pickup (no necesita dirección)', () => {
|
||||||
const order = { is_delivery: false };
|
expect(hasShippingInfo({ is_delivery: false })).toBe(true);
|
||||||
expect(hasShippingInfo(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna true para delivery con dirección', () => {
|
it('retorna true para delivery con dirección', () => {
|
||||||
const order = { is_delivery: true, shipping_address: 'Calle Falsa 123' };
|
expect(hasShippingInfo({ is_delivery: true, shipping_address: 'Calle Falsa 123' })).toBe(true);
|
||||||
expect(hasShippingInfo(order)).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false para delivery sin dirección', () => {
|
it('retorna false para delivery sin dirección', () => {
|
||||||
const order = { is_delivery: true, shipping_address: null };
|
expect(hasShippingInfo({ is_delivery: true, shipping_address: null })).toBe(false);
|
||||||
expect(hasShippingInfo(order)).toBe(false);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('retorna false si is_delivery es null', () => {
|
it('retorna false si is_delivery es null', () => {
|
||||||
const order = { is_delivery: null };
|
expect(hasShippingInfo({ is_delivery: null })).toBe(false);
|
||||||
expect(hasShippingInfo(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false para order vacío', () => {
|
|
||||||
expect(hasShippingInfo({})).toBe(false);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// hasPaymentInfo
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('hasPaymentInfo', () => {
|
|
||||||
it('retorna true para cash', () => {
|
|
||||||
const order = { payment_type: 'cash' };
|
|
||||||
expect(hasPaymentInfo(order)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna true para link', () => {
|
|
||||||
const order = { payment_type: 'link' };
|
|
||||||
expect(hasPaymentInfo(order)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false para null', () => {
|
|
||||||
const order = { payment_type: null };
|
|
||||||
expect(hasPaymentInfo(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false para undefined', () => {
|
|
||||||
const order = {};
|
|
||||||
expect(hasPaymentInfo(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false para otros valores', () => {
|
|
||||||
const order = { payment_type: 'bitcoin' };
|
|
||||||
expect(hasPaymentInfo(order)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// isPaid
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('isPaid', () => {
|
|
||||||
it('retorna true si is_paid es true', () => {
|
|
||||||
const order = { is_paid: true };
|
|
||||||
expect(isPaid(order)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false si is_paid es false', () => {
|
|
||||||
const order = { is_paid: false };
|
|
||||||
expect(isPaid(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false si is_paid es undefined', () => {
|
|
||||||
const order = {};
|
|
||||||
expect(isPaid(order)).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false si order es null', () => {
|
|
||||||
expect(isPaid(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// shouldReturnToCart
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('shouldReturnToCart', () => {
|
describe('shouldReturnToCart', () => {
|
||||||
describe('no redirige si ya está en CART o IDLE', () => {
|
it('no redirige si ya está en CART o IDLE', () => {
|
||||||
it('retorna false en CART', () => {
|
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
|
||||||
expect(shouldReturnToCart(ConversationState.CART, nlu)).toBe(false);
|
expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
|
||||||
});
|
|
||||||
|
|
||||||
it('retorna false en IDLE', () => {
|
|
||||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
|
||||||
expect(shouldReturnToCart(ConversationState.IDLE, nlu)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('redirige desde otros estados', () => {
|
it('redirige add_to_cart desde SHIPPING con producto real', () => {
|
||||||
it('redirige add_to_cart desde SHIPPING', () => {
|
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
||||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'provoleta' } };
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirige add_to_cart desde PAYMENT', () => {
|
|
||||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'vacío' } };
|
|
||||||
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redirige browse desde SHIPPING', () => {
|
|
||||||
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
|
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('no redirige números solos en checkout', () => {
|
it('redirige browse desde SHIPPING', () => {
|
||||||
it('no redirige "1" en SHIPPING', () => {
|
const nlu = { intent: 'browse', entities: { product_query: 'carnes' } };
|
||||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no redirige "2" en PAYMENT', () => {
|
|
||||||
const nlu = { intent: 'other', entities: {} };
|
|
||||||
expect(shouldReturnToCart(ConversationState.PAYMENT, nlu, '2')).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('no redirige "1.5" en SHIPPING', () => {
|
|
||||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('requiere producto real', () => {
|
it('no redirige números solos en SHIPPING (selección de opción)', () => {
|
||||||
it('no redirige sin product_query', () => {
|
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||||
const nlu = { intent: 'add_to_cart', entities: {} };
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1')).toBe(false);
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, '1.5')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('no redirige con product_query muy corto', () => {
|
it('no redirige sin producto real', () => {
|
||||||
const nlu = { intent: 'add_to_cart', entities: { product_query: 'ab' } };
|
const nlu = { intent: 'add_to_cart', entities: {} };
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(false);
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu, 'algo')).toBe(false);
|
||||||
});
|
expect(shouldReturnToCart(ConversationState.SHIPPING, { intent: 'add_to_cart', entities: { product_query: 'ab' } })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('redirige con items array', () => {
|
it('redirige con items array que tenga producto real', () => {
|
||||||
const nlu = {
|
const nlu = { intent: 'add_to_cart', entities: { items: [{ product_query: 'provoleta' }] } };
|
||||||
intent: 'add_to_cart',
|
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
||||||
entities: { items: [{ product_query: 'provoleta' }] }
|
|
||||||
};
|
|
||||||
expect(shouldReturnToCart(ConversationState.SHIPPING, nlu)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// deriveNextState
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('deriveNextState', () => {
|
describe('deriveNextState', () => {
|
||||||
describe('return_to_cart signal', () => {
|
it('return_to_cart fuerza CART', () => {
|
||||||
it('fuerza CART si return_to_cart', () => {
|
expect(deriveNextState(ConversationState.SHIPPING, {}, { return_to_cart: true })).toBe(ConversationState.CART);
|
||||||
const result = deriveNextState(
|
|
||||||
ConversationState.PAYMENT,
|
|
||||||
{},
|
|
||||||
{ return_to_cart: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe(ConversationState.CART);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pagado', () => {
|
it('IDLE va a CART si hay cart o pending', () => {
|
||||||
it('va a IDLE si está pagado', () => {
|
expect(deriveNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {})).toBe(ConversationState.CART);
|
||||||
const order = { is_paid: true };
|
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [{ status: 'NEEDS_TYPE' }] }, {})).toBe(ConversationState.CART);
|
||||||
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
|
|
||||||
expect(result).toBe(ConversationState.IDLE);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('esperando pago', () => {
|
it('IDLE queda en IDLE si vacío', () => {
|
||||||
it('va a WAITING_WEBHOOKS si tiene woo_order_id', () => {
|
expect(deriveNextState(ConversationState.IDLE, { cart: [], pending: [] }, {})).toBe(ConversationState.IDLE);
|
||||||
const order = { woo_order_id: 123, is_paid: false };
|
|
||||||
const result = deriveNextState(ConversationState.PAYMENT, order, {});
|
|
||||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('IDLE -> CART', () => {
|
it('CART queda en CART con pending', () => {
|
||||||
it('va a CART si hay cart items', () => {
|
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
|
||||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.CART);
|
||||||
const result = deriveNextState(ConversationState.IDLE, order, {});
|
|
||||||
expect(result).toBe(ConversationState.CART);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('va a CART si hay pending items', () => {
|
|
||||||
const order = { cart: [], pending: [{ status: 'NEEDS_TYPE' }] };
|
|
||||||
const result = deriveNextState(ConversationState.IDLE, order, {});
|
|
||||||
expect(result).toBe(ConversationState.CART);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('queda en IDLE si vacío', () => {
|
|
||||||
const order = { cart: [], pending: [] };
|
|
||||||
const result = deriveNextState(ConversationState.IDLE, order, {});
|
|
||||||
expect(result).toBe(ConversationState.IDLE);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('CART -> SHIPPING', () => {
|
it('CART → SHIPPING con confirm + items', () => {
|
||||||
it('queda en CART si hay pending items', () => {
|
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||||
const order = { cart: [{ woo_id: 1 }], pending: [{ status: 'NEEDS_TYPE' }] };
|
expect(deriveNextState(ConversationState.CART, order, { confirm_order: true })).toBe(ConversationState.SHIPPING);
|
||||||
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
|
|
||||||
expect(result).toBe(ConversationState.CART);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('va a SHIPPING con confirm_order y cart items', () => {
|
|
||||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
|
||||||
const result = deriveNextState(ConversationState.CART, order, { confirm_order: true });
|
|
||||||
expect(result).toBe(ConversationState.SHIPPING);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('queda en CART sin confirm_order', () => {
|
|
||||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
|
||||||
const result = deriveNextState(ConversationState.CART, order, {});
|
|
||||||
expect(result).toBe(ConversationState.CART);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('SHIPPING -> PAYMENT', () => {
|
it('SHIPPING → IDLE cuando shipping queda completo (orden creada offline)', () => {
|
||||||
it('va a PAYMENT con shipping info (pickup)', () => {
|
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: false }, {})).toBe(ConversationState.IDLE);
|
||||||
const order = { is_delivery: false };
|
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true, shipping_address: 'Calle 1' }, {})).toBe(ConversationState.IDLE);
|
||||||
const result = deriveNextState(ConversationState.SHIPPING, order, {});
|
|
||||||
expect(result).toBe(ConversationState.PAYMENT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('va a PAYMENT con shipping info (delivery + address)', () => {
|
|
||||||
const order = { is_delivery: true, shipping_address: 'Calle 123' };
|
|
||||||
const result = deriveNextState(ConversationState.SHIPPING, order, {});
|
|
||||||
expect(result).toBe(ConversationState.PAYMENT);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('queda en SHIPPING sin info completa', () => {
|
|
||||||
const order = { is_delivery: true, shipping_address: null };
|
|
||||||
const result = deriveNextState(ConversationState.SHIPPING, order, {});
|
|
||||||
expect(result).toBe(ConversationState.SHIPPING);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('PAYMENT -> WAITING_WEBHOOKS', () => {
|
it('SHIPPING queda en SHIPPING sin info completa', () => {
|
||||||
it('va a WAITING con payment_selected', () => {
|
expect(deriveNextState(ConversationState.SHIPPING, { is_delivery: true }, {})).toBe(ConversationState.SHIPPING);
|
||||||
const order = {};
|
|
||||||
const result = deriveNextState(
|
|
||||||
ConversationState.PAYMENT,
|
|
||||||
order,
|
|
||||||
{ payment_selected: true }
|
|
||||||
);
|
|
||||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('va a WAITING si ya tiene payment_type', () => {
|
|
||||||
const order = { payment_type: 'cash' };
|
|
||||||
const result = deriveNextState(ConversationState.PAYMENT, order, {});
|
|
||||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('WAITING_WEBHOOKS', () => {
|
it('woo_order_id existente vuelve a IDLE (orden ya creada)', () => {
|
||||||
it('va a IDLE si está pagado', () => {
|
expect(deriveNextState(ConversationState.SHIPPING, { woo_order_id: 999 }, {})).toBe(ConversationState.IDLE);
|
||||||
const order = { is_paid: true };
|
|
||||||
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
|
|
||||||
expect(result).toBe(ConversationState.IDLE);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('queda en WAITING si no está pagado', () => {
|
|
||||||
const order = { is_paid: false };
|
|
||||||
const result = deriveNextState(ConversationState.WAITING_WEBHOOKS, order, {});
|
|
||||||
expect(result).toBe(ConversationState.WAITING_WEBHOOKS);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('default', () => {
|
it('default sin estado previo retorna IDLE', () => {
|
||||||
it('retorna IDLE si no hay estado previo', () => {
|
expect(deriveNextState(null, {}, {})).toBe(ConversationState.IDLE);
|
||||||
const result = deriveNextState(null, {}, {});
|
|
||||||
expect(result).toBe(ConversationState.IDLE);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// validateTransition
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('validateTransition', () => {
|
describe('validateTransition', () => {
|
||||||
describe('transiciones válidas', () => {
|
it('IDLE → CART es válida', () => {
|
||||||
it('IDLE -> IDLE es válido', () => {
|
expect(validateTransition(ConversationState.IDLE, ConversationState.CART).ok).toBe(true);
|
||||||
const result = validateTransition(ConversationState.IDLE, ConversationState.IDLE);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('IDLE -> CART es válido', () => {
|
|
||||||
const result = validateTransition(ConversationState.IDLE, ConversationState.CART);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CART -> SHIPPING es válido', () => {
|
|
||||||
const result = validateTransition(ConversationState.CART, ConversationState.SHIPPING);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SHIPPING -> PAYMENT es válido', () => {
|
|
||||||
const result = validateTransition(ConversationState.SHIPPING, ConversationState.PAYMENT);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PAYMENT -> WAITING_WEBHOOKS es válido', () => {
|
|
||||||
const result = validateTransition(ConversationState.PAYMENT, ConversationState.WAITING_WEBHOOKS);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('SHIPPING -> CART (volver) es válido', () => {
|
|
||||||
const result = validateTransition(ConversationState.SHIPPING, ConversationState.CART);
|
|
||||||
expect(result.ok).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
it('CART → SHIPPING es válida', () => {
|
||||||
describe('transiciones inválidas', () => {
|
expect(validateTransition(ConversationState.CART, ConversationState.SHIPPING).ok).toBe(true);
|
||||||
it('IDLE -> PAYMENT es inválido', () => {
|
|
||||||
const result = validateTransition(ConversationState.IDLE, ConversationState.PAYMENT);
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.reason).toBe('invalid_transition');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('CART -> WAITING_WEBHOOKS es inválido', () => {
|
|
||||||
const result = validateTransition(ConversationState.CART, ConversationState.WAITING_WEBHOOKS);
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
it('SHIPPING → IDLE es válida (cierre de orden)', () => {
|
||||||
describe('estados desconocidos', () => {
|
expect(validateTransition(ConversationState.SHIPPING, ConversationState.IDLE).ok).toBe(true);
|
||||||
it('estado previo desconocido', () => {
|
|
||||||
const result = validateTransition('UNKNOWN', ConversationState.CART);
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.reason).toBe('unknown_prev_state');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('estado siguiente desconocido', () => {
|
|
||||||
const result = validateTransition(ConversationState.IDLE, 'UNKNOWN');
|
|
||||||
expect(result.ok).toBe(false);
|
|
||||||
expect(result.reason).toBe('unknown_next_state');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
it('SHIPPING → CART (volver a agregar) es válida', () => {
|
||||||
describe('maneja null/undefined', () => {
|
expect(validateTransition(ConversationState.SHIPPING, ConversationState.CART).ok).toBe(true);
|
||||||
it('prevState null se trata como IDLE', () => {
|
});
|
||||||
const result = validateTransition(null, ConversationState.CART);
|
it('IDLE → SHIPPING es inválida (debe pasar por CART)', () => {
|
||||||
expect(result.ok).toBe(true);
|
const r = validateTransition(ConversationState.IDLE, ConversationState.SHIPPING);
|
||||||
});
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('invalid_transition');
|
||||||
it('nextState null se trata como IDLE', () => {
|
});
|
||||||
const result = validateTransition(ConversationState.IDLE, null);
|
it('estado previo desconocido', () => {
|
||||||
expect(result.ok).toBe(true);
|
const r = validateTransition('UNKNOWN', ConversationState.CART);
|
||||||
});
|
expect(r.ok).toBe(false);
|
||||||
|
expect(r.reason).toBe('unknown_prev_state');
|
||||||
|
});
|
||||||
|
it('null se trata como IDLE', () => {
|
||||||
|
expect(validateTransition(null, ConversationState.CART).ok).toBe(true);
|
||||||
|
expect(validateTransition(ConversationState.IDLE, null).ok).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// safeNextState
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
describe('safeNextState', () => {
|
describe('safeNextState', () => {
|
||||||
it('retorna estado derivado si transición válida', () => {
|
it('retorna estado derivado si la transición es válida', () => {
|
||||||
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
const order = { cart: [{ woo_id: 1 }], pending: [] };
|
||||||
const result = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
const r = safeNextState(ConversationState.CART, order, { confirm_order: true });
|
||||||
|
expect(r.next_state).toBe(ConversationState.SHIPPING);
|
||||||
expect(result.next_state).toBe(ConversationState.SHIPPING);
|
expect(r.validation.ok).toBe(true);
|
||||||
expect(result.validation.ok).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fuerza CART si transición inválida', () => {
|
it('flow IDLE → CART → SHIPPING → IDLE', () => {
|
||||||
// Forzar una situación donde deriveNextState retornaría un estado inválido
|
let r = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
|
||||||
// Esto es difícil de provocar porque deriveNextState ya es bastante seguro
|
expect(r.next_state).toBe(ConversationState.CART);
|
||||||
// Pero podemos verificar que la lógica de fallback existe
|
|
||||||
const order = {};
|
|
||||||
const result = safeNextState(ConversationState.IDLE, order, {});
|
|
||||||
|
|
||||||
// Debería quedarse en IDLE (transición válida)
|
r = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
|
||||||
expect(result.next_state).toBe(ConversationState.IDLE);
|
expect(r.next_state).toBe(ConversationState.SHIPPING);
|
||||||
expect(result.validation.ok).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('incluye validation en resultado', () => {
|
r = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, { shipping_completed: true });
|
||||||
const order = { is_delivery: false };
|
expect(r.next_state).toBe(ConversationState.IDLE);
|
||||||
const result = safeNextState(ConversationState.SHIPPING, order, {});
|
|
||||||
|
|
||||||
expect(result).toHaveProperty('next_state');
|
|
||||||
expect(result).toHaveProperty('validation');
|
|
||||||
expect(result.validation).toHaveProperty('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('maneja transition IDLE -> CART -> SHIPPING flow', () => {
|
|
||||||
// Paso 1: IDLE con cart items -> CART
|
|
||||||
let result = safeNextState(ConversationState.IDLE, { cart: [{ woo_id: 1 }], pending: [] }, {});
|
|
||||||
expect(result.next_state).toBe(ConversationState.CART);
|
|
||||||
|
|
||||||
// Paso 2: CART con confirm -> SHIPPING
|
|
||||||
result = safeNextState(ConversationState.CART, { cart: [{ woo_id: 1 }], pending: [] }, { confirm_order: true });
|
|
||||||
expect(result.next_state).toBe(ConversationState.SHIPPING);
|
|
||||||
|
|
||||||
// Paso 3: SHIPPING con pickup -> PAYMENT
|
|
||||||
result = safeNextState(ConversationState.SHIPPING, { is_delivery: false }, {});
|
|
||||||
expect(result.next_state).toBe(ConversationState.PAYMENT);
|
|
||||||
|
|
||||||
// Paso 4: PAYMENT con payment_selected -> WAITING
|
|
||||||
result = safeNextState(ConversationState.PAYMENT, {}, { payment_selected: true });
|
|
||||||
expect(result.next_state).toBe(ConversationState.WAITING_WEBHOOKS);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
51
src/modules/3-turn-engine/lib/geo.js
Normal file
51
src/modules/3-turn-engine/lib/geo.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Geometría liviana para validar punto-en-polígono sin deps.
|
||||||
|
*
|
||||||
|
* Polígonos vienen en GeoJSON: { type: "Polygon", coordinates: [[[lng,lat],...]] }.
|
||||||
|
* GeoJSON usa orden [lng, lat] (X, Y). Mantenemos esa convención puertas adentro.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ray casting (algoritmo clásico). Devuelve true si (lng, lat) cae dentro del
|
||||||
|
* anillo exterior del polígono. No considera agujeros (holes) — para CABA y un
|
||||||
|
* editor que dibuja polígonos simples, alcanza.
|
||||||
|
*
|
||||||
|
* @param {number} lng
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {{type:string, coordinates:Array<Array<[number,number]>>}} polygon
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function pointInPolygon(lng, lat, polygon) {
|
||||||
|
if (!polygon || polygon.type !== "Polygon" || !Array.isArray(polygon.coordinates)) return false;
|
||||||
|
const ring = polygon.coordinates[0];
|
||||||
|
if (!Array.isArray(ring) || ring.length < 3) return false;
|
||||||
|
|
||||||
|
let inside = false;
|
||||||
|
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||||
|
const xi = ring[i][0], yi = ring[i][1];
|
||||||
|
const xj = ring[j][0], yj = ring[j][1];
|
||||||
|
const intersect =
|
||||||
|
(yi > lat) !== (yj > lat) &&
|
||||||
|
lng < ((xj - xi) * (lat - yi)) / (yj - yi + 0) + xi;
|
||||||
|
if (intersect) inside = !inside;
|
||||||
|
}
|
||||||
|
return inside;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Busca la primera zona habilitada cuyo polígono contiene al punto.
|
||||||
|
*
|
||||||
|
* @param {number} lng
|
||||||
|
* @param {number} lat
|
||||||
|
* @param {Array<Object>} zones - { id, name, polygon, enabled, ... }
|
||||||
|
* @returns {Object|null}
|
||||||
|
*/
|
||||||
|
export function findZoneForPoint(lng, lat, zones) {
|
||||||
|
if (!Array.isArray(zones)) return null;
|
||||||
|
for (const z of zones) {
|
||||||
|
if (z?.enabled === false) continue;
|
||||||
|
if (!z?.polygon) continue;
|
||||||
|
if (pointInPolygon(lng, lat, z.polygon)) return z;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
103
src/modules/3-turn-engine/lib/geo.test.js
Normal file
103
src/modules/3-turn-engine/lib/geo.test.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { pointInPolygon, findZoneForPoint } from "./geo.js";
|
||||||
|
|
||||||
|
const square = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [[
|
||||||
|
[0, 0],
|
||||||
|
[10, 0],
|
||||||
|
[10, 10],
|
||||||
|
[0, 10],
|
||||||
|
[0, 0],
|
||||||
|
]],
|
||||||
|
};
|
||||||
|
|
||||||
|
const concave = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [[
|
||||||
|
[0, 0],
|
||||||
|
[10, 0],
|
||||||
|
[10, 10],
|
||||||
|
[5, 5],
|
||||||
|
[0, 10],
|
||||||
|
[0, 0],
|
||||||
|
]],
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("pointInPolygon", () => {
|
||||||
|
it("returns true para un punto dentro de un cuadrado", () => {
|
||||||
|
expect(pointInPolygon(5, 5, square)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false para un punto fuera del cuadrado", () => {
|
||||||
|
expect(pointInPolygon(15, 5, square)).toBe(false);
|
||||||
|
expect(pointInPolygon(-1, 5, square)).toBe(false);
|
||||||
|
expect(pointInPolygon(5, 20, square)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maneja polígonos cóncavos (excluye el dent del centro)", () => {
|
||||||
|
// (5, 8) cae dentro del notch — fuera del polígono cóncavo.
|
||||||
|
expect(pointInPolygon(5, 8, concave)).toBe(false);
|
||||||
|
// (2, 2) sigue dentro.
|
||||||
|
expect(pointInPolygon(2, 2, concave)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns false para input inválido", () => {
|
||||||
|
expect(pointInPolygon(0, 0, null)).toBe(false);
|
||||||
|
expect(pointInPolygon(0, 0, { type: "Point" })).toBe(false);
|
||||||
|
expect(pointInPolygon(0, 0, { type: "Polygon", coordinates: [[[0, 0], [1, 1]]] })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trabaja con coordenadas reales de CABA (lng, lat)", () => {
|
||||||
|
// Polígono cuadrado pequeño alrededor del Obelisco.
|
||||||
|
const obeliscoBox = {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [[
|
||||||
|
[-58.39, -34.61],
|
||||||
|
[-58.37, -34.61],
|
||||||
|
[-58.37, -34.60],
|
||||||
|
[-58.39, -34.60],
|
||||||
|
[-58.39, -34.61],
|
||||||
|
]],
|
||||||
|
};
|
||||||
|
// Obelisco aprox: -58.3816, -34.6037
|
||||||
|
expect(pointInPolygon(-58.3816, -34.6037, obeliscoBox)).toBe(true);
|
||||||
|
// Mar del Plata
|
||||||
|
expect(pointInPolygon(-57.55, -38.0, obeliscoBox)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findZoneForPoint", () => {
|
||||||
|
const zones = [
|
||||||
|
{ id: "centro", name: "Centro", polygon: square, enabled: true, delivery_cost: 1500 },
|
||||||
|
{
|
||||||
|
id: "norte",
|
||||||
|
name: "Norte",
|
||||||
|
enabled: true,
|
||||||
|
polygon: {
|
||||||
|
type: "Polygon",
|
||||||
|
coordinates: [[[20, 20], [30, 20], [30, 30], [20, 30], [20, 20]]],
|
||||||
|
},
|
||||||
|
delivery_cost: 2000,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
it("devuelve la zona que contiene al punto", () => {
|
||||||
|
const z = findZoneForPoint(5, 5, zones);
|
||||||
|
expect(z?.id).toBe("centro");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("devuelve null si ningún polígono contiene al punto", () => {
|
||||||
|
expect(findZoneForPoint(15, 15, zones)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignora zonas con enabled=false", () => {
|
||||||
|
const muted = zones.map((z) => ({ ...z, enabled: z.id === "centro" ? false : z.enabled }));
|
||||||
|
expect(findZoneForPoint(5, 5, muted)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tolera input inválido", () => {
|
||||||
|
expect(findZoneForPoint(0, 0, null)).toBeNull();
|
||||||
|
expect(findZoneForPoint(0, 0, [])).toBeNull();
|
||||||
|
expect(findZoneForPoint(0, 0, [{ id: "x" }])).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
Sos {{bot_name}}, asistente de {{store_name}}. Procesá consultas sobre el catálogo.
|
|
||||||
|
|
||||||
TIPOS DE CONSULTAS:
|
|
||||||
|
|
||||||
1. price_query - Consulta de precios
|
|
||||||
Señales: "cuánto sale", "precio de", "cuánto cuesta", "a cuánto está"
|
|
||||||
Extraer: product_query (el producto que pregunta)
|
|
||||||
|
|
||||||
2. browse - Consulta de disponibilidad
|
|
||||||
Señales: "tenés", "hay", "vendés", "tienen"
|
|
||||||
Extraer: product_query
|
|
||||||
|
|
||||||
3. recommend - Pedido de recomendación/planificación
|
|
||||||
Señales: "qué me recomendás", "qué llevo", "para X personas", "para un asado"
|
|
||||||
Extraer:
|
|
||||||
- people_count: número de personas si lo menciona
|
|
||||||
- event_type: tipo de evento (asado, cumple, reunión)
|
|
||||||
- product_query: producto específico si lo menciona
|
|
||||||
|
|
||||||
EJEMPLOS:
|
|
||||||
|
|
||||||
Input: "cuánto sale el vacío?"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "price_query",
|
|
||||||
"product_query": "vacío",
|
|
||||||
"people_count": null,
|
|
||||||
"event_type": null
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "tenés chimichurri?"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "browse",
|
|
||||||
"product_query": "chimichurri",
|
|
||||||
"people_count": null,
|
|
||||||
"event_type": null
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "qué me recomendás para 8 personas?"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "recommend",
|
|
||||||
"product_query": null,
|
|
||||||
"people_count": 8,
|
|
||||||
"event_type": "asado"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "para un asado de 6, qué llevo?"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "recommend",
|
|
||||||
"product_query": null,
|
|
||||||
"people_count": 6,
|
|
||||||
"event_type": "asado"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "qué vino va bien con carne?"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "recommend",
|
|
||||||
"product_query": "vino",
|
|
||||||
"people_count": null,
|
|
||||||
"event_type": null
|
|
||||||
}
|
|
||||||
|
|
||||||
FORMATO JSON:
|
|
||||||
{
|
|
||||||
"intent": "price_query|browse|recommend",
|
|
||||||
"product_query": "texto" | null,
|
|
||||||
"people_count": number | null,
|
|
||||||
"event_type": "asado|cumple|reunion" | null
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
Sos {{bot_name}}, el asistente virtual de {{store_name}}.
|
|
||||||
|
|
||||||
PERSONALIDAD:
|
|
||||||
- Carnicero profesional argentino con años de experiencia
|
|
||||||
- Usás voseo natural (vos, querés, tenés, decime)
|
|
||||||
- Amable y cálido pero eficiente, no muy formal
|
|
||||||
- Conocedor de cortes de carne y tradiciones del asado argentino
|
|
||||||
- Podés hacer algún comentario simpático sobre el asado si viene al caso
|
|
||||||
- Respuestas concisas, no te extendés demasiado
|
|
||||||
|
|
||||||
CONTEXTO DEL NEGOCIO:
|
|
||||||
- Horario: {{store_hours}}
|
|
||||||
- Dirección: {{store_address}}
|
|
||||||
|
|
||||||
INSTRUCCIONES:
|
|
||||||
El cliente te saluda. Respondé de forma cálida y preguntá en qué podés ayudar.
|
|
||||||
Si hay alguna promo del día o corte destacado, mencionalo brevemente.
|
|
||||||
|
|
||||||
FORMATO DE RESPUESTA (JSON):
|
|
||||||
{
|
|
||||||
"intent": "greeting",
|
|
||||||
"reply": "tu respuesta al cliente"
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
Sos un sistema NLU para una carnicería argentina. Extraé productos del mensaje del usuario.
|
|
||||||
|
|
||||||
REGLAS CRÍTICAS (seguir estrictamente):
|
|
||||||
|
|
||||||
0. EXTRAER TODOS LOS PRODUCTOS - NUNCA OMITIR NINGUNO
|
|
||||||
Si el mensaje menciona 5 productos, el array items DEBE tener 5 elementos.
|
|
||||||
NUNCA omitas productos, incluso si no estás seguro del nombre exacto.
|
|
||||||
Extraé cada producto mencionado, separado por comas, "y", saltos de línea, etc.
|
|
||||||
|
|
||||||
1. SIEMPRE USAR ARRAY "items"
|
|
||||||
Aunque sea UN SOLO producto, SIEMPRE devolver un array "items" con al menos un elemento.
|
|
||||||
Cada item tiene: product_query, quantity, unit
|
|
||||||
|
|
||||||
2. COPIAR TEXTO EXACTO
|
|
||||||
El campo "product_query" debe ser el texto EXACTO que usó el cliente.
|
|
||||||
- Si dice "asado de tira" → product_query: "asado de tira"
|
|
||||||
- Si dice "vacío" → product_query: "vacío"
|
|
||||||
- Si dice "carre de cerdo" → product_query: "carre de cerdo"
|
|
||||||
- Si dice "provoletas wapi" → product_query: "provoletas wapi"
|
|
||||||
- NUNCA modifiques, combines ni inventes nombres
|
|
||||||
|
|
||||||
3. EXTRAER CANTIDADES (pueden estar antes o después del producto)
|
|
||||||
- "2kg de X" → quantity: 2, unit: "kg"
|
|
||||||
- "X 1kg" → quantity: 1, unit: "kg" (cantidad después del producto)
|
|
||||||
- "3 provoletas" → quantity: 3, unit: "unidad"
|
|
||||||
- "medio kilo" → quantity: 0.5, unit: "kg"
|
|
||||||
- Sin cantidad → quantity: null
|
|
||||||
|
|
||||||
4. UNIDADES
|
|
||||||
- kg: kilos, kilo, kilogramo
|
|
||||||
- g: gramos, gr
|
|
||||||
- unidad: unidades, u (para productos que no se pesan)
|
|
||||||
|
|
||||||
5. INTENTS
|
|
||||||
- add_to_cart: agregar productos (quiero, dame, anotame, poneme, hola quiero)
|
|
||||||
- remove_from_cart: quitar productos (sacame, quitame)
|
|
||||||
- view_cart: ver carrito (qué tengo, qué anoté, mi pedido)
|
|
||||||
- confirm_order: cerrar pedido (listo, eso es todo, cerrar)
|
|
||||||
|
|
||||||
EJEMPLOS:
|
|
||||||
|
|
||||||
Input: "hola, quiero 1kg de asado, vacio, carre de cerdo 1kg, chorizo mixto 1kg y 3 provoletas wapi"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "add_to_cart",
|
|
||||||
"confidence": 0.95,
|
|
||||||
"items": [
|
|
||||||
{"product_query": "asado", "quantity": 1, "unit": "kg"},
|
|
||||||
{"product_query": "vacio", "quantity": null, "unit": null},
|
|
||||||
{"product_query": "carre de cerdo", "quantity": 1, "unit": "kg"},
|
|
||||||
{"product_query": "chorizo mixto", "quantity": 1, "unit": "kg"},
|
|
||||||
{"product_query": "provoletas wapi", "quantity": 3, "unit": "unidad"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "Te pido:\n2kg de vacío\n3kg de asado de tira\n1kg de chorizos mixtos\n2 provoletas"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "add_to_cart",
|
|
||||||
"confidence": 0.95,
|
|
||||||
"items": [
|
|
||||||
{"product_query": "vacío", "quantity": 2, "unit": "kg"},
|
|
||||||
{"product_query": "asado de tira", "quantity": 3, "unit": "kg"},
|
|
||||||
{"product_query": "chorizos mixtos", "quantity": 1, "unit": "kg"},
|
|
||||||
{"product_query": "provoletas", "quantity": 2, "unit": "unidad"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "dame 1kg de vacío"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "add_to_cart",
|
|
||||||
"confidence": 0.95,
|
|
||||||
"items": [
|
|
||||||
{"product_query": "vacío", "quantity": 1, "unit": "kg"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "quiero asado"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "add_to_cart",
|
|
||||||
"confidence": 0.9,
|
|
||||||
"items": [
|
|
||||||
{"product_query": "asado", "quantity": null, "unit": null}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "sacame el chorizo"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "remove_from_cart",
|
|
||||||
"confidence": 0.9,
|
|
||||||
"items": [
|
|
||||||
{"product_query": "chorizo", "quantity": null, "unit": null}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "qué tengo anotado?"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "view_cart",
|
|
||||||
"confidence": 0.95,
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "listo, eso sería todo"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "confirm_order",
|
|
||||||
"confidence": 0.95,
|
|
||||||
"items": []
|
|
||||||
}
|
|
||||||
|
|
||||||
FORMATO JSON ESTRICTO:
|
|
||||||
{
|
|
||||||
"intent": "add_to_cart|remove_from_cart|view_cart|confirm_order",
|
|
||||||
"confidence": 0.0-1.0,
|
|
||||||
"items": [{product_query, quantity, unit}, ...]
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
Extraé información de pago del mensaje del usuario.
|
|
||||||
|
|
||||||
ENTIDADES A EXTRAER:
|
|
||||||
|
|
||||||
1. payment_method
|
|
||||||
- "cash": pago en efectivo
|
|
||||||
Señales: efectivo, cash, plata, en mano
|
|
||||||
- "link": pago electrónico (tarjeta, transferencia, link de pago)
|
|
||||||
Señales: tarjeta, link, transferencia, QR, mercadopago, MP
|
|
||||||
- null: no se puede determinar
|
|
||||||
|
|
||||||
EJEMPLOS:
|
|
||||||
|
|
||||||
Input: "efectivo"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_payment",
|
|
||||||
"payment_method": "cash"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "con tarjeta"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_payment",
|
|
||||||
"payment_method": "link"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "link de pago"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_payment",
|
|
||||||
"payment_method": "link"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "pago cuando llega"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_payment",
|
|
||||||
"payment_method": "cash"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "transferencia"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_payment",
|
|
||||||
"payment_method": "link"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "1" (si el contexto indica que 1=efectivo)
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_payment",
|
|
||||||
"payment_method": "cash"
|
|
||||||
}
|
|
||||||
|
|
||||||
FORMATO JSON:
|
|
||||||
{
|
|
||||||
"intent": "select_payment",
|
|
||||||
"payment_method": "cash" | "link" | null
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
Clasificá el dominio del mensaje del usuario. Respondé SOLO JSON válido.
|
|
||||||
|
|
||||||
{"domain":"greeting|orders|shipping|payment|browse|other"}
|
|
||||||
|
|
||||||
REGLAS DE CLASIFICACIÓN:
|
|
||||||
|
|
||||||
1. greeting - Saludos sin mención de productos
|
|
||||||
- "hola", "buen día", "buenas tardes", "qué tal", "hey"
|
|
||||||
- NO si menciona productos junto al saludo
|
|
||||||
|
|
||||||
2. orders - Todo relacionado con pedidos y productos
|
|
||||||
- Agregar productos: "quiero", "dame", "anotame", "poneme", cantidad + producto
|
|
||||||
- Quitar productos: "sacame", "quitame", "no quiero"
|
|
||||||
- Ver carrito: "qué tengo", "qué anoté", "mi pedido"
|
|
||||||
- Confirmar: "listo", "eso es todo", "cerrar pedido"
|
|
||||||
|
|
||||||
3. shipping - Envío y entrega
|
|
||||||
- Método: "delivery", "envío", "retiro", "buscar", "sucursal"
|
|
||||||
- Dirección: textos con calle, número, barrio
|
|
||||||
|
|
||||||
4. payment - Métodos de pago
|
|
||||||
- "efectivo", "tarjeta", "transferencia", "link", "mercadopago"
|
|
||||||
|
|
||||||
5. browse - Consultas de catálogo
|
|
||||||
- Precios: "cuánto sale", "precio de"
|
|
||||||
- Disponibilidad: "tenés", "hay", "vendés"
|
|
||||||
- Recomendaciones: "qué me recomendás", "para X personas"
|
|
||||||
|
|
||||||
6. other - Cualquier otra cosa
|
|
||||||
|
|
||||||
Estado actual: {{state}}
|
|
||||||
|
|
||||||
Mensaje a clasificar: [se provee en el input]
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
Extraé información de envío del mensaje del usuario.
|
|
||||||
|
|
||||||
ENTIDADES A EXTRAER:
|
|
||||||
|
|
||||||
1. shipping_method
|
|
||||||
- "delivery": el cliente quiere que le lleven el pedido
|
|
||||||
Señales: delivery, envío, enviar, que me lo traigan, llevar
|
|
||||||
- "pickup": el cliente pasa a buscar
|
|
||||||
Señales: retiro, retirar, buscar, paso, sucursal
|
|
||||||
- null: no se puede determinar
|
|
||||||
|
|
||||||
2. address
|
|
||||||
- Texto de la dirección de entrega
|
|
||||||
- Solo extraer si hay datos concretos (calle, número, barrio, etc.)
|
|
||||||
- null: si no hay dirección
|
|
||||||
|
|
||||||
EJEMPLOS:
|
|
||||||
|
|
||||||
Input: "delivery"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_shipping",
|
|
||||||
"shipping_method": "delivery",
|
|
||||||
"address": null
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "paso a buscar"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_shipping",
|
|
||||||
"shipping_method": "pickup",
|
|
||||||
"address": null
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "Av. Corrientes 1234, Almagro"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "provide_address",
|
|
||||||
"shipping_method": null,
|
|
||||||
"address": "Av. Corrientes 1234, Almagro"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "delivery a Palermo, calle Honduras 5000"
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_shipping",
|
|
||||||
"shipping_method": "delivery",
|
|
||||||
"address": "Palermo, calle Honduras 5000"
|
|
||||||
}
|
|
||||||
|
|
||||||
Input: "1" (si el contexto indica que 1=delivery)
|
|
||||||
Output:
|
|
||||||
{
|
|
||||||
"intent": "select_shipping",
|
|
||||||
"shipping_method": "delivery",
|
|
||||||
"address": null
|
|
||||||
}
|
|
||||||
|
|
||||||
FORMATO JSON:
|
|
||||||
{
|
|
||||||
"intent": "select_shipping|provide_address",
|
|
||||||
"shipping_method": "delivery" | "pickup" | null,
|
|
||||||
"address": "texto de dirección" | null
|
|
||||||
}
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
/**
|
|
||||||
* Human Fallback - Lógica para escalar conversaciones a humanos
|
|
||||||
*
|
|
||||||
* Se activa cuando:
|
|
||||||
* - No se encuentra un producto en el catálogo
|
|
||||||
* - El NLU tiene baja confianza
|
|
||||||
* - Casos especiales que requieren atención humana
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { ConversationState } from "../fsm.js";
|
|
||||||
import { createEmptyOrder } from "../orderModel.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Crea una respuesta de takeover para cuando no se encuentra un producto
|
|
||||||
*
|
|
||||||
* @param {Object} params
|
|
||||||
* @param {string} params.pendingQuery - La query/producto que no se encontró
|
|
||||||
* @param {Object} params.order - Estado actual del pedido
|
|
||||||
* @param {Object} params.context - Contexto adicional para el humano
|
|
||||||
* @returns {Object} Resultado con plan y decision para el pipeline
|
|
||||||
*/
|
|
||||||
export function createHumanTakeoverResponse({ pendingQuery, order, context = {} }) {
|
|
||||||
const currentOrder = order || createEmptyOrder();
|
|
||||||
|
|
||||||
// Mensaje amigable para el usuario
|
|
||||||
const reply = `Dejame consultar con el equipo sobre "${pendingQuery}". Te respondo en un momento.`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
plan: {
|
|
||||||
reply,
|
|
||||||
next_state: ConversationState.AWAITING_HUMAN,
|
|
||||||
intent: "human_takeover",
|
|
||||||
missing_fields: ["human_response"],
|
|
||||||
order_action: "none",
|
|
||||||
},
|
|
||||||
decision: {
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
type: "request_human_takeover",
|
|
||||||
payload: {
|
|
||||||
pending_query: pendingQuery,
|
|
||||||
reason: "product_not_found",
|
|
||||||
context_snapshot: {
|
|
||||||
order: currentOrder,
|
|
||||||
...context,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
order: currentOrder,
|
|
||||||
audit: {
|
|
||||||
human_takeover_requested: true,
|
|
||||||
pending_query: pendingQuery,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifica si debería escalar a humano basado en los resultados del catálogo
|
|
||||||
*
|
|
||||||
* @param {Object} params
|
|
||||||
* @param {Array} params.candidates - Candidatos encontrados en el catálogo
|
|
||||||
* @param {string} params.query - Query original del usuario
|
|
||||||
* @param {number} params.confidenceThreshold - Umbral de confianza mínimo
|
|
||||||
* @returns {boolean} true si debería escalar a humano
|
|
||||||
*/
|
|
||||||
export function shouldEscalateToHuman({ candidates = [], query, confidenceThreshold = 0.3 }) {
|
|
||||||
// Si no hay candidatos, escalar
|
|
||||||
if (!candidates || candidates.length === 0) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si el mejor candidato tiene score muy bajo, escalar
|
|
||||||
const bestScore = candidates[0]?._score || 0;
|
|
||||||
if (bestScore < confidenceThreshold) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Si la query es muy diferente al nombre del mejor candidato (por nombre)
|
|
||||||
// Esto es un heurístico simple para detectar confusiones
|
|
||||||
const bestName = (candidates[0]?.name || "").toLowerCase();
|
|
||||||
const queryLower = (query || "").toLowerCase();
|
|
||||||
|
|
||||||
// Si no hay overlap significativo de palabras, podría ser confusión
|
|
||||||
const queryWords = queryLower.split(/\s+/).filter(w => w.length > 2);
|
|
||||||
const nameWords = bestName.split(/\s+/).filter(w => w.length > 2);
|
|
||||||
|
|
||||||
if (queryWords.length > 0 && nameWords.length > 0) {
|
|
||||||
const overlap = queryWords.filter(qw =>
|
|
||||||
nameWords.some(nw => nw.includes(qw) || qw.includes(nw))
|
|
||||||
);
|
|
||||||
|
|
||||||
// Si hay muy poco overlap y el score no es muy alto, escalar
|
|
||||||
if (overlap.length === 0 && bestScore < 0.7) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Genera mensaje de respuesta cuando el humano responde al takeover
|
|
||||||
*
|
|
||||||
* @param {Object} params
|
|
||||||
* @param {string} params.humanResponse - Respuesta del humano
|
|
||||||
* @param {Object} params.order - Estado actual del pedido
|
|
||||||
* @returns {Object} Resultado para continuar el flujo normal
|
|
||||||
*/
|
|
||||||
export function createHumanResponseResult({ humanResponse, order }) {
|
|
||||||
const currentOrder = order || createEmptyOrder();
|
|
||||||
|
|
||||||
return {
|
|
||||||
plan: {
|
|
||||||
reply: humanResponse,
|
|
||||||
next_state: ConversationState.CART, // Volver al flujo normal
|
|
||||||
intent: "human_response",
|
|
||||||
missing_fields: [],
|
|
||||||
order_action: "none",
|
|
||||||
},
|
|
||||||
decision: {
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
type: "human_response_sent",
|
|
||||||
payload: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
order: currentOrder,
|
|
||||||
audit: {
|
|
||||||
human_response_processed: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verifica si el estado actual es AWAITING_HUMAN
|
|
||||||
*/
|
|
||||||
export function isAwaitingHuman(state) {
|
|
||||||
return state === ConversationState.AWAITING_HUMAN || state === "AWAITING_HUMAN";
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Genera respuesta cuando el usuario envía mensaje mientras está en AWAITING_HUMAN
|
|
||||||
*/
|
|
||||||
export function createWaitingForHumanResponse({ order }) {
|
|
||||||
const currentOrder = order || createEmptyOrder();
|
|
||||||
|
|
||||||
return {
|
|
||||||
plan: {
|
|
||||||
reply: "Estoy esperando respuesta del equipo sobre tu consulta. Te aviso apenas tenga novedades.",
|
|
||||||
next_state: ConversationState.AWAITING_HUMAN,
|
|
||||||
intent: "other",
|
|
||||||
missing_fields: ["human_response"],
|
|
||||||
order_action: "none",
|
|
||||||
},
|
|
||||||
decision: {
|
|
||||||
actions: [],
|
|
||||||
order: currentOrder,
|
|
||||||
audit: { still_waiting_human: true },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
/**
|
|
||||||
* NLU Modular - Punto de entrada principal
|
|
||||||
*
|
|
||||||
* Orquesta el Router + Specialists para procesar mensajes de usuario.
|
|
||||||
* Reemplaza a llmNluV3 con una arquitectura modular y prompts editables.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { routerClassify, quickDomainDetect } from "./router.js";
|
|
||||||
import { greetingNlu } from "./specialists/greeting.js";
|
|
||||||
import { ordersNlu } from "./specialists/orders.js";
|
|
||||||
import { shippingNlu } from "./specialists/shipping.js";
|
|
||||||
import { paymentNlu } from "./specialists/payment.js";
|
|
||||||
import { browseNlu } from "./specialists/browse.js";
|
|
||||||
import { createEmptyNlu } from "./schemas.js";
|
|
||||||
|
|
||||||
// Re-exportar utilidades útiles
|
|
||||||
export { loadPrompt, invalidatePromptCache, AVAILABLE_VARIABLES } from "./promptLoader.js";
|
|
||||||
export { PROMPT_KEYS, DEFAULT_MODELS } from "../../0-ui/db/promptsRepo.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Procesa un mensaje con el sistema NLU modular
|
|
||||||
*
|
|
||||||
* @param {Object} params
|
|
||||||
* @param {Object} params.input - Input del NLU
|
|
||||||
* @param {string} params.input.last_user_message - Mensaje del usuario
|
|
||||||
* @param {string} params.input.conversation_state - Estado actual de la conversación
|
|
||||||
* @param {Object} params.input.pending_context - Contexto de items pendientes
|
|
||||||
* @param {string} params.input.locale - Locale (default: es-AR)
|
|
||||||
* @param {number} params.tenantId - ID del tenant
|
|
||||||
* @param {Object} params.storeConfig - Configuración de la tienda (para variables)
|
|
||||||
* @returns {Object} { nlu, raw_text, model, usage, schema, validation, routing }
|
|
||||||
*/
|
|
||||||
export async function llmNluModular({ input, tenantId, storeConfig = {} } = {}) {
|
|
||||||
const text = input?.last_user_message || "";
|
|
||||||
const state = input?.conversation_state || "IDLE";
|
|
||||||
const startTime = Date.now();
|
|
||||||
|
|
||||||
// Tracking para debug
|
|
||||||
const routing = {
|
|
||||||
quick_detect: null,
|
|
||||||
router_result: null,
|
|
||||||
final_domain: null,
|
|
||||||
specialist_used: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 1) Quick detection: si es un caso obvio, evitar llamar al router LLM
|
|
||||||
const quickDomain = quickDomainDetect(text, state);
|
|
||||||
routing.quick_detect = quickDomain;
|
|
||||||
|
|
||||||
// Casos donde podemos saltar el router:
|
|
||||||
// - Saludos simples
|
|
||||||
// - Números solos (1, 2) en estados SHIPPING/PAYMENT
|
|
||||||
// - Patrones muy claros
|
|
||||||
const skipRouter = shouldSkipRouter(text, state, quickDomain);
|
|
||||||
|
|
||||||
let domain;
|
|
||||||
if (skipRouter) {
|
|
||||||
domain = quickDomain;
|
|
||||||
routing.router_result = { skipped: true, quick_domain: quickDomain };
|
|
||||||
} else {
|
|
||||||
// 2) Router LLM: clasificar dominio
|
|
||||||
const routerResult = await routerClassify({ tenantId, text, state, storeConfig });
|
|
||||||
domain = routerResult.domain;
|
|
||||||
routing.router_result = routerResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
routing.final_domain = domain;
|
|
||||||
|
|
||||||
// 3) Dispatch al specialist correspondiente
|
|
||||||
let result;
|
|
||||||
|
|
||||||
switch (domain) {
|
|
||||||
case "greeting":
|
|
||||||
routing.specialist_used = "greeting";
|
|
||||||
result = await greetingNlu({ tenantId, text, storeConfig });
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "orders":
|
|
||||||
routing.specialist_used = "orders";
|
|
||||||
result = await ordersNlu({ tenantId, text, storeConfig });
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "shipping":
|
|
||||||
routing.specialist_used = "shipping";
|
|
||||||
result = await shippingNlu({ tenantId, text, storeConfig });
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "payment":
|
|
||||||
routing.specialist_used = "payment";
|
|
||||||
result = await paymentNlu({ tenantId, text, storeConfig });
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "browse":
|
|
||||||
routing.specialist_used = "browse";
|
|
||||||
result = await browseNlu({ tenantId, text, storeConfig });
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Fallback: usar orders como default si hay texto con posibles productos
|
|
||||||
routing.specialist_used = "orders_fallback";
|
|
||||||
result = await ordersNlu({ tenantId, text, storeConfig });
|
|
||||||
// Pero marcar como "other" si el resultado no es claro
|
|
||||||
if (result.nlu.confidence < 0.7) {
|
|
||||||
result.nlu.intent = "other";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Agregar metadata de routing
|
|
||||||
result.routing = routing;
|
|
||||||
result.schema = "modular_v1";
|
|
||||||
result.processing_time_ms = Date.now() - startTime;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[nluModular] Error:", error);
|
|
||||||
|
|
||||||
// Fallback completo
|
|
||||||
const nlu = createEmptyNlu();
|
|
||||||
nlu.intent = "other";
|
|
||||||
nlu.confidence = 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
nlu,
|
|
||||||
raw_text: "",
|
|
||||||
model: null,
|
|
||||||
usage: null,
|
|
||||||
schema: "modular_v1",
|
|
||||||
validation: { ok: false, error: error.message },
|
|
||||||
routing: { ...routing, error: error.message },
|
|
||||||
processing_time_ms: Date.now() - startTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determina si podemos saltar el router LLM y usar quick detection
|
|
||||||
*/
|
|
||||||
function shouldSkipRouter(text, state, quickDomain) {
|
|
||||||
const t = String(text || "").trim();
|
|
||||||
|
|
||||||
// Saludos simples (sin productos)
|
|
||||||
if (quickDomain === "greeting" && t.length < 20) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Números solos en estados específicos
|
|
||||||
if (/^[12]$/.test(t)) {
|
|
||||||
if (state === "SHIPPING" || state === "PAYMENT") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// "efectivo" o "tarjeta" solos en estado PAYMENT
|
|
||||||
if (state === "PAYMENT" && /^(efectivo|tarjeta|link|transfer)$/i.test(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// "delivery" o "retiro" solos en estado SHIPPING
|
|
||||||
if (state === "SHIPPING" && /^(delivery|retiro|buscar|sucursal)$/i.test(t)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// En estado SHIPPING, si quickDomain ya detectó "shipping" (dirección), confiar en eso
|
|
||||||
// Esto evita que el router LLM clasifique direcciones como productos
|
|
||||||
if (state === "SHIPPING" && quickDomain === "shipping") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Versión compatible con la firma de llmNluV3
|
|
||||||
* Para usar con el feature flag sin cambiar mucho código
|
|
||||||
*/
|
|
||||||
export async function llmNluModularCompat({ input, model } = {}) {
|
|
||||||
// Extraer tenantId del input si está disponible, o usar 1 como default
|
|
||||||
// En producción, esto debería pasarse explícitamente
|
|
||||||
const tenantId = input?.tenantId || 1;
|
|
||||||
|
|
||||||
// Construir storeConfig básico (en producción se cargaría de la DB)
|
|
||||||
const storeConfig = {
|
|
||||||
name: input?.store_name || "la carnicería",
|
|
||||||
botName: input?.bot_name || "Piaf",
|
|
||||||
hours: input?.store_hours || "",
|
|
||||||
address: input?.store_address || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
return llmNluModular({ input, tenantId, storeConfig });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Export default para compatibilidad
|
|
||||||
export default llmNluModular;
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
/**
|
|
||||||
* Prompt Loader - Carga prompts de DB con fallback a defaults
|
|
||||||
*
|
|
||||||
* Características:
|
|
||||||
* - Cache en memoria con TTL configurable
|
|
||||||
* - Fallback a archivos default si no hay prompt custom
|
|
||||||
* - Reemplazo de variables básicas ({{store_name}}, etc.)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { getActivePrompt } from "../../0-ui/db/promptsRepo.js";
|
|
||||||
import { DEFAULT_MODELS } from "../../0-ui/db/promptsRepo.js";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import { fileURLToPath } from "url";
|
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
|
||||||
const __dirname = path.dirname(__filename);
|
|
||||||
|
|
||||||
const DEFAULTS_DIR = path.join(__dirname, "defaults");
|
|
||||||
|
|
||||||
// Cache en memoria
|
|
||||||
const cache = new Map();
|
|
||||||
const CACHE_TTL = 5 * 60 * 1000; // 5 minutos
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Variables disponibles para reemplazo en prompts
|
|
||||||
*/
|
|
||||||
export const AVAILABLE_VARIABLES = [
|
|
||||||
{ key: "store_name", description: "Nombre del negocio", example: "Carnicería Don Pedro" },
|
|
||||||
{ key: "store_hours", description: "Horario de atención", example: "Lun-Sab 8-20hs" },
|
|
||||||
{ key: "store_address", description: "Dirección del local", example: "Av. Corrientes 1234" },
|
|
||||||
{ key: "store_phone", description: "Teléfono", example: "+54 11 1234-5678" },
|
|
||||||
{ key: "bot_name", description: "Nombre del bot", example: "Piaf" },
|
|
||||||
{ key: "current_date", description: "Fecha actual", example: "25 de enero" },
|
|
||||||
{ key: "customer_name", description: "Nombre del cliente (si lo tiene)", example: "Juan" },
|
|
||||||
{ key: "state", description: "Estado actual de la conversación", example: "CART" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Carga un prompt de la DB o usa el default
|
|
||||||
*
|
|
||||||
* @param {Object} params
|
|
||||||
* @param {number} params.tenantId - ID del tenant
|
|
||||||
* @param {string} params.promptKey - Key del prompt ('router', 'greeting', 'orders', etc.)
|
|
||||||
* @param {Object} params.variables - Variables para reemplazar en el prompt
|
|
||||||
* @param {boolean} params.skipCache - Si es true, no usa cache
|
|
||||||
* @returns {Object} { content: string, model: string, isDefault: boolean, version: number|null }
|
|
||||||
*/
|
|
||||||
export async function loadPrompt({ tenantId, promptKey, variables = {}, skipCache = false }) {
|
|
||||||
const cacheKey = `${tenantId}:${promptKey}`;
|
|
||||||
|
|
||||||
// Verificar cache
|
|
||||||
if (!skipCache) {
|
|
||||||
const cached = cache.get(cacheKey);
|
|
||||||
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
||||||
return applyVariables(cached.content, cached.model, cached.isDefault, cached.version, variables);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Intentar cargar de DB
|
|
||||||
let content, model, isDefault = false, version = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dbPrompt = await getActivePrompt({ tenantId, promptKey });
|
|
||||||
|
|
||||||
if (dbPrompt) {
|
|
||||||
content = dbPrompt.content;
|
|
||||||
model = dbPrompt.model;
|
|
||||||
version = dbPrompt.version;
|
|
||||||
isDefault = false;
|
|
||||||
} else {
|
|
||||||
// Fallback a archivo default
|
|
||||||
const defaultContent = loadDefaultPrompt(promptKey);
|
|
||||||
content = defaultContent;
|
|
||||||
model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo";
|
|
||||||
isDefault = true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Si falla la DB, usar default
|
|
||||||
console.error(`[promptLoader] Error loading prompt from DB: ${error.message}`);
|
|
||||||
const defaultContent = loadDefaultPrompt(promptKey);
|
|
||||||
content = defaultContent;
|
|
||||||
model = DEFAULT_MODELS[promptKey] || "gpt-4-turbo";
|
|
||||||
isDefault = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Guardar en cache
|
|
||||||
cache.set(cacheKey, { content, model, isDefault, version, timestamp: Date.now() });
|
|
||||||
|
|
||||||
return applyVariables(content, model, isDefault, version, variables);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Carga el prompt default desde archivo
|
|
||||||
*/
|
|
||||||
export function loadDefaultPrompt(promptKey) {
|
|
||||||
const filePath = path.join(DEFAULTS_DIR, `${promptKey}.txt`);
|
|
||||||
|
|
||||||
if (!fs.existsSync(filePath)) {
|
|
||||||
throw new Error(`Default prompt file not found: ${filePath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fs.readFileSync(filePath, "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reemplaza variables en el contenido del prompt
|
|
||||||
*/
|
|
||||||
function applyVariables(content, model, isDefault, version, variables) {
|
|
||||||
let result = content;
|
|
||||||
|
|
||||||
// Agregar fecha actual si no está en variables
|
|
||||||
if (!variables.current_date) {
|
|
||||||
const now = new Date();
|
|
||||||
const months = ["enero", "febrero", "marzo", "abril", "mayo", "junio",
|
|
||||||
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre"];
|
|
||||||
variables.current_date = `${now.getDate()} de ${months[now.getMonth()]}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reemplazar todas las variables
|
|
||||||
for (const [key, value] of Object.entries(variables)) {
|
|
||||||
const regex = new RegExp(`{{${key}}}`, "g");
|
|
||||||
result = result.replace(regex, value || "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Limpiar variables no reemplazadas (dejar vacío)
|
|
||||||
result = result.replace(/\{\{[^}]+\}\}/g, "");
|
|
||||||
|
|
||||||
return { content: result, model, isDefault, version };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalida el cache de un prompt específico
|
|
||||||
*/
|
|
||||||
export function invalidatePromptCache(tenantId, promptKey) {
|
|
||||||
const cacheKey = `${tenantId}:${promptKey}`;
|
|
||||||
cache.delete(cacheKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalida todo el cache de un tenant
|
|
||||||
*/
|
|
||||||
export function invalidateTenantCache(tenantId) {
|
|
||||||
for (const key of cache.keys()) {
|
|
||||||
if (key.startsWith(`${tenantId}:`)) {
|
|
||||||
cache.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Limpia todo el cache
|
|
||||||
*/
|
|
||||||
export function clearAllCache() {
|
|
||||||
cache.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Obtiene estadísticas del cache (para debugging)
|
|
||||||
*/
|
|
||||||
export function getCacheStats() {
|
|
||||||
const entries = [];
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const [key, value] of cache.entries()) {
|
|
||||||
entries.push({
|
|
||||||
key,
|
|
||||||
age: Math.round((now - value.timestamp) / 1000),
|
|
||||||
isExpired: now - value.timestamp >= CACHE_TTL,
|
|
||||||
isDefault: value.isDefault,
|
|
||||||
version: value.version,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
size: cache.size,
|
|
||||||
ttlSeconds: CACHE_TTL / 1000,
|
|
||||||
entries,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Pre-carga todos los prompts de un tenant (útil al inicio)
|
|
||||||
*/
|
|
||||||
export async function preloadPrompts({ tenantId, storeConfig = {} }) {
|
|
||||||
const promptKeys = ["router", "greeting", "orders", "shipping", "payment", "browse"];
|
|
||||||
const results = {};
|
|
||||||
|
|
||||||
for (const key of promptKeys) {
|
|
||||||
try {
|
|
||||||
results[key] = await loadPrompt({
|
|
||||||
tenantId,
|
|
||||||
promptKey: key,
|
|
||||||
variables: storeConfig,
|
|
||||||
skipCache: true
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`[promptLoader] Error preloading ${key}: ${error.message}`);
|
|
||||||
results[key] = { error: error.message };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user