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:
consultoria-as
2026-06-09 21:00:50 -07:00
commit 38e9e4b91c
8 changed files with 1814 additions and 0 deletions

20
.gitignore vendored Normal file
View File

@@ -0,0 +1,20 @@
# ── Secretos y datos (NUNCA subir) ──
# clave de firma de sesiones (login)
secret.key
# base de datos del catálogo
catalogo.db
catalogo.db-wal
catalogo.db-shm
# respaldos
*.bak_*
# ── Archivos generados / temporales ──
uploads/
backups/
__pycache__/
*.pyc
*.log
# ── Sistema ──
.DS_Store
Thumbs.db

66
API.md Normal file
View File

@@ -0,0 +1,66 @@
# API REST — Catálogo de Servicios
Servidor: `server.py` (Python stdlib `http.server`), puerto **4403**. Respuestas JSON.
Autenticación por **cookie de sesión** firmada (HMAC). Sin sesión: `/api/*` y `/uploads/*` → bloqueados; las páginas muestran login.
---
## Autenticación
| Método | Ruta | Descripción |
|---|---|---|
| GET | `/api/needs-setup` | ¿Primera vez? (pública) |
| POST | `/api/setup` | Crea la cuenta admin inicial. Body `{username, password, nombre}` |
| POST | `/api/login` | Inicia sesión. Body `{username, password}` → cookie |
| POST | `/api/logout` | Cierra sesión |
Contraseñas: **PBKDF2-SHA256** + salt. Sesión firmada con HMAC (clave en `secret.key`).
---
## CRUD genérico
Un handler sirve todas las tablas vía el dict `TABLES` en `server.py`:
| Método | Ruta | Descripción |
|---|---|---|
| GET | `/api/{tabla}` | Lista registros |
| POST | `/api/{tabla}` | Crea (body = campos) |
| PUT | `/api/{tabla}/{id}` | Actualiza |
| DELETE | `/api/{tabla}/{id}` | Elimina |
Tablas: `proveedores`, `servicios`.
El dict define `fields`, `int_fields`, `float_fields`, `nullable_fields` por tabla (casteo automático).
---
## Endpoints especiales
| Método | Ruta | Descripción |
|---|---|---|
| GET | `/api/servicios` | Servicios con `proveedor_nombre` (join) |
| GET | `/api/proveedores` | Proveedores con `servicios_count` |
| GET | `/api/dashboard` | KPIs (conteos, totales) |
| GET | `/api/file-counts` | `{entidad: {count, first_image}}`. `first_image` prefiere `foto_`, excluye docs y `menu` |
| GET | `/api/files/{entidad}` | Lista archivos de un servicio |
| POST | `/api/upload/{entidad}?tipo=foto&label=...` | Sube archivo(s). `tipo`=`foto`/`menu`/`doc` define el prefijo |
| DELETE | `/api/files/{entidad}/{nombre}` | Elimina un archivo |
`{entidad}` = `servicio_{id}`.
---
## Servir contenido
| Ruta | Sirve |
|---|---|
| `/` o `/index.html` | La SPA (builder + vista cliente) |
| `/uploads/servicio_{id}/{archivo}` | Archivos del servicio (requiere sesión) |
---
## Notas
- Sin dependencias externas: `http.server`, `sqlite3`, `json`, `urllib`, `hashlib`, `hmac` (stdlib).
- CORS y preflight `OPTIONS` habilitados.
- Para agregar una tabla: `CREATE TABLE` en `init_db()` + entrada en `TABLES` → CRUD automático.

97
CLAUDE.md Normal file
View File

