Backend cleanup (todo el delivery vive ahora en delivery_zones.zones[]):
- Migration drop columns delivery_enabled / delivery_days / delivery_hours_start /
delivery_hours_end / delivery_min_order y limpieza de schedule.delivery JSONB.
- settingsRepo: SELECT/INSERT/UPDATE sólo con campos vigentes, formatScheduleHours
trabaja sobre pickup.
- handlers/settings: defaults sin legacy, validateSchedule sólo para pickup,
validateDeliveryZones nuevo (estructura GeoJSON + días).
- seed_piaf_settings_and_replies + tenant_settings migrations alineadas: schedule
sólo tiene pickup, delivery_zones queda en {} para reconfigurar via UI.
Frontend cleanup:
- settings-crud: borrado el panel "Delivery (Envío a domicilio)" + minOrder,
toggle deliveryEnabled, y el grid schedule.delivery. collectScheduleFromInputs
ahora sólo procesa pickup. save() ya no envía delivery_enabled/min_order.
Fix mapa (no cargaba):
- zone-map-editor: los <link> a leaflet.css/leaflet-geoman.css se inyectaban en
document.head, que NO cruza el shadow DOM de settings-crud, por lo que las
reglas de Leaflet no aplicaban al div del mapa. Ahora los <link> se anclan
como hijos del propio web component; al estar en light DOM dentro del shadow
root del padre, sí aplican.
- Espera explícita a que el stylesheet cargue antes de instanciar L.map.
- ResizeObserver + invalidateSize() para cuando el contenedor cambia tamaño
(router muestra/oculta panel, tabs, etc).
Smoke E2E sin regresión: 1kg vacío + envío → location en Centro → "martes 12hs"
→ orden confirmada con $28.000 (producto + envío). 157/157 tests verde.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
745 lines
30 KiB
JavaScript
745 lines
30 KiB
JavaScript
import { api } from "../lib/api.js";
|
|
|
|
const DAYS = [
|
|
{ id: "lun", label: "Lunes", short: "L" },
|
|
{ id: "mar", label: "Martes", short: "M" },
|
|
{ id: "mie", label: "Miércoles", short: "X" },
|
|
{ id: "jue", label: "Jueves", short: "J" },
|
|
{ id: "vie", label: "Viernes", short: "V" },
|
|
{ id: "sab", label: "Sábado", short: "S" },
|
|
{ id: "dom", label: "Domingo", short: "D" },
|
|
];
|
|
|
|
function makeZoneId(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 SettingsCrud extends HTMLElement {
|
|
constructor() {
|
|
super();
|
|
this.attachShadow({ mode: "open" });
|
|
this.settings = null;
|
|
this.loading = false;
|
|
this.saving = false;
|
|
this.zones = [];
|
|
this.selectedZoneId = null;
|
|
this._mapEditor = null;
|
|
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
:host { display:block; height:100%; padding:16px; overflow:auto; }
|
|
* { box-sizing:border-box; font-family:system-ui,Segoe UI,Roboto,Arial; }
|
|
.container { max-width:800px; margin:0 auto; }
|
|
.panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:20px; margin-bottom:16px; }
|
|
.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.full { grid-template-columns:1fr; }
|
|
|
|
.field { }
|
|
.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:var(--text-muted); margin-top:4px; }
|
|
|
|
input, select, textarea {
|
|
background:var(--panel-2); color:var(--text); border:1px solid var(--border-hi);
|
|
border-radius:8px; padding:10px 14px; font-size:14px; width:100%;
|
|
}
|
|
input:focus, select:focus, textarea:focus { outline:none; border-color:var(--accent); }
|
|
input:disabled { opacity:.6; cursor:not-allowed; }
|
|
|
|
button {
|
|
cursor:pointer; background:var(--accent); color:#fff; border:none;
|
|
border-radius:8px; padding:10px 20px; font-size:14px; font-weight:600;
|
|
}
|
|
button:hover { background:var(--accent-hover); }
|
|
button:disabled { opacity:.5; cursor:not-allowed; }
|
|
button.secondary { background:var(--border-hi); }
|
|
button.secondary:hover { background:var(--border-hi); }
|
|
|
|
.toggle-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; }
|
|
.toggle {
|
|
position:relative; width:48px; height:26px;
|
|
background:var(--border-hi); border-radius:13px; cursor:pointer;
|
|
transition:background .2s; flex-shrink:0;
|
|
}
|
|
.toggle.active { background:var(--accent); }
|
|
.toggle::after {
|
|
content:''; position:absolute; top:3px; left:3px;
|
|
width:20px; height:20px; background:#fff; border-radius:50%;
|
|
transition:transform .2s;
|
|
}
|
|
.toggle.active::after { transform:translateX(22px); }
|
|
.toggle-label { font-size:14px; color:var(--text); }
|
|
|
|
/* Schedule grid */
|
|
.schedule-grid { display:flex; flex-direction:column; gap:8px; }
|
|
.schedule-row {
|
|
display:grid;
|
|
grid-template-columns:90px 32px 1fr;
|
|
gap:12px;
|
|
align-items:center;
|
|
padding:8px 12px;
|
|
background:var(--panel-2);
|
|
border-radius:8px;
|
|
border:1px solid var(--border);
|
|
}
|
|
.schedule-row.disabled { opacity:0.4; }
|
|
.day-label { font-size:13px; color:var(--text); font-weight:500; }
|
|
.day-toggle {
|
|
width:32px; height:18px; background:var(--border-hi); border-radius:9px;
|
|
cursor:pointer; position:relative; transition:background .2s;
|
|
}
|
|
.day-toggle.active { background:var(--ok); }
|
|
.day-toggle::after {
|
|
content:''; position:absolute; top:2px; left:2px;
|
|
width:14px; height:14px; background:#fff; border-radius:50%;
|
|
transition:transform .2s;
|
|
}
|
|
.day-toggle.active::after { transform:translateX(14px); }
|
|
.hours-inputs { display:flex; align-items:center; gap:8px; }
|
|
.hours-inputs input {
|
|
width:70px; text-align:center; font-family:monospace;
|
|
font-size:13px; padding:6px 8px; letter-spacing:1px;
|
|
}
|
|
.hours-inputs span { color:var(--text-muted); font-size:12px; }
|
|
.hours-inputs.disabled input { opacity:0.4; pointer-events:none; }
|
|
|
|
.actions { display:flex; gap:12px; margin-top:24px; }
|
|
.loading { text-align:center; padding:60px; color:var(--text-muted); }
|
|
|
|
.success-msg {
|
|
background:var(--ok)30; border:1px solid var(--ok);
|
|
color:var(--ok); padding:12px 16px; border-radius:8px;
|
|
margin-bottom:16px; font-size:14px;
|
|
}
|
|
.error-msg {
|
|
background:var(--err)30; border:1px solid var(--err);
|
|
color:var(--err); padding:12px 16px; border-radius:8px;
|
|
margin-bottom:16px; font-size:14px;
|
|
}
|
|
|
|
.min-order-field { margin-top:16px; padding-top:16px; border-top:1px solid var(--border); }
|
|
|
|
/* Zonas de entrega — editor con mapa */
|
|
.zones-layout { display:grid; grid-template-columns:280px 1fr; gap:16px; }
|
|
.zones-side { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
|
.zones-side-header { display:flex; align-items:center; justify-content:space-between; }
|
|
.zones-side-header h4 { margin:0; font-size:13px; color:var(--text); }
|
|
.zones-side-header button { padding:6px 10px; font-size:12px; }
|
|
.zones-list { display:flex; flex-direction:column; gap:6px; max-height:480px; overflow-y:auto; padding-right:4px; }
|
|
.zone-row {
|
|
display:flex; align-items:center; gap:10px;
|
|
padding:10px 12px; border-radius:var(--r-md, 10px);
|
|
background:var(--panel-2); border:1px solid var(--border);
|
|
cursor:pointer; transition:border-color .15s, background .15s;
|
|
}
|
|
.zone-row:hover { border-color:var(--border-hi); }
|
|
.zone-row.active { border-color:var(--accent); background:var(--accent-soft); }
|
|
.zone-row.disabled { opacity:.55; }
|
|
.zone-swatch { width:14px; height:14px; border-radius:4px; flex-shrink:0; }
|
|
.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-row-meta { font-size:11px; color:var(--text-muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
|
.zones-empty { padding:24px; text-align:center; color:var(--text-muted); font-size:13px; }
|
|
.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; }
|
|
.zone-form .row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
|
.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-form input { padding:8px 10px; font-size:13px; }
|
|
.zone-days-pick { display:flex; gap:4px; flex-wrap:wrap; }
|
|
.zone-day-pick { padding:4px 8px; border-radius:6px; font-size:11px; font-weight:600; cursor:pointer;
|
|
background:var(--border); color:var(--text-muted); border:1px solid transparent; }
|
|
.zone-day-pick.active { background:var(--accent); color:var(--text-on-acc, #fff); }
|
|
.zone-row-actions { display:flex; gap:8px; justify-content:flex-end; }
|
|
.zone-row-actions button { padding:6px 10px; font-size:12px; }
|
|
.zone-row-actions .danger { background:var(--err); }
|
|
.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); }
|
|
.zones-summary strong { color:var(--text); }
|
|
</style>
|
|
|
|
<div class="container">
|
|
<div id="messages"></div>
|
|
<div id="content">
|
|
<div class="loading">Cargando configuración...</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.load();
|
|
}
|
|
|
|
async load() {
|
|
this.loading = true;
|
|
this.render();
|
|
|
|
try {
|
|
this.settings = await api.getSettings();
|
|
if (!this.settings.schedule) {
|
|
this.settings.schedule = { delivery: {}, pickup: {} };
|
|
}
|
|
const dz = this.settings.delivery_zones || {};
|
|
this.zones = Array.isArray(dz.zones) ? dz.zones.map((z) => ({ ...z })) : [];
|
|
this.selectedZoneId = this.zones[0]?.id || null;
|
|
this.loading = false;
|
|
this.render();
|
|
} catch (e) {
|
|
console.error("Error loading settings:", e);
|
|
this.loading = false;
|
|
this.showError("Error cargando configuración: " + e.message);
|
|
}
|
|
}
|
|
|
|
getScheduleSlot(type, dayId) {
|
|
return this.settings?.schedule?.[type]?.[dayId] || null;
|
|
}
|
|
|
|
setScheduleSlot(type, dayId, slot) {
|
|
if (!this.settings.schedule) {
|
|
this.settings.schedule = { delivery: {}, pickup: {} };
|
|
}
|
|
if (!this.settings.schedule[type]) {
|
|
this.settings.schedule[type] = {};
|
|
}
|
|
this.settings.schedule[type][dayId] = slot;
|
|
}
|
|
|
|
renderScheduleGrid(type, enabled) {
|
|
const defaultStart = type === "delivery" ? "09:00" : "08:00";
|
|
const defaultEnd = type === "delivery" ? "18:00" : "20:00";
|
|
|
|
return DAYS.map(day => {
|
|
const slot = this.getScheduleSlot(type, day.id);
|
|
const isActive = slot !== null && slot !== undefined;
|
|
const start = slot?.start || defaultStart;
|
|
const end = slot?.end || defaultEnd;
|
|
|
|
return `
|
|
<div class="schedule-row ${!enabled ? 'disabled' : ''}">
|
|
<span class="day-label">${day.label}</span>
|
|
<div class="day-toggle ${isActive ? 'active' : ''}"
|
|
data-type="${type}" data-day="${day.id}"
|
|
${!enabled ? 'style="pointer-events:none"' : ''}></div>
|
|
<div class="hours-inputs ${!isActive ? 'disabled' : ''}">
|
|
<input type="text"
|
|
class="hour-start"
|
|
data-type="${type}"
|
|
data-day="${day.id}"
|
|
value="${start}"
|
|
placeholder="09:00"
|
|
maxlength="5"
|
|
${!enabled || !isActive ? 'disabled' : ''} />
|
|
<span>a</span>
|
|
<input type="text"
|
|
class="hour-end"
|
|
data-type="${type}"
|
|
data-day="${day.id}"
|
|
value="${end}"
|
|
placeholder="18:00"
|
|
maxlength="5"
|
|
${!enabled || !isActive ? 'disabled' : ''} />
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
ZONE_PALETTE_VARS() {
|
|
return ["--chart-blue", "--chart-green", "--chart-purple", "--chart-orange", "--chart-pink", "--chart-gray"];
|
|
}
|
|
|
|
zoneSwatchColor(idx) {
|
|
const palette = this.ZONE_PALETTE_VARS();
|
|
return `var(${palette[idx % palette.length]})`;
|
|
}
|
|
|
|
formatDaysShort(days) {
|
|
if (!Array.isArray(days) || !days.length) return "\u2014";
|
|
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 (idx.length >= 3 && idx.every((v, i, arr) => i === 0 || v === arr[i - 1] + 1)) {
|
|
return `${order[idx[0]]}-${order[idx[idx.length - 1]]}`;
|
|
}
|
|
return idx.map((i) => order[i]).join("/");
|
|
}
|
|
|
|
renderZonesList() {
|
|
if (!this.zones.length) {
|
|
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>`;
|
|
}
|
|
return this.zones.map((z, i) => {
|
|
const cost = z.delivery_cost != null ? `$${Number(z.delivery_cost).toLocaleString("es-AR")}` : "";
|
|
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 `
|
|
<div class="zone-row ${active} ${disabled}" data-zone-id="${z.id}">
|
|
<div class="zone-swatch" style="background:${this.zoneSwatchColor(i)}"></div>
|
|
<div class="zone-row-main">
|
|
<div class="zone-row-name">${this.escapeHtml(z.name || z.id)}</div>
|
|
<div class="zone-row-meta">${this.escapeHtml(meta || "sin configurar")}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join("");
|
|
}
|
|
|
|
renderZoneForm() {
|
|
const z = this.zones.find((x) => x.id === this.selectedZoneId);
|
|
if (!z) {
|
|
return `<div class="zones-empty">Seleccioná una zona en la lista o dibujá una nueva en el mapa para configurarla.</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>
|
|
`;
|
|
}
|
|
|
|
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() {
|
|
const content = this.shadowRoot.getElementById("content");
|
|
|
|
if (this.loading) {
|
|
content.innerHTML = `<div class="loading">Cargando configuración...</div>`;
|
|
return;
|
|
}
|
|
|
|
if (!this.settings) {
|
|
content.innerHTML = `<div class="loading">No se pudo cargar la configuración</div>`;
|
|
return;
|
|
}
|
|
|
|
const s = this.settings;
|
|
|
|
content.innerHTML = `
|
|
<!-- Info del Negocio -->
|
|
<div class="panel">
|
|
<div class="panel-title">
|
|
<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>
|
|
Información del Negocio
|
|
</div>
|
|
|
|
<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>
|
|
|
|
<!-- Retiro en tienda -->
|
|
<div class="panel">
|
|
<div class="panel-title">
|
|
<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>
|
|
Retiro en Tienda
|
|
</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
|
|
</div>
|
|
<div class="field-hint" style="margin-bottom:12px;">
|
|
Dibujá los polígonos de tus zonas en el mapa. Cada zona tiene su costo de envío, días y rango horario.
|
|
El bot valida la dirección del cliente con la ubicación que 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>
|
|
<zone-map-editor id="zoneMapEditor" height="520px"></zone-map-editor>
|
|
</div>
|
|
</div>
|
|
</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>
|
|
`;
|
|
|
|
this.setupEventListeners();
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Toggle pickup
|
|
const pickupToggle = this.shadowRoot.getElementById("pickupToggle");
|
|
pickupToggle?.addEventListener("click", () => {
|
|
this.settings.pickup_enabled = !this.settings.pickup_enabled;
|
|
this.render();
|
|
});
|
|
|
|
// Day toggles
|
|
this.shadowRoot.querySelectorAll(".day-toggle").forEach(toggle => {
|
|
toggle.addEventListener("click", () => {
|
|
const type = toggle.dataset.type;
|
|
const day = toggle.dataset.day;
|
|
const currentSlot = this.getScheduleSlot(type, day);
|
|
|
|
if (currentSlot) {
|
|
// Desactivar día
|
|
this.setScheduleSlot(type, day, null);
|
|
} else {
|
|
// Activar día con horarios default
|
|
const defaultStart = type === "delivery" ? "09:00" : "08:00";
|
|
const defaultEnd = type === "delivery" ? "18:00" : "20:00";
|
|
this.setScheduleSlot(type, day, { start: defaultStart, end: defaultEnd });
|
|
}
|
|
this.render();
|
|
});
|
|
});
|
|
|
|
// Hour inputs - update on blur
|
|
this.shadowRoot.querySelectorAll(".hour-start, .hour-end").forEach(input => {
|
|
input.addEventListener("blur", () => {
|
|
const type = input.dataset.type;
|
|
const day = input.dataset.day;
|
|
const isStart = input.classList.contains("hour-start");
|
|
|
|
const slot = this.getScheduleSlot(type, day);
|
|
if (!slot) return;
|
|
|
|
const value = input.value.trim();
|
|
if (isStart) {
|
|
slot.start = value || (type === "delivery" ? "09:00" : "08:00");
|
|
} else {
|
|
slot.end = value || (type === "delivery" ? "18:00" : "20:00");
|
|
}
|
|
this.setScheduleSlot(type, day, slot);
|
|
});
|
|
});
|
|
|
|
// Save button
|
|
this.shadowRoot.getElementById("saveBtn")?.addEventListener("click", () => this.save());
|
|
|
|
// Reset button
|
|
this.shadowRoot.getElementById("resetBtn")?.addEventListener("click", () => this.load());
|
|
|
|
this.setupZoneEditor();
|
|
}
|
|
|
|
setupZoneEditor() {
|
|
const editor = this.shadowRoot.getElementById("zoneMapEditor");
|
|
if (!editor) return;
|
|
this._mapEditor = editor;
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
const z = this.zones.find((x) => x.id === this.selectedZoneId);
|
|
if (!z) return;
|
|
|
|
const onChange = () => this.refreshZonesList();
|
|
|
|
const nameEl = this.shadowRoot.getElementById("zoneName");
|
|
nameEl?.addEventListener("input", () => { z.name = nameEl.value; onChange(); });
|
|
|
|
const costEl = this.shadowRoot.getElementById("zoneCost");
|
|
costEl?.addEventListener("change", () => { z.delivery_cost = Number(costEl.value) || 0; onChange(); });
|
|
|
|
const minEl = this.shadowRoot.getElementById("zoneMin");
|
|
minEl?.addEventListener("change", () => { z.min_order_amount = Number(minEl.value) || 0; });
|
|
|
|
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();
|
|
});
|
|
|
|
const enabledEl = this.shadowRoot.getElementById("zoneEnabled");
|
|
enabledEl?.addEventListener("change", () => {
|
|
z.enabled = enabledEl.value === "true";
|
|
onChange();
|
|
});
|
|
|
|
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);
|
|
if (idx >= 0) days.splice(idx, 1);
|
|
else days.push(day);
|
|
z.delivery_days = days;
|
|
btn.classList.toggle("active", days.includes(day));
|
|
onChange();
|
|
});
|
|
});
|
|
|
|
this.shadowRoot.getElementById("zoneFitBtn")?.addEventListener("click", () => {
|
|
if (this._mapEditor) this._mapEditor.selectedId = z.id;
|
|
});
|
|
|
|
this.shadowRoot.getElementById("zoneDeleteBtn")?.addEventListener("click", () => {
|
|
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() {
|
|
// Sólo pickup: el horario de delivery vive ahora dentro de cada zona.
|
|
const schedule = { pickup: {} };
|
|
this.shadowRoot.querySelectorAll(`.hour-start[data-type="pickup"]`).forEach((input) => {
|
|
const day = input.dataset.day;
|
|
const endInput = this.shadowRoot.querySelector(`.hour-end[data-type="pickup"][data-day="${day}"]`);
|
|
const toggle = this.shadowRoot.querySelector(`.day-toggle[data-type="pickup"][data-day="${day}"]`);
|
|
if (toggle?.classList.contains("active")) {
|
|
schedule.pickup[day] = {
|
|
start: input.value.trim() || "08:00",
|
|
end: endInput?.value.trim() || "20:00",
|
|
};
|
|
}
|
|
});
|
|
return schedule;
|
|
}
|
|
|
|
async save() {
|
|
// Collect schedule from inputs
|
|
const schedule = this.collectScheduleFromInputs();
|
|
|
|
// Antes de serializar, refrescar polígonos desde el editor por si el
|
|
// 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 = {
|
|
store_name: this.shadowRoot.getElementById("storeName")?.value || "",
|
|
bot_name: this.shadowRoot.getElementById("botName")?.value || "",
|
|
store_address: this.shadowRoot.getElementById("storeAddress")?.value || "",
|
|
store_phone: this.shadowRoot.getElementById("storePhone")?.value || "",
|
|
pickup_enabled: this.settings.pickup_enabled,
|
|
schedule,
|
|
delivery_zones,
|
|
};
|
|
|
|
// Update settings with form values
|
|
this.settings = { ...this.settings, ...data };
|
|
|
|
this.saving = true;
|
|
this.render();
|
|
|
|
try {
|
|
console.log("[settings-crud] Saving:", data);
|
|
const result = await api.saveSettings(data);
|
|
console.log("[settings-crud] Save result:", result);
|
|
|
|
if (result.ok === false) {
|
|
throw new Error(result.message || result.error || "Error desconocido");
|
|
}
|
|
|
|
this.settings = result.settings || data;
|
|
this.saving = false;
|
|
this.showSuccess(result.message || "Configuración guardada correctamente");
|
|
this.render();
|
|
} catch (e) {
|
|
console.error("[settings-crud] Error saving settings:", e);
|
|
this.saving = false;
|
|
this.showError("Error guardando: " + (e.message || e));
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
escapeHtml(str) {
|
|
return (str || "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
}
|
|
|
|
showSuccess(msg) {
|
|
const messages = this.shadowRoot.getElementById("messages");
|
|
messages.innerHTML = `<div class="success-msg">${msg}</div>`;
|
|
setTimeout(() => { messages.innerHTML = ""; }, 4000);
|
|
}
|
|
|
|
showError(msg) {
|
|
const messages = this.shadowRoot.getElementById("messages");
|
|
messages.innerHTML = `<div class="error-msg">${msg}</div>`;
|
|
setTimeout(() => { messages.innerHTML = ""; }, 5000);
|
|
}
|
|
}
|
|
|
|
customElements.define("settings-crud", SettingsCrud);
|