Files
catalogo-servicios/index.html
consultoria-as 38e9e4b91c Catalogo de Servicios (builder): codigo + documentacion extensiva
Builder multi-proveedor de servicios (tour / A&B / transportacion).
Python stdlib + SQLite + vanilla JS SPA. Hereda filosofia del Hub.

Secretos y datos (catalogo.db, secret.key, uploads/) excluidos via .gitignore.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 21:00:50 -07:00

769 lines
52 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Catálogo</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#ffffff; --surface:#f7f7f8; --paper:#ffffff;
--ink:#16181d; --ink-2:#3d424b; --muted:#8b9097;
--line:#e8e9ec; --line-2:#d9dbe0;
--sea:#1f4b54; --sea-d:#143840; --sea-soft:#eef3f3;
--sand:#b59a6f; --sand-soft:#f6f1e8;
--ok:#1f7a4d; --warn:#9a6a2f; --bad:#b3322b;
--r:10px; --shadow:0 1px 2px rgba(20,24,28,.04),0 8px 24px rgba(20,24,28,.06);
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:var(--surface);color:var(--ink);font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased}
button{font-family:inherit;cursor:pointer}
input,select,textarea{font-family:inherit;font-size:14px;color:var(--ink)}
a{color:var(--sea)}
::placeholder{color:#b3b6bc}
/* ── Top bar ── */
header{background:var(--paper);border-bottom:1px solid var(--line);padding:0 24px;display:flex;align-items:center;gap:28px;height:60px;position:sticky;top:0;z-index:50}
.brand{font-weight:700;font-size:18px;letter-spacing:-.01em;white-space:nowrap;display:flex;align-items:baseline;gap:8px}
.brand .dot{width:7px;height:7px;border-radius:2px;background:var(--sea);display:inline-block}
.brand small{font-weight:500;font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--muted)}
nav{display:flex;gap:2px;flex:1}
nav button{background:none;border:none;color:var(--muted);padding:8px 14px;border-radius:8px;font-size:13px;font-weight:500;transition:.12s;position:relative}
nav button:hover{background:var(--surface);color:var(--ink)}
nav button.active{color:var(--ink);font-weight:600}
nav button.active::after{content:"";position:absolute;left:14px;right:14px;bottom:-1px;height:2px;background:var(--sea);border-radius:2px}
.logout{background:none;border:1px solid var(--line-2);color:var(--ink-2);padding:7px 14px;border-radius:8px;font-size:12px;font-weight:500}
.logout:hover{background:var(--surface)}
main{max-width:1200px;margin:0 auto;padding:26px 24px 90px}
.view{display:none} .view.active{display:block}
/* ── Toolbar ── */
.toolbar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:22px}
.toolbar h2{font-weight:700;font-size:22px;letter-spacing:-.02em;margin-right:auto}
.search{flex:1;min-width:170px;max-width:300px;padding:9px 13px;border:1px solid var(--line-2);border-radius:9px;background:var(--paper)}
.search:focus{outline:none;border-color:var(--sea);box-shadow:0 0 0 3px var(--sea-soft)}
select.filter{padding:9px 11px;border:1px solid var(--line-2);border-radius:9px;background:var(--paper)}
select.filter:focus{outline:none;border-color:var(--sea)}
.btn{background:var(--ink);color:#fff;border:none;padding:9px 16px;border-radius:9px;font-weight:600;font-size:13px;transition:.12s;white-space:nowrap}
.btn:hover{background:#000}
.btn.ghost{background:var(--paper);color:var(--ink);border:1px solid var(--line-2)}
.btn.ghost:hover{border-color:var(--ink-2)}
.btn.danger{background:none;color:var(--bad);border:1px solid #e7c7c4}
.btn.danger:hover{background:#fcf3f2}
.btn.sm{padding:6px 11px;font-size:12px}
/* segmented control builder/cliente */
.seg{display:inline-flex;background:var(--surface);border:1px solid var(--line-2);border-radius:9px;padding:3px}
.seg button{background:none;border:none;padding:6px 14px;border-radius:7px;font-size:13px;font-weight:600;color:var(--muted)}
.seg button.on{background:var(--paper);color:var(--ink);box-shadow:var(--shadow)}
/* ── Cards grid (builder) ── */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:16px}
.card{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);overflow:hidden;transition:.14s;cursor:pointer;display:flex;flex-direction:column}
.card:hover{box-shadow:var(--shadow);border-color:var(--line-2)}
.card .thumb{height:150px;background:#f0f1f2 center/cover no-repeat;position:relative;display:flex;align-items:center;justify-content:center;color:#c2c5ca;font-size:13px;font-weight:500;letter-spacing:.05em}
.card .body{padding:13px 14px;display:flex;flex-direction:column;gap:6px;flex:1}
.card .name{font-weight:600;font-size:15px;line-height:1.3;letter-spacing:-.01em}
.card .prov{font-size:12px;color:var(--muted)}
.card .row{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:8px}
.card .price{font-weight:700;color:var(--ink);font-size:16px}
.card .price small{font-weight:500;color:var(--muted);font-size:11px}
.badge{font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;padding:3px 9px;border-radius:6px;display:inline-flex;align-items:center;gap:5px;background:var(--surface);color:var(--ink-2);border:1px solid var(--line)}
.badge .tdot{width:6px;height:6px;border-radius:50%}
.tdot.tour{background:var(--sea)} .tdot.ayb{background:var(--sand)} .tdot.transportacion{background:#5a6a8a}
.chip{font-size:11px;padding:2px 9px;border-radius:6px;background:var(--surface);color:var(--ink-2);font-weight:500;border:1px solid var(--line)}
.margen{font-size:11px;font-weight:600;padding:2px 8px;border-radius:6px}
.margen.good{background:var(--sea-soft);color:var(--sea)} .margen.mid{background:var(--sand-soft);color:var(--warn)} .margen.low{background:#fbeceb;color:var(--bad)}
.web-dot{position:absolute;top:10px;right:10px;background:rgba(22,24,29,.85);color:#fff;font-size:10px;font-weight:600;padding:3px 9px;border-radius:6px;backdrop-filter:blur(4px)}
.empty{text-align:center;color:var(--muted);padding:72px 20px}
.empty .big{font-size:30px;margin-bottom:12px;opacity:.5}
/* ── Vista cliente ── */
.cli-note{background:var(--sand-soft);border:1px solid #ecdfc7;color:#7a5f33;border-radius:var(--r);padding:10px 14px;font-size:12px;margin-bottom:20px;display:flex;gap:8px;align-items:center}
.cli-section{margin-bottom:34px}
.cli-section h3{font-size:13px;font-weight:600;letter-spacing:.1em;text-transform:uppercase;color:var(--muted);margin-bottom:14px;padding-bottom:8px;border-bottom:1px solid var(--line)}
.cli-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:20px}
.cli-card{background:var(--paper);border:1px solid var(--line);border-radius:14px;overflow:hidden;cursor:pointer;transition:.14s;display:flex;flex-direction:column}
.cli-card:hover{box-shadow:var(--shadow);transform:translateY(-2px)}
.cli-card .img{height:190px;background:#f0f1f2 center/cover no-repeat;display:flex;align-items:center;justify-content:center;color:#c2c5ca}
.cli-card .ci{padding:16px 18px;display:flex;flex-direction:column;gap:8px;flex:1}
.cli-card .cn{font-weight:600;font-size:17px;letter-spacing:-.01em}
.cli-card .cd{font-size:13px;color:var(--ink-2);line-height:1.5;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.cli-card .cf{display:flex;justify-content:space-between;align-items:baseline;margin-top:auto;padding-top:10px;border-top:1px solid var(--line)}
.cli-card .cprice{font-weight:700;font-size:18px}
.cli-card .cprice small{font-weight:500;color:var(--muted);font-size:12px}
/* ── Tabla proveedores ── */
.ptable{width:100%;border-collapse:collapse;background:var(--paper);border:1px solid var(--line);border-radius:var(--r);overflow:hidden}
.ptable th{text-align:left;font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);padding:12px 16px;border-bottom:1px solid var(--line);font-weight:600}
.ptable td{padding:13px 16px;border-bottom:1px solid var(--line)}
.ptable tr:last-child td{border-bottom:none}
.ptable tr:hover td{background:var(--surface)}
/* ── Dashboard ── */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px;margin-bottom:22px}
.kpi{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);padding:20px}
.kpi .n{font-size:32px;font-weight:700;letter-spacing:-.03em;line-height:1}
.kpi .l{font-size:11px;color:var(--muted);margin-top:8px;text-transform:uppercase;letter-spacing:.06em;font-weight:600}
.panel{background:var(--paper);border:1px solid var(--line);border-radius:var(--r);padding:20px;margin-bottom:16px}
.panel h3{font-size:11px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted);margin-bottom:14px;font-weight:600}
.bar{display:flex;align-items:center;gap:10px;margin-bottom:9px}
.bar .lbl{width:150px;font-size:13px}.bar .track{flex:1;height:8px;background:var(--surface);border-radius:6px;overflow:hidden}
.bar .fill{height:100%;background:var(--sea);border-radius:6px}.bar .v{width:34px;text-align:right;font-weight:600;font-size:13px}
/* ── Modal ── */
.overlay{position:fixed;inset:0;background:rgba(22,24,29,.4);display:none;align-items:flex-start;justify-content:center;z-index:100;padding:24px;overflow-y:auto;backdrop-filter:blur(2px)}
.overlay.open{display:flex}
.modal{background:var(--bg);border-radius:14px;width:100%;max-width:720px;box-shadow:0 30px 80px rgba(0,0,0,.28);margin:auto}
.modal header{position:sticky;top:0;background:var(--paper);border-bottom:1px solid var(--line);border-radius:14px 14px 0 0;height:auto;padding:16px 22px;display:flex;align-items:center;gap:12px;z-index:2}
.modal header .mt{font-weight:700;font-size:17px;letter-spacing:-.01em;flex:1}
.modal header .x{background:var(--surface);border:1px solid var(--line);color:var(--ink-2);width:30px;height:30px;border-radius:8px;font-size:17px;line-height:1}
.modal header .x:hover{background:var(--line)}
.mbody{padding:22px}
.section{margin-bottom:22px}
.section .stitle{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--ink);margin-bottom:3px;display:flex;align-items:center;gap:7px}
.section .stitle .note{text-transform:none;letter-spacing:0;font-weight:400}
.section .shint{font-size:11px;color:var(--muted);margin-bottom:12px}
.fgrid{display:grid;grid-template-columns:1fr 1fr;gap:13px}
.fgrid.three{grid-template-columns:1fr 1fr 1fr}
.fg{display:flex;flex-direction:column;gap:5px}
.fg.full{grid-column:1/-1}
.fg label{font-size:11px;font-weight:600;color:var(--ink-2);letter-spacing:.01em}
.fg input,.fg select,.fg textarea{padding:9px 11px;border:1px solid var(--line-2);border-radius:8px;background:var(--paper);width:100%}
.fg input:focus,.fg select:focus,.fg textarea:focus{outline:none;border-color:var(--sea);box-shadow:0 0 0 3px var(--sea-soft)}
.fg textarea{resize:vertical;min-height:62px}
.hr{height:1px;background:var(--line);margin:18px 0}
.margenline{background:var(--surface);border:1px solid var(--line-2);border-radius:9px;padding:11px 15px;font-size:13px;display:flex;justify-content:space-between;align-items:center;color:var(--ink-2)}
.margenline b{font-size:17px;color:var(--ink);font-weight:700}
.toggle{display:flex;align-items:center;gap:10px;background:var(--paper);border:1px solid var(--line-2);border-radius:9px;padding:11px 14px}
.toggle input{width:auto}
/* ── Editor de horarios ── */
.hor{border:1px solid var(--line-2);border-radius:9px;overflow:hidden;background:var(--paper)}
.hor .hday{display:flex;align-items:center;gap:12px;padding:9px 13px;border-bottom:1px solid var(--line);flex-wrap:wrap}
.hor .hday:last-child{border-bottom:none}
.hor .hday.off{background:var(--surface)}
.hor .hck{display:flex;align-items:center;gap:8px;width:78px;font-weight:600;font-size:13px;flex-shrink:0}
.hor .hck input{width:auto}
.hor .htimes{display:flex;gap:6px;flex-wrap:wrap;flex:1;align-items:center}
.htime{display:inline-flex;align-items:center;gap:5px;background:var(--sea-soft);color:var(--sea-d);border-radius:6px;padding:3px 5px 3px 9px;font-size:12px;font-weight:600}
.htime button{background:none;border:none;color:var(--sea);font-size:14px;line-height:1;padding:0 2px}
.hor .haddt{display:flex;gap:5px;align-items:center}
.hor .haddt input{padding:5px 7px;border:1px solid var(--line-2);border-radius:6px;width:96px}
.hor .haddt button{padding:5px 9px;font-size:12px}
.hor .hempty{color:var(--muted);font-size:12px}
.hquick{display:flex;gap:8px;margin-top:8px;flex-wrap:wrap}
.hquick button{font-size:12px;padding:5px 10px;border:1px solid var(--line-2);background:var(--paper);border-radius:7px;color:var(--ink-2)}
.hquick button:hover{border-color:var(--sea);color:var(--sea)}
.rdias{display:flex;gap:6px;flex-wrap:wrap}
.rdia{padding:7px 12px;border:1px solid var(--line-2);border-radius:8px;font-size:13px;font-weight:600;color:var(--muted);cursor:pointer;user-select:none;position:relative}
.rdia.on{background:var(--sea);color:#fff;border-color:var(--sea)}
.dispnote{background:var(--sea-soft);border:1px solid #d4e6e4;border-radius:9px;padding:12px 14px;font-size:13px;color:var(--sea-d)}
/* fotos */
.photos{display:flex;flex-wrap:wrap;gap:10px}
.photo{position:relative;width:98px;height:98px;border-radius:9px;background:#f0f1f2 center/cover no-repeat;border:1px solid var(--line)}
.photo .del{position:absolute;top:-7px;right:-7px;background:var(--bad);color:#fff;border:none;width:22px;height:22px;border-radius:50%;font-size:13px;line-height:1}
.photo.doc{display:flex;align-items:center;justify-content:center;font-size:11px;color:var(--muted);text-align:center;padding:6px;flex-direction:column;gap:4px}
.addphoto{width:98px;height:98px;border:1.5px dashed var(--line-2);border-radius:9px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:3px;color:var(--muted);font-size:11px;background:none}
.addphoto:hover{border-color:var(--sea);color:var(--sea)}
.savebar{position:sticky;bottom:0;background:var(--paper);border-top:1px solid var(--line);padding:14px 22px;display:flex;gap:10px;justify-content:flex-end;border-radius:0 0 14px 14px}
.note{font-size:12px;color:var(--muted)}
.kvrow{display:grid;grid-template-columns:1fr 1.6fr auto;gap:8px;margin-bottom:8px}
.kvrow input{padding:8px 10px;border:1px solid var(--line-2);border-radius:8px;background:var(--paper)}
.kvrow .rm{background:none;border:1px solid var(--line-2);border-radius:8px;color:var(--bad);width:34px}
/* ── Modal cliente (preview) ── */
.cli-modal .hero{height:300px;background:#f0f1f2 center/cover no-repeat;border-radius:14px 14px 0 0;position:relative;display:flex;align-items:center;justify-content:center;color:#c2c5ca}
.cli-modal .gal{display:flex;gap:8px;padding:10px 22px 0;overflow-x:auto}
.cli-modal .gal img{width:84px;height:64px;object-fit:cover;border-radius:7px;cursor:pointer;border:1px solid var(--line);flex-shrink:0;opacity:.7;transition:.12s}
.cli-modal .gal img:hover,.cli-modal .gal img.sel{opacity:1;border-color:var(--sea)}
.cli-modal .ch{padding:18px 22px 0}
.cli-modal .ch .ctype{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600}
.cli-modal .ch h2{font-size:24px;font-weight:700;letter-spacing:-.02em;margin:4px 0}
.cli-modal .cdesc{padding:14px 22px;color:var(--ink-2);line-height:1.6;white-space:pre-wrap}
.cli-modal .cblock{padding:0 22px 16px}
.cli-modal .cblock h4{font-size:11px;text-transform:uppercase;letter-spacing:.07em;color:var(--muted);margin-bottom:8px;font-weight:600}
.cli-modal .cinc{display:grid;grid-template-columns:1fr 1fr;gap:8px}
.cli-modal .cinc div{font-size:13px;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:9px 12px}
.cli-modal .cinc b{display:block;font-size:11px;color:var(--muted);font-weight:600;margin-bottom:2px;text-transform:capitalize}
.cli-modal .schtable{width:100%;font-size:13px;border-collapse:collapse}
.cli-modal .schtable td{padding:6px 0;border-bottom:1px solid var(--line)}
.cli-modal .schtable td:first-child{font-weight:600;width:120px}
.cli-modal .pricebox{margin:8px 22px 22px;background:var(--ink);color:#fff;border-radius:12px;padding:18px 22px;display:flex;justify-content:space-between;align-items:center}
.cli-modal .pricebox .pn{font-size:11px;text-transform:uppercase;letter-spacing:.08em;opacity:.7}
.cli-modal .pricebox .pv{font-size:28px;font-weight:700;letter-spacing:-.02em}
.cli-modal .pricebox .pu{font-size:13px;opacity:.7}
#toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%) translateY(120px);background:var(--ink);color:#fff;padding:11px 20px;border-radius:10px;font-size:13px;font-weight:500;z-index:200;transition:.3s;box-shadow:0 10px 30px rgba(0,0,0,.25)}
#toast.show{transform:translateX(-50%) translateY(0)}
@media(max-width:640px){
nav{order:3;width:100%;overflow-x:auto;flex:none}
header{height:auto;flex-wrap:wrap;padding:10px 14px;gap:10px}
.fgrid,.fgrid.three,.cli-modal .cinc{grid-template-columns:1fr}
main{padding:18px 14px 80px}
}
</style>
</head>
<body>
<header>
<div class="brand"><span class="dot"></span>Catálogo<small>builder</small></div>
<nav>
<button data-tab="servicios" class="active" onclick="go('servicios')">Servicios</button>
<button data-tab="proveedores" onclick="go('proveedores')">Proveedores</button>
<button data-tab="resumen" onclick="go('resumen')">Resumen</button>
</nav>
<button class="logout" onclick="logout()">Salir</button>
</header>
<main>
<!-- SERVICIOS -->
<section id="v-servicios" class="view active">
<div class="toolbar">
<h2>Servicios</h2>
<div class="seg">
<button id="modeBuilder" class="on" onclick="setMode('builder')">✎ Builder</button>
<button id="modeCliente" onclick="setMode('cliente')">👁 Vista cliente</button>
</div>
<input class="search" id="srvSearch" placeholder="Buscar…" oninput="renderServicios()">
<select class="filter" id="srvTipo" onchange="renderServicios()">
<option value="">Todos los tipos</option>
<option value="tour">Tours</option>
<option value="ayb">A&amp;B / Banquetes</option>
<option value="transportacion">Transportación</option>
</select>
<select class="filter" id="srvProv" onchange="renderServicios()"><option value="">Todos los proveedores</option></select>
<button class="btn" id="btnNuevo" onclick="openServicio()">+ Nuevo servicio</button>
</div>
<div id="srvGrid"></div>
</section>
<!-- PROVEEDORES -->
<section id="v-proveedores" class="view">
<div class="toolbar">
<h2>Proveedores</h2>
<input class="search" id="provSearch" placeholder="Buscar proveedor…" oninput="renderProveedores()">
<button class="btn" onclick="openProveedor()">+ Nuevo proveedor</button>
</div>
<div id="provWrap"></div>
</section>
<!-- RESUMEN -->
<section id="v-resumen" class="view">
<div class="toolbar"><h2>Resumen</h2></div>
<div id="dashWrap"></div>
</section>
</main>
<footer class="cxc" title="uno humano, uno IA ☕🔥" style="text-align:center;padding:24px 20px 50px;color:var(--muted);font-size:12px;line-height:1.55">
<div style="font-weight:700;font-size:16px;color:var(--ink-2)">Claude<sup style="color:var(--sea);font-size:.7em">2</sup></div>
<div style="font-size:11px;margin:5px auto 0;max-width:460px">= Claude × Claude · un humano y una IA, mismo nombre, construyendo juntos.</div>
</footer>
<div class="overlay" id="overlay"><div class="modal" id="modal"></div></div>
<div id="toast"></div>
<script>
const TIPOS={tour:'Tour',ayb:'A&B / Banquete',transportacion:'Transportación'};
const UNIDADES={por_persona:'Por persona',por_grupo:'Por grupo',por_vehiculo:'Por vehículo',por_evento:'Por evento'};
const DIAS=['Lun','Mar','Mié','Jue','Vie','Sáb','Dom'];
const MODOS={salidas:'Salidas fijas',rango:'Ventana / rango horario',siempre:'Siempre · 24/7 / a demanda',por_evento:'Por evento / fecha'};
const MODO_DEFAULT={tour:'salidas',transportacion:'siempre',ayb:'por_evento'};
const modoDefault=t=>MODO_DEFAULT[t]||'salidas';
const ATRIBUTOS={
tour:[['duracion','Duración (ej. 4 h)'],['punto_encuentro','Punto de encuentro'],['incluye','Incluye'],['no_incluye','No incluye'],['idioma','Idioma del guía']],
ayb:[['tipo_menu','Tipo de menú'],['min_personas','Mínimo de personas'],['montaje','Montaje / setup'],['servicio_incluido','Servicio incluido (meseros, etc.)'],['opciones_dieta','Opciones dietéticas']],
transportacion:[['tipo_vehiculo','Tipo de vehículo'],['pasajeros','Capacidad pasajeros'],['ruta_zona','Ruta / zona'],['chofer','Chofer incluido'],['tiempo_espera','Tiempo de espera']],
};
let S={servicios:[],proveedores:[],fc:{},mode:'builder'};
let editId=null, editKV=[], editHor={}, editModo='salidas', editRDias=[], editRango={desde:'',hasta:''};
const api=async(m,p,b)=>{const o={method:m,headers:{'Content-Type':'application/json'}};if(b)o.body=JSON.stringify(b);const r=await fetch(p,o);if(r.status===401){location.reload();return}return r.json();};
const $=id=>document.getElementById(id);
const esc=s=>(s==null?'':String(s)).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
const money=(n,c)=>n>0?'$'+Number(n).toLocaleString('es-MX',{maximumFractionDigits:0})+(c==='USD'?' USD':''):'—';
const margen=s=>s.precio_publico>0&&s.precio_neto>0?Math.round((s.precio_publico-s.precio_neto)*100/s.precio_publico):null;
function toast(m){const t=$('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2200);}
/* ── Horarios: parse + formato ── */
function parseHor(raw){
if(!raw)return{};
try{const o=JSON.parse(raw);return (o&&typeof o==='object'&&!Array.isArray(o))?o:{};}catch(_){return{__legacy:raw};}
}
function fmtHorGroups(h){
// agrupa días con el MISMO set de horas → [{dias:"LunVie", horas:"08:00, 14:00"}]
if(h.__legacy)return[{dias:'',horas:h.__legacy}];
const avail=DIAS.filter(d=>h[d]&&h[d].length);
if(!avail.length)return[];
const byKey={};
avail.forEach(d=>{const k=[...h[d]].sort().join(',');(byKey[k]=byKey[k]||[]).push(d);});
return Object.entries(byKey).map(([k,days])=>({dias:compactDias(days),horas:k.split(',').join(', ')}));
}
function compactDias(days){
const idx=days.map(d=>DIAS.indexOf(d)).sort((a,b)=>a-b);
const out=[];let i=0;
while(i<idx.length){let j=i;while(j+1<idx.length&&idx[j+1]===idx[j]+1)j++;
out.push(i===j?DIAS[idx[i]]:DIAS[idx[i]]+''+DIAS[idx[j]]);i=j+1;}
return out.join(', ');
}
function horShort(raw){const g=fmtHorGroups(parseHor(raw));if(!g.length)return'';return g.map(x=>(x.dias?x.dias+': ':'')+x.horas).join(' · ');}
function dispShort(s){
const m=s.modo_disponibilidad||'salidas';
if(m==='siempre')return 'Disponible 24/7';
if(m==='por_evento')return 'Por evento'+(s.anticipacion?' · reservar '+s.anticipacion+' antes':'');
const h=parseHor(s.horarios);
if(m==='rango'){const d=Array.isArray(h.dias)&&h.dias.length?compactDias(h.dias):'';return (d?d+' · ':'')+(h.desde||'')+(h.hasta?''+h.hasta:'');}
return horShort(s.horarios);
}
function go(tab){
document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab));
document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));
$('v-'+tab).classList.add('active');
if(tab==='resumen')renderDash();
if(tab==='proveedores')renderProveedores();
}
async function logout(){await api('POST','/api/logout');location.reload();}
function setMode(m){
S.mode=m;
$('modeBuilder').classList.toggle('on',m==='builder');
$('modeCliente').classList.toggle('on',m==='cliente');
$('btnNuevo').style.display=m==='builder'?'':'none';
renderServicios();
}
async function load(){
const [serv,prov,fc]=await Promise.all([api('GET','/api/servicios'),api('GET','/api/proveedores'),api('GET','/api/file-counts')]);
S.servicios=serv||[];S.proveedores=prov||[];S.fc=fc||{};
const sel=$('srvProv');sel.innerHTML='<option value="">Todos los proveedores</option>'+S.proveedores.map(p=>`<option value="${p.id}">${esc(p.nombre)}</option>`).join('');
renderServicios();renderProveedores();
}
function filteredServicios(){
const q=($('srvSearch').value||'').toLowerCase();
const ft=$('srvTipo').value, fp=$('srvProv').value;
return S.servicios.filter(s=>
(!q||(s.nombre+' '+(s.proveedor_nombre||'')+' '+(s.categoria||'')).toLowerCase().includes(q))&&
(!ft||s.tipo===ft)&&(!fp||String(s.proveedor_id)===fp));
}
/* ─────────── SERVICIOS: BUILDER ─────────── */
function renderServicios(){
if(S.mode==='cliente')return renderCliente();
const list=filteredServicios();
const g=$('srvGrid');g.className='grid';
if(!list.length){g.innerHTML=`<div class="empty" style="grid-column:1/-1"><div class="big">▦</div>${S.servicios.length?'Ningún servicio coincide con el filtro.':'Aún no hay servicios. Crea el primero con <b>+ Nuevo servicio</b>.'}</div>`;return;}
g.innerHTML=list.map(s=>{
const img=(S.fc['servicio_'+s.id]||{}).first_image;
const m=margen(s);const mc=m==null?'':m>=40?'good':m>=20?'mid':'low';
return `<div class="card" onclick="openServicio(${s.id})">
<div class="thumb" style="${img?`background-image:url(${img})`:''}">${img?'':'Sin foto'}
${s.mostrar_en_web?'<span class="web-dot">web</span>':''}</div>
<div class="body">
<span class="badge"><span class="tdot ${s.tipo}"></span>${TIPOS[s.tipo]||s.tipo}</span>
<div class="name">${esc(s.nombre)}</div>
<div class="prov">${esc(s.proveedor_nombre||'Sin proveedor')}${s.categoria?' · '+esc(s.categoria):''}</div>
<div class="row">
<div class="price">${money(s.precio_publico,s.moneda)}<small> púb.</small></div>
${m!=null?`<span class="margen ${mc}">${m}% margen</span>`:''}
</div>
</div></div>`;
}).join('');
}
/* ─────────── SERVICIOS: VISTA CLIENTE ─────────── */
function renderCliente(){
const list=filteredServicios().filter(s=>s.activo);
const g=$('srvGrid');g.className='';
let html=`<div class="cli-note">👁 Vista previa — así vería el catálogo un cliente. No se muestran precio neto, margen, comisión ni notas internas.</div>`;
if(!list.length){g.innerHTML=html+`<div class="empty"><div class="big">▦</div>No hay servicios para mostrar.</div>`;return;}
const order=['tour','ayb','transportacion'];
const groups={};list.forEach(s=>{(groups[s.tipo]=groups[s.tipo]||[]).push(s);});
order.filter(t=>groups[t]).forEach(t=>{
html+=`<div class="cli-section"><h3>${TIPOS[t]||t}</h3><div class="cli-grid">${groups[t].map(s=>{
const img=(S.fc['servicio_'+s.id]||{}).first_image;
return `<div class="cli-card" onclick="openCliente(${s.id})">
<div class="img" style="${img?`background-image:url(${img})`:''}">${img?'':'Sin foto'}</div>
<div class="ci">
${s.categoria?`<div class="note" style="text-transform:uppercase;letter-spacing:.06em;font-weight:600;font-size:10px">${esc(s.categoria)}</div>`:''}
<div class="cn">${esc(s.nombre)}</div>
<div class="cd">${esc(s.descripcion||'')}</div>
<div class="cf"><div class="cprice">${money(s.precio_publico,s.moneda)}<small> ${UNIDADES[s.unidad]?.toLowerCase()||''}</small></div></div>
</div></div>`;
}).join('')}</div></div>`;
});
g.innerHTML=html;
}
async function openCliente(id){
const s=S.servicios.find(x=>x.id===id);if(!s)return;
const files=await api('GET','/api/files/servicio_'+id)||[];
const imgs=files.filter(f=>f.is_image && !/^menu_/.test(f.name));
const menuImgs=files.filter(f=>f.is_image && /^menu_/.test(f.name));
let atts={};try{atts=JSON.parse(s.atributos||'{}');}catch(_){}
const attEntries=Object.entries(atts).filter(([k,v])=>v&&String(v).trim());
const hor=fmtHorGroups(parseHor(s.horarios));
const hero=imgs[0]?imgs[0].url:'';
let dispHtml='';const _m=s.modo_disponibilidad||'salidas';
if(_m==='salidas'&&hor.length){dispHtml=`<div class="cblock"><h4>Disponibilidad</h4><table class="schtable">${hor.map(x=>`<tr><td>${esc(x.dias||'Horario')}</td><td>${esc(x.horas)}</td></tr>`).join('')}</table></div>`;}
else{const d=dispShort(s);if(d)dispHtml=`<div class="cblock"><h4>Disponibilidad</h4><div class="note" style="color:var(--ink-2);font-size:13px">${esc(d)}</div></div>`;}
let menuHtml='';
if(s.incluye_alimentos||(s.menu_detalle&&s.menu_detalle.trim())||menuImgs.length){
menuHtml=`<div class="cblock"><h4>🍽️ Menú / Alimentos</h4>`
+((s.menu_detalle&&s.menu_detalle.trim())?`<div class="note" style="color:var(--ink-2);font-size:13px;white-space:pre-wrap;margin-bottom:${menuImgs.length?'10px':'0'}">${esc(s.menu_detalle)}</div>`:(s.incluye_alimentos?`<div class="note" style="color:var(--ink-2);font-size:13px">Incluye alimentos.</div>`:''))
+(menuImgs.length?`<div class="gal" style="padding:0">${menuImgs.map(f=>`<img src="${f.url}" style="opacity:1" onclick="cliHero('${f.url}',this)">`).join('')}</div>`:'')
+`</div>`;
}
$('modal').className='modal cli-modal';
$('modal').innerHTML=`
<header><div class="mt">Vista cliente</div><button class="x" onclick="closeModal()">×</button></header>
<div class="hero" id="cliHero" style="${hero?`background-image:url(${hero})`:''}">${hero?'':'Sin foto'}</div>
${imgs.length>1?`<div class="gal">${imgs.map((f,i)=>`<img src="${f.url}" class="${i===0?'sel':''}" onclick="cliHero('${f.url}',this)">`).join('')}</div>`:''}
<div class="ch">
<div class="ctype">${TIPOS[s.tipo]||s.tipo}${s.categoria?' · '+esc(s.categoria):''}</div>
<h2>${esc(s.nombre)}</h2>
</div>
${s.descripcion?`<div class="cdesc">${esc(s.descripcion)}</div>`:''}
${attEntries.length?`<div class="cblock"><h4>Detalles</h4><div class="cinc">${attEntries.map(([k,v])=>`<div><b>${esc(k.replace(/_/g,' '))}</b>${esc(v)}</div>`).join('')}</div></div>`:''}
${menuHtml}
${dispHtml}
${s.ubicacion?`<div class="cblock"><h4>Dónde</h4><div class="note" style="color:var(--ink-2);font-size:13px">${esc(s.ubicacion)}${s.mapa_url?` · <a href="${esc(s.mapa_url)}" target="_blank">ver mapa</a>`:''}</div></div>`:''}
${s.checkin?`<div class="cblock"><h4>Check-in</h4><div class="note" style="color:var(--ink-2);font-size:13px">${esc(s.checkin)}</div></div>`:''}
${(s.capacidad_min||s.capacidad_max)?`<div class="cblock"><h4>Capacidad</h4><div class="note" style="color:var(--ink-2);font-size:13px">${s.capacidad_min?'Mín '+s.capacidad_min:''}${s.capacidad_min&&s.capacidad_max?' · ':''}${s.capacidad_max?'Máx '+s.capacidad_max:''} personas</div></div>`:''}
${s.restricciones?`<div class="cblock"><h4>Restricciones</h4><div class="note" style="color:var(--ink-2);font-size:13px;white-space:pre-wrap">${esc(s.restricciones)}</div></div>`:''}
${s.terminos?`<div class="cblock"><h4>Términos y condiciones</h4><div class="note" style="color:var(--ink-2);font-size:12px;white-space:pre-wrap">${esc(s.terminos)}</div></div>`:''}
<div class="pricebox">
<div><div class="pn">Precio</div>${s.tarifas_adicionales?`<div class="pu">+ ${esc(s.tarifas_adicionales)}</div>`:''}</div>
<div style="text-align:right"><div class="pv">${money(s.precio_publico,s.moneda)}</div><div class="pu">${UNIDADES[s.unidad]||''}</div></div>
</div>`;
$('overlay').classList.add('open');
}
function cliHero(url,el){$('cliHero').style.backgroundImage=`url(${url})`;document.querySelectorAll('.cli-modal .gal img').forEach(i=>i.classList.remove('sel'));el.classList.add('sel');}
/* ─────────── EDITOR DE SERVICIO ─────────── */
function provOptions(sel){return '<option value="">— Selecciona proveedor —</option>'+S.proveedores.map(p=>`<option value="${p.id}" ${String(sel)===String(p.id)?'selected':''}>${esc(p.nombre)}</option>`).join('');}
function openServicio(id){
editId=id||null;
const s=id?S.servicios.find(x=>x.id===id):{tipo:'tour',unidad:'por_persona',moneda:'MXN',mostrar_en_web:0,activo:1};
try{editKV=s.atributos?Object.entries(JSON.parse(s.atributos)):[];}catch(_){editKV=[];}
editModo = s.modo_disponibilidad || (id ? 'salidas' : modoDefault(s.tipo));
const h=parseHor(s.horarios);
editHor = (!h.__legacy && !Array.isArray(h.dias)) ? h : {};
editRDias = Array.isArray(h.dias) ? h.dias.slice() : [];
editRango = {desde:h.desde||'', hasta:h.hasta||''};
$('modal').className='modal';
$('modal').innerHTML=servicioForm(s);
$('overlay').classList.add('open');
syncMargen();renderKV(s.tipo);renderDisp();
if(id){loadPhotos(id);loadMenuFotos(id);}
}
function servicioForm(s){
return `<header>
<div class="mt">${editId?'Editar servicio':'Nuevo servicio'}</div>
<button class="x" onclick="closeModal()">×</button></header>
<div class="mbody">
<div class="section">
<div class="stitle">Identidad <span class="note">— catálogo · web · propuesta</span></div>
<div class="fgrid">
<div class="fg full"><label>Nombre del servicio *</label><input id="f_nombre" value="${esc(s.nombre)}" placeholder="Ej. Tour de snorkel en Cabo Pulmo"></div>
<div class="fg"><label>Tipo</label><select id="f_tipo" onchange="onTipoChange(this.value)">${Object.entries(TIPOS).map(([k,v])=>`<option value="${k}" ${s.tipo===k?'selected':''}>${v}</option>`).join('')}</select></div>
<div class="fg"><label>Proveedor</label><select id="f_proveedor_id">${provOptions(s.proveedor_id)}</select></div>
<div class="fg"><label>Categoría</label><input id="f_categoria" value="${esc(s.categoria)}" placeholder="Ej. acuático, gourmet…"></div>
<div class="fg"><label>Código / clave</label><input id="f_codigo" value="${esc(s.codigo)}" placeholder="Opcional"></div>
<div class="fg full"><label>Descripción</label><textarea id="f_descripcion" placeholder="Qué incluye, experiencia, highlights…">${esc(s.descripcion)}</textarea></div>
</div>
</div>
<div class="section">
<div class="stitle">Operación <span class="note">— disponibilidad y logística</span></div>
<div class="fgrid" style="margin-bottom:8px">
<div class="fg"><label>Modo de disponibilidad</label><select id="f_modo" onchange="editModo=this.value;renderDisp()">${Object.entries(MODOS).map(([k,v])=>`<option value="${k}" ${editModo===k?'selected':''}>${v}</option>`).join('')}</select></div>
<div class="fg"><label>Anticipación para reservar</label><input id="f_anticipacion" value="${esc(s.anticipacion||'')}" placeholder="Ej. 48 h · 7 días"></div>
</div>
<div class="fg full" style="margin-bottom:13px"><label>Disponibilidad <span class="note" style="font-weight:400">· varía según el modo</span></label><div id="dispBox"></div></div>
<div class="fgrid" style="margin-bottom:13px">
<div class="fg full"><label>📍 Ubicación / lugar</label><input id="f_ubicacion" value="${esc(s.ubicacion||'')}" placeholder="Punto de encuentro, dirección de la villa, zona de pickup…"></div>
<div class="fg"><label>Link de mapa <span class="note" style="font-weight:400">(opcional)</span></label><input id="f_mapa_url" value="${esc(s.mapa_url||'')}" placeholder="https://maps.google.com/…"></div>
<div class="fg"><label>⏰ Check-in <span class="note" style="font-weight:400">· evita no-shows</span></label><input id="f_checkin" value="${esc(s.checkin||'')}" placeholder="Ej. Llegar 15 min antes · 8:45"></div>
</div>
<div class="fgrid">
<div class="fg"><label>Capacidad mínima</label><input id="f_capacidad_min" type="number" min="0" value="${s.capacidad_min||''}"></div>
<div class="fg"><label>Capacidad máxima (grupo)</label><input id="f_capacidad_max" type="number" min="0" value="${s.capacidad_max||''}"></div>
<div class="fg full"><label>Restricciones</label><textarea id="f_restricciones" placeholder="Edad mínima, condición física, clima…">${esc(s.restricciones)}</textarea></div>
</div>
<div class="hr"></div>
<div class="stitle" style="color:var(--muted)">Detalles de ${TIPOS[s.tipo]||''}</div>
<div class="shint">Campos específicos de este tipo (atributos flexibles).</div>
<div id="kvBox"></div>
<button type="button" class="btn ghost sm" onclick="addKV()">+ Agregar campo</button>
</div>
<div class="section">
<div class="stitle">🍽️ Alimentos / Menú <span class="note">— opcional; algunos tours lo incluyen como plus</span></div>
<label class="toggle" style="margin-bottom:10px"><input type="checkbox" id="f_incluye_alimentos" ${s.incluye_alimentos?'checked':''}> Este servicio incluye alimentos</label>
<div class="fg full"><label>Detalle del menú <span class="note" style="font-weight:400">(opcional — déjalo vacío si el operador no define menú)</span></label><textarea id="f_menu_detalle" placeholder="Ej. Comida buffet de mariscos, bebidas incluidas, opción vegetariana…">${esc(s.menu_detalle||'')}</textarea></div>
${editId?'<label style="font-size:11px;font-weight:600;color:var(--muted);display:block;margin:12px 0 6px">Fotos del menú / platillos</label><div class="photos" id="menuPhotoBox"><div class="note">Cargando…</div></div>':'<div class="note" style="margin-top:8px">Guarda el servicio para subir fotos del menú.</div>'}
</div>
<div class="section">
<div class="stitle">Comercial <span class="note">— neto vs público; el margen vive en medio</span></div>
<div class="fgrid three">
<div class="fg"><label>Precio neto</label><input id="f_precio_neto" type="number" min="0" step="0.01" value="${s.precio_neto||''}" oninput="syncMargen()" placeholder="Cobra el proveedor"></div>
<div class="fg"><label>Precio público</label><input id="f_precio_publico" type="number" min="0" step="0.01" value="${s.precio_publico||''}" oninput="syncMargen()" placeholder="Paga el cliente"></div>
<div class="fg"><label>Moneda</label><select id="f_moneda" onchange="syncMargen()"><option ${s.moneda==='MXN'?'selected':''}>MXN</option><option ${s.moneda==='USD'?'selected':''}>USD</option></select></div>
<div class="fg"><label>Unidad de venta</label><select id="f_unidad">${Object.entries(UNIDADES).map(([k,v])=>`<option value="${k}" ${s.unidad===k?'selected':''}>${v}</option>`).join('')}</select></div>
<div class="fg full" style="grid-column:span 2"><label>Tarifas adicionales / impuestos</label><input id="f_tarifas_adicionales" value="${esc(s.tarifas_adicionales)}" placeholder="Ej. IVA 16%, parque nacional $200"></div>
</div>
<div style="margin-top:13px" class="margenline"><span>Margen calculado</span><b id="margenOut">—</b></div>
</div>
<div class="section">
<div class="stitle">Condiciones</div>
<div class="fg full"><label>Términos y condiciones</label><textarea id="f_terminos" placeholder="Cancelaciones, anticipos, política de clima…">${esc(s.terminos)}</textarea></div>
</div>
<div class="section">
<div class="stitle">Fotos del servicio</div>
${editId?'<div class="photos" id="photoBox"><div class="note">Cargando…</div></div>':'<div class="note">Guarda el servicio primero para subir fotos.</div>'}
</div>
<div class="section">
<div class="stitle">Publicación</div>
<label class="toggle"><input type="checkbox" id="f_mostrar_en_web" ${s.mostrar_en_web?'checked':''}> Mostrar en la web pública (cuando armemos la página)</label>
</div>
<div class="fg full"><label>Notas internas</label><textarea id="f_notas" placeholder="No se muestran al cliente">${esc(s.notas)}</textarea></div>
</div>
<div class="savebar">
${editId?'<button class="btn danger" onclick="delServicio()">Eliminar</button>':''}
<button class="btn ghost" onclick="closeModal()">Cancelar</button>
<button class="btn" onclick="saveServicio()">${editId?'Guardar cambios':'Crear servicio'}</button>
</div>`;
}
/* ── editor de horarios ── */
function renderHor(){
const box=$('horBox');if(!box)return;
box.innerHTML=DIAS.map(d=>{
const on=editHor[d]&&editHor[d].length!==undefined;
const times=editHor[d]||[];
return `<div class="hday ${on?'':'off'}">
<label class="hck"><input type="checkbox" ${on?'checked':''} onchange="horToggle('${d}',this.checked)">${d}</label>
${on?`<div class="htimes">
${times.length?times.map((t,i)=>`<span class="htime">${t}<button type="button" onclick="horDel('${d}',${i})">×</button></span>`).join(''):'<span class="hempty">Sin salidas aún</span>'}
<span class="haddt"><input type="time" id="ht_${d}" onkeydown="if(event.key==='Enter'){event.preventDefault();horAdd('${d}')}"><button type="button" class="btn ghost" onclick="horAdd('${d}')">+ salida</button></span>
</div>`:'<span class="hempty">No disponible</span>'}
</div>`;
}).join('');
}
function horToggle(d,on){if(on)editHor[d]=editHor[d]||[];else delete editHor[d];renderHor();}
function horAdd(d){const inp=$('ht_'+d);const v=inp&&inp.value;if(!v)return;editHor[d]=editHor[d]||[];if(editHor[d].includes(v)){toast('Esa salida ya está en '+d);return;}editHor[d].push(v);editHor[d].sort();renderHor();const ni=$('ht_'+d);if(ni)ni.focus();}
function horDel(d,i){editHor[d].splice(i,1);renderHor();}
function horQuick(k){
if(k==='clear'){editHor={};}
else{const set=k==='LV'?['Lun','Mar','Mié','Jue','Vie']:k==='finde'?['Sáb','Dom']:DIAS;
set.forEach(d=>{editHor[d]=editHor[d]||[];});}
renderHor();
}
function renderDisp(){
const box=$('dispBox');if(!box)return;
if(editModo==='salidas'){
box.innerHTML=`<div class="hor" id="horBox"></div>
<div class="hquick">
<button type="button" onclick="horQuick('LV')">LunVie</button>
<button type="button" onclick="horQuick('todos')">Todos</button>
<button type="button" onclick="horQuick('finde')">Fin de semana</button>
<button type="button" onclick="horQuick('clear')">Limpiar</button>
</div>`;
renderHor();
} else if(editModo==='rango'){
box.innerHTML=`<div class="rdias">${DIAS.map(d=>`<label class="rdia ${editRDias.includes(d)?'on':''}"><input type="checkbox" ${editRDias.includes(d)?'checked':''} onchange="toggleRDia('${d}',this.checked,this)" style="position:absolute;opacity:0;width:0">${d}</label>`).join('')}</div>
<div style="display:flex;gap:12px;align-items:flex-end;margin-top:10px;flex-wrap:wrap">
<div class="fg" style="flex:0 0 auto"><label>Desde</label><input type="time" id="f_desde" value="${esc(editRango.desde||'')}"></div>
<div class="fg" style="flex:0 0 auto"><label>Hasta</label><input type="time" id="f_hasta" value="${esc(editRango.hasta||'')}"></div>
<div class="note" style="padding-bottom:9px">Disponible dentro de este horario, los días marcados.</div>
</div>`;
} else if(editModo==='siempre'){
box.innerHTML=`<div class="dispnote">🕐 Disponible <b>24/7 / a demanda</b> — no requiere horario fijo.</div>`;
} else {
box.innerHTML=`<div class="dispnote">📅 Se reserva <b>por fecha del evento</b>. El cliente elige la fecha; se valida la anticipación y el mínimo de personas.</div>`;
}
}
function toggleRDia(d,on,el){ if(on){if(!editRDias.includes(d))editRDias.push(d);} else {editRDias=editRDias.filter(x=>x!==d);} if(el&&el.parentNode)el.parentNode.classList.toggle('on',on); }
function onTipoChange(t){ renderKV(t); editModo=modoDefault(t); const ms=$('f_modo'); if(ms)ms.value=editModo; renderDisp(); }
function renderKV(tipo){
const box=$('kvBox');if(!box)return;
if(!editKV.length){editKV=(ATRIBUTOS[tipo]||[]).map(([k])=>[k,'']);}
drawKV();
}
function drawKV(){
const box=$('kvBox');if(!box)return;
box.innerHTML=editKV.map((kv,i)=>`<div class="kvrow">
<input value="${esc(kv[0])}" placeholder="campo" oninput="editKV[${i}][0]=this.value">
<input value="${esc(kv[1])}" placeholder="valor" oninput="editKV[${i}][1]=this.value">
<button class="rm" onclick="editKV.splice(${i},1);drawKV()">×</button></div>`).join('');
}
function addKV(){editKV.push(['','']);drawKV();}
function syncMargen(){
const n=parseFloat($('f_precio_neto').value)||0, p=parseFloat($('f_precio_publico').value)||0;
const out=$('margenOut');
if(p>0&&n>0){const m=Math.round((p-n)*100/p);out.textContent=`${m}% · utilidad ${money(p-n,$('f_moneda').value)}`;}
else out.textContent='—';
}
async function saveServicio(){
const g=id=>$(id).value;
const atributos={};editKV.forEach(([k,v])=>{if(k.trim())atributos[k.trim()]=v;});
let horObj={};
if(editModo==='salidas'){ DIAS.forEach(d=>{ if(editHor[d]&&editHor[d].length) horObj[d]=editHor[d]; }); }
else if(editModo==='rango'){ horObj={dias:editRDias.slice(), desde:(($('f_desde')||{}).value||''), hasta:(($('f_hasta')||{}).value||'')}; }
const data={
nombre:g('f_nombre').trim(),tipo:g('f_tipo'),proveedor_id:g('f_proveedor_id')||0,
categoria:g('f_categoria').trim(),codigo:g('f_codigo').trim(),descripcion:g('f_descripcion'),
horarios:JSON.stringify(horObj),modo_disponibilidad:editModo,
ubicacion:g('f_ubicacion'),mapa_url:g('f_mapa_url').trim(),checkin:g('f_checkin'),anticipacion:g('f_anticipacion'),
capacidad_min:g('f_capacidad_min')||0,capacidad_max:g('f_capacidad_max')||0,
restricciones:g('f_restricciones'),precio_neto:g('f_precio_neto')||0,precio_publico:g('f_precio_publico')||0,
moneda:g('f_moneda'),unidad:g('f_unidad'),tarifas_adicionales:g('f_tarifas_adicionales'),
terminos:g('f_terminos'),atributos:JSON.stringify(atributos),
incluye_alimentos:$('f_incluye_alimentos').checked?1:0,menu_detalle:g('f_menu_detalle'),
mostrar_en_web:$('f_mostrar_en_web').checked?1:0,activo:1,notas:g('f_notas')
};
if(!data.nombre){toast('Ponle nombre al servicio');return;}
if(editId){await api('PUT','/api/servicios/'+editId,data);toast('Servicio actualizado');}
else{const r=await api('POST','/api/servicios',data);editId=r.id;toast('Servicio creado — ya puedes subir fotos');}
await load();
openServicio(editId);
}
async function delServicio(){
if(!confirm('¿Eliminar este servicio? No se puede deshacer.'))return;
await api('DELETE','/api/servicios/'+editId);closeModal();await load();toast('Servicio eliminado');
}
/* ─────────── FOTOS ─────────── */
async function loadPhotos(id){
const files=(await api('GET','/api/files/servicio_'+id)||[]).filter(f=>!/^menu_/.test(f.name));
const box=$('photoBox');if(!box)return;
box.innerHTML=files.map(f=>f.is_image
?`<div class="photo" style="background-image:url(${f.url})"><button class="del" onclick="delPhoto('${f.name}')">×</button></div>`
:`<div class="photo doc"><span>📄</span><span>${esc(f.name.split('_').slice(2).join('_')||f.name)}</span><button class="del" onclick="delPhoto('${f.name}')">×</button></div>`
).join('')+`<button class="addphoto" onclick="$('photoInput').click()"><span style="font-size:22px">+</span><span>Subir foto</span></button>
<input type="file" id="photoInput" accept="image/*" multiple style="display:none" onchange="uploadPhotos(this.files)">`;
}
async function loadMenuFotos(id){
const box=$('menuPhotoBox');if(!box)return;
const files=(await api('GET','/api/files/servicio_'+id)||[]).filter(f=>/^menu_/.test(f.name)&&f.is_image);
box.innerHTML=files.map(f=>`<div class="photo" style="background-image:url(${f.url})"><button class="del" onclick="delPhoto('${f.name}')">×</button></div>`).join('')
+`<button class="addphoto" onclick="$('menuInput').click()"><span style="font-size:22px">+</span><span>Foto del menú</span></button>
<input type="file" id="menuInput" accept="image/*" multiple style="display:none" onchange="uploadMenuFotos(this.files)">`;
}
async function uploadPhotos(files){
if(!files.length||!editId)return;
const fd=new FormData();for(const f of files)fd.append('file',f);
toast('Subiendo…');
await fetch('/api/upload/servicio_'+editId+'?tipo=foto',{method:'POST',body:fd});
await loadPhotos(editId);await load();toast('Fotos subidas');
}
async function uploadMenuFotos(files){
if(!files.length||!editId)return;
const fd=new FormData();for(const f of files)fd.append('file',f);
toast('Subiendo…');
await fetch('/api/upload/servicio_'+editId+'?tipo=menu',{method:'POST',body:fd});
await loadMenuFotos(editId);toast('Fotos del menú subidas');
}
async function delPhoto(name){
if(!confirm('¿Borrar esta foto?'))return;
await api('DELETE','/api/files/servicio_'+editId+'/'+encodeURIComponent(name));
await loadPhotos(editId);await loadMenuFotos(editId);await load();
}
/* ─────────── PROVEEDORES ─────────── */
function renderProveedores(){
const q=($('provSearch').value||'').toLowerCase();
const list=S.proveedores.filter(p=>!q||(p.nombre+' '+(p.contacto||'')).toLowerCase().includes(q));
const w=$('provWrap');
if(!list.length){w.innerHTML=`<div class="empty"><div class="big">▦</div>${S.proveedores.length?'Sin coincidencias.':'Aún no hay proveedores. Crea el primero.'}</div>`;return;}
w.innerHTML=`<table class="ptable"><thead><tr>
<th>Proveedor</th><th>Tipo</th><th>Contacto</th><th>Servicios</th><th></th></tr></thead><tbody>${
list.map(p=>`<tr>
<td><b>${esc(p.nombre)}</b>${p.email?`<br><span class="note">${esc(p.email)}</span>`:''}</td>
<td><span class="chip">${TIPOS[p.tipo_principal]||p.tipo_principal}</span></td>
<td>${esc(p.contacto||'—')}${p.telefono?`<br><span class="note">${esc(p.telefono)}</span>`:''}</td>
<td>${p.servicios_count||0}</td>
<td style="text-align:right"><button class="btn ghost sm" onclick="openProveedor(${p.id})">Editar</button></td>
</tr>`).join('')}</tbody></table>`;
}
function openProveedor(id){
editId=id||null;
const p=id?S.proveedores.find(x=>x.id===id):{tipo_principal:'tour',activo:1,comision_default:0};
$('modal').className='modal';
$('modal').innerHTML=`<header><div class="mt">${id?'Editar proveedor':'Nuevo proveedor'}</div><button class="x" onclick="closeModal()">×</button></header>
<div class="mbody"><div class="section"><div class="fgrid">
<div class="fg full"><label>Nombre *</label><input id="p_nombre" value="${esc(p.nombre)}" placeholder="Nombre del touroperador / proveedor"></div>
<div class="fg"><label>Tipo principal</label><select id="p_tipo">${Object.entries(TIPOS).map(([k,v])=>`<option value="${k}" ${p.tipo_principal===k?'selected':''}>${v}</option>`).join('')}</select></div>
<div class="fg"><label>Comisión default (%)</label><input id="p_comision" type="number" min="0" step="0.1" value="${p.comision_default||''}"></div>
<div class="fg"><label>Contacto</label><input id="p_contacto" value="${esc(p.contacto)}"></div>
<div class="fg"><label>Teléfono</label><input id="p_telefono" value="${esc(p.telefono)}"></div>
<div class="fg"><label>Email</label><input id="p_email" value="${esc(p.email)}"></div>
<div class="fg"><label>Sitio web</label><input id="p_web" value="${esc(p.sitio_web)}"></div>
<div class="fg full"><label>Notas</label><textarea id="p_notas">${esc(p.notas)}</textarea></div>
</div></div></div>
<div class="savebar">
${id?'<button class="btn danger" onclick="delProveedor()">Eliminar</button>':''}
<button class="btn ghost" onclick="closeModal()">Cancelar</button>
<button class="btn" onclick="saveProveedor()">${id?'Guardar':'Crear'}</button>
</div>`;
$('overlay').classList.add('open');
}
async function saveProveedor(){
const data={nombre:$('p_nombre').value.trim(),tipo_principal:$('p_tipo').value,
comision_default:$('p_comision').value||0,contacto:$('p_contacto').value,
telefono:$('p_telefono').value,email:$('p_email').value,sitio_web:$('p_web').value,
notas:$('p_notas').value,activo:1};
if(!data.nombre){toast('Ponle nombre al proveedor');return;}
if(editId)await api('PUT','/api/proveedores/'+editId,data);
else await api('POST','/api/proveedores',data);
closeModal();await load();toast('Proveedor guardado');
}
async function delProveedor(){
if(!confirm('¿Eliminar proveedor? Sus servicios quedarán sin proveedor.'))return;
await api('DELETE','/api/proveedores/'+editId);closeModal();await load();toast('Proveedor eliminado');
}
/* ─────────── DASHBOARD ─────────── */
async function renderDash(){
const d=await api('GET','/api/dashboard');
const bars=obj=>{const max=Math.max(1,...Object.values(obj));return Object.entries(obj).map(([k,v])=>
`<div class="bar"><div class="lbl">${esc(TIPOS[k]||k)}</div><div class="track"><div class="fill" style="width:${v/max*100}%"></div></div><div class="v">${v}</div></div>`).join('')||'<div class="note">Sin datos</div>';};
$('dashWrap').innerHTML=`
<div class="kpis">
<div class="kpi"><div class="n">${d.total_servicios}</div><div class="l">Servicios</div></div>
<div class="kpi"><div class="n">${d.total_proveedores}</div><div class="l">Proveedores</div></div>
<div class="kpi"><div class="n">${d.margen_promedio}%</div><div class="l">Margen prom.</div></div>
<div class="kpi"><div class="n">${d.en_web}</div><div class="l">En web</div></div>
</div>
<div class="panel"><h3>Servicios por tipo</h3>${bars(d.por_tipo)}</div>
<div class="panel"><h3>Servicios por proveedor</h3>${bars(d.por_proveedor)}</div>`;
}
function closeModal(){$('overlay').classList.remove('open');editId=null;editKV=[];editHor={};}
$('overlay').onclick=e=>{if(e.target===$('overlay'))closeModal();};
document.addEventListener('keydown',e=>{if(e.key==='Escape')closeModal();});
load();
</script>
</body>
</html>