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:
20
.gitignore
vendored
Normal file
20
.gitignore
vendored
Normal 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
66
API.md
Normal 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
97
CLAUDE.md
Normal 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
105
INSTALACION.md
Normal 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
75
MODELO_DATOS.md
Normal 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
86
README.md
Normal 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
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>
|
||||
597
server.py
Normal file
597
server.py
Normal 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()
|
||||
Reference in New Issue
Block a user