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>
This commit is contained in:
768
index.html
Normal file
768
index.html
Normal file
@@ -0,0 +1,768 @@
|
||||
<!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&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=>({'&':'&','<':'<','>':'>','"':'"'}[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:"Lun–Vie", 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')">Lun–Vie</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>
|
||||
Reference in New Issue
Block a user