@@ -0,0 +1,97 @@
# Catálogo (borrador) — Contexto para Claude
> Builder de catálogo multi-proveedor de **servicios** (no productos físicos).
> Hereda la filosofía del Art4Hotel Hub. Proyecto independiente, no toca el Hub.
## Qué es
Sistema interno para armar la base de datos de un catálogo de servicios de varios
**touroperadores / proveedores**. Tres tipos de servicio:
- **tour** · **ayb** (A&B / banquetes) · **transportacion**
Objetivo final (fases siguientes): usar esta DB para **propuestas** y una **página web**
de catálogo online optimizado. Por ahora el foco es el **builder** (captura de datos + fotos).
## ⚠️ Principio clave (del Hub): el servicio = fuente única de verdad
Cada atributo de un servicio impacta hasta 3 funciones. Antes de agregar/cambiar uno, define su impacto en:
1. **Operación** — disponibilidad, horarios, capacidad, precio neto
2. **Propuesta / cotizador** — lo que ve el cliente, precio público, términos
3. **Página web** — catálogo online
**Neto vs público**: `precio_neto` = lo que cobra el proveedor · `precio_publico` = lo que paga el cliente.
El margen vive en medio y nunca se mezcla con lo que ve el cliente.
## Stack
- Backend: Python 3 stdlib (`http.server` + `sqlite3`), **zero deps**. WAL + FK on.
- Frontend: SPA vanilla JS (`index.html`), sin frameworks.
- CRUD genérico vía dict `TABLES` en `server.py` (un solo handler POST/PUT/DELETE para todas las tablas).
- Auth: usuario/contraseña (PBKDF2) + cookie de sesión firmada (HMAC). `needs_setup` en primer acceso.
## Acceso al server
- Host: `claude@100.110.177.1` (Tailscale `iclaude`) / red local `192.168.50.46`
- Path: `/mnt/iclaude/catalogo-borrador/`
- Puerto: **4403** (4401 = Hub, 4402 = airbnb-pricing)
- URL: `http://iclaude:4403` / `http://192.168.50.46:4403`
- Servicio: `sudo systemctl restart catalogo-borrador`
- Deploy: `scp index.html server.py claude@100.110.177.1:/mnt/iclaude/catalogo-borrador/ && ssh claude@100.110.177.1 "sudo systemctl restart catalogo-borrador"`
- Logs: `sudo journalctl -u catalogo-borrador -n 50 --no-pager`
## Modelo de datos (SQLite)
- `proveedores` — quién ofrece (nombre, tipo_principal, contacto, comision_default, …)
- `servicios`**fuente única de verdad** (FK proveedor_id). Campos por sección de impacto:
- 📋 Identidad: nombre, tipo, categoria, codigo, descripcion
- ⚙️ Operación: horarios, capacidad_min/max, restricciones + `atributos` (JSON específico por tipo)
- 💰 Comercial: precio_neto, precio_publico, moneda, unidad, tarifas_adicionales
- 📄 Condiciones: terminos
- 🌐 Publicación: mostrar_en_web
- `usuarios` — auth
### Atributos flexibles por tipo (`servicios.atributos`, JSON)
En vez de columnas rígidas, los extras específicos van en JSON. Sugeridos por tipo (en `index.html``ATRIBUTOS`):
- tour: duracion, punto_encuentro, incluye, no_incluye, idioma
- ayb: tipo_menu, min_personas, montaje, servicio_incluido, opciones_dieta
- transportacion: tipo_vehiculo, pasajeros, ruta_zona, chofer, tiempo_espera
## Disponibilidad — modos (`servicios.modo_disponibilidad` + `horarios` JSON)
Eje independiente del `tipo`. **`modo_disponibilidad`**: `salidas` | `rango` | `siempre` (24/7) | `por_evento`.
Default sugerido por tipo (`MODO_DEFAULT`: tour→salidas, transportacion→siempre, ayb→por_evento), editable. El editor (`renderDisp`) cambia los controles según el modo:
- **salidas**: editor semanal de días + varias salidas por día → `horarios`=`{"Lun":["08:00","14:00"],...}`. (`+ salida` agrega varias; `parseHor`/`fmtHorGroups`/`compactDias` para mostrar.)
- **rango**: días + ventana → `horarios`=`{"dias":[...],"desde":"06:00","hasta":"22:00"}`.
- **siempre** / **por_evento**: `horarios`={} (sin horario; la reserva es por fecha).
`dispShort(s)` arma el texto de disponibilidad según el modo (usado en vista cliente).
Compat: servicios viejos sin `modo` = `salidas` con su `horarios` día-keyed.
## Alimentos / Menú (opcional, flexible)
`servicios.incluye_alimentos` (0/1) + `menu_detalle` (texto). Fotos del menú: se suben con `tipo=menu` (prefijo `menu_`) al mismo `uploads/servicio_{id}/`; se separan de las fotos del servicio por prefijo (`loadPhotos` excluye `menu_`, `loadMenuFotos` solo `menu_`). En `file-counts` el prefijo `menu` está excluido del `first_image` (no roba el thumbnail). En vista cliente se muestra el bloque "🍽️ Menú / Alimentos" (detalle + galería) solo si aplica. Pensado para tours que incluyen comida como plus; opcional porque depende del operador.
## Campos "reserva-lista" (`servicios`)
`ubicacion` (lugar/punto de encuentro/dirección), `mapa_url` (link Google Maps), `checkin` (anti no-show, ej. "Llegar 15 min antes"), `anticipacion` (lead time, ej. "48 h"). Se ven en la **vista cliente** (bloques Disponibilidad / Dónde / Check-in). Cada modo + estos campos definen qué pedirá el futuro formulario de reserva (lead casi listo).
## Vistas (toggle en tab Servicios)
- **✎ Builder** — grid de edición (muestra precio público + margen).
- **👁 Vista cliente** — catálogo limpio agrupado por tipo, como lo vería un cliente. `openCliente()` abre
detalle estilo público. **Oculta**: precio_neto, margen, comisión, notas internas, proveedor, código.
## Diseño / UI
Formal y minimalista: tipografía **Inter**, paleta blanco/negro/gris (`--ink`,`--muted`,`--line`),
acentos sobrios **mar** (`--sea` #1f4b54, primario/activos) y **arena** (`--sand`, detalles puntuales como A&B).
Botón primario negro. Sin serif decorativo.
## Archivos / fotos
- En `uploads/servicio_{id}/`, prefijo `foto_` (fotos) o `doc_` (documentos).
- `/api/file-counts` elige `first_image` PREFIRIENDO `foto_` y excluyendo docs (mismo patrón que el Hub).
## Endpoints
- `GET/POST/PUT/DELETE /api/{proveedores|servicios}`
- `GET /api/servicios` (join proveedor_nombre) · `GET /api/proveedores` (con servicios_count)
- `GET /api/dashboard` · `GET /api/file-counts` · `GET /api/files/{entidad}`
- `POST /api/upload/{entidad}?tipo=foto&label=...` · `DELETE /api/files/{entidad}/{name}`
## Convenciones de edición
- Editar LOCAL aquí (`/Users/claudeandrefg/Documents/catalogo-borrador/`) y desplegar con scp.
- No instalar dependencias — stdlib + vanilla JS. Si una feature lo requiere, discutir antes.
- Para agregar una tabla nueva: `CREATE TABLE` en `init_db()` + entrada en `TABLES` (CRUD automático).
## Pendientes / fases siguientes
1. (Hecho) Builder: proveedores + servicios con fotos y todos los campos.
2. Builder de **propuestas/catálogos** (selección de servicios → documento → PDF). Reusar patrón `catalogos` del Hub.
3. **Página web** pública de catálogo online optimizado (sync desde la DB).

105
INSTALACION.md Normal file
View File

@@ -0,0 +1,105 @@
# Instalación y Despliegue — Catálogo de Servicios
Sin Docker, sin pip, sin CI/CD. Solo Python 3 y `systemd`.
---
## Requisitos
- **Python 3.8+** (stdlib únicamente)
- Linux con `systemd` para correr como servicio (en Windows/Mac corre con `python3 server.py`)
---
## 1. Desarrollo local
```bash
git clone https://git.consultoria-as.com/consultoria-as/catalogo-servicios.git
cd catalogo-servicios
python3 server.py
# → http://localhost:4403
```
Primera vez: pantalla de **setup** para crear la cuenta admin. Genera `catalogo.db` y `secret.key`.
---
## 2. Despliegue en servidor (Tailscale `iclaude`)
Ubicación en producción: `/mnt/iclaude/catalogo-borrador/`. Puerto **4403**
(4401 = Hub, 4402 = airbnb-pricing, 4404 = foto-studio).
### a) Copiar archivos (sin secretos ni datos)
```bash
scp server.py index.html claude@iclaude:/mnt/iclaude/catalogo-borrador/
```
> **No copiar** `secret.key` ni `catalogo.db` — el servidor ya tiene los suyos. Los datos viven solo en el servidor.
### b) Servicio systemd
`/etc/systemd/system/catalogo-borrador.service`:
```ini
[Unit]
Description=Catalogo (borrador) - builder de servicios
After=network.target
[Service]
Type=simple
User=claude
WorkingDirectory=/mnt/iclaude/catalogo-borrador
ExecStart=/usr/bin/python3 /mnt/iclaude/catalogo-borrador/server.py
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
```
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now catalogo-borrador
```
### c) Redespliegue
```bash
scp index.html server.py claude@iclaude:/mnt/iclaude/catalogo-borrador/
ssh claude@iclaude "sudo systemctl restart catalogo-borrador"
```
Migraciones de esquema corren solas al reiniciar (idempotentes).
### Logs
```bash
sudo journalctl -u catalogo-borrador -n 50 --no-pager
```
---
## 3. Acceso
- **Privado** (tailnet): `http://iclaude:4403/` · `http://192.168.50.46:4403/`
- **Público** (clientes, sin Tailscale): vía **Tailscale Funnel** en puerto dedicado (8443).
```bash
tailscale funnel --bg 8443
tailscale funnel status
```
> Nota: usar puerto dedicado (no un path bajo `/catalogo`) porque la app usa rutas absolutas `/api/...`.
---
## 4. Solución de problemas
| Síntoma | Causa | Solución |
|---|---|---|
| "Sesión inválida" constante | reloj del servidor desfasado | corregir hora (cron `sync_clock.sh`) |
| Logins rotos bajo Funnel con path | rutas absolutas `/api/` | exponer en puerto propio (8443), no en `/catalogo` |
| Thumbnail equivocado | prefijos de archivo | `first_image` prefiere `foto_`, excluye `menu`/docs |
---
*Stack mínimo: todo corre con un solo `python3 server.py`.*

75
MODELO_DATOS.md Normal file
View File

@@ -0,0 +1,75 @@
# Modelo de Datos — Catálogo de Servicios
Base **SQLite** (`catalogo.db`), modo WAL + foreign keys ON. 3 tablas.
El esquema se crea/migra solo al arrancar (`init_db()` en `server.py`); migraciones idempotentes (`ALTER TABLE` solo si la columna falta).
```
proveedores (1) ──< (N) servicios [FK proveedor_id, ON DELETE SET NULL]
usuarios (login, independiente)
```
---
## `proveedores` — Quién ofrece el servicio
Touroperadores / proveedores.
| Campo | Tipo | Notas |
|---|---|---|
| `id` | INTEGER PK | |
| `nombre` | TEXT | requerido |
| `tipo_principal` | TEXT | `tour` (default) / `ayb` / `transportacion` |
| `contacto`, `telefono`, `email`, `sitio_web` | TEXT | datos de contacto |
| `comision_default` | REAL | comisión base del proveedor |
| `notas` | TEXT | internas |
| `activo` | INTEGER | 1/0 |
| `created_at`, `updated_at` | TEXT | timestamps |
---
## `servicios` — Fuente única de verdad
La tabla central. Campos agrupados por **sección de impacto**.
| Sección | Campos |
|---|---|
| 📋 **Identidad** | `codigo`, `tipo` (tour/ayb/transportacion), `categoria`, `nombre` (req.), `descripcion` |
| ⚙️ **Operación** | `modo_disponibilidad`, `horarios` (JSON), `capacidad_min`, `capacidad_max`, `restricciones`, `atributos` (JSON por tipo) |
| 💰 **Comercial** | `precio_neto`, `precio_publico`, `moneda` (MXN), `unidad` (por_persona/por_grupo/por_vehiculo/por_evento), `tarifas_adicionales` |
| 📄 **Condiciones** | `terminos` |
| 📍 **Reserva-lista** | `ubicacion`, `mapa_url`, `checkin`, `anticipacion` |
| 🍽️ **Alimentos** | `incluye_alimentos` (0/1), `menu_detalle` |
| 🌐 **Publicación** | `mostrar_en_web` (0/1) |
| | `activo`, `notas`, `created_at`, `updated_at`, `proveedor_id` (FK) |
### `atributos` — JSON flexible por tipo
En vez de columnas rígidas, los extras específicos van en JSON. Sugeridos (definidos en `index.html → ATRIBUTOS`):
- **tour**: duracion, punto_encuentro, incluye, no_incluye, idioma
- **ayb**: tipo_menu, min_personas, montaje, servicio_incluido, opciones_dieta
- **transportacion**: tipo_vehiculo, pasajeros, ruta_zona, chofer, tiempo_espera
### `modo_disponibilidad` + `horarios` (JSON)
Eje **independiente** del `tipo`. Default por tipo (`MODO_DEFAULT`): tour→`salidas`, transportacion→`siempre`, ayb→`por_evento`. Editable.
| Modo | `horarios` |
|---|---|
| `salidas` | `{"Lun":["08:00","14:00"], ...}` (días + varias salidas/día) |
| `rango` | `{"dias":[...],"desde":"06:00","hasta":"22:00"}` |
| `siempre` (24/7) | `{}` |
| `por_evento` | `{}` (reserva por fecha) |
> Compatibilidad: servicios viejos sin `modo` se tratan como `salidas` con su `horarios` día-keyed.
---
## `usuarios` — Login
`id`, `username` (único), `pass_hash` (PBKDF2-SHA256 + salt), `nombre`, `activo`, `created_at`.
Sesión = cookie firmada con HMAC (clave en `secret.key`, NO versionada).
---
## Archivos (no en DB)
En `uploads/servicio_{id}/` con prefijo:
- `foto_` — fotos del servicio
- `menu_` — fotos del menú/alimentos (separadas; `loadPhotos` excluye `menu_`, `loadMenuFotos` solo `menu_`)
- `doc_` — documentos
`/api/file-counts` elige `first_image` **prefiriendo** `foto_` y **excluyendo** docs y `menu` (para no robar el thumbnail).

86
README.md Normal file
View File

@@ -0,0 +1,86 @@
# Catálogo de Servicios (builder)
Builder de catálogo **multi-proveedor de servicios** (tours, A&B/banquetes, transportación) para Los Cabos, México.
Hereda la filosofía del **Art4Hotel Hub**, pero aplicada a **servicios** (no productos físicos): capturar en una sola base de datos la oferta de varios touroperadores/proveedores, con fotos y todos los atributos, para luego generar **propuestas** y una **página web de catálogo online**.
> Proyecto independiente. No toca el Hub.
---
## ✨ Qué hace
- **Proveedores** — alta de touroperadores / proveedores (contacto, comisión default)
- **Servicios** — la *fuente única de verdad*: identidad, operación, precios neto/público, condiciones, publicación
- **Atributos flexibles por tipo** — extras específicos en JSON (no rompen el esquema)
- **Disponibilidad** — 4 modos (salidas / rango / siempre / por evento) con editor adaptativo
- **Fotos y menú** — galería por servicio + galería de menú separada por prefijo
- **Dos vistas** — Builder (con margen/precio neto) y Vista cliente (catálogo limpio, oculta lo interno)
- **Login** — usuario/contraseña (PBKDF2) + sesión firmada (HMAC)
---
## 🧱 Stack
| Capa | Tecnología |
|---|---|
| Backend | **Python 3 stdlib** (`http.server` + `sqlite3`) — cero dependencias |
| Base de datos | **SQLite** (WAL, foreign keys) |
| Frontend | **Vanilla JS SPA** en un solo `index.html` |
| Archivos | Filesystem (`uploads/servicio_{id}/`) |
| Deploy | `scp` + `systemctl` |
Mismo patrón que el Hub: CRUD genérico vía dict `TABLES` en `server.py` (un handler para todas las tablas).
---
## 🚀 Arranque rápido
```bash
python3 server.py
# → http://localhost:4403
```
Primera vez: pantalla para **crear la cuenta admin** (genera `catalogo.db` y `secret.key`).
Ver **[INSTALACION.md](./INSTALACION.md)** para el despliegue en servidor.
---
## 📁 Estructura
```
catalogo-borrador/
├── server.py # Backend: HTTP + SQLite + API + auth
├── index.html # Frontend: SPA (builder + vista cliente)
├── README.md # Este archivo
├── MODELO_DATOS.md # Esquema de la base de datos
├── API.md # Referencia de endpoints
├── INSTALACION.md # Despliegue desde cero
└── CLAUDE.md # Contexto técnico detallado (para asistentes IA)
```
> **No incluidos** (ver `.gitignore`): `catalogo.db`, `secret.key`, `uploads/`.
---
## 🔑 Principio de diseño: el servicio = fuente única de verdad
Cada atributo de un servicio impacta hasta **3 funciones**. Antes de agregar/cambiar uno, define su impacto en:
1. **Operación** — disponibilidad, horarios, capacidad, precio neto
2. **Propuesta / cotizador** — lo que ve el cliente, precio público, términos
3. **Página web** — catálogo online
**Neto vs público:** `precio_neto` = lo que cobra el proveedor · `precio_publico` = lo que paga el cliente. El margen vive en medio y **nunca** se muestra al cliente (la Vista cliente lo oculta).
---
## 🗺 Fases
1.**Builder** — proveedores + servicios con fotos y todos los campos *(actual)*
2.**Propuestas/catálogos** — selección de servicios → documento → PDF (reusar patrón del Hub)
3.**Web pública** — catálogo online optimizado (sync desde la DB)
---
*Parte del ecosistema Art4Hotel · Los Cabos, BCS, México.*

768
index.html Normal file
View 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&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>

597
server.py Normal file
View File

@@ -0,0 +1,597 @@
"""
Catálogo (borrador) — Servidor local
Builder de catálogo multi-proveedor de servicios (tours / A&B / transportación).
Base: filosofía del Art4Hotel Hub (servicio = fuente única de verdad).
Zero dependencias externas (stdlib only).
Uso:
python3 server.py
http://localhost:4402
"""
import http.server, json, sqlite3, os, urllib.parse, re, mimetypes
import hashlib, hmac, base64, secrets, time, http.cookies
from datetime import datetime
from pathlib import Path
PORT = 4403
BASE = Path(__file__).parent
DB_PATH = BASE / "catalogo.db"
STATIC_DIR = BASE
UPLOADS_DIR = BASE / "uploads"
UPLOADS_DIR.mkdir(exist_ok=True)
MAX_UPLOAD = 25 * 1024 * 1024 # 25MB
# ── Autenticación ──
SESSION_HOURS = 24 * 14
SECRET_FILE = BASE / "secret.key"
def _load_secret():
if SECRET_FILE.exists():
return SECRET_FILE.read_bytes()
s = secrets.token_bytes(32)
SECRET_FILE.write_bytes(s)
try: os.chmod(SECRET_FILE, 0o600)
except Exception: pass
return s
SECRET = _load_secret()
def hash_password(password, salt=None):
if salt is None: salt = secrets.token_hex(16)
h = hashlib.pbkdf2_hmac('sha256', password.encode(), salt.encode(), 200000).hex()
return salt + '$' + h
def verify_password(password, stored):
try:
salt, _ = stored.split('$', 1)
return hmac.compare_digest(hash_password(password, salt), stored)
except Exception:
return False
def make_session(username):
exp = int(time.time()) + SESSION_HOURS * 3600
payload = f"{username}|{exp}"
sig = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(payload.encode()).decode() + '.' + sig
def check_session(token):
try:
b64, sig = token.split('.', 1)
payload = base64.urlsafe_b64decode(b64.encode()).decode()
expect = hmac.new(SECRET, payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expect): return None
username, exp = payload.split('|')
if int(exp) < time.time(): return None
return username
except Exception:
return None
def get_db():
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
conn = get_db(); c = conn.cursor()
# ── Usuarios (auth) ──
c.execute("""
CREATE TABLE IF NOT EXISTS usuarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
pass_hash TEXT NOT NULL,
nombre TEXT DEFAULT '',
activo INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now','localtime'))
)
""")
# ── Proveedores (touroperadores / quien ofrece el servicio) ──
c.execute("""
CREATE TABLE IF NOT EXISTS proveedores (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nombre TEXT NOT NULL,
tipo_principal TEXT DEFAULT 'tour',
contacto TEXT DEFAULT '',
telefono TEXT DEFAULT '',
email TEXT DEFAULT '',
sitio_web TEXT DEFAULT '',
comision_default REAL DEFAULT 0,
notas TEXT DEFAULT '',
activo INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime'))
)
""")
# ── Servicios (FUENTE ÚNICA DE VERDAD) ──
# tipo: tour | ayb | transportacion
# unidad: por_persona | por_grupo | por_vehiculo | por_evento
# atributos: JSON con extras específicos por tipo (flexible, no rompe esquema)
c.execute("""
CREATE TABLE IF NOT EXISTS servicios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
proveedor_id INTEGER,
codigo TEXT DEFAULT '',
tipo TEXT DEFAULT 'tour',
categoria TEXT DEFAULT '',
nombre TEXT NOT NULL,
descripcion TEXT DEFAULT '',
terminos TEXT DEFAULT '',
horarios TEXT DEFAULT '',
capacidad_min INTEGER DEFAULT 0,
capacidad_max INTEGER DEFAULT 0,
unidad TEXT DEFAULT 'por_persona',
precio_neto REAL DEFAULT 0,
precio_publico REAL DEFAULT 0,
moneda TEXT DEFAULT 'MXN',
tarifas_adicionales TEXT DEFAULT '',
restricciones TEXT DEFAULT '',
atributos TEXT DEFAULT '',
mostrar_en_web INTEGER DEFAULT 0,
activo INTEGER DEFAULT 1,
notas TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now','localtime')),
updated_at TEXT DEFAULT (datetime('now','localtime')),
FOREIGN KEY (proveedor_id) REFERENCES proveedores(id) ON DELETE SET NULL
)
""")
# ── migración: modo de disponibilidad + ubicación + check-in (reserva-lista) ──
scols={r[1] for r in c.execute("PRAGMA table_info(servicios)")}
for col in ['modo_disponibilidad','ubicacion','mapa_url','checkin','anticipacion','menu_detalle']:
if col not in scols: c.execute(f"ALTER TABLE servicios ADD COLUMN {col} TEXT DEFAULT ''")
if 'incluye_alimentos' not in scols: c.execute("ALTER TABLE servicios ADD COLUMN incluye_alimentos INTEGER DEFAULT 0")
conn.commit(); conn.close()
# Config para el CRUD genérico (mismo patrón que el Hub)
TABLES = {
'proveedores': {
'fields': ['nombre','tipo_principal','contacto','telefono','email','sitio_web',
'comision_default','notas','activo'],
'int_fields': ['activo'],
'float_fields': ['comision_default'],
},
'servicios': {
'fields': ['proveedor_id','codigo','tipo','categoria','nombre','descripcion','terminos',
'horarios','modo_disponibilidad','ubicacion','mapa_url','checkin','anticipacion',
'incluye_alimentos','menu_detalle',
'capacidad_min','capacidad_max','unidad','precio_neto','precio_publico',
'moneda','tarifas_adicionales','restricciones','atributos','mostrar_en_web',
'activo','notas'],
'int_fields': ['proveedor_id','capacidad_min','capacidad_max','mostrar_en_web','activo','incluye_alimentos'],
'float_fields': ['precio_neto','precio_publico'],
'nullable_fields': ['proveedor_id'],
},
}
LOGIN_PAGE = """<!DOCTYPE html>
<html lang="es"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Catálogo — Acceso</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:#f7f7f8;min-height:100vh;display:flex;align-items:center;justify-content:center;padding:20px;color:#16181d;-webkit-font-smoothing:antialiased}
.card{background:#fff;border:1px solid #e8e9ec;border-radius:14px;box-shadow:0 1px 2px rgba(20,24,28,.04),0 12px 32px rgba(20,24,28,.08);width:100%;max-width:380px;padding:38px 34px;text-align:center}
.logo{font-size:20px;font-weight:700;letter-spacing:-.01em;margin-bottom:4px;display:flex;align-items:center;justify-content:center;gap:8px}
.logo .dot{width:7px;height:7px;border-radius:2px;background:#1f4b54;display:inline-block}
.tag{font-size:10px;color:#8b9097;letter-spacing:.14em;text-transform:uppercase;font-weight:500;margin-bottom:26px}
h1{font-size:20px;color:#16181d;font-weight:700;letter-spacing:-.01em;margin-bottom:6px}
.sub{font-size:13px;color:#8b9097;margin-bottom:22px}
label{display:block;text-align:left;font-size:11px;color:#3d424b;letter-spacing:.01em;font-weight:600;margin:12px 0 5px}
input{width:100%;padding:11px 13px;border:1px solid #d9dbe0;border-radius:8px;font-family:inherit;font-size:15px;background:#fff;outline:none}
input:focus{border-color:#1f4b54;box-shadow:0 0 0 3px #eef3f3}
button{width:100%;margin-top:22px;padding:12px;background:#16181d;color:#fff;border:none;border-radius:8px;font-family:inherit;font-size:14px;font-weight:600;letter-spacing:.01em;cursor:pointer;transition:.12s}
button:hover{background:#000}
.err{color:#b3322b;font-size:12px;margin-top:14px;min-height:16px}
</style></head><body>
<div class="card">
<div class="logo"><span class="dot"></span>Catálogo</div>
<div class="tag">builder</div>
<h1 id="title">Iniciar sesión</h1>
<div class="sub" id="subtitle">Acceso al builder de catálogo</div>
<form id="f">
<div id="nombre-fg" style="display:none"><label>Tu nombre</label><input id="nombre" autocomplete="name"></div>
<label>Usuario</label><input id="username" autocomplete="username" autocapitalize="none">
<label>Contraseña</label><input id="password" type="password" autocomplete="current-password">
<button type="submit" id="btn">Entrar</button>
<div class="err" id="err"></div>
</form>
</div>
<script>
let setup=false;
fetch('/api/needs-setup').then(r=>r.json()).then(d=>{
if(d.needs_setup){
setup=true;
document.getElementById('title').textContent='Crear cuenta de administrador';
document.getElementById('subtitle').textContent='Primera vez — define tu usuario y contraseña';
document.getElementById('nombre-fg').style.display='block';
document.getElementById('btn').textContent='Crear cuenta';
document.getElementById('password').setAttribute('autocomplete','new-password');
}
});
document.getElementById('f').onsubmit=async e=>{
e.preventDefault();
const err=document.getElementById('err'); err.textContent='';
const body={username:document.getElementById('username').value.trim().toLowerCase(),
password:document.getElementById('password').value,
nombre:document.getElementById('nombre').value.trim()};
try{
const r=await fetch(setup?'/api/setup':'/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d=await r.json();
if(d.ok){location.href='/';}
else{err.textContent=d.error||'Error';}
}catch(_){err.textContent='Error de conexión';}
};
</script></body></html>"""
class CatalogoHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *a, **kw):
super().__init__(*a, directory=str(STATIC_DIR), **kw)
# ══════ ROUTING ══════
def do_GET(self):
p = urllib.parse.urlparse(self.path)
if p.path == "/api/needs-setup": return self.handle_needs_setup()
if not self.current_user():
if p.path.startswith("/api/") or p.path.startswith("/uploads/"):
return self.json_ok({"error": "no autenticado"}, 401)
return self.serve_login_page()
if p.path in ("", "/"): self.path = "/index.html"; return super().do_GET()
if p.path.startswith("/api/"): return self.api_get(p.path)
if p.path.startswith("/uploads/"): return self.serve_upload(p.path)
return super().do_GET()
def do_POST(self):
if self.path == "/api/login": return self.handle_login()
if self.path == "/api/setup": return self.handle_setup()
if self.path == "/api/logout": return self.handle_logout()
if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401)
if self.path.startswith("/api/upload/"): return self.handle_upload()
if self.path.startswith("/api/"): return self.api_write("POST", self.path)
self.send_error(404)
def do_PUT(self):
if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401)
if self.path.startswith("/api/"): return self.api_write("PUT", self.path)
self.send_error(404)
def do_DELETE(self):
if not self.current_user(): return self.json_ok({"error": "no autenticado"}, 401)
if self.path.startswith("/api/files/"): return self.delete_file()
if self.path.startswith("/api/"): return self.api_delete(self.path)
self.send_error(404)
def do_OPTIONS(self):
self.send_response(200)
for h, v in [("Access-Control-Allow-Origin","*"),
("Access-Control-Allow-Methods","GET,POST,PUT,DELETE,OPTIONS"),
("Access-Control-Allow-Headers","Content-Type")]:
self.send_header(h, v)
self.end_headers()
# ══════ HELPERS ══════
def json_ok(self, data, status=200, cookie=None):
self.send_response(status)
self.send_header("Content-Type","application/json")
self.send_header("Access-Control-Allow-Origin","*")
if cookie: self.send_header("Set-Cookie", cookie)
self.end_headers()
self.wfile.write(json.dumps(data, ensure_ascii=False, default=str).encode())
def body(self):
n = int(self.headers.get("Content-Length", 0))
return json.loads(self.rfile.read(n)) if n else {}
# ══════ AUTENTICACIÓN ══════
def current_user(self):
cookie = self.headers.get('Cookie', '')
if not cookie: return None
try:
c = http.cookies.SimpleCookie(cookie)
if 'cat_session' in c:
return check_session(c['cat_session'].value)
except Exception:
return None
return None
def _session_cookie(self, token):
return f"cat_session={token}; HttpOnly; Path=/; Max-Age={SESSION_HOURS*3600}; SameSite=Lax"
def handle_needs_setup(self):
conn = get_db()
n = conn.execute("SELECT COUNT(*) FROM usuarios").fetchone()[0]
conn.close()
self.json_ok({"needs_setup": n == 0})
def handle_setup(self):
conn = get_db()
n = conn.execute("SELECT COUNT(*) FROM usuarios").fetchone()[0]
if n > 0:
conn.close(); return self.json_ok({"error": "Ya hay usuarios configurados"}, 403)
data = self.body()
u = (data.get('username') or '').strip().lower()
p = data.get('password') or ''
nombre = (data.get('nombre') or '').strip()
if not u or len(p) < 6:
conn.close(); return self.json_ok({"error": "Usuario requerido y contraseña de 6+ caracteres"}, 400)
conn.execute("INSERT INTO usuarios (username,pass_hash,nombre) VALUES (?,?,?)",
(u, hash_password(p), nombre or u))
conn.commit(); conn.close()
self.json_ok({"ok": True}, cookie=self._session_cookie(make_session(u)))
def handle_login(self):
data = self.body()
u = (data.get('username') or '').strip().lower()
p = data.get('password') or ''
conn = get_db()
row = conn.execute("SELECT * FROM usuarios WHERE username=? AND activo=1", (u,)).fetchone()
conn.close()
if not row or not verify_password(p, row['pass_hash']):
return self.json_ok({"error": "Usuario o contraseña incorrectos"}, 401)
self.json_ok({"ok": True, "nombre": row['nombre'] or u},
cookie=self._session_cookie(make_session(u)))
def handle_logout(self):
self.json_ok({"ok": True}, cookie="cat_session=; Path=/; Max-Age=0")
def serve_login_page(self):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(LOGIN_PAGE.encode())
# ══════ UPLOADS ══════
def handle_upload(self):
"""POST /api/upload/{entidad}?tipo=foto|doc&label=..."""
parts = self.path.split("/")
entidad = urllib.parse.unquote("/".join(parts[3:])).split("?")[0]
params = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
tipo = params.get("tipo", ["foto"])[0]
label = params.get("label", [""])[0].strip()
content_type = self.headers.get("Content-Type", "")
content_length = int(self.headers.get("Content-Length", 0))
if content_length > MAX_UPLOAD:
return self.json_ok({"error": "Archivo muy grande (max 25MB)"}, 413)
if "multipart/form-data" not in content_type:
return self.json_ok({"error": "Usar multipart/form-data"}, 400)
boundary = content_type.split("boundary=")[1].strip()
raw = self.rfile.read(content_length)
files_saved = []
idx = 0
for part in raw.split(f"--{boundary}".encode()):
if b"filename=" not in part: continue
header_end = part.find(b"\r\n\r\n")
if header_end == -1: continue
header = part[:header_end].decode("utf-8", errors="replace")
file_data = part[header_end+4:]
if file_data.endswith(b"\r\n"): file_data = file_data[:-2]
if file_data.endswith(b"--\r\n"): file_data = file_data[:-4]
if file_data.endswith(b"--"): file_data = file_data[:-2]
fn_match = re.search(r'filename="([^"]+)"', header)
if not fn_match or not file_data: continue
orig_name = fn_match.group(1)
ext = ('.' + orig_name.rsplit('.', 1)[1]) if '.' in orig_name else ''
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
if label:
desc = label + (f"_{idx+1}" if idx > 0 else "")
safe_desc = re.sub(r'[^\w\-. ]', '', desc).strip().replace(' ', '_')
final_name = f"{tipo}_{ts}_{safe_desc}{ext}"
else:
safe_name = re.sub(r'[^\w\-._]', '_', orig_name)
final_name = f"{tipo}_{ts}_{safe_name}"
idx += 1
ent_dir = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', entidad)
ent_dir.mkdir(exist_ok=True)
(ent_dir / final_name).write_bytes(file_data)
files_saved.append({
"name": final_name, "original": orig_name, "tipo": tipo,
"size": len(file_data), "url": f"/uploads/{ent_dir.name}/{final_name}"
})
if files_saved:
self.json_ok({"files": files_saved}, 201)
else:
self.json_ok({"error": "No se encontraron archivos"}, 400)
def serve_upload(self, path):
clean = path.replace("..", "").lstrip("/")
filepath = STATIC_DIR / clean
if not filepath.exists() or not filepath.is_file():
return self.send_error(404)
try:
filepath.resolve().relative_to(UPLOADS_DIR.resolve())
except ValueError:
return self.send_error(403)
mime, _ = mimetypes.guess_type(str(filepath))
if not mime: mime = "application/octet-stream"
data = filepath.read_bytes()
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", len(data))
self.send_header("Content-Disposition", f'inline; filename="{filepath.name}"')
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(data)
def delete_file(self):
parts = self.path.split("/")
if len(parts) < 5: return self.send_error(400)
entidad = parts[3]
filename = urllib.parse.unquote(parts[4])
filepath = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', entidad) / filename
if filepath.exists():
filepath.unlink(); self.json_ok({"ok": True})
else:
self.send_error(404)
# ══════ API GET ══════
def api_get(self, path):
conn = get_db()
try:
if path.startswith("/api/files/"):
entidad = urllib.parse.unquote(path[11:])
ent_dir = UPLOADS_DIR / re.sub(r'[^\w\-.]', '_', entidad)
files = []
if ent_dir.exists():
for f in sorted(ent_dir.iterdir()):
if f.is_file():
ext = f.suffix.lower()
files.append({
"name": f.name, "size": f.stat().st_size,
"url": f"/uploads/{ent_dir.name}/{f.name}",
"is_image": ext in ('.jpg','.jpeg','.png','.gif','.webp','.bmp'),
"ext": ext,
})
return self.json_ok(files)
if path == "/api/file-counts":
IMG_EXT = ('.jpg','.jpeg','.png','.gif','.webp','.bmp')
DOC_PREFIXES = ('doc','terminos','contrato','factura','menu')
PHOTO_PREFIXES = ('foto_','mockup')
result = {}
if UPLOADS_DIR.exists():
for d in UPLOADS_DIR.iterdir():
if not d.is_dir(): continue
files = [f for f in d.iterdir() if f.is_file()]
if not files: continue
imgs = sorted([f for f in files if f.suffix.lower() in IMG_EXT], key=lambda f: f.name)
photo = next((f for f in imgs if f.name.lower().startswith(PHOTO_PREFIXES)), None)
if not photo:
photo = next((f for f in imgs if not f.name.lower().startswith(DOC_PREFIXES)), None)
result[d.name] = {
"count": len(files),
"first_image": f"/uploads/{d.name}/{photo.name}" if photo else None
}
return self.json_ok(result)
if path == "/api/dashboard":
return self.json_ok(self.dashboard(conn))
if path == "/api/proveedores":
rows = conn.execute("""
SELECT p.*, (SELECT COUNT(*) FROM servicios s WHERE s.proveedor_id=p.id) AS servicios_count
FROM proveedores p ORDER BY p.nombre
""").fetchall()
return self.json_ok([dict(r) for r in rows])
if path == "/api/servicios":
rows = conn.execute("""
SELECT s.*, p.nombre AS proveedor_nombre
FROM servicios s LEFT JOIN proveedores p ON p.id=s.proveedor_id
ORDER BY s.id DESC
""").fetchall()
return self.json_ok([dict(r) for r in rows])
# Fallback genérico para cualquier tabla registrada
table = path.split("/")[2] if len(path.split("/")) >= 3 else None
if table in TABLES:
rows = conn.execute(f"SELECT * FROM {table} ORDER BY id DESC").fetchall()
return self.json_ok([dict(r) for r in rows])
self.send_error(404)
finally:
conn.close()
# ══════ API WRITE (CRUD genérico) ══════
def api_write(self, method, path):
parts = path.split("/")
table = parts[2] if len(parts) >= 3 else None
item_id = int(parts[3]) if len(parts) >= 4 and parts[3].isdigit() else None
data = self.body()
if table not in TABLES:
return self.send_error(404)
conn = get_db()
try:
cfg = TABLES[table]
if method == "POST":
cols, vals = [], []
for f in cfg['fields']:
if f in data:
v = data[f]
if f in cfg.get('int_fields',[]): v = int(v or 0)
if f in cfg.get('float_fields',[]): v = float(v or 0)
if f in cfg.get('nullable_fields',[]) and (v == 0 or v == '' or v is None): v = None
cols.append(f); vals.append(v)
placeholders = ','.join(['?']*len(cols))
cur = conn.execute(f"INSERT INTO {table} ({','.join(cols)}) VALUES ({placeholders})", vals)
conn.commit()
return self.json_ok({"id": cur.lastrowid}, 201)
elif method == "PUT" and item_id:
sets, vals = [], []
for f in cfg['fields']:
if f in data:
v = data[f]
if f in cfg.get('int_fields',[]): v = int(v or 0)
if f in cfg.get('float_fields',[]): v = float(v or 0)
if f in cfg.get('nullable_fields',[]) and (v == 0 or v == '' or v is None): v = None
sets.append(f"{f}=?"); vals.append(v)
if sets:
cols_in_table = {r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if 'updated_at' in cols_in_table:
sets.append("updated_at=datetime('now','localtime')")
conn.execute(f"UPDATE {table} SET {','.join(sets)} WHERE id=?", vals+[item_id])
conn.commit()
return self.json_ok({"ok": True})
self.send_error(400)
finally:
conn.close()
def api_delete(self, path):
parts = path.split("/")
table = parts[2] if len(parts) >= 3 else None
item_id = int(parts[3]) if len(parts) >= 4 and parts[3].isdigit() else None
if table not in TABLES or not item_id:
return self.send_error(404)
conn = get_db()
try:
conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,))
conn.commit()
self.json_ok({"ok": True})
finally:
conn.close()
# ══════ DASHBOARD ══════
def dashboard(self, conn):
por_tipo = {r['tipo']: r['n'] for r in conn.execute(
"SELECT tipo, COUNT(*) n FROM servicios WHERE activo=1 GROUP BY tipo")}
por_prov = {}
for r in conn.execute("""
SELECT COALESCE(p.nombre,'(sin proveedor)') prov, COUNT(*) n
FROM servicios s LEFT JOIN proveedores p ON p.id=s.proveedor_id
WHERE s.activo=1 GROUP BY prov ORDER BY n DESC"""):
por_prov[r['prov']] = r['n']
total_serv = conn.execute("SELECT COUNT(*) FROM servicios WHERE activo=1").fetchone()[0]
total_prov = conn.execute("SELECT COUNT(*) FROM proveedores WHERE activo=1").fetchone()[0]
en_web = conn.execute("SELECT COUNT(*) FROM servicios WHERE activo=1 AND mostrar_en_web=1").fetchone()[0]
# margen promedio (donde hay precios)
mrow = conn.execute("""
SELECT AVG((precio_publico-precio_neto)*100.0/precio_publico)
FROM servicios WHERE activo=1 AND precio_publico>0 AND precio_neto>0""").fetchone()[0]
return {
'por_tipo': por_tipo, 'por_proveedor': por_prov,
'total_servicios': total_serv, 'total_proveedores': total_prov,
'en_web': en_web, 'margen_promedio': round(mrow or 0, 1),
}
def log_message(self, fmt, *args):
if "/api/" in str(args): super().log_message(fmt, *args)
if __name__ == "__main__":
init_db()
print(f"\n Catálogo (borrador) — http://localhost:{PORT}")
print(f" DB: {DB_PATH.name}")
print(f" Ctrl+C para detener\n")
srv = http.server.HTTPServer(("", PORT), CatalogoHandler)
try: srv.serve_forever()
except KeyboardInterrupt: print("\nDetenido."); srv.server_close()