Compare commits

...

18 Commits

Author SHA1 Message Date
a31b411437 Re estructuración de patrones de diseño con el código de Manso 2025-10-08 18:12:58 +00:00
b4c5d2af4f Puesta a punto 2025-09-22 16:59:29 +00:00
69f5860b7f Inclusión del dominio suitecoffee.uy al NPM.
Se ajustaron los problemas de renderizado y redirección mayores de https://suitecoffee.uy/
Se re-creó el archivo ~/SuiteCoffee/services/app/src/index.js para mantener un orden adecuado
Las rutas exigen una cookie de seción para cargarse, de o contrario redireccionan a  https://suitecoffee.uy/auth/login para iniciar o crear sesión de usuario, excepto https://suitecoffee.uy/inicio que se mantene de esta manera con motivos de desarrollo
2025-09-09 14:20:05 +00:00
5d078f3932 Carga completa 2025-09-06 11:19:42 +00:00
237a5427dd Mucha cosa y es muy tarde.
- Anda parte del registro
2025-09-05 08:13:09 +00:00
80778c0ed9 Pre-reordenación 2025-09-05 04:02:39 +00:00
8522d02170 Intento de integrar Authentik 2025-09-05 01:33:52 +00:00
cbcea72848 Importación de feature/registration 2025-09-05 00:45:16 +00:00
25876e733b Actualización de archivos para corresponder a las
funcionalidades de "Compras" y de "Reportes".
2025-09-01 20:32:43 +00:00
93ac1db5f1 Creación de la sección "Reportes" y "Compras" 2025-09-01 20:32:39 +00:00
c9b4b4871d Creación de sección Usuarios para administrar las entradas y salidas del personal 2025-08-30 04:49:59 +00:00
9c5219863b Modificación o agregado de productos y materias primas 2025-08-29 14:22:30 +00:00
ce3d01a180 Impresión de tickets correcta. 2025-08-29 06:22:10 +00:00
57dbd5b1fa 290825-0209 2025-08-29 05:09:44 +00:00
44d1adecdc Desarrollo de views + frontend 2025-08-29 02:27:28 +00:00
09610df995 Conexión satisfactoria con la base de datos creada para el workarround, las tablas, columnas y filas se muestran en el bashboard 2025-08-25 18:41:51 +00:00
922da441eb Creado y levantado del workaround 2025-08-25 17:21:27 +00:00
f7962f894d Actualización de función /planes en base de datos + primera versión del README 2025-08-25 16:05:12 +00:00
108 changed files with 29271 additions and 1048 deletions

37
.env.development Normal file
View File

@ -0,0 +1,37 @@
# Archivo de variables de entorno para docker-compose.yml
COMPOSE_PROJECT_NAME=suitecoffee_dev
# Entorno de desarrollo
NODE_ENV=development
# app - app
APP_PORT=3030
# auth - app
AUTH_PORT=4040
# tenants - postgres
TENANTS_DB_NAME=dev-postgres
TENANTS_DB_USER=dev-user-postgres
TENANTS_DB_PASS=dev-pass-postgres
# db primaria - postgres
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# Authentik PostgreSQL Setup
AK_HOST_DB=ak-db
AK_PG_DB=authentik
AK_PG_USER=authentik
AK_PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
# Authentik Cookies
AUTHENTIK_COOKIE__DOMAIN=sso.suitecoffee.uy
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS=https://sso.suitecoffee.uy,https://suitecoffee.uy
# Authentik Security
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
# Authentik Bootstrap
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com

53
.env.production Normal file
View File

@ -0,0 +1,53 @@
# Archivo de variables de entorno para docker-compose.yml
COMPOSE_PROJECT_NAME=suitecoffee_prod
# Entorno de desarrollo
NODE_ENV=production
# app - app
APP_PORT=3000
# auth - app
AUTH_PORT=4000
# tenants - postgres
TENANTS_DB_NAME=postgres
TENANTS_DB_USER=postgres
TENANTS_DB_PASS=postgres
# db primaria - postgres
DB_NAME=suitecoffee
DB_USER=suitecoffee
DB_PASS=suitecoffee
# Authentik PostgreSQL Setup
AK_HOST_DB=ak-db
AK_PG_DB=authentik
AK_PG_USER=authentik
AK_PG_PASS=gOWjL8V564vyh1aXUcqh4o/xo7eObraaCVZezPi3iw2LzPlU
# Authentik Cookies
AUTHENTIK_COOKIE__DOMAIN=sso.suitecoffee.uy
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS=https://sso.suitecoffee.uy,https://suitecoffee.uy
# Authentik Security
AUTHENTIK_SECRET_KEY=11zMsUL57beO+okjeGh7OB3lQdGUWII+VaATHs/zsw1+6KMSTyGfAY0yHpq3C442+3CwrZ/KtjgHBfbv
AUTHENTIK_ERROR_REPORTING__ENABLED=true
# Authentik Email
AUTHENTIK_EMAIL__HOST=smtp.gmail.com # SMTP Host Emails are sent to
AUTHENTIK_EMAIL__PORT=465
AUTHENTIK_EMAIL__PASSWORD=Succulent-Sanded7
AUTHENTIK_EMAIL__USE_TLS=false # Use StartTLS
AUTHENTIK_EMAIL__USE_SSL=true # Use SSL
AUTHENTIK_EMAIL__TIMEOUT=10
# Email address authentik will send from, should have a correct @domain
AUTHENTIK_EMAIL__FROM=info.suitecoffee@gmail.com
# Authentik Bootstrap
AUTHENTIK_BOOTSTRAP_PASSWORD=info.suitecoffee@gmail.com
AUTHENTIK_BOOTSTRAP_EMAIL=info.suitecoffee@gmail.com

4
.gitignore vendored
View File

@ -1,7 +1,7 @@
# Ignorar los directorios de dependencias
node_modules/
# Ignorar los volumenes respaldados
# Ignorar los volumenes respaldados de docker compose
docker-volumes*
# Ignorar las carpetas de bases de datos
@ -33,6 +33,6 @@ tests/
.gitmodules
# Ignorar archivos personales o privados (si existen)
.env.*
# .env.*
*.pem
*.key

303
README.md Normal file
View File

@ -0,0 +1,303 @@
# SuiteCoffee — Sistema de gestión para cafeterías (Dockerizado y multiservicio)
SuiteCoffee es un sistema modular pensado para la **gestión de cafeterías** (y negocios afines), con servicios Node.js para **aplicación** y Authentik **autenticación**, bases de datos **PostgreSQL** separadas para negocio y multitenencia, y un **stack Docker Compose** que facilita levantar entornos de **desarrollo** y **producción**. Incluye herramientas auxiliares como **Nginx Proxy Manager (NPM)** y **CloudBeaver** para administrar bases de datos desde el navegador.
> Repositorio: https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git
---
## Tabla de contenidos
- [Arquitectura](#arquitectura)
- [Características principales](#características-principales)
- [Requisitos](#requisitos)
- [Inicio rápido](#inicio-rápido)
- [Variables de entorno](#variables-de-entorno)
- [Endpoints](#endpoints)
- [Estructura del proyecto](#estructura-del-proyecto)
- [Herramientas auxiliares (NPM y CloudBeaver)](#herramientas-auxiliares-npm-y-cloudbeaver)
- [Backups y restauración de volúmenes](#backups-y-restauración-de-volúmenes)
- [Comandos útiles](#comandos-útiles)
- [Licencia](#licencia)
- [Sugerencias de mejora](#sugerencias-de-mejora)
---
## Arquitectura
**Servicios principales**
- **app** (Node.js / Express): API de negocio y páginas simples para cargar y listar *roles, usuarios, categorías y productos*.
- **auth** (Node.js / Express + bcrypt): endpoints de **registro** e **inicio de sesión**.
- **db** (PostgreSQL 16): base de datos de la aplicación.
- **tenants** (PostgreSQL 16): base de datos separada para **multi-tenencia** (aislar clientes/tiendas).
**Herramientas**
- **Nginx Proxy Manager (NPM)**: reverse proxy y certificados (Lets Encrypt) para exponer servicios.
- **CloudBeaver (DBeaver)**: administración de PostgreSQL vía web.
**Redes & Volúmenes**
- Redes independientes por entorno (`suitecoffee_dev_net` / `suitecoffee_prod_net`).
- Volúmenes gestionados por Compose para persistencia: `suitecoffee-db`, `tenants-db`, etc.
### Diagrama (alto nivel)
```plantuml
@startuml
skinparam componentStyle rectangle
skinparam rectangle {
BorderColor #555
RoundCorner 10
}
actor Usuario
package "Entorno DEV/PROD" {
[app (Express)] as APP
[auth (Express + bcrypt)] as AUTH
database "db (PostgreSQL)" as DB
database "tenants (PostgreSQL)" as TENANTS
APP -down-> DB : Pool PG
APP -down-> TENANTS : Pool PG
AUTH -down-> DB : Pool PG (usuarios)
Usuario --> APP : UI / API
Usuario --> AUTH : Login/Registro
}
package "Herramientas" {
[Nginx Proxy Manager] as NPM
[CloudBeaver] as DBVR
NPM ..> APP : proxy
NPM ..> AUTH : proxy
DBVR ..> DB : admin
DBVR ..> TENANTS : admin
}
@enduml
```
---
## Características principales
- **API REST** para entidades clave (roles, usuarios, categorías y productos).
- **Autenticación básica** (registro y login) con **hash de contraseñas** (bcrypt).
- **Multitenencia** con base `tenants` separada para aislar clientes/tiendas.
- **Docker Compose v2** con entornos de **desarrollo** y **producción**.
- **Herramientas integradas** (NPM + CloudBeaver) en un `compose.tools.yaml` aparte.
- **Scripts** de **backup/restauración de volúmenes** y **gestión de entornos**.
---
## Requisitos
- **Docker** y **Docker Compose v2** (recomendado).
- **Python 3.9+** (para scripts `suitecoffee.py`, backups y utilidades).
- **Node.js 20+** (sólo si vas a ejecutar servicios Node fuera de Docker).
---
## Inicio rápido
### Opción A — Gestor interactivo (recomendado)
1. Clona el repo y entra al directorio:
```bash
git clone https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git
cd SuiteCoffee
```
2. (Opcional) Crea/copía tus archivos `.env` para **app** y **auth** en `./services/<service>/.env.development` (ver sección de variables).
3. Ejecuta el gestor:
```bash
python3 suitecoffee.py
```
- Verás un **menú** para levantar **DESARROLLO** o **PRODUCCIÓN**.
- Desde ahí también puedes **levantar/apagar** las herramientas **NPM** y **CloudBeaver**.
4. Accede:
- App (dev): suele estar disponible via NPM o directamente dentro de la red, según tu configuración.
- Páginas simples: `/roles`, `/usuarios`, `/categorias`, `/productos` (servidas por `app`).
- Salud: `/health` en `app` y `auth`.
> Consejo: primero levanta **desarrollo/producción** y luego las **herramientas** para que existan las redes externas `suitecoffee_dev_net`/`suitecoffee_prod_net` que usa `compose.tools.yaml`.
### Opción B — Comandos Docker Compose (avanzado)
- **Desarrollo**:
```bash
docker compose -f compose.yaml -f compose.dev.yaml --env-file ./services/app/.env.development --env-file ./services/auth/.env.development -p suitecoffee_dev up -d
```
- **Producción**:
```bash
docker compose -f compose.yaml -f compose.prod.yaml --env-file ./services/app/.env.production --env-file ./services/auth/.env.production -p suitecoffee_prod up -d
```
> Los puertos se **exponen** para herramientas (NPM UI `:81`, CloudBeaver `:8978`); los servicios `app` y `auth` se **exponen dentro de la red** y se publican externamente a través de NPM.
---
## Variables de entorno
Crea un archivo `.env.development` (y uno `.env.production`) en **cada servicio** (`./services/app` y `./services/auth`). Variables comunes:
```dotenv
# Servidor
PORT=4000 # puerto HTTP del servicio
NODE_ENV=development # development | production
# Base de datos
DB_HOST=db # nombre del servicio postgres (o host)
DB_LOCAL_PORT=5432 # puerto de PG al que conectarse
DB_USER=postgres
DB_PASS=postgres
DB_NAME=suitecoffee_db # para 'db' (aplicación)
TENANTS_DB_NAME=tenants_db # si el servicio necesita apuntar a 'tenants'
```
> Ajusta `DB_HOST` a `db` o `tenants` según corresponda. En desarrollo, los alias útiles son `dev-db` y `dev-tenants`; en producción: `prod-db` y `prod-tenants`.
---
## Endpoints
### Servicio **app** (negocio)
- `GET /health`
- `GET /api/roles` — lista roles
- `POST /api/roles` — crea un rol
- `GET /api/usuarios` — lista usuarios
- `POST /api/usuarios` — crea un usuario
- `GET /api/categorias` — lista categorías
- `POST /api/categorias` — crea una categoría
- `GET /api/productos` — lista productos
- `POST /api/productos` — crea un producto
- Páginas estáticas simples para probar: `/roles`, `/usuarios`, `/categorias`, `/productos`
### Servicio **auth** (autenticación)
- `GET /health`
- `POST /register` — registro de usuario (password con **bcrypt**)
- `POST /auth/login` — inicio de sesión
> **Nota**: En esta etapa los endpoints son **básicos** y pensados para desarrollo/PoC. Ver la sección *Sugerencias de mejora* para próximos pasos (JWT, autorización, etc.).
---
## Estructura del proyecto
```
SuiteCoffee/
├─ services/
│ ├─ app/
│ │ ├─ src/
│ │ │ ├─ index.js # API y páginas simples
│ │ │ └─ pages/ # roles.html, usuarios.html, categorias.html, productos.html
│ │ ├─ .env.development # variables (ejemplo)
│ │ └─ .env.production
│ └─ auth/
│ ├─ src/
│ │ └─ index.js # /register y /auth/login
│ ├─ .env.development
│ └─ .env.production
├─ compose.yaml # base (db, tenants)
├─ compose.dev.yaml # entorno desarrollo (app, auth, db, tenants)
├─ compose.prod.yaml # entorno producción (app, auth, db, tenants)
├─ compose.tools.yaml # herramientas (NPM, CloudBeaver) con redes externas
├─ suitecoffee.py # gestor interactivo (Docker Compose)
├─ backup_compose_volumes.py # backups de volúmenes Compose
└─ restore_compose_volumes.py# restauración de volúmenes Compose
```
---
## Herramientas auxiliares (NPM y CloudBeaver)
Los servicios de **herramientas** están separados para poder usarlos con **ambos entornos** (dev y prod) a la vez. Se levantan con `compose.tools.yaml` y se conectan a las **redes externas** `suitecoffee_dev_net` y `suitecoffee_prod_net`.
- **Nginx Proxy Manager (NPM)**
Puertos: `80` (HTTP), `81` (UI). Volúmenes: `npm_data`, `npm_letsencrypt`.
- **CloudBeaver**
Puerto: `8978`. Volúmenes: `dbeaver_logs`, `dbeaver_workspace`.
> Si es la primera vez, arranca un entorno (dev/prod) para que Compose cree las redes; luego levanta las herramientas:
>
> ```bash
> docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
> docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
> ```
---
## Backups y restauración de volúmenes
Este repo incluye dos utilidades:
- `backup_compose_volumes.py` — detecta volúmenes de un proyecto de Compose (por **labels** y nombres) y los exporta a `tar.gz` usando un contenedor `alpine` temporal.
- `restore_compose_volumes.py` — permite restaurar esos `tar.gz` en volúmenes (útil para migraciones y pruebas).
**Ejemplos básicos**
```bash
# Listar ayuda
python3 backup_compose_volumes.py --help
# Respaldar volúmenes asociados a "suitecoffee_dev" en ./backups
python3 backup_compose_volumes.py --project suitecoffee_dev --output ./backups
# Restaurar un archivo a un volumen
python3 restore_compose_volumes.py --archive ./backups/suitecoffee_dev_suitecoffee-db-YYYYmmddHHMMSS.tar.gz --volume suitecoffee_dev_suitecoffee-db
```
> Consejo: si migraste manualmente y ves advertencias tipo “volume ... already exists but was not created by Docker Compose”, considera marcar el volumen como `external: true` en el YAML o recrearlo para que Compose lo etiquete correctamente.
---
## Comandos útiles
```bash
# Ver estado (menú interactivo)
python3 suitecoffee.py
# Levantar DEV/PROD por menú (con o sin --force-recreate)
python3 suitecoffee.py
# Levantar herramientas (también desde menú)
docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
# Inspeccionar servicios/volúmenes que Compose detecta desde los YAML
docker compose -f compose.yaml -f compose.dev.yaml config --services
docker compose -f compose.yaml -f compose.dev.yaml config --format json | jq .volumes
```
---
## Licencia
- **ISC** (ver `package.json`).
---
## Sugerencias de mejora
- **Autenticación y seguridad**
- Emitir **JWT** en el login y proteger rutas (roles/autorización por perfil).
- Configurar **CORS** por orígenes (en dev está abierto; en prod restringir).
- Añadir **ratelimit** y **helmet** en Express.
- **Esquema de datos y migraciones**
- Añadir migraciones automatizadas (p.ej. **Prisma**, **Knex**, **Sequelize** o SQL versionado) y seeds iniciales.
- Clarificar el **modelo multitenant**: por **BD por tenant** o **schema por tenant**; documentar estrategia.
- **Calidad & DX**
- Tests (unitarios e integración) y **CI** básico.
- Validación de entrada (**zod / joi**), manejo de errores consistente y logs estructurados.
- **Docker/DevOps**
- Documentar variables `.env` completas por servicio.
- Publicar imágenes de producción y usar `IMAGE:TAG` en `compose.prod.yaml` (evitar build en servidor).
- Añadir **healthchecks** a `app`/`auth` (ya hay ejemplos comentados).
- **Frontend**
- Reemplazar páginas HTML de prueba por un **frontend** (React/Vite) o una UI admin mínima.
- **Pequeños fixes**
- En los HTML de ejemplo corregir las referencias a campos `id_rol`, `id_categoria`, etc.
- Centralizar constantes (nombres de tablas/campos) y normalizar respuestas API.
---

1015
auth.index.js Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Capa_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1024 1024">
<!-- Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9) -->
<defs>
<style>
.st0 {
fill: #fff;
}
</style>
</defs>
<path class="st0" d="M1024,0v1024H0V0h1024ZM555.65,53.34c-9.62.5-42.47,33.75-50.29,42.27-52.83,57.58-92.54,133.71-99.27,212.63-9.61,112.64,65.25,175.4,107.41,269.2,52.92,117.75,31.19,241.15-37.67,346.37-5.24,8.01-19.02,22.41-21.61,30.02-2.38,7.01,4.2,10.95,10.05,6.97,98.88-80.26,173.94-198.57,145.59-331.12-19.98-93.4-85.71-170.1-121.65-256.47-40.46-97.24-10.37-194.22,47.61-276.58,5.77-8.2,22.16-24.87,25.06-32.31.97-2.5,1.81-4.69.97-7.43-.72-2.16-3.99-3.67-6.19-3.56Z"/>
<path d="M555.65,53.34c2.2-.12,5.46,1.4,6.19,3.56.85,2.74,0,4.92-.97,7.43-2.89,7.44-19.28,24.11-25.06,32.31-57.98,82.36-88.07,179.34-47.61,276.58,35.94,86.37,101.67,163.07,121.65,256.47,28.35,132.55-46.71,250.87-145.59,331.12-5.85,3.99-12.43.04-10.05-6.97,2.59-7.61,16.36-22.01,21.61-30.02,68.86-105.22,90.59-228.62,37.67-346.37-42.16-93.81-117.02-156.57-107.41-269.2,6.74-78.93,46.45-155.06,99.27-212.63,7.81-8.51,40.66-41.77,50.29-42.27Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

View File

@ -1,44 +1,45 @@
# docker-compose.overrride.yml
# Docker Comose para entorno de desarrollo o development.
# compose.dev.yaml
# Docker Compose para entorno de desarrollo.
services:
app:
image: node:20-bookworm
expose:
- ${APP_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/app:/app:rw
- ./services/app/node_modules:/app/node_modules
# - ./services/shared:/app/shared
env_file:
- ./services/app/.env.development
environment:
- NODE_ENV=${NODE_ENV}
NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
expose:
- ${APP_PORT}
networks:
net:
aliases: [dev-app]
command: npm run dev
auth:
image: node:20-bookworm
expose:
- ${AUTH_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/auth:/app:rw
- ./services/auth/node_modules:/app/node_modules
env_file:
- ./services/auth/.env.development
environment:
- NODE_ENV=${NODE_ENV}
command: npm run dev
networks:
net:
aliases: [dev-auth]
# auth:
# image: node:20-bookworm
# working_dir: /app
# user: "${UID:-1000}:${GID:-1000}"
# volumes:
# - ./services/auth:/app:rw
# - ./services/auth/node_modules:/app/node_modules
# - ./services/shared:/app/shared
# env_file:
# - ./services/auth/.env.development
# environment:
# NODE_ENV: development # <- fuerza el entorno para que el loader tome .env.development
# expose:
# - ${AUTH_PORT}
# networks:
# net:
# aliases: [dev-auth]
# command: npm run dev
db:
image: postgres:16
@ -63,11 +64,91 @@ services:
networks:
net:
aliases: [dev-tenants]
#################
### Authentik ###
#################
ak-db:
image: docker.io/library/postgres:16-alpine
env_file:
- .env.development
environment:
POSTGRES_DB: ${AK_PG_DB:-authentik}
POSTGRES_PASSWORD: ${AK_PG_PASS:?database password required}
POSTGRES_USER: ${AK_PG_USER:-authentik}
volumes:
- authentik-db:/var/lib/postgresql/data
networks:
net:
aliases: [dev-ak-db]
ak-redis:
image: docker.io/library/redis:alpine
command: --save 60 1 --loglevel warning
networks:
net:
aliases: [dev-ak-redis]
volumes:
- ak-redis:/data
ak:
image: ghcr.io/goauthentik/server:latest
env_file:
- .env.development
command: server
environment:
AUTHENTIK_DEBUG: false
AUTHENTIK_POSTGRESQL__HOST: ak-db
AUTHENTIK_POSTGRESQL__NAME: ${AK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AK_PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${AK_PG_USER:-authentik}
AUTHENTIK_REDIS__HOST: ak-redis
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
networks:
net:
aliases: [dev-authentik]
volumes:
- ./authentik-media:/media
- ./authentik-custom-templates:/templates
ak-worker:
image: ghcr.io/goauthentik/server:latest
command: worker
environment:
AUTHENTIK_POSTGRESQL__HOST: ak-db
AUTHENTIK_POSTGRESQL__NAME: ${AK_PG_DB:-authentik}
AUTHENTIK_POSTGRESQL__PASSWORD: ${AK_PG_PASS}
AUTHENTIK_POSTGRESQL__USER: ${AK_PG_USER:-authentik}
AUTHENTIK_REDIS__HOST: ak-redis
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
AUTHENTIK_HTTP__TRUSTED_PROXY__CIDRS: "0.0.0.0/0,::/0"
AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS: ${AUTHENTIK_SECURITY__CSRF_TRUSTED_ORIGINS}
AUTHENTIK_COOKIE__DOMAIN: ${AUTHENTIK_COOKIE__DOMAIN}
networks:
net:
aliases: [dev-ak-work]
user: root
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./authentik-media:/media
- ./authentik-certs:/certs
- ./authentik-custom-templates:/templates
volumes:
tenants-db:
suitecoffee-db:
authentik-db:
ak-redis:
networks:
net:
driver: bridge
driver: bridge

68
compose.manso.yaml Normal file
View File

@ -0,0 +1,68 @@
# docker-compose.overrride.yml
# Docker Comose para entorno de desarrollo o development.
services:
manso:
image: node:20-bookworm
# depends_on:
# db:
# condition: service_healthy
# tenants:
# condition: service_healthy
# expose:
# - ${APP_LOCAL_PORT}
working_dir: /app
user: "${UID:-1000}:${GID:-1000}"
volumes:
- ./services/manso:/app:rw
- ./services/manso/node_modules:/app/node_modules
env_file:
- ./services/manso/.env.development
environment:
- NODE_ENV=${NODE_ENV}
networks:
net:
aliases: [manso]
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
command: npm run dev
profiles: [manso]
restart: unless-stopped
# db:
# image: postgres:16
# environment:
# POSTGRES_DB: ${DB_NAME}
# POSTGRES_USER: ${DB_USER}
# POSTGRES_PASSWORD: ${DB_PASS}
# volumes:
# - suitecoffee-db:/var/lib/postgresql/data
# networks:
# net:
# aliases: [dev-db]
# tenants:
# image: postgres:16
# environment:
# POSTGRES_DB: ${TENANTS_DB_NAME}
# POSTGRES_USER: ${TENANTS_DB_USER}
# POSTGRES_PASSWORD: ${TENANTS_DB_PASS}
# volumes:
# - tenants-db:/var/lib/postgresql/data
# networks:
# net:
# aliases: [dev-tenants]
volumes:
tenants-db:
suitecoffee-db:
networks:
net:
driver: bridge

View File

@ -9,7 +9,7 @@ services:
context: ./services/app
dockerfile: Dockerfile.production
expose:
- ${APP_LOCAL_PORT}
- ${APP_PORT}
volumes:
- ./services/app:/app
env_file:
@ -26,7 +26,7 @@ services:
context: ./services/auth
dockerfile: Dockerfile.production
expose:
- ${AUTH_LOCAL_PORT}
- ${AUTH_PORT}
volumes:
- ./services/auth:/app
env_file:

View File

@ -1,4 +1,4 @@
# compose.tools.yaml
# $ compose.tools.yaml
name: suitecoffee_tools
services:
@ -14,7 +14,7 @@ services:
- dbeaver_logs:/opt/cloudbeaver/logs
- dbeaver_workspace:/opt/cloudbeaver/workspace
networks:
suitecoffee_prod_net: {}
# suitecoffee_prod_net: {}
suitecoffee_dev_net: {}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8978 || exit 1"]
@ -37,7 +37,7 @@ services:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
networks:
suitecoffee_prod_net: {}
# suitecoffee_prod_net: {}
suitecoffee_dev_net: {}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
@ -50,8 +50,8 @@ services:
networks:
suitecoffee_dev_net:
external: true
suitecoffee_prod_net:
external: true
# suitecoffee_prod_net:
# external: true
volumes:
npm_data:

View File

@ -1,29 +1,30 @@
# compose.yml
# Comose base
# Compose base
name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
services:
# app:
# depends_on:
# db:
# condition: service_healthy
# tenants:
# condition: service_healthy
# healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
# start_period: 20s
# restart: unless-stopped
app:
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
restart: unless-stopped
# auth:
# depends_on:
# db:
# condition: service_healthy
# ak:
# condition: service_started
# healthcheck:
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_DOCKER_PORT}/health || exit 1"]
# test: ["CMD-SHELL", "curl -fsS http://localhost:${AUTH_PORT}/health || exit 1"]
# interval: 10s
# timeout: 3s
# retries: 10
@ -52,4 +53,43 @@ services:
timeout: 3s
retries: 20
start_period: 10s
restart: unless-stopped
restart: unless-stopped
ak-db:
image: postgres:16-alpine
healthcheck:
test: ["CMD-SHELL", "pg_isready -d ${AK_PG_DB} -U ${AK_PG_USER} || exit 1"]
interval: 30s
retries: 5
start_period: 20s
timeout: 5s
restart: unless-stopped
ak-redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
timeout: 5s
retries: 5
start_period: 10s
restart: unless-stopped
ak:
image: ghcr.io/goauthentik/server:latest
depends_on:
ak-db:
condition: service_healthy
ak-redis:
condition: service_healthy
restart: unless-stopped
ak-worker:
image: ghcr.io/goauthentik/server:latest
depends_on:
ak-db:
condition: service_healthy
ak-redis:
condition: service_healthy
restart: unless-stopped

316
docs/ak.md Normal file
View File

@ -0,0 +1,316 @@
# Documentación detallada de funciones: `ak.js`
Este documento fue generado automáticamente a partir del archivo `ak.js` proporcionado. Incluye una sección por función detectada, con firma, ubicación, descripción, parámetros, valores de retorno, posibles errores y un ejemplo de uso.
> **Nota:** Las descripciones y tipos se infieren heurísticamente a partir de los nombres, comentarios y cuerpo de cada función. Revise y ajuste donde corresponda.
---
### `getConfig`
**Firma:** `function getConfig()`
**Ubicación:** línea 28
**Comentario previo en el código:**
```js
// ------------------------------------------------------------
// Cliente para la API Admin de Authentik (v3)
// - Sin dependencias externas (usa fetch nativo de Node >=18)
// - ESM compatible
// - Timeouts, reintentos opcionales y mensajes de error claros
// - Compatible con services/auth/src/index.js actual
// ------------------------------------------------------------
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
// Devuelve la URL base y el Token que se leyó desde .env
/**
* @typedef {Object} AkCfg
* @property {string} BASE // p.ej. "https://idp.example.com"
* @property {string} TOKEN // bearer
*/
/**
* @typedef {Object} AkOpts
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
* @property {any} [body]
* @property {number} [timeoutMs=10000]
* @property {number} [retries=0]
* @property {Record<string,string>} [headers]
*/
```
**Descripción:** Obtiene Config.
**Parámetros:** *(sin parámetros)*
**Retorna (aprox.):** `{ BASE, TOKEN }`
**Errores/excepciones:**
- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL')`.
- Puede lanzar `Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN')`.
**Ejemplo de uso:**
```js
const result = getConfig();
console.log(result);
```
---
### `akListGroups`
**Firma:** `export async function akListGroups(search = "")`
**Ubicación:** línea 60
**Comentario previo en el código:**
```js
// Listar grupos con búsqueda por nombre/slug
```
**Descripción:** Función `akListGroups`. Interactúa con una API HTTP.
**Parámetros:**
- `search` (opcional, por defecto = `""`): descripción.
**Retorna (aprox.):** `[]`
**Ejemplo de uso:**
```js
const result = await akListGroups(search);
console.log(result);
```
---
### `akPatchUserAttributes`
**Firma:** `export async function akPatchUserAttributes(userPk, partialAttrs = {})`
**Ubicación:** línea 73
**Descripción:** Función `akPatchUserAttributes`.
**Parámetros:**
- `userPk`: descripción.
- `partialAttrs` (opcional, por defecto = `{}`): descripción.
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
**Ejemplo de uso:**
```js
const result = await akPatchUserAttributes(userPk, partialAttrs);
console.log(result);
```
---
### `akEnsureGroupForTenant`
**Firma:** `export async function akEnsureGroupForTenant(tenantHex)`
**Ubicación:** línea 97
**Descripción:** Función `akEnsureGroupForTenant`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
**Parámetros:**
- `tenantHex`: descripción.
**Retorna (aprox.):** `found.pk ?? found.id`
**Errores/excepciones:**
- Puede lanzar `TypeError("akEnsureGroupForTenant: `tenantHex` is required")`.
**Ejemplo de uso:**
```js
const result = await akEnsureGroupForTenant(tenantHex);
console.log(result);
```
---
### `akAddUserToGroup`
**Firma:** `export async function akAddUserToGroup(userPk, groupPk)`
**Ubicación:** línea 130
**Descripción:** Función `akAddUserToGroup`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
**Parámetros:**
- `userPk`: descripción.
- `groupPk`: descripción.
**Retorna (aprox.):** `await akPOST("/core/group_memberships/", { body: { user, group } })`
**Errores/excepciones:**
- Puede lanzar `TypeError("akAddUserToGroup: `userPk` is required")`.
- Puede lanzar `TypeError("akAddUserToGroup: `groupPk` is required")`.
**Ejemplo de uso:**
```js
const result = await akAddUserToGroup(userPk, groupPk);
console.log(result);
```
---
### `request`
**Firma:** `export async function request(method, path, opts = {}, cfg)`
**Ubicación:** línea 167
**Comentario previo en el código:**
```js
/**
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
* @param {AkOpts} [opts]
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
* @returns {Promise<any|null>}
*/
```
**Descripción:** Función `request`.
**Parámetros:**
- `method`: descripción.
- `path`: descripción.
- `opts` (opcional, por defecto = `{}`): descripción.
- `cfg`: descripción.
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
**Ejemplo de uso:**
```js
const result = await request(method, path, opts, cfg);
console.log(result);
```
---
### `akFindUserByEmail`
**Firma:** `export async function akFindUserByEmail(email)`
**Ubicación:** línea 262
**Comentario previo en el código:**
```js
// ------------------------------------------------------------
// Funciones públicas
// ------------------------------------------------------------
```
**Descripción:** Función `akFindUserByEmail`. Interactúa con una API HTTP.
**Parámetros:**
- `email`: descripción.
**Retorna (aprox.):** `null`
**Errores/excepciones:**
- Puede lanzar `TypeError("akFindUserByEmail: `email` is required")`.
**Ejemplo de uso:**
```js
const result = await akFindUserByEmail(email);
console.log(result);
```
---
### `akCreateUser`
**Firma:** `export async function akCreateUser(p = {})`
**Ubicación:** línea 298
**Descripción:** Función `akCreateUser`.
**Parámetros:**
- `p` (opcional, por defecto = `{}`): descripción.
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
**Ejemplo de uso:**
```js
const result = await akCreateUser(p);
console.log(result);
```
---
### `akSetPassword`
**Firma:** `export async function akSetPassword(userPk, password, requireChange = true)`
**Ubicación:** línea 349
**Descripción:** Función `akSetPassword`. Interactúa con una API HTTP. Maneja errores con bloques try/catch.
**Parámetros:**
- `userPk`: descripción.
- `password`: descripción.
- `requireChange` (opcional, por defecto = `true`): descripción.
**Retorna (aprox.):** `true`
**Errores/excepciones:**
- Puede lanzar `TypeError("akSetPassword: `userPk` is required")`.
- Puede lanzar `TypeError("akSetPassword: `password` is required")`.
**Ejemplo de uso:**
```js
const result = await akSetPassword(userPk, password, requireChange);
console.log(result);
```
---
### `akResolveGroupIdByName`
**Firma:** `export async function akResolveGroupIdByName(name)`
**Ubicación:** línea 373
**Descripción:** Función `akResolveGroupIdByName`.
**Parámetros:**
- `name`: descripción.
**Retorna (aprox.):** `byName?.pk ?? byName?.id ?? null`
**Errores/excepciones:**
- Puede lanzar `TypeError("akResolveGroupIdByName: `name` is required")`.
**Ejemplo de uso:**
```js
const result = await akResolveGroupIdByName(name);
console.log(result);
```
---
### `akResolveGroupId`
**Firma:** `export async function akResolveGroupId({ id, pk, uuid, name, slug } = {})`
**Ubicación:** línea 389
**Descripción:** Función `akResolveGroupId`.
**Parámetros:**
- `{ id`: descripción.
- `pk`: descripción.
- `uuid`: descripción.
- `name`: descripción.
- `slug }` (opcional, por defecto = `{}`): descripción.
**Retorna:** Puede no retornar valor explícito (`void`) o retorna según la rama de ejecución.
**Ejemplo de uso:**
```js
const result = await akResolveGroupId({ id, pk, uuid, name, slug });
console.log(result);
```
---
### `toPk`
**Firma:** `const => toPk(v)`
**Ubicación:** línea 390
**Descripción:** Función `toPk`.
**Parámetros:**
- `v`: descripción.
**Retorna (aprox.):** `Number.isFinite(n) ? n : String(v)`
**Ejemplo de uso:**
```js
const result = toPk(v);
console.log(result);
```
---

0
docs/db's.md Normal file
View File

View File

@ -0,0 +1,38 @@
# ===== Runtime =====
NODE_ENV=development
PORT=3030
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
SESSION_COOKIE_NAME=sc.sid
REDIS_URL=redis://ak-redis:6379
# ===== DB principal (metadatos de SuiteCoffee) =====
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
DB_HOST=dev-db
DB_PORT=5432
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# ===== DB tenants (Tenants de SuiteCoffee) =====
TENANTS_HOST=dev-tenants
TENANTS_DB=dev-postgres
TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
TENANT_INIT_SQL=/app/src/db/initTenant_v2.sql
OIDC_CLIEN_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
OIDC_CONFIG_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/.well-known/openid-configuration
OIDC_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
OIDC_AUTHORIZE_URL=https://sso.suitecoffee.uy/application/o/authorize/
OIDC_TOKEN_URL=https://sso.suitecoffee.uy/application/o/token/
OIDC_USERINFO_URL=https://sso.suitecoffee.uy/application/o/userinfo/
OIDC_LOGOUT_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/end-session/
OIDC_JWKS_URL=https://sso.suitecoffee.uy/application/o/suitecoffee/jwks/
OIDC_LOGIN_URL=https://sso.suitecoffee.uy
APP_BASE_URL=https://suitecoffee.uy

View File

@ -0,0 +1,22 @@
NODE_ENV=production # Entorno de desarrollo
PORT=3000 # Variables del servicio -> suitecoffee-app
# Variables del servicio -> suitecoffee-db de suitecoffee-app
DB_HOST=prod-tenants
# Nombre de la base de datos
DB_NAME=postgres
# Usuario y contraseña
DB_USER=postgres
DB_PASS=postgres
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja

File diff suppressed because it is too large Load Diff

View File

@ -15,13 +15,26 @@
"nodemon": "^3.1.10"
},
"dependencies": {
"bcrypt": "^6.0.0",
"chalk": "^5.6.0",
"connect-redis": "^9.0.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"morgan": "^1.10.1",
"node-fetch": "^3.3.2",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
"pg-format": "^1.0.4",
"redis": "^5.8.2",
"serve-favicon": "^2.5.1"
},
"keywords": [],
"description": ""

181
services/app/src/api/api.js Normal file
View File

@ -0,0 +1,181 @@
// // ----------------------------------------------------------
// // API
// // ----------------------------------------------------------
// app.get('/api/tables', async (_req, res) => {
// res.json(ALLOWED_TABLES);
// });
// app.get('/api/schema/:table', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const client = await getClient();
// try {
// const columns = await loadColumns(client, table);
// const fks = await loadForeignKeys(client, table);
// const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
// res.json({ table, columns: enriched });
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/options/:table/:column', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const column = req.params.column;
// if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
// const client = await getClient();
// try {
// const fks = await loadForeignKeys(client, table);
// const fk = fks[column];
// if (!fk) return res.json([]);
// const refTable = fk.foreign_table;
// const refId = fk.foreign_column;
// const labelCol = await pickLabelColumn(client, refTable);
// const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
// const result = await client.query(sql);
// res.json(result.rows);
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/table/:table', async (req, res) => {
// try {
// const table = ensureTable(req.params.table);
// const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
// const client = await getClient();
// try {
// const pks = await loadPrimaryKey(client, table);
// const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
// const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
// const result = await client.query(sql);
// // Normalizar: siempre devolver objetos {col: valor}
// const colNames = result.fields.map(f => f.name);
// let rows = result.rows;
// if (rows.length && Array.isArray(rows[0])) {
// rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
// }
// res.json(rows);
// } finally { client.release(); }
// } catch (e) {
// res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
// }
// });
// app.post('/api/table/:table', async (req, res) => {
// const table = ensureTable(req.params.table);
// const payload = req.body || {};
// try {
// const client = await getClient();
// try {
// const columns = await loadColumns(client, table);
// const insertable = columns.filter(c =>
// !c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
// );
// const allowedCols = new Set(insertable.map(c => c.column_name));
// const cols = [];
// const vals = [];
// const params = [];
// let idx = 1;
// for (const [k, v] of Object.entries(payload)) {
// if (!allowedCols.has(k)) continue;
// if (!VALID_IDENT.test(k)) continue;
// cols.push(q(k));
// vals.push(`$${idx++}`);
// params.push(v);
// }
// if (!cols.length) {
// const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
// res.status(201).json({ inserted: rows[0] });
// } else {
// const { rows } = await client.query(
// `INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
// params
// );
// res.status(201).json({ inserted: rows[0] });
// }
// } catch (e) {
// if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
// if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
// if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
// if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
// throw e;
// }
// } catch (e) {
// res.status(400).json({ error: e.message });
// }
// });
// app.get('/api/comandas', async (req, res, next) => {
// try {
// const estado = (req.query.estado || '').trim() || null;
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
// const { rows } = await mainPool.query(
// `SELECT * FROM public.f_comandas_resumen($1, $2)`,
// [estado, limit]
// );
// res.json(rows);
// } catch (e) { next(e); }
// });
// // Detalle de una comanda (con nombres de productos)
// // GET /api/comandas/:id/detalle
// app.get('/api/comandas/:id/detalle', (req, res, next) =>
// mainPool.query(
// `SELECT id_det_comanda, id_producto, producto_nombre,
// cantidad, pre_unitario, subtotal, observaciones
// FROM public.v_comandas_detalle_items
// WHERE id_comanda = $1::int
// ORDER BY id_det_comanda`,
// [req.params.id]
// )
// .then(r => res.json(r.rows))
// .catch(next)
// );
// // Cerrar comanda (setea estado y fec_cierre en DB)
// app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
// try {
// const id = Number(req.params.id);
// if (!Number.isInteger(id) || id <= 0) {
// return res.status(400).json({ error: 'id inválido' });
// }
// const { rows } = await mainPool.query(
// `SELECT public.f_cerrar_comanda($1) AS data`,
// [id]
// );
// if (!rows.length || rows[0].data === null) {
// return res.status(404).json({ error: 'Comanda no encontrada' });
// }
// res.json(rows[0].data);
// } catch (err) { next(err); }
// });
// Abrir (reabrir) comanda
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await mainPool.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
} catch (err) { next(err); }
});

230
services/app/src/api/rpc.js Normal file
View File

@ -0,0 +1,230 @@
// // GET producto + receta
// app.get('/api/rpc/get_producto/:id', async (req, res) => {
// const id = Number(req.params.id);
// const { rows } = await mainPool.query('SELECT public.get_producto($1) AS data', [id]);
// res.json(rows[0]?.data || {});
// });
// // POST guardar producto + receta
// app.post('/api/rpc/save_producto', async (req, res) => {
// try {
// // console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
// const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
// const { id_producto = null, nombre, img_producto = null, precio = 0, activo = true, id_categoria = null, receta = [] } = req.body || {};
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta || [])];
// const { rows } = await mainPool.query(q, params);
// res.json(rows[0] || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'save_producto failed' });
// }
// });
// // GET MP + proveedores
// app.get('/api/rpc/get_materia/:id', async (req, res) => {
// const id = Number(req.params.id);
// try {
// const { rows } = await mainPool.query('SELECT public.get_materia_prima($1) AS data', [id]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'get_materia failed' });
// }
// });
// // SAVE MP + proveedores (array)
// app.post('/api/rpc/save_materia', async (req, res) => {
// const { id_mat_prima = null, nombre, unidad, activo = true, proveedores = [] } = req.body || {};
// try {
// const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
// const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores || [])];
// const { rows } = await mainPool.query(q, params);
// res.json(rows[0] || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'save_materia failed' });
// }
// });
// // POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
// app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
// try {
// const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
// const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
// const { rows } = await mainPool.query(sql, [JSON.stringify(docs)]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
// }
// });
// // POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
// app.post('/api/rpc/import_asistencia', async (req, res) => {
// try {
// const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
// const origen = req.body?.origen || null;
// const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
// const { rows } = await mainPool.query(sql, [JSON.stringify(registros), origen]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'import_asistencia failed' });
// }
// });
// // Consultar datos de asistencia (raw + pares) para un usuario y rango
// app.post('/api/rpc/asistencia_get', async (req, res) => {
// try {
// const { doc, desde, hasta } = req.body || {};
// const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
// const { rows } = await mainPool.query(sql, [doc, desde, hasta]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
// }
// });
// // Editar un registro crudo y recalcular pares
// app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
// try {
// const { id_raw, fecha, hora, modo } = req.body || {};
// const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
// const { rows } = await mainPool.query(sql, [id_raw, fecha, hora, modo ?? null]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
// }
// });
// // Eliminar un registro crudo y recalcular pares
// app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
// try {
// const { id_raw } = req.body || {};
// const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
// const { rows } = await mainPool.query(sql, [id_raw]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
// }
// });
// // POST /api/rpc/report_tickets { year }
// app.post('/api/rpc/report_tickets', async (req, res) => {
// try {
// const y = parseInt(req.body?.year ?? req.query?.year, 10);
// const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
// ? y
// : (new Date()).getFullYear();
// const { rows } = await mainPool.query(
// 'SELECT public.report_tickets_year($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_tickets error:', e);
// res.status(500).json({
// error: 'report_tickets failed',
// message: e.message, detail: e.detail, where: e.where, code: e.code
// });
// }
// });
// // POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
// app.post('/api/rpc/report_asistencia', async (req, res) => {
// try {
// let { desde, hasta } = req.body || {};
// // defaults si vienen vacíos/invalidos
// const re = /^\d{4}-\d{2}-\d{2}$/;
// if (!re.test(desde) || !re.test(hasta)) {
// const end = new Date();
// const start = new Date(end); start.setDate(end.getDate() - 30);
// desde = start.toISOString().slice(0, 10);
// hasta = end.toISOString().slice(0, 10);
// }
// const { rows } = await mainPool.query(
// 'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_asistencia error:', e);
// res.status(500).json({
// error: 'report_asistencia failed',
// message: e.message, detail: e.detail, where: e.where, code: e.code
// });
// }
// });
// // Guardar (insert/update)
// app.post('/api/rpc/save_compra', async (req, res) => {
// try {
// const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
// const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
// const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
// const { rows } = await mainPool.query(sql, args);
// res.json(rows[0]); // { id_compra, total }
// } catch (e) {
// console.error('save_compra error:', e);
// res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
// }
// });
// // Obtener para editar
// app.post('/api/rpc/get_compra', async (req, res) => {
// try {
// const { id_compra } = req.body || {};
// const sql = `SELECT public.get_compra($1::int) AS data`;
// const { rows } = await mainPool.query(sql, [id_compra]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'get_compra failed' });
// }
// });
// // Eliminar
// app.post('/api/rpc/delete_compra', async (req, res) => {
// try {
// const { id_compra } = req.body || {};
// await mainPool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
// res.json({ ok: true });
// } catch (e) {
// console.error(e); res.status(500).json({ error: 'delete_compra failed' });
// }
// });
// // POST /api/rpc/report_gastos { year: 2025 }
// app.post('/api/rpc/report_gastos', async (req, res) => {
// try {
// const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
// const { rows } = await mainPool.query(
// 'SELECT public.report_gastos($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_gastos error:', e);
// res.status(500).json({
// error: 'report_gastos failed',
// message: e.message, detail: e.detail, code: e.code
// });
// }
// });
// // (Opcional) GET para probar rápido desde el navegador:
// // /api/rpc/report_gastos?year=2025
// app.get('/api/rpc/report_gastos', async (req, res) => {
// try {
// const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
// const { rows } = await mainPool.query(
// 'SELECT public.report_gastos($1::int) AS j', [year]
// );
// res.json(rows[0].j);
// } catch (e) {
// console.error('report_gastos error:', e);
// res.status(500).json({
// error: 'report_gastos failed',
// message: e.message, detail: e.detail, code: e.code
// });
// }
// });

View File

@ -1,250 +1,377 @@
// app/src/index.js
import chalk from 'chalk'; // Colores!
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import cors from 'cors';
import { Pool } from 'pg';
// // services/app/src/index.js
// // ------------------------------------------------------------
// // SuiteCoffee — Servicio APP (UI + APIs negocio)
// // - Vistas EJS en ./views (dashboard.ejs, comandas.ejs, etc.)
// // - Sesión compartida con AUTH (cookie: sc.sid, Redis)
// // ------------------------------------------------------------
import 'dotenv/config'; // Variables de entorno directamente
// import dotenv from 'dotenv';
import favicon from 'serve-favicon'; // Favicon
import cors from 'cors'; // Seguridad en solicitudes de orige
import { Pool } from 'pg'; // Controlador node-postgres
import path from 'node:path'; // Rutas del servidor
import { fileURLToPath } from 'url'; // Converts a file:// URL string or URL object into a platform-specific file
import expressLayouts from 'express-ejs-layouts';
import express from 'express'; // Framework para enderizado de apps Web
import { jwtVerify, createRemoteJWKSet } from "jose";
import cookieParser from 'cookie-parser';
import { loadColumns, loadForeignKeys, loadPrimaryKey, pickLabelColumn } from "./utilities/cargaEnVista.js";
import { createRedisSession } from "../shared/middlewares/redisConnect.js";
// // ----------------------------------------------------------
// // Utilidades
// // ----------------------------------------------------------
// Rutas
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
// const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
// Variables de Entorno
import dotenv, { config } from 'dotenv';
// // Identificadores SQL -> comillas dobles y escape correcto
// const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
// const qi = (ident) => `"${String(ident).replace(/"/g, '""')}"`;
// const CLEAN_HEX = (s) => (String(s || '').toLowerCase().replace(/[^0-9a-f]/g, '') || null);
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
try {
if (process.env.NODE_ENV === 'development') {
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
} else if (process.env.NODE_ENV === 'stage') {
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
} else if (process.env.NODE_ENV === 'production') {
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
// Función para verificar que ciertas variables de entorno estén definida
function checkRequiredEnvVars(...requiredKeys) {
const missingKeys = requiredKeys.filter((key) => !process.env[key]); // Filtramos las que NO existen en process.env
if (missingKeys.length > 0) { // Si falta alguna, mostramos una advertencia
console.warn(
`[APP] No se encontraron las siguientes variables de entorno: ${missingKeys.join(', ')}`
);
}
} catch (error) {
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
}
// Renderiado
// ¿Está permitida la tabla?
function ensureTable(name) {
const t = String(name || '').toLowerCase();
if (!ALLOWED_TABLES.includes(t)) throw new Error(`Tabla ${t} no permitida`);
return t;
}
//
async function getClient() {
const client = await mainPool.connect();
return client;
}
// -----------------------------------------------------------------------------
// Validación de entorno mínimo (ajusta nombres si difieren)
// -----------------------------------------------------------------------------
checkRequiredEnvVars(
// Sesión
'SESSION_SECRET', 'REDIS_URL',
// DB principal
'DB_HOST', 'DB_NAME', 'DB_USER', 'DB_PASS',
// DB de tenants
'TENANTS_HOST', 'TENANTS_DB', 'TENANTS_USER', 'TENANTS_PASS'
);
// ----------------------------------------------------------
// App
// ----------------------------------------------------------
const app = express();
app.use(cors());
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
app.disable("x-powered-by");
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(express.static(path.join(__dirname, 'pages')));
// Configuración de conexión PostgreSQL
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT
};
// ----------------------------------------------------------
// Motor de vistas EJS
// ----------------------------------------------------------
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");
app.set("layout", "layouts/main");
app.use(expressLayouts);
app.use(cookieParser(process.env.SESSION_SECRET));
const pool = new Pool(dbConfig);
// Archivos estáticos que fuerzan la re-descarga de arhivos
app.use(express.static(path.join(__dirname, "public"), {
etag: false, maxAge: 0,
setHeaders: (res, path) => {
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
}
}));
app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), { maxAge: '1y' }));
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), { maxAge: '1y' }));
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// ----------------------------------------------------------
// Configuración de Pool principal a PostgreSQL
// ----------------------------------------------------------
const mainPool = new Pool({
host: process.env.DB_HOST || '',
database: process.env.DB_NAME || '',
port: Number(process.env.DB_PORT || 5432),
user: process.env.DB_USER || '',
password: process.env.DB_PASS || '',
// ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
max: -1,
idleTimeoutMillis: 30_000,
});
// ----------------------------------------------------------
// Configuración Pool de Tenants a PostgreSQL
// ----------------------------------------------------------
const tenantsPool = new Pool({
host: process.env.TENANTS_HOST,
database: process.env.TENANTS_DB,
port: Number(process.env.TENANTS_PORT || 5432),
user: process.env.TENANTS_USER,
password: process.env.TENANTS_PASS,
max: -1,
idleTimeoutMillis: 30_000,
});
// ----------------------------------------------------------
// Seguridad: Tablas permitidas
// ----------------------------------------------------------
// const ALLOWED_TABLES = [
// 'roles', 'usuarios', 'usua_roles',
// 'categorias', 'productos',
// 'clientes', 'mesas',
// 'comandas', 'deta_comandas',
// 'proveedores', 'compras', 'deta_comp_producto',
// 'mate_primas', 'deta_comp_materias',
// 'prov_producto', 'prov_mate_prima',
// 'receta_producto', 'asistencia_resumen_diario',
// 'asistencia_intervalo', 'vw_compras'
// ];
// -----------------------------------------------------------------------------
// Sesión (Redis) — misma cookie que AUTH
// -----------------------------------------------------------------------------
const PORT = process.env.PORT || 3030;
const ISSUER = process.env.AUTHENTIK_ISSUER?.replace(/\/?$/, "/");
const CLIENT_ID = process.env.OIDC_CLIENT_ID;
const SSO_ENTRY_URL = process.env.SSO_ENTRY_URL || "https://sso.suitecoffee.uy";
// 1) SIEMPRE montar sesión ANTES de las rutas
const { sessionMw, trustProxy } = await createRedisSession();
app.use(sessionMw);
const JWKS = createRemoteJWKSet(new URL(`${ISSUER}jwks/`));
async function verifyIdToken(idToken) {
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: ISSUER.replace(/\/$/, ""),
audience: CLIENT_ID,
});
return payload;
}
function requireToken(req, res, next) {
const id = req.session?.tokens?.id_token; // <- defensivo
if (!id) return res.redirect(302, SSO_ENTRY_URL);
next();
}
app.get("/", requireToken, async (req, res) => {
try {
const idToken = req.session?.tokens?.id_token; // <- defensivo
if (!idToken) return res.redirect(302, SSO_ENTRY_URL);
const claims = await verifyIdToken(idToken);
const email = claims.email || claims.preferred_username || "sin-email";
res.json({ usuario: { email, sub: claims.sub } });
} catch (e) {
console.error("/ verificación ID token", e);
res.redirect(302, SSO_ENTRY_URL);
}
});
// // -----------------------------------------------------------------------------
// // Comprobaciones de tenants en DB principal
// // -----------------------------------------------------------------------------
// // Abre un client al DB de tenants y fija search_path al esquema del usuario
// async function withTenant(req, res, next) {
// try {
// const hex = CLEAN_HEX(req.session?.user?.tenant_uuid);
// if (!hex) return res.status(400).json({ error: 'tenant-missing' });
// const schema = `schema_tenant_${hex}`;
// const client = await tenantsPool.connect();
// // Fijar search_path para que las consultas apunten al esquema del tenant
// await client.query(`SET SESSION search_path TO ${qi(schema)}, public`);
// // Hacemos el client accesible para los handlers de routes.legacy.js
// req.pg = client;
// // Liberar el client al finalizar la respuesta
// const release = () => {
// try { client.release(); } catch {}
// };
// res.on('finish', release);
// res.on('close', release);
// next();
// } catch (e) {
// next(e);
// }
// }
// // ----------------------------------------------------------
// // Rutas de UI
// // ----------------------------------------------------------
// app.get("/inicio", (req, res) => {
// try {
// const safeUser = req.session?.user || null;
// const safeCookies = req.cookies || {};
// const safeSession = req.session ? JSON.parse(JSON.stringify(req.session)) : {};
// res.locals.pageTitle = "Inicio";
// res.locals.pageId = "inicio"; // <- importante
// return res.render('inicio', {
// user: safeUser,
// cookies: safeCookies,
// session: safeSession,
// });
// } catch (e) {
// next(e);
// }
// });
// app.get("/dashboard", requireToken,(req, res) => {
// res.locals.pageTitle = "Dashboard";
// res.locals.pageId = "dashboard"; // <- importante
// res.render("dashboard");
// });
// app.get("/comandas", requireToken,(req, res) => {
// res.locals.pageTitle = "Comandas";
// res.locals.pageId = "comandas"; // <- importante para el sidebar contextual
// res.render("comandas");
// });
// app.get("/estadoComandas", requireToken,(req, res) => {
// res.locals.pageTitle = "Estado de Comandas";
// res.locals.pageId = "estadoComandas";
// res.render("estadoComandas");
// });
// app.get("/productos", requireToken,(req, res) => {
// res.locals.pageTitle = "Productos";
// res.locals.pageId = "productos";
// res.render("productos");
// });
// app.get('/usuarios', requireToken,(req, res) => {
// res.locals.pageTitle = 'Usuarios';
// res.locals.pageId = 'usuarios';
// res.render('usuarios');
// });
// app.get('/reportes', requireToken,(req, res) => {
// res.locals.pageTitle = 'Reportes';
// res.locals.pageId = 'reportes';
// res.render('reportes');
// });
// app.get('/compras', requireToken,(req, res) => {
// res.locals.pageTitle = 'Compras';
// res.locals.pageId = 'compras';
// res.render('compras');
// });
// // Página para definir contraseña (el form envía al servicio AUTH)
// app.get('/set-password', (req, res) => {
// const pp = req.session?.pendingPassword;
// if (!pp) return req.session?.user ? res.redirect('/') : res.redirect('https://sso.suitecoffee.uy/if/flow/default-authentication-flow/');
// res.type('html').send(`
// <!doctype html><meta charset="utf-8">
// <title>SuiteCoffee · Definir contraseña</title>
// <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
// <div class="container py-5" style="max-width:520px;">
// <h2 class="mb-4">Definir contraseña</h2>
// <form method="post" action="/auth/password/set" class="vstack gap-3">
// <input class="form-control" type="password" name="password" placeholder="Nueva contraseña" minlength="8" required>
// <input class="form-control" type="password" name="password2" placeholder="Repetí la contraseña" minlength="8" required>
// <button class="btn btn-primary" type="submit">Guardar y continuar</button>
// <small class="text-muted">Luego te redirigiremos a iniciar sesión por SSO.</small>
// </form>
// </div>
// `);
// });
// ----------------------------------------------------------
// Verificación de conexión
// ----------------------------------------------------------
async function verificarConexion() {
try {
const client = await pool.connect();
const res = await client.query('SELECT NOW() AS hora');
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
client.release(); // liberar el cliente de nuevo al pool
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar:', error.message);
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
}
}
// === Servir páginas estáticas ===
app.get('/roles', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'roles.html')));
app.get('/usuarios', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'usuarios.html')));
app.get('/categorias',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'categorias.html')));
app.get('/productos', (req, res) => res.sendFile(path.join(__dirname, 'pages', 'productos.html')));
// Helper de consulta con acquire/release explícito
async function q(text, params) {
const client = await pool.connect();
try {
return await client.query(text, params);
} finally {
console.log(`[APP] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
const client = await mainPool.connect();
const { rows } = await client.query('SELECT NOW() AS ahora');
console.log(`\n[APP] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora);
client.release();
} catch (error) {
console.error('[APP] Error al conectar con la base de datos al iniciar:', error.message);
console.error('[APP] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
}
}
// === API Roles ===
// GET: listar
app.get('/api/roles', async (req, res) => {
try {
const { rows } = await q('SELECT id_rol, nombre FROM roles ORDER BY id_rol ASC');
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar roles' });
}
});
// // -----------------------------------------------------------------------------
// // Healthcheck
// -----------------------------------------------------------------------------
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
// POST: crear
app.post('/api/roles', async (req, res) => {
try {
const { nombre } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
const { rows } = await q(
'INSERT INTO roles (nombre) VALUES ($1) RETURNING id_rol, nombre',
[nombre.trim()]
);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
// Manejo de único/duplicado
if (e.code === '23505') return res.status(409).json({ error: 'El rol ya existe' });
res.status(500).json({ error: 'No se pudo crear el rol' });
}
});
// -----------------------------------------------------------------------------
// 404 + Manejo de errores
// -----------------------------------------------------------------------------
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
// === API Usuarios ===
// GET: listar
app.get('/api/usuarios', async (req, res) => {
try {
const { rows } = await q(`
SELECT id_usuario, documento, img_perfil, nombre, apellido, correo, telefono, fec_nacimiento, activo
FROM usuarios
ORDER BY id_usuario ASC
`);
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar usuarios' });
}
});
app.use((err, _req, res, _next) => {
console.error('[APP] ', err);
if (res.headersSent) return;
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor app.', detail: err.stack || String(err) });
});
// POST: crear
app.post('/api/usuarios', async (req, res) => {
try {
const { documento, nombre, apellido, correo, telefono, fec_nacimiento } = req.body;
if (!nombre || !apellido) return res.status(400).json({ error: 'Nombre y apellido requeridos' });
const { rows } = await q(`
INSERT INTO usuarios (documento, nombre, apellido, correo, telefono, fec_nacimiento)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id_usuario, documento, nombre, apellido, correo, telefono, fec_nacimiento, activo
`, [
documento || null,
nombre.trim(),
apellido.trim(),
correo || null,
telefono || null,
fec_nacimiento || null
]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
if (e.code === '23505') return res.status(409).json({ error: 'Documento/Correo/Teléfono ya existe' });
res.status(500).json({ error: 'No se pudo crear el usuario' });
}
});
// === API Categorías ===
// GET: listar
app.get('/api/categorias', async (req, res) => {
try {
const { rows } = await q('SELECT id_categoria, nombre, visible FROM categorias ORDER BY id_categoria ASC');
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar categorías' });
}
});
// POST: crear
app.post('/api/categorias', async (req, res) => {
try {
const { nombre, visible } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
const vis = (typeof visible === 'boolean') ? visible : true;
const { rows } = await q(`
INSERT INTO categorias (nombre, visible)
VALUES ($1, $2)
RETURNING id_categoria, nombre, visible
`, [nombre.trim(), vis]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
if (e.code === '23505') return res.status(409).json({ error: 'La categoría ya existe' });
res.status(500).json({ error: 'No se pudo crear la categoría' });
}
});
// === API Productos ===
// GET: listar
app.get('/api/productos', async (req, res) => {
try {
const { rows } = await q(`
SELECT id_producto, nombre, img_producto, precio, activo, id_categoria
FROM productos
ORDER BY id_producto ASC
`);
res.json(rows);
} catch (e) {
console.error(e);
res.status(500).json({ error: 'No se pudo listar productos' });
}
});
// POST: crear
app.post('/api/productos', async (req, res) => {
try {
let { nombre, id_categoria, precio } = req.body;
if (!nombre || !nombre.trim()) return res.status(400).json({ error: 'Nombre requerido' });
id_categoria = parseInt(id_categoria, 10);
precio = parseFloat(precio);
if (!Number.isInteger(id_categoria)) return res.status(400).json({ error: 'id_categoria inválido' });
if (!(precio >= 0)) return res.status(400).json({ error: 'precio inválido' });
const { rows } = await q(`
INSERT INTO productos (nombre, id_categoria, precio)
VALUES ($1, $2, $3)
RETURNING id_producto, nombre, precio, activo, id_categoria
`, [nombre.trim(), id_categoria, precio]);
res.status(201).json(rows[0]);
} catch (e) {
console.error(e);
// FK categories / checks
if (e.code === '23503') return res.status(400).json({ error: 'La categoría no existe' });
res.status(500).json({ error: 'No se pudo crear el producto' });
}
});
// Colores personalizados
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
app.use(expressLayouts);
// Iniciar servidor
app.listen( process.env.PORT, () => {
console.log(`Servidor de ${chalk.red('aplicación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
// ----------------------------------------------------------
// Inicio del servidor
// ----------------------------------------------------------
app.listen(3030, () => {
console.log(`[APP] SuiteCoffee corriendo en http://localhost:${3030}`);
verificarConexion();
});
app.get("/health", async (req, res) => {
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
res.status(200).json({ status: "ok" });
});

View File

@ -0,0 +1,37 @@
// -----------------------------------------------------------------------------
// Middlewares de Auth/Tenant
// -----------------------------------------------------------------------------
export function requireAuth(req, res, next) {
const authHeader = req.headers["authorization"];
if (!authHeader) return res.status(401).send("Falta token");
const token = authHeader.split(" ")[1];
jwt.verify(token, getKey, { algorithms: ["RS256"] }, (err, decoded) => {
if (err) return res.status(403).send("Token inválido o vencido");
// Guardamos los claims del token en req.user
req.user = {
id: decoded.sub,
email: decoded.email,
username: decoded.preferred_username,
name: decoded.name,
roles: decoded.groups || []
};
next();
});
}
// export function exposeViewState(req, res, next) {
// res.locals.pageTitle = res.locals.pageTitle || '';
// res.locals.pageId = res.locals.pageId || '';
// res.locals.tenant_uuid = req.session?.tenant?.uuid || null;
// res.locals.ak_user_uuid = req.session?.tenant?.ak_user_uuid || null;
// // también pásalos como props al render
// res.locals.viewUser = req.session?.user || null;
// res.locals.viewCookies = req.cookies || {};
// res.locals.viewSession = req.session ? JSON.parse(JSON.stringify(req.session)) : {};
// next();
// }

View File

@ -1,70 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Categorías</title>
</head>
<body>
<h1>Categorías</h1>
<h2>Crear categoría</h2>
<form id="form-categoria">
<label>Nombre:
<input type="text" name="nombre" required />
</label>
<label>Visible:
<select name="visible">
<option value="true" selected></option>
<option value="false">No</option>
</select>
</label>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead><tr><th>ID</th><th>Nombre</th><th>Visible</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/categorias';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(c => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${c.id_categoria}</td><td>${c.nombre}</td><td>${c.visible ? 'Sí' : 'No'}</td>`;
tbody.appendChild(tr);
});
}
document.getElementById('form-categoria').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const nombre = fd.get('nombre').trim();
const visible = fd.get('visible') === 'true';
if (!nombre) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nombre, visible })
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>

View File

@ -1,106 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Productos</title>
</head>
<body>
<h1>Productos</h1>
<h2>Crear producto</h2>
<form id="form-producto">
<div>
<label>Nombre:
<input name="nombre" type="text" required />
</label>
</div>
<div>
<label>Precio:
<input name="precio" type="number" step="0.01" min="0" required />
</label>
</div>
<div>
<label>Categoría:
<select name="id_categoria" id="sel-categoria" required></select>
</label>
</div>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead>
<tr><th>ID</th><th>Nombre</th><th>Precio</th><th>Activo</th><th>ID Categoría</th></tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/productos';
const API_CAT = '/api/categorias';
async function cargarCategorias() {
const res = await fetch(API_CAT);
const data = await res.json();
const sel = document.getElementById('sel-categoria');
sel.innerHTML = '<option value="" disabled selected>Seleccione...</option>';
data.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id_categoria;
opt.textContent = `${c.id_categoria} - ${c.nombre}`;
sel.appendChild(opt);
});
}
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(p => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.id_producto}</td>
<td>${p.nombre}</td>
<td>${Number(p.precio).toFixed(2)}</td>
<td>${p.activo ? 'Sí' : 'No'}</td>
<td>${p.id_categoria}</td>
`;
tbody.appendChild(tr);
});
}
document.getElementById('form-producto').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = {
nombre: fd.get('nombre').trim(),
precio: parseFloat(fd.get('precio')),
id_categoria: parseInt(fd.get('id_categoria'), 10)
};
if (!payload.nombre || isNaN(payload.precio) || isNaN(payload.id_categoria)) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
(async () => {
await cargarCategorias();
await listar();
})();
</script>
</body>
</html>

View File

@ -1,62 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Roles</title>
</head>
<body>
<h1>Roles</h1>
<h2>Crear rol</h2>
<form id="form-rol">
<label>Nombre del rol:
<input type="text" name="nombre" required />
</label>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead><tr><th>ID</th><th>Nombre</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/roles';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(r => {
const tr = document.createElement('tr');
tr.innerHTML = `<td>${r.id_rol}</td><td>${r.nombre}</td>`;
tbody.appendChild(tr);
});
}
document.getElementById('form-rol').addEventListener('submit', async (e) => {
e.preventDefault();
const nombre = e.target.nombre.value.trim();
if (!nombre) return;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ nombre })
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>

View File

@ -1,104 +0,0 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<title>Usuarios</title>
</head>
<body>
<h1>Usuarios</h1>
<h2>Crear usuario</h2>
<form id="form-usuario">
<div>
<label>Documento:
<input name="documento" type="text" />
</label>
</div>
<div>
<label>Nombre:
<input name="nombre" type="text" required />
</label>
</div>
<div>
<label>Apellido:
<input name="apellido" type="text" required />
</label>
</div>
<div>
<label>Correo:
<input name="correo" type="email" />
</label>
</div>
<div>
<label>Teléfono:
<input name="telefono" type="text" />
</label>
</div>
<div>
<label>Fecha de nacimiento:
<input name="fec_nacimiento" type="date" />
</label>
</div>
<button type="submit">Guardar</button>
</form>
<h2>Listado</h2>
<button id="btn-recargar">Recargar</button>
<table border="1" cellpadding="6">
<thead>
<tr>
<th>ID</th><th>Documento</th><th>Nombre</th><th>Apellido</th>
<th>Correo</th><th>Teléfono</th><th>Nacimiento</th><th>Activo</th>
</tr>
</thead>
<tbody id="tbody"></tbody>
</table>
<script>
const API = '/api/usuarios';
async function listar() {
const res = await fetch(API);
const data = await res.json();
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
data.forEach(u => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${u.id_usuario}</td>
<td>${u.documento ?? ''}</td>
<td>${u.nombre}</td>
<td>${u.apellido}</td>
<td>${u.correo ?? ''}</td>
<td>${u.telefono ?? ''}</td>
<td>${u.fec_nacimiento ? u.fec_nacimiento.substring(0,10) : ''}</td>
<td>${u.activo ? 'Sí' : 'No'}</td>
`;
tbody.appendChild(tr);
});
}
document.getElementById('form-usuario').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const payload = Object.fromEntries(fd.entries());
if (payload.fec_nacimiento === '') delete payload.fec_nacimiento;
const res = await fetch(API, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
if (!res.ok) {
const err = await res.json().catch(()=>({error:'Error'}));
alert('Error: ' + (err.error || res.statusText));
return;
}
e.target.reset();
await listar();
});
document.getElementById('btn-recargar').addEventListener('click', listar);
listar();
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

View File

@ -0,0 +1,76 @@
// ----------------------------------------------------------
// Introspección de esquema
// ----------------------------------------------------------
export async function loadColumns(client, table) {
const sql = `
SELECT
c.column_name,
c.data_type,
c.is_nullable = 'YES' AS is_nullable,
c.column_default,
(SELECT EXISTS (
SELECT 1 FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
)) AS is_primary,
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
WHERE t.relname = $1 AND a.attname = c.column_name
) AS is_identity
FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=$1
ORDER BY c.ordinal_position
`;
const { rows } = await client.query(sql, [table]);
return rows;
}
export async function loadForeignKeys(client, table) {
const sql = `
SELECT
kcu.column_name,
ccu.table_name AS foreign_table,
ccu.column_name AS foreign_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
`;
const { rows } = await client.query(sql, [table]);
const map = {};
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
return map;
}
export async function loadPrimaryKey(client, table) {
const sql = `
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = $1 AND i.indisprimary
`;
const { rows } = await client.query(sql, [table]);
return rows.map(r => r.column_name);
}
// label column for FK options
export async function pickLabelColumn(client, refTable) {
const preferred = ['nombre', 'raz_social', 'apodo', 'documento', 'correo', 'telefono'];
const { rows } = await client.query(
`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1
ORDER BY ordinal_position`, [refTable]
);
for (const cand of preferred) {
if (rows.find(r => r.column_name === cand)) return cand;
}
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
if (textish) return textish.column_name;
return rows[0]?.column_name || 'id';
}

View File

@ -0,0 +1,558 @@
<!-- services/manso/src/views/comandas.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">📋 Nueva Comanda</h1>
<span class="badge rounded-pill text-bg-light">/api/*</span>
</div>
<div class="row g-3">
<!-- Columna izquierda: Productos -->
<div class="col-12 col-lg-7">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Productos</strong>
<div class="ms-auto small text-muted" id="prodCount">0 ítems</div>
</div>
<div class="card-body">
<div class="row g-2 align-items-center mb-2">
<div class="col-12 col-sm">
<input id="busqueda" type="search" class="form-control" placeholder="Buscar por nombre o categoría…">
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary" id="limpiarBusqueda">Limpiar</button>
</div>
</div>
<div id="listadoProductos" class="border rounded" style="max-height:58vh; overflow:auto;">
<!-- tabla de productos renderizada por JS -->
<div class="p-3 text-muted">Cargando…</div>
</div>
</div>
</div>
</div>
<!-- Columna derecha: Detalles + Carrito -->
<div class="col-12 col-lg-5">
<div class="card shadow-sm mb-3">
<div class="card-header"><strong>Detalles</strong></div>
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-sm-6">
<label for="selMesa" class="form-label text-muted small mb-1">Mesa</label>
<select id="selMesa" class="form-select"></select>
</div>
<div class="col-12 col-sm-6">
<label for="selUsuario" class="form-label text-muted small mb-1">Usuario</label>
<select id="selUsuario" class="form-select"></select>
</div>
</div>
<div class="mt-2">
<label for="obs" class="form-label text-muted small mb-1">Observaciones</label>
<textarea id="obs" class="form-control" rows="3"></textarea>
</div>
<div class="alert alert-secondary mt-3 mb-0 small">
La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header"><strong>Carrito</strong></div>
<div class="card-body p-0" id="carritoWrap">
<div class="p-3 text-muted">Aún no agregaste productos.</div>
</div>
<div class="d-flex align-items-center gap-2 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
<div class="small"><span class="text-muted">Ítems:</span> <strong id="kpiItems">0</strong></div>
<div class="small ms-2"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
<div class="ms-auto"></div>
<button class="btn btn-outline-secondary" id="vaciar">Vaciar</button>
<button class="btn btn-primary" id="crear">Crear Comanda</button>
</div>
</div>
<div id="msg" class="mt-2 small text-muted"></div>
</div>
</div>
<!-- ====== LÓGICA ====== -->
<script>
// Helpers DOM
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
// Estado
const state = {
productos: [],
mesas: [],
usuarios: [],
categorias: [], // <--- NUEVO
carrito: [],
filtro: ''
};
function norm(s='') {
return s.toString().toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu,''); // "café" -> "cafe"
}
function isTakeaway(apodo) {
return /^takeaway$/i.test(String(apodo || '').trim());
}
function groupOrderByCatName(catName='') {
const n = norm(catName);
if (n.includes('bar')) return 1;
if (n.includes('cafe')) return 2;
if (n.includes('cafeter')) return 3;
if (n.includes('trago') || n.includes('refresc')) return 4;
return 99; // otros
}
// Genera el HTML del ticket de cocina (80mm aprox)
function buildKitchenTicketHTML(data) {
const apodo = String(data.mesa_apodo ?? '').trim();
const numero = data.mesa_numero ?? '';
const take = isTakeaway(apodo);
const mesaTxt = take ? apodo.toUpperCase() : `Mesa #${numero}${apodo ? ' · ' + apodo : ''}`;
// const isTakeaway = /Takeaway/i.test(String(data.mesa_apodo ?? '')) || /Takeaway/i.test(String(data.mesa_numero ?? ''));
const mesaClass = take ? 'bigline' : 'mesa-medium';
const obs = (data.observaciones && data.observaciones.trim()) ? data.observaciones.trim() : '';
// Productos ya vienen con su "g" (grupo numérico 1..4/99) y cantidad formateada
const items = data.productos.slice().sort((a,b)=> (a.g||99) - (b.g||99));
let productosHtml = '';
let prevG = null;
for (const p of items) {
if (prevG !== null && p.g !== prevG) {
productosHtml += `<div class="hr dotted"></div>`; // separación punteada entre grupos
}
productosHtml += `
<div class="row">
<div class="qty">x${p.cantidad}</div>
<div class="name">${p.nombre}</div>
</div>`;
prevG = p.g;
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Ticket Cocina</title>
<style>
:root {
--w: 80mm;
--fz-base: 16px;
--fz-md: 16px; /* observaciones */
--fz-item: 18px; /* filas */
--fz-xl: 26px; /* <--- NUEVO: tamaño “grande” (mesa) */
--fz-xxl: 34px; /* título (#comanda) */
--fz-sm: 12px;
}
html, body { margin:0; padding:0; }
body {
width: var(--w);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: var(--fz-base);
line-height: 1.35;
color:#000;
font-weight: 700;
}
#ticket { padding: 10px 8px; }
.center { text-align:center; }
.row { display:flex; gap:8px; margin: 4px 0; }
.row .qty { min-width: 22mm; font-size: var(--fz-item); letter-spacing:.2px; }
.row .name { flex:1; font-size: var(--fz-item); text-transform: uppercase; word-break: break-word; }
.hr { border-top: 2px dashed #000; margin: 8px 0; }
.hr.dotted { border-top: 2px dotted #000; }
.small { font-size: var(--fz-sm); }
.bigline { font-size: var(--fz-xxl); text-transform: uppercase; }
.mesa-medium { font-size: var(--fz-xl); text-transform: uppercase; }
.obs { font-size: var(--fz-md); }
.mt4{margin-top:4px}.mt8{margin-top:8px}.mb4{margin-bottom:4px}.mb8{margin-bottom:8px}
@page { size: var(--w) auto; margin: 0; }
@media print { body { width: var(--w); } }
</style>
</head>
<body>
<div id="ticket">
<!-- SIN TÍTULO -->
<div class="center bigline">#${data.id_comanda}</div>
<div class="center ${mesaClass}">${mesaTxt}</div>
<div class="small mt4">Fecha: ${data.fecha} ${data.hora}</div>
<div class="small mt4">Mozo: ${data.usuario || '—'}</div>
${obs ? `<div class="obs mt8">Obs: ${obs}</div>` : ''}
<div class="hr"></div>
${productosHtml}
<div class="hr"></div>
<div class="small">Ítems: ${data.items} · Unidades: ${data.units}</div>
<div class="center mt8 small">— fin —</div>
</div>
<script>window.onload = () => { window.focus(); window.print(); }<\/script>
</body>
</html>`;
}
// Imprime HTML usando un iframe oculto (menos bloqueos de pop-up)
function printHtmlViaIframe(html) {
return new Promise((resolve) => {
let iframe = document.getElementById('printFrame');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'printFrame';
iframe.style.position = 'fixed';
iframe.style.right = '-9999px';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
document.body.appendChild(iframe);
}
const doc = iframe.contentWindow.document;
doc.open();
doc.write(html);
doc.close();
// Salida: remover iframe después de un rato para no acumular
setTimeout(() => {
resolve();
// (si prefieres mantenerlo para reimpresiones, no lo quites)
// document.body.removeChild(iframe);
}, 1500);
});
}
// Utils
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'mt-2 small ok text-success' : 'mt-2 small err text-danger';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='mt-2 small text-muted'; }, 3500);
};
async function jget(url) {
const res = await fetch(url);
let data; try { data = await res.json(); } catch { data = null; }
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// Carga inicial
async function init() {
const [prods, mesas, usuarios, categorias] = await Promise.all([
jget('/api/table/productos?limit=1000'),
jget('/api/table/mesas?limit=1000'),
jget('/api/table/usuarios?limit=1000'),
jget('/api/table/categorias?limit=1000') // <--- NUEVO
]);
state.productos = prods.filter(p => p.activo !== false);
state.mesas = mesas;
state.usuarios = usuarios.filter(u => u.activo !== false);
state.categorias = Array.isArray(categorias) ? categorias : [];
// Mapas para buscar categoría por id de producto
state.catById = new Map(state.categorias.map(c => [c.id_categoria, (c.nombre||'').toString()]));
state.prodCatNameById = new Map(state.productos.map(p => [p.id_producto, state.catById.get(p.id_categoria)||'']));
hydrateMesas();
hydrateUsuarios();
renderProductos();
renderCarrito();
$('#busqueda').addEventListener('input', () => {
state.filtro = $('#busqueda').value.trim().toLowerCase();
renderProductos();
});
$('#limpiarBusqueda').addEventListener('click', () => {
$('#busqueda').value = '';
state.filtro = '';
renderProductos();
});
$('#vaciar').addEventListener('click', () => { state.carrito = []; renderCarrito(); });
$('#crear').addEventListener('click', crearComanda);
}
function hydrateMesas() {
const sel = $('#selMesa'); sel.innerHTML = '';
for (const m of state.mesas) {
const o = document.createElement('option');
o.value = m.id_mesa;
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
sel.appendChild(o);
}
}
function hydrateUsuarios() {
const sel = $('#selUsuario'); sel.innerHTML = '';
for (const u of state.usuarios) {
const o = document.createElement('option');
o.value = u.id_usuario;
o.textContent = `${u.nombre} ${u.apellido}`.trim();
sel.appendChild(o);
}
}
// Render productos
function renderProductos() {
let rows = state.productos.slice();
if (state.filtro) {
rows = rows.filter(p =>
(p.nombre || '').toLowerCase().includes(state.filtro) ||
String(p.id_categoria ?? '').includes(state.filtro)
);
}
$('#prodCount').textContent = `${rows.length} ítems`;
if (!rows.length) {
$('#listadoProductos').innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Precio</th>
<th style="width:210px;">Cantidad</th>
<th style="width:100px;"></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
for (const p of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.nombre}</td>
<td class="text-end">${money(p.precio)}</td>
<td>
<div class="d-flex align-items-center gap-2">
<input type="number" min="0.001" step="0.001" value="1.000" data-qty class="form-control form-control-sm" style="max-width:120px;">
<button class="btn btn-sm btn-outline-secondary" data-dec>-</button>
<button class="btn btn-sm btn-outline-secondary" data-inc>+</button>
</div>
</td>
<td><button class="btn btn-sm btn-primary" data-add>Agregar</button></td>
`;
const qty = tr.querySelector('[data-qty]');
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
tb.appendChild(tr);
}
$('#listadoProductos').innerHTML = '';
$('#listadoProductos').appendChild(tbl);
}
function addToCart(prod, cantidad) {
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
const precio = parseFloat(prod.precio);
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
renderCarrito();
}
// Render carrito
function renderCarrito() {
const wrap = $('#carritoWrap');
if (!state.carrito.length) {
wrap.innerHTML = '<div class="p-3 text-muted">Aún no agregaste productos.</div>';
$('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0);
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let items = 0, total = 0;
state.carrito.forEach((it, idx) => {
items += 1;
const sub = Number(it.pre_unitario) * Number(it.cantidad);
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${it.nombre}</td>
<td class="text-end">${money(it.pre_unitario)}</td>
<td class="text-end">
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" class="form-control form-control-sm text-end" style="max-width:120px;">
</td>
<td class="text-end">${money(sub)}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary" data-del>Quitar</button>
</td>
`;
const qty = tr.querySelector('input[type="number"]');
qty.addEventListener('change', () => {
const v = parseFloat(qty.value||'0');
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
it.cantidad = Number(v.toFixed(3));
renderCarrito();
});
tr.querySelector('[data-del]').addEventListener('click', () => {
state.carrito.splice(idx,1);
renderCarrito();
});
tb.appendChild(tr);
});
wrap.innerHTML = '';
wrap.appendChild(tbl);
$('#kpiItems').textContent = String(items);
$('#kpiTotal').textContent = money(total);
}
const fmtQty = (n) => Number(n).toFixed(3).replace(/\.?0+$/,'');
async function crearComanda() {
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
const id_mesa = parseInt($('#selMesa').value, 10);
const id_usuario = parseInt($('#selUsuario').value, 10);
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
// Snapshot del carrito ANTES de limpiar (para imprimir)
const cartSnapshot = state.carrito.map(it => ({ ...it }));
const observaciones = $('#obs').value.trim() || null;
try {
// 1) encabezado comanda
const { inserted: com } = await jpost('/api/table/comandas', {
id_usuario,
id_mesa,
estado: 'abierta',
observaciones
});
// 2) detalle
const id_comanda = com.id_comanda;
const payloads = cartSnapshot.map(it => ({
id_comanda,
id_producto: it.id_producto,
cantidad: it.cantidad,
pre_unitario: it.pre_unitario
}));
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
// 3) Datos auxiliares para ticket
const mesa = state.mesas.find(m => m.id_mesa === id_mesa) || {};
const usuario = state.usuarios.find(u => u.id_usuario === id_usuario) || {};
const now = new Date();
const fecha = now.toLocaleDateString();
const hora = now.toLocaleTimeString();
// 4) Construir e imprimir Ticket de Cocina (sin precios)
const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0);
const items = cartSnapshot.length;
// map producto -> nombre de categoría
const prodCat = state.prodCatNameById || new Map();
const productosParaTicket = cartSnapshot.map(it => ({
nombre: it.nombre,
cantidad: fmtQty(it.cantidad),
g: groupOrderByCatName(prodCat.get(it.id_producto) || '') // 1..4/99
}));
const ticketHtml = buildKitchenTicketHTML({
id_comanda,
fecha, hora,
mesa_numero: mesa?.numero,
mesa_apodo: mesa?.apodo,
usuario: `${usuario?.nombre || ''} ${usuario?.apellido || ''}`.trim(),
observaciones,
items,
units,
productos: productosParaTicket // <--- con grupos
});
await printHtmlViaIframe(ticketHtml);
// 5) Reset UI
state.carrito = [];
renderCarrito();
$('#obs').value = '';
toast(`Comanda #${id_comanda} creada e impresa`, true);
} catch (e) {
toast(e.message || 'No se pudo crear la comanda');
}
}
// // Crear comanda
// async function crearComanda() {
// if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
// const id_mesa = parseInt($('#selMesa').value, 10);
// const id_usuario = parseInt($('#selUsuario').value, 10);
// if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
// const observaciones = $('#obs').value.trim() || null;
// try {
// // 1) encabezado comanda
// const { inserted: com } = await jpost('/api/table/comandas', {
// id_usuario,
// id_mesa,
// estado: 'abierta',
// observaciones
// });
// // 2) detalle
// const id_comanda = com.id_comanda;
// const payloads = state.carrito.map(it => ({
// id_comanda,
// id_producto: it.id_producto,
// cantidad: it.cantidad,
// pre_unitario: it.pre_unitario
// }));
// await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
// state.carrito = [];
// renderCarrito();
// $('#obs').value = '';
// toast(`Comanda #${id_comanda} creada`, true);
// } catch (e) {
// toast(e.message || 'No se pudo crear la comanda');
// }
// }
// GO
init().catch(err => toast(err.message || 'Error cargando datos'));
</script>

View File

@ -0,0 +1,361 @@
<% /* Compras / Gastos */ %>
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-2">
<h3 class="mb-0">Compras / Gastos</h3>
<div class="ms-auto d-flex gap-2">
<button id="btnNueva" class="btn btn-outline-secondary btn-sm">Nueva</button>
<span id="status" class="small text-muted">—</span>
</div>
</div>
<!-- Formulario -->
<div class="card shadow-sm mb-3">
<div class="card-header"><strong id="formTitle">Nueva compra</strong></div>
<div class="card-body">
<form id="frmCompra" class="row g-3">
<input type="hidden" id="id_compra" value="">
<div class="col-12 col-md-5">
<label class="form-label">Proveedor</label>
<select id="id_proveedor" class="form-select" required></select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Fecha</label>
<input id="fec_compra" type="datetime-local" class="form-control" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Total</label>
<input id="total" type="text" class="form-control" value="$ 0" disabled>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Renglones</div>
<div>
<button type="button" id="addRow" class="btn btn-sm btn-outline-primary">Agregar renglón</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="tblDet">
<thead class="table-light">
<tr>
<th style="width:110px">Tipo</th>
<th>Ítem</th>
<th style="width:140px" class="text-end">Cantidad</th>
<th style="width:160px" class="text-end">Precio</th>
<th style="width:140px" class="text-end">Subtotal</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
<tr class="empty">
<td colspan="6" class="p-3 text-muted">Sin renglones</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button id="btnGuardar" type="submit" class="btn btn-success">Guardar</button>
<button id="btnEliminar" type="button" class="btn btn-outline-danger d-none">Eliminar</button>
</div>
</form>
</div>
</div>
<!-- Listado -->
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Compras recientes</strong>
<input id="buscar" class="form-control form-control-sm ms-auto" style="max-width:260px" placeholder="Buscar proveedor…">
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0" id="tblCompras">
<thead class="table-light">
<tr>
<th>#</th>
<th>Proveedor</th>
<th>Fecha</th>
<th class="text-end">Total</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<style>
#tblDet select, #tblDet input { min-height: 34px; }
.money { text-align: right; }
</style>
<script>
const $ = s => document.querySelector(s);
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
const z2 = n => String(n).padStart(2,'0');
const parseNum = v => (typeof v==='number') ? v : Number(String(v).replace(/[^\d.,-]/g,'').replace('.','').replace(',','.')) || 0;
function fmtMoneyInt(v, mode = 'round') {
const n = Number(v || 0);
const i = mode === 'trunc' ? Math.trunc(n) : mode === 'floor' ? Math.floor(n) : Math.round(n);
return '$ ' + i.toLocaleString('es-UY', { maximumFractionDigits: 0 });
}
const onlyDigits = s => String(s ?? '').replace(/\D+/g, '');
function wireIntInput(input, onChange) {
const sync = () => {
const n = Number(onlyDigits(input.value) || '0'); // entero
input.dataset.raw = String(n); // guardo valor crudo
input.value = n.toLocaleString('es-UY'); // muestro con miles
if (onChange) onChange(n);
};
input.addEventListener('input', () => setTimeout(sync, 0));
input.addEventListener('blur', sync);
// 1a sync
sync();
}
function getIntInput(input) {
const s = input?.dataset?.raw ?? onlyDigits(input?.value);
return Number(s || '0');
}
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
// Catálogos
let CATS = { prov:[], mat:[], prod:[] };
async function loadCatalogos(){
$('#status').textContent = 'Cargando catálogos…';
const [prov, mat, prod] = await Promise.all([
jget('/api/table/proveedores?limit=10000'),
jget('/api/table/mate_primas?limit=10000'),
jget('/api/table/productos?limit=10000')
]);
CATS.prov = prov||[]; CATS.mat = mat||[]; CATS.prod = prod||[];
const sel = $('#id_proveedor'); sel.innerHTML = '<option value="">—</option>' + CATS.prov.map(p=>`<option value="${p.id_proveedor}">${p.raz_social||p.nombre||('Prov#'+p.id_proveedor)}</option>`).join('');
$('#status').textContent = 'Listo';
}
// Renglón
function addRow(data){
const tb = $('#tblDet tbody');
tb.querySelector('.empty')?.remove();
const tr = document.createElement('tr');
const tipo = data?.tipo || 'MAT'; // MAT | PROD
const id = data?.id || '';
const cant = data?.cantidad != null ? data.cantidad : 1;
const pu = data?.precio != null ? data.precio : 0;
tr.innerHTML = `
<td>
<select class="form-select form-select-sm tipo">
<option value="MAT"${tipo==='MAT'?' selected':''}>Materia</option>
<option value="PROD"${tipo==='PROD'?' selected':''}>Producto</option>
</select>
</td>
<td>
<select class="form-select form-select-sm item"></select>
</td>
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
class="form-control form-control-sm text-end qty" value="${cant}"></td>
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
class="form-control form-control-sm text-end price" value="${pu}"></td>
<td class="text-end sub">$ 0</td>
<td><button type="button" class="btn btn-sm btn-outline-danger del">✕</button></td>
`;
tb.appendChild(tr);
// load items segun tipo
function fillItems(selTipo, selItem, selectedId){
const list = selTipo.value === 'MAT' ? CATS.mat : CATS.prod;
selItem.innerHTML = '<option value="">—</option>' + list.map(i => {
const id = selTipo.value === 'MAT' ? i.id_mat_prima : i.id_producto;
const nm = i.nombre || ('#'+id);
return `<option value="${id}">${nm}</option>`;
}).join('');
if (selectedId) selItem.value = selectedId;
}
const selTipo = tr.querySelector('.tipo');
const selItem = tr.querySelector('.item');
const qty = tr.querySelector('.qty');
const price = tr.querySelector('.price');
const subCell = tr.querySelector('.sub');
selTipo.addEventListener('change', ()=>{ fillItems(selTipo, selItem, null); updateRow(); });
[selItem, qty, price].forEach(el => el.addEventListener('input', updateRow));
tr.querySelector('.del').addEventListener('click', ()=>{ tr.remove(); recalcTotal(); if (!tb.children.length) tb.innerHTML='<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>'; });
fillItems(selTipo, selItem, id);
function updateRow(){
const s = getIntInput(qty) * getIntInput(price);
subCell.textContent = fmtMoneyInt(s);
recalcTotal();
}
wireIntInput(qty, updateRow);
wireIntInput(price, updateRow);
updateRow();
}
function recalcTotal(){
let tot = 0;
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
if (tr.classList.contains('empty')) return;
const q = getIntInput(tr.querySelector('.qty'));
const p = getIntInput(tr.querySelector('.price'));
tot += q * p;
});
$('#total').value = fmtMoneyInt(tot);
return tot;
}
function readFormToPayload(){
const id_compra = $('#id_compra').value ? Number($('#id_compra').value) : null;
const id_proveedor = Number($('#id_proveedor').value || 0);
const fec_compra = $('#fec_compra').value
? new Date($('#fec_compra').value).toISOString().slice(0,19).replace('T',' ')
: null;
const det = [];
// 👇 OJO: iteramos sobre TODAS las filas reales
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
if (tr.classList.contains('empty')) return;
const tipo = tr.querySelector('.tipo').value; // 'MAT' | 'PROD'
const id = Number(tr.querySelector('.item').value||0);
const qty = getIntInput(tr.querySelector('.qty')); // entero
const pu = getIntInput(tr.querySelector('.price')); // entero
if (id && qty>0 && pu>=0) det.push({ tipo, id, cantidad: qty, precio: pu });
});
return { id_compra, id_proveedor, fec_compra, detalles: det };
}
// Guardar / Eliminar
async function saveCompra(){
const payload = readFormToPayload();
if (!payload.id_proveedor) { alert('Seleccioná un proveedor.'); return; }
if (!payload.fec_compra) { alert('Indicá la fecha.'); return; }
if (!payload.detalles.length){ alert('Agregá al menos un renglón.'); return; }
$('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…';
try{
const res = await jpost('/api/rpc/save_compra', payload);
$('#id_compra').value = res.id_compra;
$('#btnEliminar').classList.remove('d-none');
$('#formTitle').textContent = 'Editar compra #' + res.id_compra;
await loadListado();
alert('Compra guardada.');
}catch(e){
alert('Error al guardar: ' + e.message);
}finally{
$('#btnGuardar').disabled = false; $('#status').textContent = 'Listo';
}
}
async function deleteCompra(){
const id = Number($('#id_compra').value||0);
if (!id) return;
if (!confirm('¿Eliminar compra #' + id + '?')) return;
$('#btnEliminar').disabled = true;
try{
await jpost('/api/rpc/delete_compra', { id_compra: id });
nuevaCompra();
await loadListado();
}catch(e){
alert('Error al eliminar: '+e.message);
}finally{
$('#btnEliminar').disabled = false;
}
}
function nuevaCompra(){
$('#formTitle').textContent = 'Nueva compra';
$('#id_compra').value = '';
$('#id_proveedor').value = '';
$('#fec_compra').value = new Date().toISOString().slice(0,16);
$('#total').value = '$ 0';
$('#btnEliminar').classList.add('d-none');
const tb = $('#tblDet tbody'); tb.innerHTML = '<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>';
}
async function cargarCompra(id){
$('#status').textContent = 'Cargando compra…';
try{
const data = await jpost('/api/rpc/get_compra', { id_compra: id });
$('#id_compra').value = data.id_compra;
$('#id_proveedor').value = data.id_proveedor;
$('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16);
const tb = $('#tblDet tbody'); tb.innerHTML='';
(data.detalles||[]).forEach(d => addRow(d));
recalcTotal();
$('#btnEliminar').classList.remove('d-none');
$('#formTitle').textContent = 'Editar compra #' + id;
} catch(e){
alert('No se pudo cargar: ' + e.message);
} finally {
$('#status').textContent = 'Listo';
}
}
// Listado
async function loadListado(){
// Recomendado: vista vw_compras (más abajo)
const rows = await jget('/api/table/vw_compras?limit=200&order_by=fec_compra%20desc');
const tb = $('#tblCompras tbody');
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.id_compra}</td>
<td>${r.proveedor}</td>
<td>${(r.fec_compra||'').replace('T',' ').slice(0,16)}</td>
<td class="text-end">${fmtMoneyInt(r.total)}</td>
<td class="text-end"><button class="btn btn-sm btn-outline-primary ver" data-id="${r.id_compra}">Ver/Editar</button></td>`;
tb.appendChild(tr);
});
$('#buscar').addEventListener('input', (e)=>{
const q = e.target.value.toLowerCase();
tb.querySelectorAll('tr').forEach(tr=>{
const prov = tr.children[1]?.textContent.toLowerCase() || '';
tr.style.display = prov.includes(q) ? '' : 'none';
});
});
tb.addEventListener('click', (ev)=>{
const btn = ev.target.closest('button.ver');
if (!btn) return;
const id = Number(btn.dataset.id);
cargarCompra(id);
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// Eventos
document.getElementById('addRow').addEventListener('click', ()=> addRow());
document.getElementById('frmCompra').addEventListener('submit', (ev)=>{ ev.preventDefault(); saveCompra(); });
document.getElementById('btnEliminar').addEventListener('click', deleteCompra);
document.getElementById('btnNueva').addEventListener('click', nuevaCompra);
// Init
(async function init(){
await loadCatalogos();
nuevaCompra();
await loadListado();
})();
</script>

View File

@ -0,0 +1,487 @@
<!-- views/dashboard.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">Dashboard Operativo</h1>
<div class="d-flex align-items-center gap-2">
<button id="dashRefresh" class="btn btn-outline-secondary btn-sm">Recargar</button>
<span id="dashStatus" class="text-muted small"></span>
</div>
</div>
<!-- KPIs -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Comandas activas</div>
<div class="h3 m-0" id="kpiActivas">—</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Ventas hoy</div>
<div class="h3 m-0"><span id="kpiVentasHoy">—</span></div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Ticket promedio (hoy)</div>
<div class="h3 m-0"><span id="kpiTicketProm">—</span></div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Productos distintos (hoy)</div>
<div class="h3 m-0" id="kpiProdDist">—</div>
</div>
</div>
</div>
</div>
<!-- Gráficos -->
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Top 5 productos (hoy)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartTopProductos"></canvas>
</div>
<div class="text-muted small mt-2">Basado en detalle de comandas de hoy.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Comandas por hora (últimas 12 h)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartComandasHora"></canvas>
</div>
<div class="text-muted small mt-2">Se agrupa por hora de creación.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Estados de comandas (hoy)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartEstados"></canvas>
</div>
<div class="text-muted small mt-2">Distribución por estado.</div>
</div>
</div>
</div>
<!-- Últimas comandas -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Últimas 10 comandas</strong>
<div class="ms-auto text-muted small" id="ultAct">—</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Fecha</th>
<th>Cierre</th> <!-- NUEVO -->
<th>Estado</th>
<th class="text-end">Total</th>
<th>Acción</th> <!-- NUEVO -->
</tr>
</thead>
<tbody id="ultimasTbody">
<tr><td colspan="6" class="text-muted p-3">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer text-muted small">
Totales calculados como Σ (pre_unitario × cantidad) por comanda.
</div>
</div>
</div>
</div>
<!-- Librería para gráficos -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// ===== Utilidades =====
const $ = (s, r=document)=>r.querySelector(s);
const fmtMoney = (n)=> isNaN(n) ? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(+n);
const fmtTs = (s)=> { const d = new Date(s); return isNaN(d) ? '—' : d.toLocaleString('es-UY'); };
const setStatus = (t)=> $('#dashStatus').textContent = t || '';
const todayBounds = ()=> {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = new Date(start); end.setDate(end.getDate()+1);
return {start, end};
};
const guessKey = (obj, candidates)=> (candidates.find(k => k in obj) || null);
const toDate = (v)=> (v instanceof Date ? v : new Date(v));
const inRange = (d, a, b)=> (d>=a && d<b);
// ===== Estado =====
let charts = {};
const state = {
comandas: [],
deta: [],
productos: [],
keys: {
comFecha: null, comFechaCierre: null, comEstado: null, comId: null, // <-- agregado comFechaCierre
detIdCom: null, detPrecio: null, detCant: null,
prodId: null, prodNombre: null
}
};
// ===== Carga =====
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function loadData() {
setStatus('Cargando datos…');
const [comandas, deta, productos] = await Promise.all([
jget('/api/table/comandas?limit=2000').catch(()=>[]),
jget('/api/table/deta_comandas?limit=5000').catch(()=>[]),
jget('/api/table/productos?limit=5000').catch(()=>[])
]);
state.comandas = Array.isArray(comandas)? comandas : [];
state.deta = Array.isArray(deta)? deta : [];
state.productos= Array.isArray(productos)? productos : [];
// Descubrir claves
const c0 = state.comandas[0] || {};
// incluimos fec_creacion y fec_cierre como prioridades
state.keys.comFecha = guessKey(c0, ['fec_creacion','fecha','created_at','creado_en','ts','timestamp','hora','datetime']);
state.keys.comFechaCierre = guessKey(c0, ['fec_cierre','cierre','closed_at','fecha_cierre','ts_cierre','hora_cierre']);
state.keys.comEstado = guessKey(c0, ['estado','status']);
state.keys.comId = guessKey(c0, ['id_comanda','id','comanda_id']);
const d0 = state.deta[0] || {};
state.keys.detIdCom = guessKey(d0, ['id_comanda','comanda_id']);
state.keys.detPrecio = guessKey(d0, ['pre_unitario','precio_unitario','precio','unit_price']);
state.keys.detCant = guessKey(d0, ['cantidad','qty','cantidad_total']);
const p0 = state.productos[0] || {};
state.keys.prodId = guessKey(p0, ['id_producto','id','producto_id']);
state.keys.prodNombre = guessKey(p0, ['nombre','descripcion','titulo','name']);
renderAll();
setStatus('');
}
// ===== Cálculos =====
function isActiva(estadoRaw){
const s = String(estadoRaw||'').toLowerCase();
return ['abierta','activa','activo','open','pendiente','en curso'].some(x => s.includes(x));
}
function isAnulada(estadoRaw){
const s = String(estadoRaw||'').toLowerCase();
return ['anulada','anulado','cancelada','cancelado','void'].some(x => s.includes(x));
}
function computeKpis(){
const {comFecha, comEstado, comId} = state.keys;
const {detIdCom, detPrecio, detCant} = state.keys;
const {start, end} = todayBounds();
// activas
const activas = state.comandas.filter(c => comEstado && isActiva(c[comEstado])).length;
$('#kpiActivas').textContent = activas;
// ventas hoy
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
let totalHoy = 0, ticketsHoy = 0;
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
const total = dets.reduce((acc,d)=> acc + Number(d[detPrecio]||0) * Number(d[detCant]||0), 0);
if (total>0) { totalHoy += total; ticketsHoy++; }
}
$('#kpiVentasHoy').textContent = fmtMoney(totalHoy);
$('#kpiTicketProm').textContent = ticketsHoy ? fmtMoney(totalHoy / ticketsHoy) : '—';
// productos distintos hoy
const setProd = new Set();
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
dets.forEach(d => setProd.add(d.id_producto ?? d.producto_id ?? d[state.keys.prodId]));
}
$('#kpiProdDist').textContent = setProd.size || '0';
}
function computeTopProductosHoy(){
const {comFecha, comEstado, comId} = state.keys;
const {detIdCom, detCant, detPrecio} = state.keys;
const {prodId, prodNombre} = state.keys;
const {start, end} = todayBounds();
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
const qtyByProd = new Map(); // id -> cantidad total
const amtByProd = new Map(); // id -> importe total
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
dets.forEach(d => {
const pid = d.id_producto ?? d.producto_id ?? d[prodId];
if (pid==null) return;
const q = Number(d[detCant]||0);
const a = Number(d[detPrecio]||0) * q;
qtyByProd.set(pid, (qtyByProd.get(pid)||0)+q);
amtByProd.set(pid, (amtByProd.get(pid)||0)+a);
});
}
// id -> label
const nameById = new Map(state.productos.map(p => [p[prodId], p[prodNombre] || ('#'+p[prodId])]));
// ordenar por cantidad
const arr = [...qtyByProd.entries()]
.map(([id,qty]) => ({ id, qty, amt: amtByProd.get(id)||0, name: nameById.get(id)||('#'+id) }))
.sort((a,b)=> b.qty - a.qty)
.slice(0,5);
return arr;
}
function computeComandasPorHora12h(){
const {comFecha} = state.keys;
const now = new Date();
const buckets = [];
for (let i=11;i>=0;i--){
const h = new Date(now); h.setHours(now.getHours()-i, 0, 0, 0);
buckets.push({ label: h.getHours().toString().padStart(2,'0')+':00', ts: +h, count: 0 });
}
if (!comFecha) return buckets;
state.comandas.forEach(c => {
const d = toDate(c[comFecha]); if (isNaN(d)) return;
const diffH = Math.floor((now - d) / (60*60*1000));
if (diffH<12 && diffH>=0) {
// bucket por hora exacta
const hour = new Date(d); hour.setMinutes(0,0,0);
const idx = buckets.findIndex(b => b.ts === +hour);
if (idx>=0) buckets[idx].count++;
}
});
return buckets;
}
function computeEstadosHoy(){
const {comFecha, comEstado} = state.keys;
const {start, end} = todayBounds();
const map = new Map();
state.comandas.forEach(c=>{
const when = comFecha ? toDate(c[comFecha]) : null;
if (!when || !inRange(when, start, end)) return;
const st = (c[comEstado] ?? '—').toString().toLowerCase();
map.set(st, (map.get(st)||0)+1);
});
return [...map.entries()].map(([estado,count])=>({estado, count}));
}
// ===== Render =====
function renderAll(){
computeKpis();
// Top productos
const top = computeTopProductosHoy();
drawBar('chartTopProductos', top.map(x=>x.name), top.map(x=>x.qty));
// Comandas por hora
const porHora = computeComandasPorHora12h();
drawLine('chartComandasHora', porHora.map(x=>x.label), porHora.map(x=>x.count));
// Estados
const estados = computeEstadosHoy();
drawDoughnut('chartEstados', estados.map(x=>x.estado), estados.map(x=>x.count));
// Últimas 10
renderUltimas();
}
function renderUltimas(){
const {comFecha, comFechaCierre, comEstado, comId} = state.keys;
const {detIdCom, detPrecio, detCant} = state.keys;
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
const rows = state.comandas
.slice()
.sort((a,b)=> {
const da = comFecha ? +new Date(a[comFecha]) : 0;
const db = comFecha ? +new Date(b[comFecha]) : 0;
return db - da;
})
.slice(0,10);
const tb = $('#ultimasTbody'); tb.innerHTML = '';
let lastTs = null;
rows.forEach(c=>{
const dets = byCom.get(c[comId]) || [];
const total = dets.reduce((acc,d)=> acc + Number(d[detPrecio]||0) * Number(d[detCant]||0), 0);
const ts = comFecha ? new Date(c[comFecha]) : null;
const tsc = comFechaCierre ? new Date(c[comFechaCierre]) : null;
if (ts) lastTs = (!lastTs || ts>lastTs) ? ts : lastTs;
const activa = isActiva(c[comEstado]);
const btn = activa
? `<button class="btn btn-outline-danger btn-sm js-cerrar" data-id="${c[comId]}">Cerrar</button>`
: `<button class="btn btn-outline-success btn-sm js-abrir" data-id="${c[comId]}">Abrir</button>`;
const tr = document.createElement('tr');
tr.dataset.id = c[comId];
tr.innerHTML = `
<td>${c[comId] ?? '—'}</td>
<td>${ts ? fmtTs(ts) : '—'}</td>
<td class="c-cierre">${tsc && !isNaN(tsc) ? fmtTs(tsc) : '—'}</td>
<td class="c-estado">${c[comEstado] ?? '—'}</td>
<td class="text-end">${fmtMoney(total)}</td>
<td class="c-accion">${btn}</td>
`;
tb.appendChild(tr);
});
$('#ultAct').textContent = lastTs ? ('Actualizado: ' + fmtTs(lastTs)) : '—';
}
// ===== Charts helpers =====
function destroyChart(id){ if (charts[id]) { charts[id].destroy(); charts[id]=null; } }
function drawBar(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: 'Cantidad', data }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}} }
});
}
function drawLine(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'line',
data: { labels, datasets: [{ label: 'Comandas', data, tension:.3, fill:false }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}} }
});
}
function drawDoughnut(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'doughnut',
data: { labels, datasets: [{ data }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom'}} }
});
}
// ===== Eventos =====
$('#dashRefresh').addEventListener('click', loadData);
window.addEventListener('sc:refresh-list', loadData); // desde el sidebar "Actualizar listado"
// Abrir/Cerrar comanda (actualiza fila + estado interno + re-render KPIs/gráficos)
document.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.js-cerrar, .js-abrir');
if (!btn) return;
const id = btn.dataset.id;
const isCerrar = btn.classList.contains('js-cerrar');
const url = isCerrar ? `/api/comandas/${id}/cerrar` : `/api/comandas/${id}/abrir`;
btn.disabled = true;
try {
const r = await fetch(url, { method: 'POST' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
// Actualizar estado local
const { comId, comEstado, comFechaCierre } = state.keys;
const idx = state.comandas.findIndex(c => String(c[comId]) === String(id));
if (idx >= 0) {
state.comandas[idx][comEstado] = data.estado ?? state.comandas[idx][comEstado];
if (comFechaCierre) state.comandas[idx][comFechaCierre] = data.fec_cierre ?? state.comandas[idx][comFechaCierre];
}
// Actualizar fila visual
const tr = document.querySelector(`tr[data-id="${id}"]`);
if (tr) {
const tdEstado = tr.querySelector('.c-estado');
const tdCierre = tr.querySelector('.c-cierre');
if (tdEstado) tdEstado.textContent = data.estado ?? tdEstado.textContent;
if (tdCierre) tdCierre.textContent = data.fec_cierre ? fmtTs(data.fec_cierre) : '—';
const acc = tr.querySelector('.c-accion');
if (acc) {
acc.innerHTML = (data.estado && data.estado.toLowerCase().includes('cerr'))
? `<button class="btn btn-outline-success btn-sm js-abrir" data-id="${id}">Abrir</button>`
: `<button class="btn btn-outline-danger btn-sm js-cerrar" data-id="${id}">Cerrar</button>`;
}
}
// Recalcular KPIs y gráficos (sin “crecimiento infinito”, se destruyen antes de redibujar)
renderAll();
} catch (e) {
alert('No se pudo actualizar la comanda: ' + (e.message || 'Error'));
} finally {
btn.disabled = false;
}
});
// Go!
loadData().catch(e => setStatus(e.message || 'Error'));
// Exporta CSV con KPIs y cortes básicos
window.scExportCsv = function () {
const rows = [];
rows.push(["kpi", "valor"]);
rows.push(["comandas_activas", document.getElementById("kpiActivas").textContent.trim()]);
rows.push(["ventas_hoy", document.getElementById("kpiVentasHoy").textContent.trim()]);
rows.push(["ticket_promedio_hoy", document.getElementById("kpiTicketProm").textContent.trim()]);
rows.push(["productos_distintos_hoy", document.getElementById("kpiProdDist").textContent.trim()]);
const csv = rows.map(r => r.map(v => `"${String(v).replaceAll('"','""')}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dashboard_${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
</script>

View File

@ -0,0 +1,532 @@
<!-- services/manso/src/views/estadoComandas.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">🧾 Estado de Comandas</h1>
<a class="btn btn-sm btn-dark" href="/comandas"> Nueva comanda</a>
</div>
<div class="row g-3">
<!-- ===== Listado (izquierda) ===== -->
<div class="col-12 col-lg-7">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Listado</strong>
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" id="soloAbiertas" checked>
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
</div>
</div>
<div class="card-body">
<div class="row g-2 align-items-center mb-2">
<div class="col">
<input id="buscar" type="search" class="form-control" placeholder="Buscar por #, mesa o usuario…">
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary" id="limpiar">Limpiar</button>
</div>
</div>
<div id="lista" class="table-responsive" style="max-height:62vh; overflow:auto;">
<div class="p-3 text-muted">Cargando…</div>
</div>
</div>
</div>
</div>
<!-- ===== Detalle (derecha) ===== -->
<div class="col-12 col-lg-5">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Detalle</strong>
<span id="detalleEstado" class="badge badge-outline ms-auto">—</span>
</div>
<div class="card-body" id="detalle">
<div class="text-muted">Selecciona una comanda para ver el detalle.</div>
</div>
<div class="d-flex align-items-center gap-3 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
<div class="small"><span class="text-muted">ID:</span> <strong id="kpiId">—</strong></div>
<div class="small"><span class="text-muted">Mesa:</span> <strong id="kpiMesa">—</strong></div>
<div class="small"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
<div class="ms-auto"></div>
<button class="btn btn-outline-secondary" id="reabrir">Reabrir</button>
<button class="btn btn-primary" id="cerrar">Cerrar</button>
</div>
<div class="card-body">
<div id="msg" class="text-muted small"></div>
</div>
</div>
</div>
</div>
<script>
// ===== Helpers =====
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'text-success small' : 'text-danger small';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
};
const badgeClass = (estadoRaw) => {
const s = String(estadoRaw||'').toLowerCase();
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
return 'badge badge-outline';
};
async function jget(url){
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body){
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body ?? {}) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ===== Estado =====
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
// ===== Data =====
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : [];
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
state.detalle = Array.isArray(det) ? det : [];
renderDetalle();
}
// ===== UI: Lista =====
function renderLista(){
let rows = state.lista.slice();
const f = state.filtro?.trim().toLowerCase();
if (f) {
rows = rows.filter(r =>
String(r.id_comanda).includes(f) ||
String(r.mesa_numero ?? '').includes(f) ||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="text-end">Ítems</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
<td class="text-end">${r.items ?? '—'}</td>
<td class="text-end">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ===== UI: Detalle + KPIs =====
function applyHeader(r){
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = badgeClass(r.estado);
$('#detalleEstado').textContent = r.estado ?? '—';
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones (más precisos según estado)
const cerr = $('#cerrar'), reab = $('#reabrir');
const s = String(r.estado||'').toLowerCase();
if (s.includes('abier')) {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else if (s.includes('cerr')) {
cerr.disabled = true; cerr.title = 'Ya está cerrada';
reab.disabled = false; reab.title = '';
} else {
// Otros estados: permitir ambas acciones
cerr.disabled = false; cerr.title = '';
reab.disabled = false; reab.title = '';
}
}
function renderDetalle(){
const box = $('#detalle');
if (!state.detalle.length) {
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre ?? '—'}</td>
<td class="text-end">${money(r.pre_unitario)}</td>
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
<td class="text-end">${money(sub)}</td>
<td>${r.observaciones || ''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ===== Actions (usa /abrir y /cerrar) =====
async function accionComanda(accion){ // 'abrir' | 'cerrar'
if (!state.sel) return;
try {
await jpost(`/api/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
// Recargar lista y re-aplicar cabecera/detalle si sigue visible
const id = state.sel;
await loadLista();
const found = state.lista.find(x => x.id_comanda === id);
if (found) {
applyHeader(found);
await loadDetalle(found.id_comanda);
} else {
// Puede desaparecer del listado si está activado "Solo abiertas" y la cerramos
state.sel = null;
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo actualizar la comanda');
}
}
// ===== Hooks con Sidebar (offcanvas) =====
window.scRefreshList = loadLista;
window.scExportCsv = function(){
const rows = state.lista.slice();
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
const csv = [header.join(",")].concat(rows.map(r => {
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
const vals = [
r.id_comanda,
r.mesa_numero ?? '',
(r.mesa_apodo ?? '').replaceAll('"','""'),
usuario.replaceAll('"','""'),
r.fec_creacion ?? '',
r.estado ?? '',
r.items ?? '',
r.total ?? ''
];
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
})).join("\n");
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
};
// ===== Init =====
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
// Ahora los botones llaman a los nuevos endpoints
$('#cerrar').addEventListener('click', () => accionComanda('cerrar'));
$('#reabrir').addEventListener('click', () => accionComanda('abrir'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script>
<!-- <script>
// ===== Helpers =====
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'text-success small' : 'text-danger small';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
};
const badgeClass = (estadoRaw) => {
const s = String(estadoRaw||'').toLowerCase();
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
return 'badge badge-outline';
};
async function jget(url){
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body){
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ===== Estado =====
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
// ===== Data =====
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : [];
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
state.detalle = Array.isArray(det) ? det : [];
renderDetalle();
}
// ===== UI: Lista =====
function renderLista(){
let rows = state.lista.slice();
const f = state.filtro?.trim().toLowerCase();
if (f) {
rows = rows.filter(r =>
String(r.id_comanda).includes(f) ||
String(r.mesa_numero ?? '').includes(f) ||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="text-end">Ítems</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
<td class="text-end">${r.items ?? '—'}</td>
<td class="text-end">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ===== UI: Detalle + KPIs =====
function applyHeader(r){
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = badgeClass(r.estado);
$('#detalleEstado').textContent = r.estado ?? '—';
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones
const cerr = $('#cerrar'), reab = $('#reabrir');
if ((r.estado||'').toLowerCase().includes('abier')) {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else {
cerr.disabled = false;
reab.disabled = false;
cerr.title = ''; reab.title = '';
}
}
function renderDetalle(){
const box = $('#detalle');
if (!state.detalle.length) {
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre ?? '—'}</td>
<td class="text-end">${money(r.pre_unitario)}</td>
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
<td class="text-end">${money(sub)}</td>
<td>${r.observaciones || ''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ===== Actions =====
async function setEstado(estado){
if (!state.sel) return;
try {
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
await loadLista();
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
else {
state.sel = null;
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo cambiar el estado');
}
}
// ===== Hooks con Sidebar (offcanvas) =====
// Permite que el botón "Actualizar" del sidebar recargue este listado
window.scRefreshList = loadLista;
// Exportación simple del listado actual
window.scExportCsv = function(){
const rows = state.lista.slice();
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
const csv = [header.join(",")].concat(rows.map(r => {
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
const vals = [
r.id_comanda,
r.mesa_numero ?? '',
(r.mesa_apodo ?? '').replaceAll('"','""'),
usuario.replaceAll('"','""'),
r.fec_creacion ?? '',
r.estado ?? '',
r.items ?? '',
r.total ?? ''
];
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
})).join("\n");
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
};
// ===== Init =====
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script> -->

View File

@ -0,0 +1,158 @@
<!-- views/inicio_v2.ejs -->
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Inicio • SuiteCoffee</title>
<style>
:root { --bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
h1 { font-size: 26px; margin: 0 0 8px; }
p.lead { color: var(--muted); margin: 0 0 20px; }
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
tbody tr:last-child td { border-bottom: 0; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
.muted { color: var(--muted); }
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
.k { color:#93c5fd; }
.v { color:#fca5a5; word-break: break-all; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<%
// Espera que el backend pase: { user, cookies, session }
const hasUser = typeof user !== 'undefined' && user;
const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
const hasSession = typeof session !== 'undefined' && session && Object.keys(session).length;
const displayName =
(hasUser && (user.name || user.displayName || user.email)) ||
(hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
(hasSession && (session.user?.email || session.user?.name)) ||
'usuario';
%>
<h1>Hola, <span><%= displayName %></span> 👋</h1>
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio y panel de diagnóstico de cookies/sesión.</p>
<% if (hasUser) { %>
<h2>Sesión de Aplicación (user)</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(user)) { %>
<tr>
<th><code class="k"><%= k %></code></th>
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<% if (hasSession) { %>
<h2>Sesión Express (req.session)</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(session)) { %>
<tr>
<th><code class="k"><%= k %></code></th>
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<div class="grid" style="margin-top:18px;">
<section class="card">
<h2>Cookies (servidor: <code>req.cookies</code>)</h2>
<% if (hasCookies) { %>
<table>
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<% for (const [name, value] of Object.entries(cookies)) { %>
<tr>
<td><code class="k"><%= name %></code></td>
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } else { %>
<p class="muted">
No se recibieron cookies del lado servidor (<code>req.cookies</code>).
Asegurate de usar <code>cookie-parser</code> y de pasar <code>cookies</code> al render:
<br /><code>res.render('inicio_v2', { user: req.session.user, cookies: req.cookies, session: req.session })</code>
</p>
<% } %>
</section>
<section class="card">
<h2>Cookies (navegador: <code>document.cookie</code>)</h2>
<table id="client-cookies">
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<tr><td colspan="2" class="muted">Cargando…</td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:10px;">
Total cookies en navegador: <span id="cookie-count">0</span>
</p>
<p class="muted" style="margin-top:10px;">Raw <code>document.cookie</code>:</p>
<pre id="cookie-raw" class="muted" style="white-space: pre-wrap; word-break: break-all;"></pre>
</section>
</div>
</div>
</div>
<script>
(function () {
try {
const tbody = document.querySelector('#client-cookies tbody');
const raw = document.cookie || '';
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
const pairs = raw ? raw.split(/;\s*/) : [];
document.getElementById('cookie-count').textContent = pairs.length;
if (!pairs.length) {
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
return;
}
tbody.innerHTML = '';
for (const kv of pairs) {
const i = kv.indexOf('=');
const name = i >= 0 ? kv.slice(0, i) : kv;
const value = i >= 0 ? kv.slice(i + 1) : '';
const tr = document.createElement('tr');
const td1 = document.createElement('td');
const td2 = document.createElement('td');
td1.innerHTML = '<code class="k"></code>';
td2.innerHTML = '<code class="v"></code>';
td1.querySelector('code').textContent = decodeURIComponent(name);
td2.querySelector('code').textContent = decodeURIComponent(value);
tr.append(td1, td2);
tbody.appendChild(tr);
}
} catch (err) {
console.error('cookie render error:', err);
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,130 @@
<!-- views/inicio.ejs -->
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta http-equiv="x-ua-compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Inicio • SuiteCoffee</title>
<style>
:root { --bg:#0b0b0c; --card:#141519; --text:#e7e9ee; --muted:#a5acb8; --accent:#6ee7b7; --border:#232733; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Helvetica Neue, Arial, "Apple Color Emoji","Segoe UI Emoji"; background: var(--bg); color: var(--text); }
.wrap { max-width: 960px; margin: 48px auto; padding: 0 20px; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; padding: 20px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }
h1 { font-size: 26px; margin: 0 0 8px; }
p.lead { color: var(--muted); margin: 0 0 20px; }
h2 { margin: 24px 0 10px; font-size: 18px; color: var(--accent); }
table { width: 100%; border-collapse: collapse; overflow: hidden; border-radius: 12px; border: 1px solid var(--border); }
th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 600; letter-spacing: .02em; }
tbody tr:last-child td { border-bottom: 0; }
code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.grid { display: grid; gap: 18px; grid-template-columns: 1fr; }
@media (min-width: 900px) { .grid { grid-template-columns: 1fr 1fr; } }
.muted { color: var(--muted); }
.pill { display:inline-block; padding:2px 8px; border:1px solid var(--border); border-radius:999px; color:var(--muted); font-size:12px; }
.k { color:#93c5fd; }
.v { color:#fca5a5; word-break: break-all; }
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<%
const hasUser = typeof user !== 'undefined' && user;
const hasCookies = typeof cookies !== 'undefined' && cookies && Object.keys(cookies).length;
const displayName =
(hasUser && (user.name || user.displayName || user.email)) ||
(hasCookies && (cookies.user_name || cookies.displayName || cookies.email)) ||
'usuario';
%>
<h1>Hola, <span><%= displayName %></span> 👋</h1>
<p class="lead">Bienvenido a SuiteCoffee. Este es tu inicio.</p>
<% if (hasUser) { %>
<h2>Sesión</h2>
<table>
<tbody>
<% for (const [k,v] of Object.entries(user)) { %>
<tr>
<th><code class="k"><%= k %></code></th>
<td><code class="v"><%= typeof v === 'object' ? JSON.stringify(v) : String(v) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } %>
<div class="grid" style="margin-top:18px;">
<section class="card">
<h2>Cookies (servidor)</h2>
<% if (hasCookies) { %>
<table>
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<% for (const [name, value] of Object.entries(cookies)) { %>
<tr>
<td><code class="k"><%= name %></code></td>
<td><code class="v"><%= typeof value === 'object' ? JSON.stringify(value) : String(value) %></code></td>
</tr>
<% } %>
</tbody>
</table>
<% } else { %>
<p class="muted">No se recibieron cookies desde el servidor (req.cookies). ¿Estás usando <code>cookie-parser</code> o pasando <code>cookies</code> al render?</p>
<% } %>
</section>
<section class="card">
<h2>Cookies (navegador)</h2>
<table id="client-cookies">
<thead>
<tr><th>Nombre</th><th>Valor</th></tr>
</thead>
<tbody>
<tr><td colspan="2" class="muted">Cargando…</td></tr>
</tbody>
</table>
<p class="muted" style="margin-top:10px;">Raw <code>document.cookie</code>:</p>
<pre id="cookie-raw" class="muted" style="white-space: pre-wrap; word-break: break-all;"></pre>
</section>
</div>
</div>
</div>
<script>
(function () {
try {
const tbody = document.querySelector('#client-cookies tbody');
const raw = document.cookie || '';
document.getElementById('cookie-raw').textContent = raw || '(sin cookies)';
const pairs = raw ? raw.split(/;\s*/) : [];
if (!pairs.length) {
tbody.innerHTML = '<tr><td colspan="2"><em class="muted">sin cookies</em></td></tr>';
return;
}
tbody.innerHTML = '';
for (const kv of pairs) {
const i = kv.indexOf('=');
const name = i >= 0 ? kv.slice(0, i) : kv;
const value = i >= 0 ? kv.slice(i + 1) : '';
const tr = document.createElement('tr');
const td1 = document.createElement('td');
const td2 = document.createElement('td');
td1.innerHTML = '<code class="k"></code>';
td2.innerHTML = '<code class="v"></code>';
td1.querySelector('code').textContent = decodeURIComponent(name);
td2.querySelector('code').textContent = decodeURIComponent(value);
tr.append(td1, td2);
tbody.appendChild(tr);
}
} catch (err) {
console.error('cookie render error:', err);
}
})();
</script>
</body>
</html>

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="es">
<head>
<%- include('../partials/_head') %>
</head>
<body data-page="<%= pageId %>">
<%- include('../partials/_navbar') %>
<main class="container">
<%- body %>
</main>
<%- include('../partials/_sidebar') %>
<%- include('../partials/_footer') %>
</body>
</html>

View File

@ -0,0 +1,164 @@
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= typeof pageTitle !== 'undefined' ? pageTitle : 'Iniciar sesión · SuiteCoffee' %></title>
<!-- Bootstrap 5 (minimal) -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<style>
:root {
--col-pri: #<%= (process.env.COL_PRI || '452D19') %>;
--col-sec: #<%= (process.env.COL_SEC || 'D7A666') %>;
--col-bg: #<%= (process.env.COL_BG || 'FFA500') %>33; /* con alpha */
}
body { background: radial-gradient(1200px 600px at 10% -10%, var(--col-bg), transparent), #f8f9fa; }
.brand { color: var(--col-pri); }
.btn-sso { background: var(--col-pri); color: #fff; border-color: var(--col-pri); }
.btn-sso:hover { filter: brightness(1.05); color: #fff; }
.card { border-radius: 14px; }
.form-hint { font-size: .875rem; color: #6c757d; }
.divider { display:flex; align-items:center; text-transform:uppercase; font-size:.8rem; color:#6c757d; }
.divider::before, .divider::after { content:""; height:1px; background:#dee2e6; flex:1; }
.divider:not(:empty)::before { margin-right:.75rem; }
.divider:not(:empty)::after { margin-left:.75rem; }
</style>
</head>
<body>
<div class="container py-5">
<div class="row justify-content-center">
<div class="col-12 col-sm-10 col-md-8 col-lg-6 col-xl-5">
<div class="text-center mb-4">
<h1 class="brand fw-bold">SuiteCoffee</h1>
<p class="text-secondary mb-0">Accedé a tu cuenta</p>
</div>
<!-- Mensajes (query ?msg= / ?error=) -->
<div id="flash" class="mb-3" style="display:none"></div>
<div class="card shadow-sm">
<div class="card-body p-4 p-md-5">
<!-- SSO con Authentik -->
<div class="d-grid gap-2 mb-3">
<a href="/auth/login" class="btn btn-sso btn-lg" id="btn-sso">
Ingresar con SSO (Authentik)
</a>
</div>
<div class="divider my-3">o</div>
<!-- Registro mínimo (usa POST /api/users/register) -->
<form id="form-register" class="needs-validation" novalidate>
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" placeholder="tu@correo.com" required>
<div class="invalid-feedback">Ingresá un email válido.</div>
</div>
<div class="mb-3">
<label for="display_name" class="form-label">Nombre a mostrar</label>
<input type="text" class="form-control" id="display_name" name="display_name" placeholder="Ej.: Juan Pérez" required>
<div class="invalid-feedback">Ingresá tu nombre.</div>
</div>
<div class="mb-3">
<label for="tenant_uuid" class="form-label">Código de organización (tenant UUID)</label>
<input type="text" class="form-control" id="tenant_uuid" name="tenant_uuid" placeholder="Ej.: 4b8d0f6a-...">
<div class="form-hint">Si te invitaron a una organización existente, pegá aquí su UUID. Si sos el primero de tu empresa, dejalo vacío y el equipo te asignará uno.</div>
</div>
<div class="mb-3">
<label for="role" class="form-label">Rol</label>
<select id="role" name="role" class="form-select">
<option value="owner">Owner</option>
<option value="admin">Admin</option>
<option value="staff">Staff</option>
</select>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-outline-dark">Crear cuenta</button>
</div>
</form>
<p class="text-center text-muted mt-3 mb-0" style="font-size:.9rem;">
Al continuar aceptás nuestros términos y políticas.
</p>
</div>
</div>
<p class="text-center text-secondary mt-3" style="font-size:.9rem;">
¿Ya tenés cuenta? <a href="/auth/login" class="link-dark">Iniciá sesión con SSO</a>
</p>
</div>
</div>
</div>
<script>
// Mostrar mensajes por querystring (?msg=... / ?error=...)
(function() {
const params = new URLSearchParams(location.search);
const el = document.getElementById('flash');
const msg = params.get('msg');
const err = params.get('error');
if (msg) {
el.innerHTML = `<div class="alert alert-success mb-0" role="alert">${decodeURIComponent(msg)}</div>`;
el.style.display = '';
} else if (err) {
el.innerHTML = `<div class="alert alert-danger mb-0" role="alert">${decodeURIComponent(err)}</div>`;
el.style.display = '';
}
})();
// Validación Bootstrap + envío del registro contra /api/users/register
(function() {
const form = document.getElementById('form-register');
form.addEventListener('submit', async function(e) {
e.preventDefault();
e.stopPropagation();
form.classList.add('was-validated');
if (!form.checkValidity()) return;
const btn = form.querySelector('button[type="submit"]');
btn.disabled = true; btn.innerText = 'Creando...';
try {
const payload = {
email: document.getElementById('email').value.trim(),
display_name: document.getElementById('display_name').value.trim(),
tenant_uuid: document.getElementById('tenant_uuid').value.trim() || undefined,
role: document.getElementById('role').value
};
const res = await fetch('/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include'
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.error || data?.message || 'No se pudo registrar');
}
// Registro OK → redirigimos a login SSO
const redir = '/auth/login';
location.href = redir + '?msg=' + encodeURIComponent('Registro exitoso. Iniciá sesión con SSO.');
} catch (err) {
alert(err.message || String(err));
} finally {
btn.disabled = false; btn.innerText = 'Crear cuenta';
}
});
})();
</script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!-- /partials/_footer.html -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
/**
* (Solo si usas HTML estático)
* Carga “partials” desde elementos con [data-include="/partials/..."].
* Si usas EJS/templating, podés quitar esto.
*/
async function scLoadPartials(){
const includes = document.querySelectorAll("[data-include]");
for (const el of includes) {
const url = el.getAttribute("data-include");
try {
const res = await fetch(url, {cache:"no-store"});
el.innerHTML = await res.text();
} catch (e) {
el.innerHTML = `<div class="text-danger small">No se pudo cargar ${url}</div>`;
}
}
}
// Export util por si querés llamarlo manualmente
window.scLoadPartials = scLoadPartials;
// Eventos genéricos que el sidebar dispara (ajustá a tu lógica real)
window.addEventListener("sc:toggle-abiertas", () => {
// Ej.: togglear checkbox/estado en páginas que lo usen
const chk = document.getElementById("soloAbiertas");
if (chk) { chk.checked = !chk.checked; chk.dispatchEvent(new Event("change")); }
});
window.addEventListener("sc:export-csv", () => {
// Implementá tu export acá
if (window.scExportCsv) return window.scExportCsv();
alert("Exportar CSV: implementame 😄");
});
window.addEventListener("sc:refresh-list", () => {
if (window.scRefreshList) return window.scRefreshList();
location.reload();
});
</script>

View File

@ -0,0 +1,45 @@
<!-- /partials/_head.html -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= typeof pageTitle !== "undefined" ? pageTitle : "SuiteCoffee" %></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="icon" href="/favicon/favicon.ico" sizes="any">
<link rel="icon" href="/favicon/favicon-16x16.png" sizes="16x16">
<link rel="icon" href="/favicon/favicon-32x32.png" sizes="32x32">
<link rel="icon" href="/favicon/apple-touch-icon.png" sizes="180x180">
<link rel="icon" href="/favicon/android-chrome-512x512.png" sizes="512x512">
<link rel="icon" href="/favicon/android-chrome-192x192.png" sizes="192x192">
<link rel="manifest" href="/favicon/site.webmanifest">
<style>
:root { --navbar-h: 56px; }
body { padding-top: var(--navbar-h); background: #f7f8fb; }
.brand-mini { font-weight: 700; letter-spacing: .2px; }
/* Layout contenedor principal */
main { padding-block: 1rem 2rem; }
/* Tabla compacta */
.table-sm th, .table-sm td { padding: .5rem .6rem; }
/* Chips/etiquetas de estado */
.badge-outline { border: 1px solid #dee2e6; background: #fff; color: #495057; }
.badge-estado-abierta { border-color:#198754; color:#198754; }
.badge-estado-cerrada { border-color:#6c757d; color:#6c757d; }
.badge-estado-anulada { border-color:#dc3545; color:#dc3545; }
.badge-estado-pagada { border-color:#146c43; color:#146c43; }
/* Evita crecimiento infinito de los charts */
.chart-box {
position: relative;
height: 260px; /* altura fija base */
}
@media (min-width: 992px) {
.chart-box { height: 320px; } /* un poquito más grande en desktop */
}
.chart-box > canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important; /* ocupa todo el alto del contenedor */
}
</style>

View File

@ -0,0 +1,31 @@
<!-- /partials/_navbar.html -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom fixed-top">
<div class="container-fluid">
<a class="navbar-brand brand-mini" href="/">SuiteCoffee</a>
<!-- Links principales (colapsables en mobile) -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#scNav" aria-controls="scNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <!-- hamburguesa principal -->
</button>
<div class="collapse navbar-collapse" id="scNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0 small">
<li class="nav-item"><a class="nav-link" href="/comandas">Comandas</a></li>
<li class="nav-item"><a class="nav-link" href="/estadoComandas">Estado</a></li>
<li class="nav-item"><a class="nav-link" href="/productos">Productos</a></li>
<li class="nav-item"><a class="nav-link" href="/usuarios">Usuarios</a></li>
<li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li>
<li class="nav-item"><a class="nav-link" href="/compras">Compras</a></li>
<!-- <li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li> -->
<!-- agrega las que necesites -->
</ul>
<!-- Botón “hamburguesa” para abrir el menú contextual (sidebar derecha) -->
<button class="btn btn-outline-secondary btn-sm d-flex align-items-center" type="button"
data-bs-toggle="offcanvas" data-bs-target="#scSidebar" aria-controls="scSidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" class="me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
Opciones
</button>
</div>
</div>
</nav>

View File

@ -0,0 +1,72 @@
<!-- /partials/_sidebar.html -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="scSidebar" aria-labelledby="scSidebarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="scSidebarLabel">Opciones</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Cerrar"></button>
</div>
<div class="offcanvas-body">
<!-- Contenido se inyecta según la página actual -->
<div id="scSidebarContent" class="list-group list-group-flush small"></div>
</div>
</div>
<script>
// Map de opciones por página. Usa body[data-page] o window.scPageId.
const SC_SIDEBAR_ITEMS = {
// === ejemplos ===
"dashboard": [
{ text: "Ver reportes", href: "/reportes" },
{ text: "Actualizar", href: "#", attr: { "data-action": "refresh-list" } },
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
{ text: "Nueva comanda", href: "/comandas" },
{ text: "Ir a Estado", href: "/estadoComandas" }
],
"estadoComandas": [
{ text: " Nueva comanda", href: "/comandas" },
{ text: "Solo abiertas", href: "#", attr: { "data-action": "toggle-abiertas" } },
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
{ text: "Actualizar listado", href: "#", attr: { "data-action": "refresh-list" } },
],
"comandas": [
{ text: "Volver a Estado", href: "/estadoComandas" },
{ text: "Cargar productos", href: "/productos" },
{ text: "Mesas", href: "/mesas" },
],
"productos": [
{ text: "Nuevo producto", href: "/productos/nuevo" },
{ text: "Importar catálogo", href: "/productos/importar" },
{ text: "Reportes", href: "/reportes" },
],
"usuarios": [
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
]
};
(function initSidebar(){
const page = (document.body.dataset.page || window.scPageId || "").trim();
const items = SC_SIDEBAR_ITEMS[page] || [
{ text: "Inicio", href: "/" }
];
const box = document.getElementById("scSidebarContent");
box.innerHTML = "";
for (const it of items) {
const a = document.createElement("a");
a.className = "list-group-item list-group-item-action";
a.textContent = it.text;
a.href = it.href || "#";
if (it.attr) for (const [k,v] of Object.entries(it.attr)) a.setAttribute(k,v);
box.appendChild(a);
}
// Acciones ejemplo (opcionales). Adaptá a tus funciones reales.
box.addEventListener("click", (ev) => {
const a = ev.target.closest("a[data-action]");
if (!a) return;
ev.preventDefault();
const action = a.getAttribute("data-action");
if (action === "toggle-abiertas") window.dispatchEvent(new CustomEvent("sc:toggle-abiertas"));
if (action === "export-csv") window.dispatchEvent(new CustomEvent("sc:export-csv"));
if (action === "refresh-list") window.dispatchEvent(new CustomEvent("sc:refresh-list"));
});
})();
</script>

View File

@ -0,0 +1,559 @@
<!-- services/manso/src/views/productos.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">🛒 Productos</h1>
<div class="d-flex gap-2">
<button id="btnNuevo" class="btn btn-outline-secondary btn-sm">Nuevo</button>
<button id="btnGuardar" class="btn btn-primary btn-sm">Guardar</button>
<button class="btn btn-outline-dark btn-sm" data-bs-toggle="collapse" data-bs-target="#mpWrap" aria-expanded="false">Materias primas</button>
</div>
</div>
<div class="row g-3">
<!-- ===== Listado ===== -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Listado</strong>
<div class="ms-auto d-flex gap-2">
<input id="q" type="search" class="form-control form-control-sm" placeholder="Buscar…">
<button id="btnLimpiar" class="btn btn-outline-secondary btn-sm">Limpiar</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 65vh; overflow: auto;">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Nombre</th>
<th class="text-end">Precio</th>
<th>Activo</th>
<th>Categoría</th>
</tr>
</thead>
<tbody id="tbProductos">
<tr><td colspan="5" class="p-3 text-muted">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ===== Edición / Alta ===== -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Ficha</strong></div>
<div class="card-body">
<input type="hidden" id="id_producto">
<div class="row g-3">
<div class="col-12">
<label class="form-label small text-muted mb-1" for="nombre">Nombre</label>
<input id="nombre" class="form-control" autocomplete="off">
</div>
<div class="col-12 col-sm-6">
<label class="form-label small text-muted mb-1" for="precio">Precio</label>
<input id="precio" type="number" step="0.01" min="0" class="form-control">
</div>
<div class="col-12 col-sm-6">
<label class="form-label small text-muted mb-1" for="id_categoria">Categoría</label>
<select id="id_categoria" class="form-select"></select>
</div>
<div class="col-12">
<label class="form-label small text-muted mb-1" for="img_producto">Imagen (URL)</label>
<input id="img_producto" class="form-control" placeholder="img_producto.png">
<div class="mt-2">
<img id="preview" src="" alt="" class="img-thumbnail d-none" style="max-height: 140px;">
</div>
</div>
<div class="col-12">
<div class="form-check">
<input id="activo" class="form-check-input" type="checkbox" checked>
<label for="activo" class="form-check-label">Activo</label>
</div>
</div>
</div>
</div>
</div>
<!-- ===== Receta ===== -->
<div class="card shadow-sm mt-3">
<div class="card-header d-flex align-items-center">
<strong>Receta (materias primas por unidad)</strong>
<button id="btnAddIng" class="btn btn-outline-secondary btn-sm ms-auto">Agregar ingrediente</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 55%;">Materia prima</th>
<th class="text-end" style="width: 25%;">Cantidad</th>
<th style="width: 20%;"></th>
</tr>
</thead>
<tbody id="tbReceta">
<tr><td colspan="3" class="p-3 text-muted">Sin ingredientes.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer d-flex align-items-center small text-muted">
Cantidades en la unidad definida por cada materia prima.
<span id="msg" class="ms-auto"></span>
</div>
</div>
</div>
</div>
<!-- ====== GESTIÓN DE MATERIAS PRIMAS (OCULTO POR DEFECTO) ====== -->
<div class="collapse mt-4" id="mpWrap">
<div class="d-flex align-items-center justify-content-between mb-2">
<h2 class="h5 m-0">⚙️ Materias primas</h2>
<div class="d-flex gap-2">
<button id="mpNuevo" class="btn btn-outline-secondary btn-sm">Nuevo</button>
<button id="mpGuardar" class="btn btn-primary btn-sm">Guardar</button>
</div>
</div>
<div class="row g-3">
<!-- Listado MP -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Listado</strong>
<div class="ms-auto d-flex gap-2">
<input id="mpQ" type="search" class="form-control form-control-sm" placeholder="Buscar…">
<button id="mpLimpiar" class="btn btn-outline-secondary btn-sm">Limpiar</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height:60vh;overflow:auto;">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Nombre</th>
<th>Unidad</th>
<th>Activo</th>
</tr>
</thead>
<tbody id="mpTb">
<tr><td colspan="4" class="p-3 text-muted">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Ficha MP -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Ficha</strong></div>
<div class="card-body">
<input type="hidden" id="mp_id_mat_prima">
<div class="row g-3">
<div class="col-12">
<label class="form-label small text-muted mb-1" for="mp_nombre">Nombre</label>
<input id="mp_nombre" class="form-control" autocomplete="off">
</div>
<div class="col-12 col-sm-6">
<label class="form-label small text-muted mb-1" for="mp_unidad">Unidad</label>
<input id="mp_unidad" class="form-control" placeholder="ej: gr, ml, u.">
</div>
<div class="col-12 col-sm-6 d-flex align-items-end">
<div class="form-check">
<input id="mp_activo" class="form-check-input" type="checkbox" checked>
<label class="form-check-label" for="mp_activo">Activo</label>
</div>
</div>
<div class="col-12">
<label class="form-label small text-muted mb-1" for="mp_proveedores">Proveedores (asignación)</label>
<select id="mp_proveedores" class="form-select" multiple></select>
<div class="form-text">Mantén presionadas Ctrl/⌘ para seleccionar varios.</div>
</div>
</div>
</div>
<div class="card-footer small text-muted d-flex">
<span id="mpMsg"></span>
</div>
</div>
</div>
</div>
</div>
<script>
// ===== Helpers =====
const $ = (s, r=document)=>r.querySelector(s);
const $$ = (s, r=document)=>Array.from(r.querySelectorAll(s));
const money = (n)=> isNaN(n) ? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(+n);
const toast = (t, ok=false)=> { const el=$('#msg'); el.className = 'ms-auto ' + (ok?'text-success':'text-danger'); el.textContent=t; setTimeout(()=>{ el.textContent=''; el.className='ms-auto text-muted'; }, 3000); };
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
// ===== Estado =====
const state = {
productos: [],
categorias: [],
materias: [],
receta: [], // [{id_mat_prima, nombre, unidad, qty_por_unidad}]
filtro: '',
selId: null
};
// ===== Carga inicial =====
async function init(){
const [prods, cats, mats] = await Promise.all([
jget('/api/table/productos?limit=2000'),
jget('/api/table/categorias?limit=2000').catch(()=>[]),
jget('/api/table/mate_primas?limit=2000')
]);
state.productos = Array.isArray(prods)? prods : [];
state.categorias = Array.isArray(cats)? cats : [];
state.catById = new Map(state.categorias.map(c => [c.id_categoria, c.nombre]));
state.materias = Array.isArray(mats)? mats : [];
hydrateCategorias();
renderLista();
clearForm();
}
function hydrateCategorias(){
const sel = $('#id_categoria'); sel.innerHTML='';
sel.appendChild(new Option('(sin categoría)', '', true, true));
state.categorias.forEach(c => sel.appendChild(new Option(c.nombre || ('#'+c.id_categoria), c.id_categoria)));
}
// ===== Listado =====
const catName = (id) => state?.catById?.get(id) ?? (id ? `#${id}` : '');
function renderLista(){
const tb = $('#tbProductos');
let rows = state.productos.slice();
const f = state.filtro.trim().toLowerCase();
if (f) rows = rows.filter(p => (p.nombre||'').toLowerCase().includes(f) || String(p.id_producto).includes(f));
if (!rows.length) { tb.innerHTML = '<tr><td colspan="5" class="p-3 text-muted">Sin resultados.</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(p => {
const tr = document.createElement('tr');
tr.style.cursor='pointer';
tr.innerHTML = `
<td>${p.id_producto}</td>
<td>${p.nombre||'—'}</td>
<td class="text-end">${money(p.precio)}</td>
<td>${p.activo ? 'Sí' : 'No'}</td>
<td>${catName(p.id_categoria)}</td>
`;
tr.addEventListener('click', ()=> loadProducto(p.id_producto));
tb.appendChild(tr);
});
}
// ===== Ficha =====
function clearForm(){
state.selId = null;
$('#id_producto').value = '';
$('#nombre').value = '';
$('#precio').value = '';
$('#id_categoria').value = '';
$('#img_producto').value = '';
$('#preview').src = ''; $('#preview').classList.add('d-none');
$('#activo').checked = true;
state.receta = [];
renderReceta();
}
async function loadProducto(id){
try {
// Usamos la función SQL (RPC) para traer producto + receta en un solo tiro
const data = await jget(`/api/rpc/get_producto/${id}`);
const p = data.producto || {};
const r = Array.isArray(data.receta) ? data.receta : [];
state.selId = p.id_producto;
$('#id_producto').value = p.id_producto ?? '';
$('#nombre').value = p.nombre ?? '';
$('#precio').value = p.precio ?? '';
$('#id_categoria').value = p.id_categoria ?? '';
$('#img_producto').value = p.img_producto ?? '';
if (p.img_producto) { $('#preview').src = p.img_producto; $('#preview').classList.remove('d-none'); } else { $('#preview').src=''; $('#preview').classList.add('d-none'); }
$('#activo').checked = (p.activo !== false);
// receta
state.receta = r.map(x => ({
id_mat_prima: x.id_mat_prima,
nombre: x.nombre ?? (state.materias.find(m=>m.id_mat_prima===x.id_mat_prima)?.nombre || ('#'+x.id_mat_prima)),
unidad: x.unidad ?? (state.materias.find(m=>m.id_mat_prima===x.id_mat_prima)?.unidad || ''),
qty_por_unidad: Number(x.qty_por_unidad||0)
}));
renderReceta();
} catch(e) {
toast(e.message || 'No se pudo cargar el producto');
}
}
// ===== Receta (UI) =====
function renderReceta(){
const tb = $('#tbReceta');
if (!state.receta.length) { tb.innerHTML = '<tr><td colspan="3" class="p-3 text-muted">Sin ingredientes.</td></tr>'; return; }
tb.innerHTML = '';
state.receta.forEach((it, idx) => {
const tr = document.createElement('tr');
const sel = document.createElement('select');
sel.className = 'form-select form-select-sm';
state.materias.forEach(m => sel.appendChild(new Option(`${m.nombre} (${m.unidad||'-'})`, m.id_mat_prima, false, m.id_mat_prima===it.id_mat_prima)));
sel.addEventListener('change', () => {
const val = parseInt(sel.value, 10);
if (!Number.isInteger(val) || val <= 0) { // si algo raro
const first = state.materias[0];
it.id_mat_prima = Number(first?.id_mat_prima || 0);
} else {
it.id_mat_prima = val;
}
const m = state.materias.find(x => x.id_mat_prima === it.id_mat_prima);
it.nombre = m?.nombre || '';
it.unidad = m?.unidad || '';
});
const qty = document.createElement('input');
qty.type = 'number'; qty.min='0.001'; qty.step='0.001'; qty.value = (it.qty_por_unidad||0).toFixed(3);
qty.className = 'form-control form-control-sm text-end';
qty.addEventListener('change', ()=> it.qty_por_unidad = Number(qty.value||0));
const del = document.createElement('button');
del.className = 'btn btn-outline-secondary btn-sm';
del.textContent = 'Quitar';
del.addEventListener('click', ()=> { state.receta.splice(idx,1); renderReceta(); });
const td1 = document.createElement('td'); td1.appendChild(sel);
const td2 = document.createElement('td'); td2.className='text-end'; td2.appendChild(qty);
const td3 = document.createElement('td'); td3.appendChild(del);
tr.append(td1,td2,td3);
tb.appendChild(tr);
});
}
function addIngrediente(){
if (!state.materias.length) {
toast('Primero cargá materias primas', false);
return;
}
const m = state.materias[0];
state.receta.push({
id_mat_prima: Number(m.id_mat_prima), // siempre número válido
nombre: m.nombre || '',
unidad: m.unidad || '',
qty_por_unidad: 1.000
});
renderReceta();
}
// ===== Guardar (INSERT/UPDATE + receta) vía función SQL =====
async function guardar(){
try {
const cleanedReceta = state.receta
.map(r => ({
id: parseInt(r.id_mat_prima, 10),
qty: Number(r.qty_por_unidad)
}))
.filter(x => Number.isInteger(x.id) && x.id > 0 && Number.isFinite(x.qty) && x.qty > 0)
.map(x => ({ id_mat_prima: x.id, qty_por_unidad: +x.qty.toFixed(3) }));
const payload = {
id_producto: $('#id_producto').value ? Number($('#id_producto').value) : null,
nombre: $('#nombre').value.trim(),
img_producto: $('#img_producto').value.trim() || null,
precio: Number($('#precio').value || 0),
activo: $('#activo').checked,
id_categoria: $('#id_categoria').value ? Number($('#id_categoria').value) : null,
receta: cleanedReceta
};
if (cleanedReceta.length !== state.receta.length) {
toast('Se ignoraron ingredientes inválidos (id o cantidad).', false);
}
if (!payload.nombre) { toast('Nombre requerido'); return; }
if (!(payload.precio >= 0)) { toast('Precio inválido'); return; }
const { id_producto } = await jpost('/api/rpc/save_producto', payload);
toast(`Guardado #${id_producto}`, true);
// refrescar listado y reabrir seleccionado
state.productos = await jget('/api/table/productos?limit=2000');
renderLista();
await loadProducto(id_producto);
} catch (e) {
toast(e.message || 'No se pudo guardar');
}
}
// ===== Eventos =====
$('#q').addEventListener('input', ()=> { state.filtro = $('#q').value||''; renderLista(); });
$('#btnLimpiar').addEventListener('click', ()=> { $('#q').value=''; state.filtro=''; renderLista(); });
$('#btnNuevo').addEventListener('click', clearForm);
$('#btnAddIng').addEventListener('click', addIngrediente);
$('#btnGuardar').addEventListener('click', guardar);
$('#img_producto').addEventListener('input', ()=> {
const v=$('#img_producto').value.trim();
if (v) { $('#preview').src=v; $('#preview').classList.remove('d-none'); } else { $('#preview').src=''; $('#preview').classList.add('d-none'); }
});
// Hooks con sidebar (opcional)
window.scRefreshList = async function(){ state.productos = await jget('/api/table/productos?limit=2000'); renderLista(); };
window.scExportCsv = function(){
const rows = state.productos.slice();
const head = ["id_producto","nombre","precio","activo","id_categoria"];
const csv = [head.join(",")].concat(rows.map(r => {
const vals = [r.id_producto,r.nombre,(r.precio??''),(r.activo??''),(r.id_categoria??'')];
return vals.map(v => `"${String(v??'').replaceAll('"','""')}"`).join(",");
})).join("\n");
const blob = new Blob([csv],{type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {href:url, download:`productos_${new Date().toISOString().slice(0,10)}.csv`});
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
};
/* ========= EXTENSIÓN: MATERIAS PRIMAS ========= */
// 1) ampliar el estado global existente:
state.proveedores = state.proveedores || [];
state.mpFiltro = '';
state.mpSelId = null;
state.mpAsignados = []; // array de id_proveedor seleccionados para la MP
// 2) cargar proveedores también en init()
const __oldInit = init;
init = async function() {
const [provs] = await Promise.all([
jget('/api/table/proveedores?limit=5000').catch(()=>[])
]);
state.proveedores = Array.isArray(provs) ? provs : [];
await __oldInit(); // llama a tu init original (productos + categorías + materias)
hydrateMpProveedoresOptions(); // por si abres el panel de MP
};
// helpers UI MP
function mpToast(t, ok=false){ const el=$('#mpMsg'); el.className = ok?'text-success':'text-danger'; el.textContent=t; setTimeout(()=>{el.textContent=''; el.className='';}, 3000); }
function hydrateMpProveedoresOptions(selectedIds=[]) {
const sel = $('#mp_proveedores'); if (!sel) return;
sel.innerHTML = '';
state.proveedores.forEach(p => {
const opt = new Option(p.raz_social || ('#'+p.id_proveedor), p.id_proveedor, false, selectedIds.includes(p.id_proveedor));
sel.appendChild(opt);
});
}
// 3) listado MP
function renderMpLista() {
const tb = $('#mpTb'); if (!tb) return;
let rows = state.materias.slice();
const f = (state.mpFiltro||'').trim().toLowerCase();
if (f) rows = rows.filter(m => (m.nombre||'').toLowerCase().includes(f) || String(m.id_mat_prima).includes(f));
if (!rows.length) { tb.innerHTML = '<tr><td colspan="4" class="p-3 text-muted">Sin resultados.</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(m => {
const tr = document.createElement('tr');
tr.style.cursor='pointer';
tr.innerHTML = `
<td>${m.id_mat_prima}</td>
<td>${m.nombre||'—'}</td>
<td>${m.unidad||'—'}</td>
<td>${m.activo ? 'Sí' : 'No'}</td>
`;
tr.addEventListener('click', ()=> loadMp(m.id_mat_prima));
tb.appendChild(tr);
});
}
// 4) limpiar ficha MP
function clearMpForm() {
state.mpSelId = null;
$('#mp_id_mat_prima').value = '';
$('#mp_nombre').value = '';
$('#mp_unidad').value = '';
$('#mp_activo').checked = true;
state.mpAsignados = [];
hydrateMpProveedoresOptions([]);
}
// 5) cargar MP + proveedores asignados (via función SQL)
async function loadMp(id) {
try {
const data = await jget(`/api/rpc/get_materia/${id}`); // { materia: {...}, proveedores: [...] }
const m = data.materia || {};
const provs = Array.isArray(data.proveedores) ? data.proveedores : [];
state.mpSelId = m.id_mat_prima;
$('#mp_id_mat_prima').value = m.id_mat_prima ?? '';
$('#mp_nombre').value = m.nombre ?? '';
$('#mp_unidad').value = m.unidad ?? '';
$('#mp_activo').checked = (m.activo !== false);
state.mpAsignados = provs.map(x => x.id_proveedor);
hydrateMpProveedoresOptions(state.mpAsignados);
} catch (e) {
mpToast(e.message || 'No se pudo cargar la materia prima');
}
}
// 6) guardar MP (insert/update) y sincronizar proveedores (JSONB)
async function saveMp() {
try {
const payload = {
id_mat_prima: $('#mp_id_mat_prima').value ? Number($('#mp_id_mat_prima').value) : null,
nombre: $('#mp_nombre').value.trim(),
unidad: $('#mp_unidad').value.trim(),
activo: $('#mp_activo').checked,
proveedores: Array.from($('#mp_proveedores').selectedOptions).map(o => Number(o.value))
};
if (!payload.nombre) { mpToast('Nombre requerido'); return; }
const r = await jpost('/api/rpc/save_materia', payload); // => { id_mat_prima }
mpToast(`Guardado #${r.id_mat_prima}`, true);
// refrescar listas globales
state.materias = await jget('/api/table/mate_primas?limit=5000');
renderMpLista();
hydrateCategorias(); // no hace falta, pero mantenemos consistencia si dependiera de MPs
// refrescar selects de receta del producto (por si se usa en receta)
renderReceta(); // tu función existente reusará state.materias
await loadMp(r.id_mat_prima);
} catch (e) {
mpToast(e.message || 'No se pudo guardar');
}
}
// 7) listeners MP
document.getElementById('mpQ')?.addEventListener('input', ()=> { state.mpFiltro = $('#mpQ').value||''; renderMpLista(); });
document.getElementById('mpLimpiar')?.addEventListener('click', ()=> { $('#mpQ').value=''; state.mpFiltro=''; renderMpLista(); });
document.getElementById('mpNuevo')?.addEventListener('click', clearMpForm);
document.getElementById('mpGuardar')?.addEventListener('click', saveMp);
// 8) cuando se despliega el panel MP por primera vez, renderizar listado
document.getElementById('mpWrap')?.addEventListener('shown.bs.collapse', ()=> renderMpLista());
function imgUrl(v){
if (!v) return '';
return v.startsWith('http') ? v : `/img/productos/${v}`;
}
$('#img_producto').addEventListener('input', ()=>{
const v = $('#img_producto').value.trim();
const src = imgUrl(v);
if (src) { $('#preview').src = src; $('#preview').classList.remove('d-none'); }
else { $('#preview').src = ''; $('#preview').classList.add('d-none'); }
});
// Go
init().catch(e => toast(e.message||'Error cargando datos'));
</script>

View File

@ -0,0 +1,836 @@
<% /* Reportes - Asistencias, Tickets y Gastos */ %>
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-2">
<h3 class="mb-0">Reportes</h3>
<span class="ms-auto small text-muted" id="repStatus">—</span>
</div>
<!-- Filtros -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Asistencias · Rango</label>
<div class="row g-2">
<div class="col-6 col-md-4"><input id="asistDesde" type="date" class="form-control"></div>
<div class="col-6 col-md-4"><input id="asistHasta" type="date" class="form-control"></div>
<div class="col-12 col-md-4 d-grid d-md-block">
<button id="btnAsistCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnAsistExcel" class="btn btn-outline-success me-2">Excel</button>
<button id="btnAsistPDF" class="btn btn-outline-secondary">PDF</button>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Año (Tickets / Gastos)</label>
<div class="row g-2">
<div class="col-6 col-md-4"><input id="anualYear" type="number" min="2000" step="1" class="form-control"></div>
<div class="col-6 col-md-8 d-grid d-md-block">
<button id="btnAnualCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnAnualExcel" class="btn btn-outline-success me-2">Excel (Comparativo)</button>
<button id="btnAnualPDF" class="btn btn-outline-secondary">PDF (Comparativo)</button>
</div>
</div>
</div>
</div>
<div class="small text-muted mt-2">
Los Excel se generan como CSV. Los PDF se generan con “Imprimir área” del navegador.
</div>
</div>
</div>
<!-- ASISTENCIA: Resumen diario (últimos 30 días) -->
<div class="card shadow-sm mb-3" id="PRINT_ASIST">
<div class="card-header d-flex align-items-center">
<strong>Asistencia — Resumen diario (últimos 30 días)</strong>
<span class="ms-auto small text-muted" id="resumeCount">Cargando…</span>
</div>
<div class="card-body">
<div id="resumenCards" class="row g-3"></div>
</div>
</div>
<!-- Tickets -->
<div class="card shadow-sm mb-3" id="PRINT_TICKETS">
<div class="card-header d-flex align-items-center">
<strong>Ventas (Tickets)</strong>
<span class="ms-auto small text-muted" id="ticketsInfo">—</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Resumen del año</div>
<div class="small text-muted" id="ticketsYearTitle">—</div>
</div>
<div class="row text-center">
<div class="col-4"><div class="small text-muted">Tickets YTD</div><div class="fs-5 fw-semibold" id="tYtd">—</div></div>
<div class="col-4"><div class="small text-muted">Promedio</div><div class="fs-5 fw-semibold" id="tAvg">—</div></div>
<div class="col-4"><div class="small text-muted">Ingresos YTD</div><div class="fs-5 fw-semibold" id="tToDate">—</div></div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Tickets por mes</div>
<div class="small text-muted">Cantidad</div>
</div>
<div id="ticketsChart" style="height:140px;"></div>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr><th>Mes</th><th class="text-end">Tickets</th><th class="text-end">Importe</th><th class="text-end">Ticket promedio</th></tr>
</thead>
<tbody id="tbTickets"><tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Gastos detallados (filtrable por mes/año) -->
<div class="card shadow-sm mb-3" id="PRINT_GASTOS_DET">
<div class="card-header">
<div class="row g-2 align-items-center">
<div class="col-12 col-lg-4 d-flex align-items-center">
<strong class="me-2">Gastos detallados — </strong>
<span id="gdetTitle" class="text-muted small">mes anterior</span>
</div>
<div class="col-12 col-lg-4">
<div class="row g-2">
<div class="col-7">
<select id="gdetMes" class="form-select">
<option value="1">Enero</option>
<option value="2">Febrero</option>
<option value="3">Marzo</option>
<option value="4">Abril</option>
<option value="5">Mayo</option>
<option value="6">Junio</option>
<option value="7">Julio</option>
<option value="8">Agosto</option>
<option value="9">Setiembre</option>
<option value="10">Octubre</option>
<option value="11">Noviembre</option>
<option value="12">Diciembre</option>
</select>
</div>
<div class="col-5">
<input id="gdetAnio" type="number" min="2000" step="1" class="form-control" placeholder="Año">
</div>
</div>
</div>
<div class="col-12 col-lg-4 d-grid d-md-block text-lg-end">
<button id="btnGdetCargar" class="btn btn-primary btn-sm me-2">Cargar</button>
<button id="btnGdetExcel" class="btn btn-outline-success btn-sm me-2">Excel</button>
<button id="btnGdetPDF" class="btn btn-outline-secondary btn-sm">PDF</button>
</div>
</div>
</div>
<div class="card-body">
<div class="row g-3 mb-2">
<div class="col-12">
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-primary-subtle border text-primary">Total: <span id="gdetTotal">—</span></span>
<span class="badge bg-secondary-subtle border text-secondary">Compras: <span id="gdetCompras">—</span></span>
<span class="badge bg-info-subtle border text-info">Renglones: <span id="gdetRows">—</span></span>
<span class="badge bg-light border text-muted ms-auto" id="gdetInfo">—</span>
</div>
</div>
</div>
<div class="table-responsive table-scroll" id="gdetScroll">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Fecha</th>
<th>Proveedor</th>
<th>Tipo</th>
<th>Ítem</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Precio</th>
<th class="text-end">Subtotal</th>
</tr>
</thead>
<tbody id="tbGdet">
<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Comparativo Ventas vs Gastos -->
<div class="card shadow-sm" id="PRINT_COMP">
<div class="card-header d-flex align-items-center">
<strong>Comparativo: Ventas vs Gastos</strong>
<span class="ms-auto small text-muted" id="compInfo">—</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="row text-center">
<div class="col-4"><div class="small text-muted">Ingresos YTD</div><div class="fs-5 fw-semibold" id="cmpSales">—</div></div>
<div class="col-4"><div class="small text-muted">Gastos YTD</div><div class="fs-5 fw-semibold" id="cmpCost">—</div></div>
<div class="col-4"><div class="small text-muted">Resultado</div><div class="fs-5 fw-semibold" id="cmpDiff">—</div></div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Mensual (UYU)</div>
<div class="small text-muted" id="compYearTitle">—</div>
</div>
<div id="compChart" style="height:160px;"></div>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr><th>Mes</th><th class="text-end">Ingresos</th><th class="text-end">Gastos</th><th class="text-end">Resultado</th></tr>
</thead>
<tbody id="tbComp"><tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-2">
<button id="btnCompExcel" class="btn btn-outline-success btn-sm">Excel</button>
<button id="btnCompPDF" class="btn btn-outline-secondary btn-sm">PDF</button>
</div>
</div>
</div>
</div>
<style>
.spark rect:hover { filter: brightness(0.9); }
.emp-card { border:1px solid #e9ecef; border-radius:.75rem; padding:12px; }
.emp-meta .badge { background:#f8f9fa; color:#212529; border:1px solid #e9ecef; }
.spark-wrap { width:100%; height:80px; }
.spark { width:100%; height:100%; }
.spark text { font-size:10px; fill:#6c757d; }
.spark rect:hover { filter: brightness(.9); }
@media print {
body * { visibility: hidden !important; }
#PRINT_ASIST, #PRINT_ASIST *,
#PRINT_TICKETS, #PRINT_TICKETS *,
#PRINT_GASTOS_DET, #PRINT_GASTOS_DET *,
#PRINT_COMP, #PRINT_COMP * { visibility: visible !important; }
#PRINT_ASIST, #PRINT_TICKETS, #PRINT_GASTOS_DET, #PRINT_COMP { position:absolute; left:0; top:0; width:100%; }
}
#PRINT_GASTOS_DET { --gdet-h: 48vh; } /* ~mitad de la pantalla */
@media (min-width: 992px){ #PRINT_GASTOS_DET { --gdet-h: 420px; } } /* desktop fijo */
/* Scroll vertical con encabezado fijo */
#PRINT_GASTOS_DET .table-scroll{
max-height: var(--gdet-h);
overflow: auto; /* vertical + horizontal si hace falta */
}
#PRINT_GASTOS_DET .table-scroll thead th{
position: sticky;
top: 0;
z-index: 2;
background: var(--bs-table-bg, #fff);
}
#PRINT_GASTOS_DET .table-scroll tbody tr:last-child td{
border-bottom: 0;
}
/* Al imprimir, expandir todo (sin scroll) */
@media print{
#PRINT_GASTOS_DET .table-scroll{
max-height: none !important;
overflow: visible !important;
}
}
</style>
<script>
/* ===== Helpers ===== */
const $ = s => document.querySelector(s);
const z2 = n => String(n).padStart(2,'0');
const fmtMoney = v => (v==null? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(Number(v)));
const fmtInt = v => Math.round(Number(v||0));
const fmtHM = mins => { const h=Math.floor(mins/60); const m=Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
const ymd = s => String(s||'').slice(0,10);
const monthNames = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const MONTH_NAMES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Setiembre','Octubre','Noviembre','Diciembre'];
async function jget(url){ const r=await fetch(url); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||`${r.status}`); return j; }
async function jpost(url, body){ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||`${r.status}`); return j; }
function getSelectedMonthYear() {
const m = parseInt(document.querySelector('#gdetMes')?.value, 10);
const y = parseInt(document.querySelector('#gdetAnio')?.value, 10);
const now = new Date();
const month = (Number.isFinite(m) && m>=1 && m<=12) ? m : (now.getMonth()+1);
const year = (Number.isFinite(y) && y>=2000 && y<=2100) ? y : now.getFullYear();
return {month, year};
}
function monthRange(month, year) {
// month: 1..12
const from = new Date(year, month-1, 1, 0,0,0,0);
const to = new Date(year, month, 0, 23,59,59,999);
return {
desdeISO: from.toISOString(),
hastaISO: to.toISOString(),
titulo: `${MONTH_NAMES[month-1]} ${year}`,
spanTxt: `${from.toLocaleDateString('es-UY')} - ${to.toLocaleDateString('es-UY')}`
};
}
function toCSV(rows, headers){
const esc = v => v==null? '' : (typeof v==='number'? String(v) : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g,'""')}"` : String(v));
const cols = headers && headers.length? headers : Object.keys(rows?.[0]||{});
const out = []; if(headers) out.push(cols.join(','));
for(const r of (rows||[])) out.push(cols.map(c=>esc(r[c])).join(','));
return out.join('\r\n');
}
function downloadText(name, text){ const blob=new Blob([text],{type:'text/csv;charset=utf-8;'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name; a.click(); URL.revokeObjectURL(a.href); }
function printArea(){ window.print(); }
/* === Mini SVGs === */
function barsSVG(series){ // [{label, value}]
const W=560,H=120,P=10,G=6;
const n=series.length||1, max=Math.max(1,...series.map(d=>Number(d.value||0)));
const bw=Math.max(6,Math.floor((W-P*2-G*(n-1))/n)); let x=P, bars='';
series.forEach(d=>{ const vh=Math.round((Number(d.value||0)/max)*(H-P-26)); const y=H-20-vh;
bars+=`<g><rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="3" ry="3" fill="#0d6efd"><title>${d.label} · ${fmtMoney(d.value)}</title></rect><text x="${x+bw/2}" y="${H-6}" text-anchor="middle">${d.label}</text></g>`; x+=bw+G; });
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none"><line x1="${P}" y1="${H-20}" x2="${W-P}" y2="${H-20}" stroke="#adb5bd"/></svg>` .replace('</svg>', bars+'</svg>');
}
function barsCompareSVG(a,b){ // a=ventas, b=gastos: arrays [{label,value}]
const W=560,H=160,P=10,G=8,PAIR=2,INNER=3;
const n=a.length, max=Math.max(1,...a.map(d=>Number(d.value||0)),...b.map(d=>Number(d.value||0)));
const bw=Math.max(5,Math.floor((W-P*2-G*(n-1))/(n*PAIR)));
let x=P, g=''; for(let i=0;i<n;i++){
const av=Number(a[i].value||0), bv=Number(b[i].value||0);
const ah=Math.round((av/max)*(H-P-26)), bh=Math.round((bv/max)*(H-P-26));
const ay=H-20-ah, by=H-20-bh;
g+=`<g><rect x="${x}" y="${ay}" width="${bw}" height="${ah}" rx="3" ry="3" fill="#198754"><title>${a[i].label} · Ventas ${fmtMoney(av)}</title></rect></g>`;
x+=bw+INNER;
g+=`<g><rect x="${x}" y="${by}" width="${bw}" height="${bh}" rx="3" ry="3" fill="#dc3545"><title>${b[i].label} · Gastos ${fmtMoney(bv)}</title></rect><text x="${x-bw/2}" y="${H-6}" text-anchor="middle">${a[i].label}</text></g>`;
x+=bw+G;
}
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none"><line x1="${P}" y1="${H-20}" x2="${W-P}" y2="${H-20}" stroke="#adb5bd"/></svg>` .replace('</svg>', g+'</svg>');
}
/* ===== Asistencias ===== */
let cacheAsist=[];
async function fetchAsistencias(desde,hasta){
try { return await jpost('/api/rpc/report_asistencia', { desde, hasta }); }
catch { const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`; return await jget(url); }
}
function renderAsistTabla(rows){
const tb=$('#tbAsist'); if(!rows?.length){ tb.innerHTML='<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML=''; rows.forEach(r=>{ const tr=document.createElement('tr'); tr.innerHTML=`
<td>${r.documento||'—'}</td><td>${r.nombre||'—'}</td><td>${r.apellido||'—'}</td><td>${r.fecha||'—'}</td>
<td class="text-end">${r.desde_hora||'—'}</td><td class="text-end">${r.hasta_hora||'—'}</td>
<td class="text-end">${fmtHM(Number(r.dur_min||0))}</td>`; tb.appendChild(tr); });
}
async function loadAsist(){
let d = $('#asistDesde')?.value;
let h = $('#asistHasta')?.value;
// fallback: últimos 30 días
if (!d || !h){
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 30);
d = start.toISOString().slice(0,10);
h = end.toISOString().slice(0,10);
if ($('#asistDesde')) $('#asistDesde').value = d;
if ($('#asistHasta')) $('#asistHasta').value = h;
}
$('#repStatus').textContent = 'Cargando asistencias…';
cacheAsist = await jpost('/api/rpc/report_asistencia', { desde: d, hasta: h })
.catch(async ()=>{
// fallback a tabla genérica si el RPC no está
const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(d)}&hasta=${encodeURIComponent(h)}&limit=10000`;
return await jget(url);
});
renderAsistTabla(cacheAsist||[]);
const minsTot = (cacheAsist||[]).reduce((s,r)=>s+Number(r.dur_min||0),0);
$('#asistInfo').textContent = `${cacheAsist?.length||0} registros · ${fmtHM(minsTot)}`;
$('#repStatus').textContent = 'Listo';
}
function asistBarsSVG(series /* [{x:'YYYY-MM-DD', h:Number}] */, maxH = null) {
const W=520, H=80, PAD=6, GAP=3;
const n = series.length || 1;
const max = maxH ?? Math.max(1, ...series.map(d => d.h || 0));
const bw = Math.max(2, Math.floor((W - PAD*2 - GAP*(n-1)) / n));
let x = PAD, bars = '';
series.forEach(d => {
const vh = max ? Math.round((d.h / max) * (H - PAD*2)) : 0;
const y = H - PAD - vh;
const label = `${d.x} · ${fmtHM((d.h||0)*60)}`;
bars += `<rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="2" ry="2" data-x="${d.x}" data-h="${d.h??0}"><title>${label}</title></rect>`;
x += bw + GAP;
});
const axis = `<line x1="${PAD}" y1="${H-PAD}" x2="${W-PAD}" y2="${H-PAD}" stroke="#adb5bd" stroke-width="1"/>`;
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="spark"><style>rect{fill:#0d6efd}</style>${axis}${bars}</svg>`;
}
// Render de tarjetas por empleado (idéntico a usuarios.ejs)
function asistRenderCards(grouped) {
const cont = $('#resumenCards');
if (!cont) return;
cont.innerHTML = '';
for (const [key, data] of grouped.entries()) {
const { doc, nombre, apellido, rows } = data;
rows.sort((a,b)=> a.fecha.localeCompare(b.fecha));
const series = rows.map(r => ({ x: r.fecha, h: Number(r.horas)||0 }));
const totalH = series.reduce((s,d)=> s + d.h, 0);
const dias = series.length;
const avgH = dias ? totalH / dias : 0;
const pairs = rows.reduce((s,r)=> s + (Number(r.pares)||0), 0);
const last = series.at(-1) || {x:'',h:0};
const svg = asistBarsSVG(series);
const col = document.createElement('div');
col.className = 'col-12 col-md-6 col-lg-4';
col.innerHTML = `
<div class="emp-card h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<div class="fw-semibold">${nombre||''} ${apellido||''}</div>
<div class="text-muted small">${doc}</div>
</div>
<div class="text-end">
<div class="small text-muted">Total</div>
<div class="fs-5 fw-semibold">${fmtHM(totalH*60)}</div>
</div>
</div>
<div class="spark-wrap mb-1">${svg}</div>
<div class="small text-muted legend">Pasá el mouse por una barra…</div>
<div class="d-flex flex-wrap gap-1 emp-meta mt-2">
<span class="badge">Días: ${dias}</span>
<span class="badge">Prom: ${fmtHM(avgH*60)}</span>
<span class="badge">Pares: ${pairs}</span>
<span class="badge">Último: ${fmtHM((last.h||0)*60)} ${last.x?`(${last.x})`:''}</span>
</div>
</div>`;
cont.appendChild(col);
}
const badge = $('#resumeCount'); if (badge) badge.textContent = `${grouped.size} empleado(s)`;
}
// Leyenda al sobrevolar barras
const cardsRoot = $('#resumenCards');
if (cardsRoot){
cardsRoot.addEventListener('mouseover', (e)=>{
const r = e.target;
if (!(r instanceof SVGRectElement)) return;
const card = r.closest('.emp-card');
const legend = card?.querySelector('.legend');
if (!legend) return;
const x = r.getAttribute('data-x')||'';
const h = Number(r.getAttribute('data-h')||0);
legend.textContent = `${x} · ${fmtHM(h*60)}`;
});
}
// Loader: trae la vista asistencia_resumen_diario y arma tarjetas (30 días)
async function asistLoadResumenDiario30d(){
const badge = $('#resumeCount'); if (badge) badge.textContent = 'Cargando…';
try{
const rows = await jget('/api/table/asistencia_resumen_diario?limit=5000').catch(()=>[]);
const today = new Date(); const cut = new Date(today); cut.setDate(today.getDate()-30);
const byKey = new Map();
for (const r of (rows||[])) {
const fStr = ymd(r.fecha); const fDt = new Date(fStr);
if (!(fDt >= cut)) continue;
const key = `${r.documento}::${r.nombre||''}::${r.apellido||''}`;
if (!byKey.has(key)) byKey.set(key, { doc:r.documento, nombre:r.nombre||'', apellido:r.apellido||'', rows:[] });
byKey.get(key).rows.push({
fecha: fStr,
horas: Number(r.horas_dia ?? r.horas ?? (r.minutos_dia||0)/60),
pares: Number(r.pares_dia ?? r.pares ?? 0)
});
}
asistRenderCards(byKey);
if (badge) badge.textContent = 'Listo';
}catch(e){
if (badge) badge.textContent = 'Error';
console.error('asistLoadResumenDiario30d:', e);
}
}
// Auto-carga al abrir reportes (si la card está en el DOM)
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('PRINT_ASIST')) {
asistLoadResumenDiario30d();
}
});
/* ===== Tickets (ventas) ===== */
let cacheTickets=null;
function getYearSafe(val){
const y = parseInt(val, 10);
return Number.isFinite(y) && y >= 2000 && y <= 2100
? y
: new Date().getFullYear();
}
async function fetchTickets(year){
const y = getYearSafe(year);
return await jpost('/api/rpc/report_tickets', { year: y });
}
function renderTickets(data){
const months=data?.months||[]; $('#ticketsYearTitle').textContent=data?.year||'—';
$('#tYtd').textContent=months.reduce((s,m)=>s+Number(m.cant||0),0);
$('#tAvg').textContent=fmtMoney(data?.avg||data?.avg_ticket||0);
$('#tToDate').textContent=fmtMoney(data?.to_date||0);
$('#ticketsChart').innerHTML=barsSVG(months.map(m=>({label:m.nombre||m.mes,value:Number(m.cant||0)})));
const tb=$('#tbTickets'); if(!months.length){ tb.innerHTML='<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>'; } else {
tb.innerHTML=''; months.forEach(m=>{ const tr=document.createElement('tr'); tr.innerHTML=`
<td>${m.nombre||m.mes}</td><td class="text-end">${m.cant||0}</td>
<td class="text-end">${fmtMoney(m.importe||0)}</td><td class="text-end">${fmtMoney(m.avg||0)}</td>`; tb.appendChild(tr); });
}
$('#ticketsInfo').textContent=`${months.length} meses`;
}
/* ===== Gastos ===== */
let cacheGastos=null; // {year, months:[{mes,nombre,importe}], total, avg}
async function fetchGastos(year){
// 1) Intentar RPC
try { return await jpost('/api/rpc/report_gastos', { year }); } catch {}
// 2) Fallback: traer compras y agrupar en el cliente
const rows = await jget('/api/table/compras?limit=10000&order_by=fec_compra%20asc').catch(()=>[]);
const months = Array.from({length:12},(_,i)=>({mes:i+1,nombre:monthNames[i],importe:0}));
let total=0;
(rows||[]).forEach(r=>{
const d=new Date(r.fec_compra||r.fec||r.fecha); if(!d.getFullYear) return;
if (d.getFullYear() !== Number(year)) return;
const m=d.getMonth(); const t=Number(r.total||0);
months[m].importe += t; total += t;
});
const avg = months.reduce((s,m)=>s+m.importe,0)/12;
return { year, months, total, avg };
}
function renderGastos(data){
// siempre cacheo para el comparativo
cacheGastos = data || { year: new Date().getFullYear(), months: [], total: 0, avg: 0 };
const months = cacheGastos.months || [];
// elementos de la antigua card (pueden NO existir)
const yTitle = document.querySelector('#gastosYearTitle');
const toDate = document.querySelector('#gToDate');
const avgEl = document.querySelector('#gAvg');
const chart = document.querySelector('#gastosChart');
const tb = document.querySelector('#tbGastos');
const info = document.querySelector('#gastosInfo');
// si NO existe ninguno, significa que ya no está la card de Gastos ⇒ solo mantener cache y salir
if (!yTitle && !toDate && !avgEl && !chart && !tb && !info) return;
// a partir de acá, escribir solo si el elemento existe
if (yTitle) yTitle.textContent = cacheGastos.year ?? '—';
if (toDate) toDate.textContent = fmtMoney(cacheGastos.total || 0);
if (avgEl) avgEl.textContent = fmtMoney(cacheGastos.avg || 0);
if (chart) {
chart.innerHTML = barsSVG(months.map(m => ({
label: m.nombre || m.mes, value: Number(m.importe || 0)
})));
}
if (tb) {
if (!months.length) {
tb.innerHTML = '<tr><td colspan="2" class="p-3 text-muted">Sin datos</td></tr>';
} else {
tb.innerHTML = '';
months.forEach(m => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${m.nombre || m.mes}</td>
<td class="text-end">${fmtMoney(m.importe || 0)}</td>`;
tb.appendChild(tr);
});
}
}
if (info) info.textContent = `${months.length} meses`;
}
/* ===== Comparativo ===== */
function renderComparativo(){
if(!cacheTickets?.months || !cacheGastos?.months) return;
const y = cacheTickets.year || cacheGastos.year; $('#compYearTitle').textContent=y;
const ventas = Array.from({length:12},(_,i)=>Number(cacheTickets.months.find(m=>(m.mes||monthNames.indexOf(m.nombre)+1)===i+1)?.importe||0));
const gastos = Array.from({length:12},(_,i)=>Number(cacheGastos.months[i]?.importe||0));
const seriesA = ventas.map((v,i)=>({label:monthNames[i], value:v}));
const seriesB = gastos.map((v,i)=>({label:monthNames[i], value:v}));
$('#compChart').innerHTML = barsCompareSVG(seriesA, seriesB);
const tb=$('#tbComp'); tb.innerHTML='';
let ySales=0,yCost=0;
for(let i=0;i<12;i++){
const s=ventas[i]||0, g=gastos[i]||0, d=s-g; ySales+=s; yCost+=g;
const tr=document.createElement('tr'); tr.innerHTML=`
<td>${monthNames[i]}</td>
<td class="text-end">${fmtMoney(s)}</td>
<td class="text-end">${fmtMoney(g)}</td>
<td class="text-end ${d>=0?'text-success':'text-danger'}">${fmtMoney(d)}</td>`;
tb.appendChild(tr);
}
$('#cmpSales').textContent = fmtMoney(ySales);
$('#cmpCost').textContent = fmtMoney(yCost);
$('#cmpDiff').textContent = fmtMoney(ySales - yCost);
$('#compInfo').textContent = '12 meses';
}
/* ===== Exportaciones ===== */
function exportAsistCSV(){
if(!cacheAsist?.length) return;
const headers=['Documento','Nombre','Apellido','Fecha','Desde','Hasta','Duración(min)'];
const rows = cacheAsist.map(r=>({Documento:r.documento||'',Nombre:r.nombre||'',Apellido:r.apellido||'',Fecha:r.fecha||'',Desde:r.desde_hora||'',Hasta:r.hasta_hora||'','Duración(min)':Number(r.dur_min||0)}));
downloadText(`asistencias_${$('#asistDesde').value}_${$('#asistHasta').value}.csv`, toCSV(rows,headers));
}
function exportTicketsCSV(){
if(!cacheTickets?.months?.length) return;
const toInt=v=>fmtInt(v);
const headers=['Año','Mes','Tickets','Importe','Ticket promedio'];
const rows=cacheTickets.months.map(m=>({'Año':cacheTickets.year,'Mes':m.nombre||m.mes,'Tickets':Number(m.cant||0),'Importe':toInt(m.importe),'Ticket promedio':toInt(m.avg)}));
downloadText(`tickets_${cacheTickets.year}.csv`, toCSV(rows,headers));
}
function exportGastosCSV(){
if(!cacheGastos?.months?.length) return;
const toInt=v=>fmtInt(v);
const headers=['Año','Mes','Gasto'];
const rows=cacheGastos.months.map(m=>({'Año':cacheGastos.year,'Mes':m.nombre,'Gasto':toInt(m.importe)}));
downloadText(`gastos_${cacheGastos.year}.csv`, toCSV(rows,headers));
}
function exportCompCSV(){
if(!cacheGastos?.months || !cacheTickets?.months) return;
const headers=['Mes','Ingresos','Gastos','Resultado'];
const rows=monthNames.map((nm,i)=>{ const s=Number(cacheTickets.months[i]?.importe||0), g=Number(cacheGastos.months[i]?.importe||0); return {Mes:nm,Ingresos:fmtInt(s),Gastos:fmtInt(g),Resultado:fmtInt(s-g)}; });
downloadText(`comparativo_${cacheTickets.year||cacheGastos.year}.csv`, toCSV(rows,headers));
}
/* ===== Gastos detallados (mes anterior) ===== */
let cacheGastosDet = [];
let cacheGdetMeta = null;
async function loadGastosDetallado(optMonth, optYear){
// 1) rango según select (o params)
const {month, year} = (Number.isFinite(optMonth) && Number.isFinite(optYear))
? {month: optMonth, year: optYear}
: getSelectedMonthYear();
const {desdeISO, hastaISO, titulo, spanTxt} = monthRange(month, year);
cacheGdetMeta = { desdeISO, hastaISO, month, year };
// 2) traer tablas base
const [compras, provs, detMats, detProds, mates, prods] = await Promise.all([
jget('/api/table/compras?limit=10000&order_by=fec_compra%20desc').catch(()=>[]),
jget('/api/table/proveedores?limit=10000').catch(()=>[]),
jget('/api/table/deta_comp_materias?limit=100000').catch(()=>[]),
jget('/api/table/deta_comp_producto?limit=100000').catch(()=>[]),
jget('/api/table/mate_primas?limit=10000').catch(()=>[]),
jget('/api/table/productos?limit=10000').catch(()=>[]),
]);
// 3) filtro por rango seleccionado
const from = new Date(desdeISO), to = new Date(hastaISO);
const comprasMes = (compras||[]).filter(c=>{
const d = new Date(c.fec_compra || c.fecha || c.fec);
return d >= from && d <= to;
});
const ids = new Set(comprasMes.map(c=>c.id_compra));
// 4) mapas auxiliares
const provById = Object.fromEntries((provs||[]).map(p=>[p.id_proveedor, p.raz_social||p.rut||`#${p.id_proveedor}`]));
const matName = Object.fromEntries((mates||[]).map(x=>[x.id_mat_prima, x.nombre]));
const prodName = Object.fromEntries((prods||[]).map(x=>[x.id_producto, x.nombre]));
const mapCompra = Object.fromEntries(comprasMes.map(c=>[c.id_compra, c]));
// 5) construir filas
const filas = [];
(detMats||[]).forEach(d=>{
if(!ids.has(d.id_compra)) return;
const c = mapCompra[d.id_compra]; const fecha = new Date(c.fec_compra||c.fecha||c.fec);
const prov = provById[c.id_proveedor] || '—';
const qty = Number(d.cantidad||0);
const pu = Number(d.pre_unitario||0);
filas.push({
fecha, fecha_txt: fecha.toLocaleDateString('es-UY'),
proveedor: prov, tipo: 'Materia', item: (matName[d.id_mat_prima] || `#${d.id_mat_prima}`),
cantidad: qty, precio: pu, subtotal: qty*pu
});
});
(detProds||[]).forEach(d=>{
if(!ids.has(d.id_compra)) return;
const c = mapCompra[d.id_compra]; const fecha = new Date(c.fec_compra||c.fecha||c.fec);
const prov = provById[c.id_proveedor] || '—';
const qty = Number(d.cantidad||0);
const pu = Number(d.pre_unitario||0);
filas.push({
fecha, fecha_txt: fecha.toLocaleDateString('es-UY'),
proveedor: prov, tipo: 'Producto', item: (prodName[d.id_producto] || `#${d.id_producto}`),
cantidad: qty, precio: pu, subtotal: qty*pu
});
});
filas.sort((a,b)=> b.fecha - a.fecha);
cacheGastosDet = filas;
// 6) render
document.querySelector('#gdetTitle')?.replaceChildren(document.createTextNode(titulo));
document.querySelector('#gdetInfo') ?.replaceChildren(document.createTextNode(spanTxt));
const tb = document.querySelector('#tbGdet');
if(!filas.length){ tb.innerHTML = '<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; }
else{
tb.innerHTML = '';
filas.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.fecha_txt}</td>
<td>${r.proveedor}</td>
<td>${r.tipo}</td>
<td>${r.item}</td>
<td class="text-end">${r.cantidad.toLocaleString('es-UY')}</td>
<td class="text-end">${new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(r.precio)}</td>
<td class="text-end">${new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(r.subtotal)}</td>`;
tb.appendChild(tr);
});
}
const total = filas.reduce((s,r)=>s+r.subtotal,0);
document.querySelector('#gdetTotal') ?.replaceChildren(document.createTextNode(new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(total)));
document.querySelector('#gdetCompras')?.replaceChildren(document.createTextNode(comprasMes.length.toLocaleString('es-UY')));
document.querySelector('#gdetRows') ?.replaceChildren(document.createTextNode(filas.length.toLocaleString('es-UY')));
}
function exportGdetCSV(){
if(!cacheGastosDet?.length) return;
const headers = ['Fecha','Proveedor','Tipo','Ítem','Cantidad','Precio','Subtotal'];
const rows = cacheGastosDet.map(r=>({
'Fecha': r.fecha_txt,
'Proveedor': r.proveedor,
'Tipo': r.tipo,
'Ítem': r.item,
'Cantidad': r.cantidad,
'Precio': Math.round(r.precio),
'Subtotal': Math.round(r.subtotal)
}));
const title = document.querySelector('#gdetTitle')?.textContent?.replace(/\s+/g,'_') || 'mes';
downloadText(`gastos_detalle_${title}.csv`, toCSV(rows, headers));
}
const onPDFGdet = ()=>printArea('PRINT_GASTOS_DET');
const onPDFAsist=()=>printArea('PRINT_ASIST');
const onPDFTicket=()=>printArea('PRINT_TICKETS');
const onPDFGastos=()=>printArea('PRINT_GASTOS');
const onPDFComp=()=>printArea('PRINT_COMP');
/* ===== Eventos ===== */
const btnGdetCargar = document.querySelector('#btnGdetCargar');
if (btnGdetCargar) btnGdetCargar.addEventListener('click', ()=> loadGastosDetallado());
// por UX: recargar al cambiar mes/año
document.querySelector('#gdetMes') ?.addEventListener('change', ()=> loadGastosDetallado());
document.querySelector('#gdetAnio')?.addEventListener('change', ()=> loadGastosDetallado());
document.querySelector('#btnGdetExcel')?.addEventListener('click', exportGdetCSV);
document.querySelector('#btnGdetPDF') ?.addEventListener('click', onPDFGdet);
$('#btnAsistCargar').addEventListener('click', loadAsist);
$('#btnAsistExcel'). addEventListener('click', exportAsistCSV);
$('#btnAsistPDF'). addEventListener('click', onPDFAsist);
$('#btnAnualExcel').addEventListener('click', exportCompCSV);
$('#btnAnualPDF'). addEventListener('click', onPDFComp);
/* ✅ Enlaza los botones de la sección comparativo */
$('#btnCompExcel').addEventListener('click', exportCompCSV);
$('#btnCompPDF'). addEventListener('click', onPDFComp);
/* ✅ Botones de la nueva sección de gastos detallados */
$('#btnGdetExcel').addEventListener('click', exportGdetCSV);
$('#btnGdetPDF'). addEventListener('click', onPDFGdet);
$('#btnAnualCargar').addEventListener('click', async ()=>{
const y=Number($('#anualYear').value);
$('#repStatus').textContent='Cargando ventas y gastos…';
cacheTickets = await fetchTickets(y).catch(()=>null);
if (cacheTickets) renderTickets(cacheTickets);
cacheGastos = await fetchGastos(y).catch(()=>null);
if (cacheGastos) renderGastos(cacheGastos);
if (cacheTickets && cacheGastos) renderComparativo();
$('#repStatus').textContent='Listo';
});
$('#btnAnualExcel').addEventListener('click', exportCompCSV);
$('#btnAnualPDF'). addEventListener('click', onPDFComp);
/* ===== Defaults al cargar ===== */
(function init(){
const today = new Date();
const y = today.getFullYear();
if (!$('#anualYear').value) $('#anualYear').value = y;
if (!$('#asistDesde').value || !$('#asistHasta').value){
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate()-30);
$('#asistDesde').value = start.toISOString().slice(0,10);
$('#asistHasta').value = end.toISOString().slice(0,10);
}
loadAsist().catch(()=>{});
(async()=>{
cacheTickets = await fetchTickets($('#anualYear').value).catch(()=>null);
if (cacheTickets) renderTickets(cacheTickets);
cacheGastos = await fetchGastos($('#anualYear').value).catch(()=>null);
if (cacheGastos) renderGastos(cacheGastos);
if (cacheTickets && cacheGastos) renderComparativo();
$('#repStatus').textContent = 'Listo';
})();
})();
(function presetMesAnterior(){
const now = new Date();
const prev = new Date(now.getFullYear(), now.getMonth()-1, 1);
const mesSel = document.querySelector('#gdetMes');
const anioIn = document.querySelector('#gdetAnio');
if (mesSel && !mesSel.value) mesSel.value = String(prev.getMonth()+1);
if (anioIn && !anioIn.value) anioIn.value = String(prev.getFullYear());
// primera carga
loadGastosDetallado(prev.getMonth()+1, prev.getFullYear()).catch(()=>{});
})();
</script>

View File

@ -0,0 +1,402 @@
<% /* Reportes - Asistencias y Tickets (Comandas) */ %>
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-2">
<h3 class="mb-0">Reportes</h3>
<span class="ms-auto small text-muted" id="repStatus">—</span>
</div>
<!-- Filtros -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Asistencias · Rango</label>
<div class="row g-2">
<div class="col-6 col-md-4">
<input id="asistDesde" type="date" class="form-control">
</div>
<div class="col-6 col-md-4">
<input id="asistHasta" type="date" class="form-control">
</div>
<div class="col-12 col-md-4 d-grid d-md-block">
<button id="btnAsistCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnAsistExcel" class="btn btn-outline-success me-2">Excel</button>
<button id="btnAsistPDF" class="btn btn-outline-secondary">PDF</button>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Tickets (Comandas) · Año</label>
<div class="row g-2">
<div class="col-6 col-md-4">
<input id="ticketsYear" type="number" min="2000" step="1" class="form-control">
</div>
<div class="col-6 col-md-8 d-grid d-md-block">
<button id="btnTicketsCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnTicketsExcel" class="btn btn-outline-success me-2">Excel</button>
<button id="btnTicketsPDF" class="btn btn-outline-secondary">PDF</button>
</div>
</div>
</div>
</div>
<div class="small text-muted mt-2">
Los archivos Excel se generan como CSV (compatibles). Los PDF se generan con “Imprimir área” del navegador.
</div>
</div>
</div>
<!-- Reporte Asistencias -->
<div class="card shadow-sm mb-3" id="PRINT_ASIST">
<div class="card-header d-flex align-items-center">
<strong>Asistencias</strong>
<span class="ms-auto small text-muted" id="asistInfo">—</span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Documento</th>
<th>Nombre</th>
<th>Apellido</th>
<th>Fecha</th>
<th class="text-end">Desde</th>
<th class="text-end">Hasta</th>
<th class="text-end">Duración</th>
</tr>
</thead>
<tbody id="tbAsist">
<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Reporte Tickets -->
<div class="card shadow-sm" id="PRINT_TICKETS">
<div class="card-header d-flex align-items-center">
<strong>Tickets</strong>
<span class="ms-auto small text-muted" id="ticketsInfo">—</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Resumen del año</div>
<div class="small text-muted" id="ticketsYearTitle">—</div>
</div>
<div class="row text-center">
<div class="col-4">
<div class="small text-muted">Tickets YTD</div>
<div class="fs-5 fw-semibold" id="tYtd">—</div>
</div>
<div class="col-4">
<div class="small text-muted">Promedio</div>
<div class="fs-5 fw-semibold" id="tAvg">—</div>
</div>
<div class="col-4">
<div class="small text-muted">Hasta la fecha</div>
<div class="fs-5 fw-semibold" id="tToDate">—</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Tickets por mes</div>
<div class="small text-muted">Cantidad</div>
</div>
<div class="spark-wrap" id="ticketsChart" style="height:140px;"></div>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Mes</th>
<th class="text-end">Tickets</th>
<th class="text-end">Importe</th>
<th class="text-end">Ticket promedio</th>
</tr>
</thead>
<tbody id="tbTickets">
<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.spark rect:hover { filter: brightness(0.9); }
@media print {
body * { visibility: hidden !important; }
#PRINT_ASIST, #PRINT_ASIST *,
#PRINT_TICKETS, #PRINT_TICKETS * { visibility: visible !important; }
#PRINT_ASIST, #PRINT_TICKETS { position: absolute; left:0; top:0; width:100%; }
}
</style>
<script>
/* =========================
Helpers reutilizables
========================= */
const $ = s => document.querySelector(s);
const z2 = n => String(n).padStart(2,'0');
const fmtMoney = v => (v==null? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(Number(v)));
const fmtHM = mins => { const h = Math.floor(mins/60); const m = Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
// GET JSON simple
async function jget(url){
const r = await fetch(url);
const j = await r.json().catch(()=>null);
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
return j;
}
// POST JSON simple
async function jpost(url, body){
const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
const j = await r.json().catch(()=>null);
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
return j;
}
// CSV (Excel-friendly)
function toCSV(rows, headers){
const esc = v => {
if (v == null) return '';
if (typeof v === 'number') return String(v); // números sin comillas
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s;
};
const cols = headers && headers.length ? headers : Object.keys(rows?.[0] || {});
const lines = [];
if (headers) lines.push(cols.join(','));
for (const r of (rows || [])) lines.push(cols.map(c => esc(r[c])).join(','));
return lines.join('\r\n');
}
function downloadText(filename, text){
const blob = new Blob([text], {type:'text/csv;charset=utf-8;'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
// Print solo área
function printArea(id){
// cambiamos el hash para que @media print muestre el área; luego invocamos print
const el = document.getElementById(id);
if (!el) return;
window.print();
}
// SVG barras simple (sin librerías)
function barsSVG(series /* [{label:'Ene', value:Number}] */){
const W=560, H=120, PAD=10, GAP=6;
const n = series.length||1;
const max = Math.max(1, ...series.map(d=>d.value||0));
const bw = Math.max(6, Math.floor((W-PAD*2-GAP*(n-1))/n));
let x = PAD;
let bars = '';
series.forEach((d,i)=>{
const vh = Math.round((d.value/max)*(H-PAD-26)); // 26px para etiquetas
const y = H-20 - vh;
const title = `${d.label} · ${d.value}`;
bars += `<g>
<rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="3" ry="3" class="bar">
<title>${title}</title>
</rect>
<text x="${x + bw/2}" y="${H-6}" text-anchor="middle">${d.label}</text>
</g>`;
x += bw + GAP;
});
const css = `.bar{fill:#0d6efd}`;
const axis = `<line x1="${PAD}" y1="${H-20}" x2="${W-PAD}" y2="${H-20}" stroke="#adb5bd" stroke-width="1"/>`;
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none">
<style>${css}</style>
${axis}
${bars}
</svg>`;
}
/* =========================
Data access (enchufable)
=========================
Estas funciones llaman RPCs del server, que a su vez deben
delegar en funciones SQL. Si aún no existen, ver más abajo
el bloque "Sugerencia de funciones SQL".
*/
async function fetchAsistencias(desde, hasta){
// endpoint recomendado (RPC):
// POST /api/rpc/report_asistencia { desde, hasta }
// Retorna [{documento,nombre,apellido,fecha,desde_hora,hasta_hora,dur_min}]
try {
return await jpost('/api/rpc/report_asistencia', { desde, hasta });
} catch {
// fallback (si aún no tienes RPC): lee la vista "asistencia_detalle" hipotética
const url = `/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`;
return await jget(url);
}
}
async function fetchTickets(year){
// endpoint recomendado (RPC):
// POST /api/rpc/report_tickets { year }
// Retorna: { year, total_ytd, avg_ticket, to_date, months:[{mes:1..12, nombre:'Ene', cant, importe, avg}] }
return await jpost('/api/rpc/report_tickets', { year });
}
/* =========================
Render Asistencias
========================= */
let cacheAsist = [];
function renderAsistTabla(rows){
const tb = $('#tbAsist');
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.documento||'—'}</td>
<td>${r.nombre||'—'}</td>
<td>${r.apellido||'—'}</td>
<td>${r.fecha||'—'}</td>
<td class="text-end">${r.desde_hora||'—'}</td>
<td class="text-end">${r.hasta_hora||'—'}</td>
<td class="text-end">${fmtHM(Number(r.dur_min||0))}</td>
`;
tb.appendChild(tr);
});
}
async function loadAsist(){
const d = $('#asistDesde').value;
const h = $('#asistHasta').value;
$('#repStatus').textContent = 'Cargando asistencias…';
const rows = await fetchAsistencias(d,h);
cacheAsist = rows||[];
renderAsistTabla(cacheAsist);
const minsTot = cacheAsist.reduce((s,r)=> s + Number(r.dur_min||0), 0);
$('#asistInfo').textContent = `${cacheAsist.length} registros · ${fmtHM(minsTot)}`;
$('#repStatus').textContent = 'Listo';
}
/* =========================
Render Tickets
========================= */
let cacheTickets = null;
function renderTickets(data){
const months = data?.months||[];
$('#ticketsYearTitle').textContent = data?.year || '—';
$('#tYtd').textContent = months.reduce((s,m)=> s + Number(m.cant||0), 0);
$('#tAvg').textContent = fmtMoney(data?.avg_ticket ?? 0);
$('#tToDate').textContent = data?.to_date != null ? fmtMoney(data.to_date) : '—';
const series = months.map(m=>({ label:m.nombre||m.mes, value:Number(m.cant||0) }));
$('#ticketsChart').innerHTML = barsSVG(series);
const tb = $('#tbTickets');
if (!months.length){ tb.innerHTML = '<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
months.forEach(m=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${m.nombre||m.mes}</td>
<td class="text-end">${m.cant||0}</td>
<td class="text-end">${fmtMoney(m.importe||0)}</td>
<td class="text-end">${fmtMoney(m.avg||0)}</td>
`;
tb.appendChild(tr);
});
$('#ticketsInfo').textContent = `${months.length} meses`;
}
async function loadTickets(){
const y = Number($('#ticketsYear').value);
$('#repStatus').textContent = 'Cargando tickets…';
const data = await fetchTickets(y);
cacheTickets = data;
renderTickets(cacheTickets);
$('#repStatus').textContent = 'Listo';
}
/* =========================
Excel (CSV) & PDF
========================= */
function exportAsistCSV(){
if (!cacheAsist?.length) return;
const headers = ['Documento','Nombre','Apellido','Fecha','Desde','Hasta','Duración(min)'];
const rows = cacheAsist.map(r=>({
Documento:r.documento||'',
Nombre:r.nombre||'',
Apellido:r.apellido||'',
Fecha:r.fecha||'',
Desde:r.desde_hora||'',
Hasta:r.hasta_hora||'',
'Duración(min)':Number(r.dur_min||0)
}));
const csv = toCSV(rows, headers);
downloadText(`asistencias_${$('#asistDesde').value}_${$('#asistHasta').value}.csv`, csv);
}
function exportTicketsCSV(){
if (!cacheTickets?.months?.length) return;
const toInt = v => Math.round(Number(v || 0)); // sin decimales
const headers = ['Año','Mes','Tickets','Importe','Ticket promedio'];
const rows = cacheTickets.months.map(m => ({
'Año': cacheTickets.year,
'Mes': m.nombre || m.mes,
'Tickets': Number(m.cant || 0),
'Importe': toInt(m.importe), // ← entero
'Ticket promedio': toInt(m.avg) // ← entero
}));
const csv = toCSV(rows, headers);
downloadText(`tickets_${cacheTickets.year}.csv`, csv);
}
// PDF vía print-area del navegador
const onPDFAsist = () => printArea('PRINT_ASIST');
const onPDFTicket = () => printArea('PRINT_TICKETS');
/* =========================
Eventos + defaults
========================= */
document.getElementById('btnAsistCargar').addEventListener('click', loadAsist);
document.getElementById('btnTicketsCargar').addEventListener('click', loadTickets);
document.getElementById('btnAsistExcel').addEventListener('click', exportAsistCSV);
document.getElementById('btnTicketsExcel').addEventListener('click', exportTicketsCSV);
document.getElementById('btnAsistPDF').addEventListener('click', onPDFAsist);
document.getElementById('btnTicketsPDF').addEventListener('click', onPDFTicket);
// Defaults: último mes y año actual
(function initDefaults(){
const today = new Date();
const y = today.getFullYear();
const hasta = today.toISOString().slice(0,10);
const d = new Date(today); d.setMonth(d.getMonth()-1);
const desde = d.toISOString().slice(0,10);
$('#asistDesde').value = desde;
$('#asistHasta').value = hasta;
$('#ticketsYear').value = y;
// carga inicial
loadAsist().catch(()=>{});
loadTickets().catch(()=>{});
})();
</script>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
# ===== Runtime =====
NODE_ENV=development
PORT=4040
# ===== Session (usa el Redis del stack) =====
# Para DEV podemos reutilizar el Redis de Authentik. En prod conviene uno separado.
SESSION_SECRET=Neon*Mammal*Boaster*Ludicrous*Fender8*Crablike
SESSION_COOKIE_NAME=sc.sid
REDIS_URL=redis://ak-redis:6379
# ===== DB principal (metadatos de SuiteCoffee) =====
# Usa el alias de red del servicio 'db' (compose: aliases [dev-db])
DB_HOST=dev-db
DB_PORT=5432
DB_NAME=dev-suitecoffee
DB_USER=dev-user-suitecoffee
DB_PASS=dev-pass-suitecoffee
# ===== DB tenants (Tenants de SuiteCoffee) =====
TENANTS_HOST=dev-tenants
TENANTS_DB=dev-postgres
TENANTS_USER=dev-user-postgres
TENANTS_PASS=dev-pass-postgres
TENANTS_PORT=5432
TENANT_INIT_SQL=/app/src/db/initTenant_v2.sql
# ===== Authentik — Admin API (server-to-server dentro de la red) =====
# Usa el alias de red del servicio 'authentik' y su puerto interno 9000
AUTHENTIK_TOKEN=h2apVHbd3ApMcnnSwfQPXbvximkvP8HnUE25ot3zXWuEEtJFaNCcOzDHB6Xw
AUTH_CALLBACK_URL=https://suitecoffee.uy/auth/callback
# ===== OIDC (DEBE coincidir con el Provider) =====
# DEV (todo dentro de la red de Docker):
# - El auth service redirige al navegador a este issuer. Si NO tenés reverse proxy hacia Authentik,
# esta URL interna NO será accesible desde el navegador del host. En ese caso, ver nota más abajo.
# AUTHENTIK_ISSUER=https://sso.suitecoffee.mateosaldain.uy/application/o/suitecoffee/
AUTHENTIK_ISSUER=https://sso.suitecoffee.uy/application/o/suitecoffee/
OIDC_CLIENT_ID=1orMM8vOvf3WkN2FejXYvUFpPtONG0Lx1eMlwIpW
OIDC_CLIENT_SECRET=t5wx13qBcM0EFQ3cGnUIAmLzvbdsQrUVPv1OGWjszWkEp35pJQ55t7vZeeShqG49kuRAaiXv6PSGJLhRfGaponGaJl8gH1uCL7KIxdmm7UihgYoAXB2dFhZV4zRxfze2
OIDC_REDIRECT_URI=https://suitecoffee.uy/auth/callback
OIDC_ENROLLMENT_URL=https://sso.suitecoffee.uy/if/flow/registro-suitecoffee/

View File

@ -0,0 +1,22 @@
NODE_ENV=production # Entorno de desarrollo
PORT=4000 # Variables del servicio -> suitecoffee-app
# AUTH_HOST=prod-auth
DB_HOST=prod-db
# Nombre de la base de datos
DB_NAME=suitecoffee
# Usuario y contraseña
DB_USER=suitecoffee
DB_PASS=suitecoffee
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja

File diff suppressed because it is too large Load Diff

View File

@ -15,14 +15,26 @@
"nodemon": "^3.1.10"
},
"dependencies": {
"axios": "^1.11.0",
"bcrypt": "^5.1.1",
"chalk": "^5.6.0",
"connect-redis": "^9.0.0",
"cookie-session": "^2.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.18.2",
"ioredis": "^5.7.0",
"jose": "^6.1.0",
"jsonwebtoken": "^9.0.2",
"jwks-rsa": "^3.2.0",
"node-fetch": "^3.3.2",
"openid-client": "^5.7.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
"pg-format": "^1.0.4",
"redis": "^5.8.2"
},
"keywords": [],
"description": ""

436
services/auth/src/ak.js Normal file
View File

@ -0,0 +1,436 @@
// services/auth/src/ak.js
// ------------------------------------------------------------
// Cliente para la API Admin de Authentik (v3)
// - Sin dependencias externas (usa fetch nativo de Node >=18)
// - ESM compatible
// - Timeouts, reintentos opcionales y mensajes de error claros
// - Compatible con services/auth/src/index.js actual
// ------------------------------------------------------------
// Utiliza AUTHENTIK_BASE_URL y AUTHENTIK_TOKEN para validar y devuelve la configuración (base URL y token) desde variables de entorno.
// Devuelve la URL base y el Token que se leyó desde .env
/**
* @typedef {Object} AkCfg
* @property {string} BASE // p.ej. "https://idp.example.com"
* @property {string} TOKEN // bearer
*/
/**
* @typedef {Object} AkOpts
* @property {Record<string, string|number|boolean|Array<string|number|boolean>>} [qs]
* @property {any} [body]
* @property {number} [timeoutMs=10000]
* @property {number} [retries=0]
* @property {Record<string,string>} [headers]
*/
function getConfig() {
const BASE = (process.env.AUTHENTIK_BASE_URL || "").trim().replace(/\/+$/, "");
const TOKEN = process.env.AUTHENTIK_TOKEN || '';
if (!BASE) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_BASE_URL');
if (!TOKEN) throw new Error('[AK_CONFIG] Falta variable AUTHENTIK_TOKEN');
return { BASE, TOKEN };
}
// ------------------------------------------------------------
// Utilidades
// ------------------------------------------------------------
// Espera
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// ------------------------------------------------------------
// Helpers de sincronización
// ------------------------------------------------------------
export function createAkClient(cfg = getConfig()) {
return {
request: (method, path, opts = {}) => request(method, path, opts, cfg),
akGET: (path, opts) => request("GET", path, opts, cfg),
akPOST: (path, opts) => request("POST", path, opts, cfg),
akPUT: (path, opts) => request("PUT", path, opts, cfg),
akPATCH: (path, opts) => request("PATCH", path, opts, cfg),
akDELETE:(path, opts) => request("DELETE", path, opts, cfg),
};
}
// Listar grupos con búsqueda por nombre/slug
export async function akListGroups(search = "") {
const { akGET } = createAkClient();
const term = String(search ?? "").trim();
const data = await akGET("/core/groups/", {
qs: term ? { search: term } : undefined,
});
if (Array.isArray(data)) return data;
if (data && Array.isArray(data.results)) return data.results;
return [];
}
export async function akPatchUserAttributes(userPk, partialAttrs = {}) {
const id = String(userPk ?? "").trim();
if (!id) throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `userPk` is required");
if (partialAttrs == null || typeof partialAttrs !== "object" || Array.isArray(partialAttrs)) {
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` must be a plain object");
}
// Remove undefineds to avoid unintentionally nulling keys server-side
const cleaned = Object.fromEntries(
Object.entries(partialAttrs).filter(([, v]) => v !== undefined)
);
if (Object.keys(cleaned).length === 0) {
throw new TypeError("[AK_PATH_USER_ATRIBUTES] atribute `partialAttrs` is required");
}
// NOTE: pass path WITHOUT /api/v3; the client prefixes it
return akPATCH(`/core/users/${encodeURIComponent(id)}/`, {
body: { attributes: cleaned },
});
}
export async function akEnsureGroupForTenant(tenantHex) {
const { akGET, akPOST } = createAkClient();
const hex = String(tenantHex ?? "").trim();
if (!hex) throw new TypeError("akEnsureGroupForTenant: `tenantHex` is required");
const groupName = `tenant_${hex}`;
// 1) Buscar existente (normaliza {results:[]}/[])
const data = await akGET("/core/groups/", { qs: { search: groupName } });
const list = Array.isArray(data) ? data : (Array.isArray(data?.results) ? data.results : []);
const existing = list.find(g => g?.name === groupName);
if (existing?.pk ?? existing?.id) return existing.pk ?? existing.id;
// 2) Crear si no existe
try {
const created = await akPOST("/core/groups/", {
body: { name: groupName, attributes: { tenant_uuid: hex } },
});
return created?.pk ?? created?.id;
} catch (e) {
// 3) Condición de carrera (otro proceso lo creó): reconsulta y devuelve
const msg = String(e?.message || "");
if (/already exists|unique|duplicate|409/i.test(msg)) {
const data2 = await akGET("/core/groups/", { qs: { search: groupName } });
const list2 = Array.isArray(data2) ? data2 : (Array.isArray(data2?.results) ? data2.results : []);
const found = list2.find(g => g?.name === groupName);
if (found?.pk ?? found?.id) return found.pk ?? found.id;
}
throw e;
}
}
export async function akAddUserToGroup(userPk, groupPk) {
const { akPOST } = createAkClient();
const user = String(userPk ?? "").trim();
const group = String(groupPk ?? "").trim();
if (!user) throw new TypeError("akAddUserToGroup: `userPk` is required");
if (!group) throw new TypeError("akAddUserToGroup: `groupPk` is required");
// API reciente: POST /core/users/<pk>/groups/ { group: <pk> }
const path = `/core/users/${encodeURIComponent(user)}/groups/`;
try {
return await akPOST(path, { body: { group } });
} catch (e) {
const msg = String(e?.message || "");
// Si ya es miembro, tratamos como éxito idempotente
if (/already.*member|exists|duplicate|409/i.test(msg)) {
return { ok: true, alreadyMember: true, userPk: user, groupPk: group };
}
// Fallback para instancias viejas: /core/group_memberships/ { user, group }
if (/404|not\s*found/i.test(msg)) {
return await akPOST("/core/group_memberships/", { body: { user, group } });
}
throw e;
}
}
/**
* Llamada HTTP genérica con fetch + timeout + manejo de errores.
* @param {'GET'|'POST'|'PUT'|'PATCH'|'DELETE'} method
* @param {string} path Ruta relativa (ej. "/core/users/") o absoluta; si es relativa se antepone "/api/v3".
* @param {AkOpts} [opts]
* @param {AkCfg} [cfg] Config inyectada; si se omite se usa getConfig()
* @returns {Promise<any|null>}
*/
export async function request(method, path, opts = {}, cfg) {
const { BASE, TOKEN } = cfg ?? getConfig();
const {
qs,
body,
timeoutMs = 10_000,
retries = 0,
headers = {},
} = opts;
// Construcción segura de URL + QS
const base = BASE.endsWith("/") ? BASE : `${BASE}/`;
let p = /^https?:\/\//i.test(path) ? path : (path.startsWith("/") ? path : `/${path}`);
if (!/^https?:\/\//i.test(p) && !p.startsWith("/api/")) p = `/api/v3${p}`;
const url = new URL(p, base);
if (qs && typeof qs === "object") {
for (const [k, v] of Object.entries(qs)) {
if (v == null) continue;
if (Array.isArray(v)) v.forEach((x) => url.searchParams.append(k, String(x)));
else url.searchParams.set(k, String(v));
}
}
// Reintentos + timeout
const maxAttempts = Math.max(1, retries + 1);
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(new Error("AK_TIMEOUT")), timeoutMs);
try {
const init = {
method,
signal: ctrl.signal,
headers: {
Authorization: `Bearer ${TOKEN}`,
Accept: "application/json",
...headers,
},
};
if (body !== undefined) {
// Sólo forzar JSON si es objeto plano
const isPlainObj = body && typeof body === "object" &&
!(body instanceof FormData) &&
!(body instanceof URLSearchParams) &&
!(typeof Blob !== "undefined" && body instanceof Blob);
if (isPlainObj) {
init.headers["Content-Type"] = init.headers["Content-Type"] || "application/json";
init.body = JSON.stringify(body);
} else {
init.body = body; // deja que fetch maneje el Content-Type
}
}
const res = await fetch(url, init);
clearTimeout(t);
if (res.status === 204 || res.status === 205) return null;
const ctype = res.headers.get("content-type") || "";
const payload = /\bapplication\/json\b/i.test(ctype) ? await res.json().catch(() => ({})) : await res.text();
if (!res.ok) {
const detail = typeof payload === "string" ? payload : payload?.detail || payload?.error || JSON.stringify(payload);
const err = new Error(`AK ${method} ${url.pathname}${url.search}${res.status}: ${detail}`);
err.status = res.status; // @ts-ignore
// Reintenta 5xx y 429
if ((res.status >= 500 && res.status <= 599) || res.status === 429) {
lastErr = err;
if (attempt < maxAttempts) {
let delay = 500 * 2 ** (attempt - 1);
const ra = parseInt(res.headers.get("retry-after") || "", 10);
if (!Number.isNaN(ra)) delay = Math.max(delay, ra * 1000);
await new Promise(r => setTimeout(r, delay));
continue;
}
}
throw err;
}
return payload;
} catch (e) {
clearTimeout(t);
lastErr = e;
const msg = String(e?.message || "");
const retriable = msg.includes("AK_TIMEOUT") || msg.includes("ECONNREFUSED") || msg.includes("fetch failed");
if (!retriable || attempt >= maxAttempts) throw e;
await new Promise(r => setTimeout(r, 500 * 2 ** (attempt - 1)));
}
}
throw lastErr;
}
// ------------------------------------------------------------
// Funciones públicas
// ------------------------------------------------------------
export async function akFindUserByEmail(email) {
const { akGET } = createAkClient();
const needle = String(email ?? "").trim().toLowerCase();
if (!needle) throw new TypeError("akFindUserByEmail: `email` is required");
const PAGE_SIZE = 100;
let page = 1;
const MAX_PAGES = 10;
while (page <= MAX_PAGES) {
const data = await akGET("/core/users/", {
qs: { search: needle, page_size: PAGE_SIZE, page },
retries: 2,
});
const list = Array.isArray(data)
? data
: (Array.isArray(data?.results) ? data.results : []);
const found = list.find(u => String(u?.email || "").toLowerCase() === needle);
if (found) return found || null;
// Continuar paginando sólo si hay más resultados
const hasNext =
Array.isArray(data)
? list.length === PAGE_SIZE // array plano: inferimos por tamaño
: Boolean(data?.next); // DRF: link "next"
if (!hasNext) break;
page += 1;
}
return null;
}
export async function akCreateUser(p = {}) {
const { akPOST } = createAkClient();
const email = String(p.email ?? "").trim().toLowerCase();
if (!email) throw new TypeError("akCreateUser: `email` is required");
const name = String(p.displayName ?? email).trim() || email;
const tenantUuid = String(p.tenantUuid ?? "").replace(/-/g, "").trim();
const isActive = p.isActive ?? true;
const body = {
username: email,
name,
email,
is_active: !!isActive,
attributes: tenantUuid ? { tenant_uuid: tenantUuid } : {},
};
let user;
try {
user = await akPOST("/core/users/", { body, retries: 2 });
} catch (e) {
const msg = String(e?.message || "");
if (/409|already\s*exists|unique|duplicate/i.test(msg)) {
// Idempotencia: si ya existe, lo buscamos por email y lo devolvemos
const existing = await akFindUserByEmail(email);
if (existing) return existing;
}
throw e;
}
// Agregar a grupo (opcional, no rompe el flujo si falla)
const groupId = p.addToGroupId != null ? String(p.addToGroupId).trim() : "";
if (groupId) {
try {
const userPk = encodeURIComponent(user.pk ?? user.id);
await akPOST(`/core/users/${userPk}/groups/`, {
body: { group: groupId },
retries: 2,
});
} catch (err) {
console.warn(
`akCreateUser: could not add user ${user.pk ?? user.id} to group ${groupId}:`,
err?.message || err
);
}
}
return user;
}
export async function akSetPassword(userPk, password, requireChange = true) {
const { akPOST } = createAkClient();
const id = String(userPk ?? "").trim();
if (!id) throw new TypeError("akSetPassword: `userPk` is required");
const pwd = String(password ?? "");
if (!pwd) throw new TypeError("akSetPassword: `password` is required");
try {
await akPOST(`/core/users/${encodeURIComponent(id)}/set_password/`, {
body: { password: pwd, require_change: !!requireChange },
retries: 1,
timeoutMs: 15_000,
});
return true;
} catch (e) {
const status = e?.status ? `HTTP ${e.status}: ` : "";
const err = new Error(`akSetPassword: failed to set password (${status}${e?.message || e})`);
err.cause = e;
throw err;
}
}
export async function akResolveGroupIdByName(name) {
const term = String(name ?? "").trim();
if (!term) throw new TypeError("akResolveGroupIdByName: `name` is required");
const needle = term.toLowerCase();
const groups = await akListGroups(term);
if (!Array.isArray(groups) || groups.length === 0) return null;
// Prefer exact slug match, then exact name match
const bySlug = groups.find(g => String(g?.slug ?? "").toLowerCase() === needle);
if (bySlug) return bySlug.pk ?? bySlug.id ?? null;
const byName = groups.find(g => String(g?.name ?? "").toLowerCase() === needle);
return byName?.pk ?? byName?.id ?? null;
}
export async function akResolveGroupId({ id, pk, uuid, name, slug } = {}) {
const toPk = (v) => {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isFinite(n) ? n : String(v);
};
// 1) Direct pk/id
const direct = pk ?? id;
const directPk = toPk(direct);
if (directPk != null) return directPk;
const { akGET } = createAkClient();
// 2) By UUID (detail endpoint)
const uuidStr = String(uuid ?? "").trim();
if (uuidStr) {
try {
const g = await akGET(`/core/groups/${encodeURIComponent(uuidStr)}/`, { retries: 1 });
const fromDetail = toPk(g?.pk ?? g?.id);
if (fromDetail != null) return fromDetail;
} catch { /* continue with name/slug */ }
}
// 3) By exact name/slug
const needle = String(name ?? slug ?? "").trim();
if (needle) {
const lower = needle.toLowerCase();
const list = await akListGroups(needle); // expects [] or {results:[]}, handled in akListGroups
const found =
list.find(g => String(g?.slug ?? "").toLowerCase() === lower) ||
list.find(g => String(g?.name ?? "").toLowerCase() === lower);
const fromList = toPk(found?.pk ?? found?.id);
if (fromList != null) return fromList;
}
return null;
}
// ------------------------------------------------------------
// Exportación de constantes
// ------------------------------------------------------------
export const akGET = (path, opts) => request("GET", path, opts);
export const akPOST = (path, opts) => request("POST", path, opts);
export const akPUT = (path, opts) => request("PUT", path, opts);
export const akPATCH = (path, opts) => request("PATCH", path, opts);
export const akDELETE = (path, opts) => request("DELETE", path, opts);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,199 +1,374 @@
// auth/src/index.js
import chalk from 'chalk';
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import cors from 'cors';
import { Pool } from 'pg';
import bcrypt from'bcrypt';
// services/auth/src/index.js
// ------------------------------------------------------------
// SuiteCoffee — Servicio de Autenticación (Express + OIDC)
// - ESM compatible (Node >=18)
// - Sesiones con Redis (compartibles con otros servicios)
// - Vistas EJS (login)
// - Registro de usuario: /auth/api/users/register (DB + Authentik)
// ------------------------------------------------------------
// Rutas
import path from 'path';
import { fileURLToPath } from 'url';
import 'dotenv/config';
import path from 'node:path';
import fs from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { Pool } from 'pg';
import express from 'express';
import crypto from 'node:crypto';
import fetch from "node-fetch";
import { createRedisSession } from "../shared/middlewares/redisConnect.js";
// -----------------------------------------------------------------------------
// Variables globales
// -----------------------------------------------------------------------------
const PORT = process.env.PORT || 4040;
const ISSUER = process.env.AUTHENTIK_ISSUER?.replace(/\/?$/, "/"); // asegura barra final
const CLIENT_ID = process.env.OIDC_CLIENT_ID;
const CLIENT_SECRET = process.env.OIDC_CLIENT_SECRET;
const REDIRECT_URI = process.env.OIDC_REDIRECT_URI || process.env.AUTH_CALLBACK_URL;
const APP_BASE_URL = process.env.APP_BASE_URL || "http://localhost:3030";
// -----------------------------------------------------------------------------
// Utilidades / Helpers
// -----------------------------------------------------------------------------
// -----------------------------------------------------------------------------
// Utilidades
// -----------------------------------------------------------------------------
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Variables de Entorno
import dotenv, { config } from 'dotenv';
// Obtención de la ruta de la variable de entorno correspondiente a NODE_ENV
try {
if (process.env.NODE_ENV === 'development') {
dotenv.config({ path: path.resolve(__dirname, '../.env.development' )});
console.log(`Activando entorno de ->${chalk.green(` DEVELOPMENT `)}`);
} else if (process.env.NODE_ENV === 'stage') {
dotenv.config({ path: path.resolve(__dirname, '../.env.test' )});
console.log(`Activando entorno de ->${chalk.yellow(` TESTING `)}`);
} else if (process.env.NODE_ENV === 'production') {
dotenv.config({ path: path.resolve(__dirname, '../.env.production' )});
console.log(`Activando entorno de ->${chalk.red(` PRODUCTION `)}`);
function requiredEnv(keys) {
const missing = keys.filter((k) => !process.env[k]);
if (missing.length) {
console.warn(`Falta configurar variables de entorno: ${missing.join(', ')}`);
}
} catch (error) {
console.log("A ocurrido un error al seleccionar el entorno. \nError: " + error);
}
// Configuración de renderizado
// -----------------------------------------------------------------------------
// Configuración Express
// -----------------------------------------------------------------------------
const app = express();
app.use(cors());
app.set('trust proxy', Number(process.env.TRUST_PROXY_HOPS || 2));
app.disable("x-powered-by");
app.use(express.json());
app.set('trust proxy', true);
app.use(express.static(path.join(__dirname, 'pages')));
app.use(express.urlencoded({ extended: true }));
// Vistas EJS
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'ejs');
// Archivos estáticos opcionales (ajusta si tu estructura difiere)
app.use(express.static(path.join(__dirname, 'public')));
app.use('/pages', express.static(path.join(__dirname, 'pages')));
// Configuración de conexión PostgreSQL
// -----------------------------------------------------------------------------
// Sesión (Redis)
// -----------------------------------------------------------------------------
// --- Sesión/Redis ---
const { sessionMw, trustProxy } = await createRedisSession();
if (trustProxy) app.set("trust proxy", 1);
app.use(sessionMw);
app.use(express.json());
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT
};
const pool = new Pool(dbConfig);
// --- Utiles OIDC ---
function base64url(buf) {
return Buffer.from(buf).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function genPKCE() {
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash("sha256").update(verifier).digest());
return { verifier, challenge };
}
function authorizeUrl({ state, challenge }) {
const u = new URL(`${ISSUER}authorize/`);
u.searchParams.set("client_id", CLIENT_ID);
u.searchParams.set("redirect_uri", REDIRECT_URI);
u.searchParams.set("response_type", "code");
u.searchParams.set("scope", "openid email profile");
u.searchParams.set("state", state);
u.searchParams.set("code_challenge", challenge);
u.searchParams.set("code_challenge_method", "S256");
return u.toString();
}
async function exchangeCodeForTokens({ code, verifier }) {
const tokenUrl = `${ISSUER}token/`;
const body = new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: verifier,
});
// auth básica si el proveedor la requiere (Authentik soporta ambos modos)
const basic = Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64");
const res = await fetch(tokenUrl, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
"authorization": `Basic ${basic}`,
},
body,
});
if (!res.ok) throw new Error(`Token endpoint ${res.status}`);
return res.json();
}
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// -----------------------------------------------------------------------------
// PostgreSQL — DB tenants (usuarios de suitecoffee)
// -----------------------------------------------------------------------------
const tenantsPool = new Pool({
host: process.env.TENANTS_HOST || 'dev-tenants',
port: Number(process.env.TENANTS_PORT || 5432),
user: process.env.TENANTS_USER || 'dev-user-postgres',
password: process.env.TENANTS_PASS || 'dev-pass-postgres',
database: process.env.TENANTS_DB || 'dev-postgres',
max: 10,
});
// -----------------------------------------------------------------------------
// PostgreSQL — DB principal (metadatos de negocio)
// -----------------------------------------------------------------------------
requiredEnv(['DB_HOST', 'DB_USER', 'DB_PASS', 'DB_NAME']);
const mainPool = new Pool({
host: process.env.DB_HOST || 'dev-db',
port: Number(process.env.DB_PORT || 5432),
user: process.env.DB_USER || 'dev-user-suitecoffee',
password: process.env.DB_PASS || 'dev-pass-suitecoffee',
database: process.env.DB_NAME || 'dev-suitecoffee',
max: 10,
idleTimeoutMillis: 30_000,
});
// ----------------------------------------------------------
// Verificación de conexión
// ----------------------------------------------------------
async function verificarConexion() {
try {
const client = await pool.connect();
const res = await client.query('SELECT NOW() AS hora');
console.log(`\nConexión con la base de datos ${chalk.green(process.env.DB_NAME)} fue exitosa.`);
console.log('Fecha y hora actual de la base de datos:', res.rows[0].hora);
client.release(); // liberar el cliente de nuevo al pool
console.log(`[AUTH] Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
var client = await mainPool.connect();
var { rows } = await client.query('SELECT NOW() AS ahora');
console.log(`\n[AUTH] Conexión con ${process.env.DB_NAME} OK. Hora DB:`, rows[0].ahora);
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar:', error.message);
console.error(`Troubleshooting:\n1. Compruebe que las bases de datos se iniciaron correctamente.\n2. Verifique las credenciales y puertos de acceso a la base de datos.\n3. Si está conectandose a una base de datos externa a localhost, verifique las reglas del firewal de entrada y salida de ambos dispositivos.`);
console.error('[AUTH] Error al conectar con la base de datos al iniciar:', error.message);
console.error('[AUTH] Revisar DB_HOST/USER/PASS/NAME, accesos de red y firewall.');
} finally {
client.release();
}
}
// === Servir páginas estáticas ===
// -----------------------------------------------------------------------------
// Vistas
// -----------------------------------------------------------------------------
app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html')));
app.get('/planes', async (req, res) => {
// =============================================
// Registro de usuario (DB principal)
// =============================================
requiredEnv(['TENANT_INIT_SQL']);
async function loadInitSqlFromEnv() {
const v = process.env.TENANT_INIT_SQL?.trim();
if (!v) return '';
try {
const result = await pool.query(`
SELECT id, nombre, descripcion, precio
FROM plan
WHERE activo = true
ORDER BY id
`);
res.json(result.rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al cargar planes' });
// ¿Es una ruta existente?
const p = path.isAbsolute(v) ? v : path.resolve(__dirname, v);
const txt = await fs.readFile(p, 'utf8');
console.log(`[TENANT INIT] Cargado desde archivo: ${p} (${txt.length} bytes)`);
return String(txt || '');
} catch {
// Tratar como literal
console.log(`[TENANT INIT] Usando SQL literal desde TENANT_INIT_SQL (${v.length} chars).`);
return v;
}
}
// Reemplaza placeholders simples en la plantilla de SQL (opcional)
function renderInitSqlTemplate(sql, { schema, owner }) {
return sql
.replaceAll(':TENANT_SCHEMA', `"${schema}"`)
.replaceAll(':OWNER', `"${owner}"`);
}
// Genera ids sencillos
function newTenantIds() {
return {
tenant_uuid: crypto.randomUUID(),
tenant_role: null, // lo decidirás luego (owner, barman, staff)
};
}
async function createTenantUserAndSchema(tenClient, { tenant_uuid, password }) {
const roleName = `tenant_${tenant_uuid.replace(/-/g, '')}`;
const schemaName = `t_${tenant_uuid.replace(/-/g, '')}`;
const escapedPassword = `'${String(password).replace(/'/g, "''")}'`;
// 1) crear role y schema (misma conexión que ya viene en BEGIN desde la ruta)
await tenClient.query(`CREATE ROLE "${roleName}" LOGIN PASSWORD ${escapedPassword}`);
await tenClient.query(`CREATE SCHEMA "${schemaName}" AUTHORIZATION "${roleName}"`);
await tenClient.query(`GRANT USAGE ON SCHEMA "${schemaName}" TO "${roleName}"`);
await tenClient.query(`ALTER ROLE "${roleName}" INHERIT`);
// (idempotente)
await tenClient.query(`CREATE SCHEMA IF NOT EXISTS "${schemaName}"`);
// 2) cargar y sanear la plantilla
let sql = await loadInitSqlFromEnv();
if (!sql?.trim()) {
console.log('[TENANT INIT] No hay SQL de plantilla; se omite.');
return { roleName, schemaName };
}
// 👉 quita metacomandos psql '\' (por si alguno quedó) y cualquier cambio de search_path dentro del dump
sql = sql
.split(/\r?\n/)
.filter(line => !line.trim().startsWith('\\')) // \restrict, \unrestrict, \i, etc.
.filter(line => !/^SET\s+search_path\b/i.test(line)) // SET search_path = ...
.filter(line => !/set_config\(\s*'search_path'/i.test(line)) // SELECT set_config('search_path',...
.join('\n');
// si usás placeholders, renderealos acá (opcional)
// sql = renderInitSqlTemplate(sql, { schema: schemaName, owner: roleName });
// 3) forzá el search_path SOLO dentro de esta transacción
await tenClient.query(`SET LOCAL search_path TO "${schemaName}", public`);
// 4) ejecutar el dump (una sola vez, no lo partas por ';' para no romper $$...$$)
await tenClient.query(sql);
console.log(`[TENANT INIT] OK usuario="${roleName}" schema="${schemaName}"`);
return { roleName, schemaName };
}
//=============================================
// ---------- Authentik (API & OIDC) ----------
//=============================================
// ===========================
// GET /auth/users/register
// ===========================
// ===========================
// POST /auth/login
// ===========================
app.get("/auth/login", (req, res) => {
const { verifier, challenge } = genPKCE();
const state = base64url(crypto.randomBytes(24));
req.session.pkce_verifier = verifier;
req.session.oidc_state = state;
const url = authorizeUrl({ state, challenge });
res.redirect(302, url);
});
// ===========================
// GET /auth/callback
// ===========================
app.get("/auth/callback", async (req, res) => {
try {
const { code, state } = req.query;
if (!code || !state) return res.status(400).send("Faltan parámetros");
if (state !== req.session.oidc_state) return res.status(400).send("State inválido");
app.post('/api/registro', async (req, res) => {
const {
nombre_empresa,
rut,
correo,
telefono,
direccion,
logo,
clave_acceso,
plan_id
} = req.body;
try {
const client = await pool.connect();
const verifier = req.session.pkce_verifier;
if (!verifier) return res.status(400).send("PKCE verifier faltante");
// 1. Hashear la contraseña
const hash = await bcrypt.hash(clave_acceso, 10);
// 2. Insertar el tenant
const result = await client.query(`
INSERT INTO tenant (
nombre_empresa, rut, correo, telefono, direccion, logo,
clave_acceso, plan_id, nombre_base_datos
) VALUES (
$1, $2, $3, $4, $5, $6,
$7, $8, 'TEMPORAL'
)
RETURNING uuid;
`, [
nombre_empresa, rut, correo, telefono, direccion, logo,
hash, plan_id
]);
const tokens = await exchangeCodeForTokens({ code, verifier });
// Guarda en sesión (ID Token, Access Token, Refresh Token si viene)
req.session.tokens = {
id_token: tokens.id_token,
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: tokens.token_type,
expires_in: tokens.expires_in,
received_at: Date.now(),
};
// Limpia PKCE/state
delete req.session.pkce_verifier;
delete req.session.oidc_state;
const uuid = result.rows[0].uuid;
const nombre_base_datos = `tenantdb_${uuid}`.replace(/-/g, '').substring(0, 24); // ajustamos para longitud segura
// 3. Actualizar el campo nombre_base_datos
await client.query(`
UPDATE tenant SET nombre_base_datos = $1 WHERE uuid = $2
`, [nombre_base_datos, uuid]);
client.release();
return res.status(201).json({
message: 'Tenant registrado correctamente',
uuid,
nombre_base_datos
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al registrar tenant' });
}
// Redirige al home de App
res.redirect(303, `${APP_BASE_URL}/`);
} catch (e) {
console.error("/auth/callback error", e);
res.status(500).send("Error en callback");
}
});
app.post('/api/login', async (req, res) => {
const { correo, clave_acceso } = req.body;
try {
const client = await pool.connect();
const result = await client.query(`
SELECT uuid, clave_acceso, nombre_empresa, nombre_base_datos
FROM tenant
WHERE correo = $1 AND estado = true
`, [correo]);
client.release();
if (result.rows.length === 0) {
return res.status(401).json({ error: 'Correo no registrado o inactivo' });
}
const tenant = result.rows[0];
const coincide = await bcrypt.compare(clave_acceso, tenant.clave_acceso);
if (!coincide) {
return res.status(401).json({ error: 'Clave incorrecta' });
}
return res.status(200).json({
message: 'Login correcto',
uuid: tenant.uuid,
nombre_empresa: tenant.nombre_empresa,
base_datos: tenant.nombre_base_datos
});
} catch (err) {
console.error(err);
return res.status(500).json({ error: 'Error al validar login' });
}
// ===========================
// POST /auth/logout
// ===========================
app.get("/auth/logout", (req, res) => {
req.session.destroy(() => {
res.clearCookie(process.env.SESSION_COOKIE_NAME || "sc.sid");
res.redirect(303, APP_BASE_URL || "/");
});
});
// Colores personalizados
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);
// let backgroundColor = chalk.hex('#'+`${process.env.COL_BG}`);
// =============================================
// Healthcheck
// =============================================
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));
// =============================================
// 404 + Manejo de errores
// =============================================
app.use((req, res) => res.status(404).json({ error: 'Error 404, No se encontró la página', path: req.originalUrl }));
app.use(expressLayouts);
// Iniciar servidor
app.listen( process.env.PORT, () => {
console.log(`Servidor de ${chalk.yellow('autenticación')} de ${secondaryColor('SuiteCoffee')} corriendo en ${chalk.yellow(`http://localhost:${process.env.PORT}\n`)}` );
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.green(process.env.DB_NAME)} del host ${chalk.white(`${process.env.DB_HOST}`)} ...`));
app.use((err, _req, res, _next) => {
console.error('[AUTH] ', err);
if (res.headersSent) return;
res.status(500).json({ error: '¡Oh! A ocurrido un error en el servidor auth.', detail: err.stack || String(err) });
});
/*
-----------------------------------------------------------------------------
Exportación principal del módulo.
Es típico exportar la instancia (app) y arrancarla en otro archivo.
- Facilita tests (p.ej. con supertest: import app from './app.js')
- Evita que el servidor se inicie al importar el módulo.
# Default
export default app; // importar: import app from './app.js'
# Con nombre
export const app = express(); // importar: import { app } from './app.js'
-----------------------------------------------------------------------------
*/
export default app;
// -----------------------------------------------------------------------------
// Arranque
// -----------------------------------------------------------------------------
app.listen(PORT, () => {
console.log(`[AUTH] Servicio de autenticación escuchando en http://localhost:${PORT}`);
verificarConexion();
});
app.get("/health", async (req, res) => {
// Podés chequear DB aquí. 200 = healthy; 503 = not ready.
res.status(200).json({ status: "ok" });
// OIDCdiscover();
});

View File

@ -0,0 +1,19 @@
NODE_ENV=development
PORT=3030
DB_HOST=dev-tenants
DB_NAME=manso
# Usuario y contraseña
DB_USER=manso
DB_PASS=manso
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja

View File

@ -0,0 +1,20 @@
NODE_ENV=production # Entorno de desarrollo
PORT=3000 # Variables del servicio -> suitecoffee-app
# Variables del servicio -> suitecoffee-db de suitecoffee-app
DB_HOST=dev-tenants
DB_NAME=manso
# Usuario y contraseña
DB_USER=manso
DB_PASS=manso
# Puertos del servicio de db
DB_LOCAL_PORT=5432
DB_DOCKER_PORT=5432
# Colores personalizados
COL_PRI=452D19 # Marrón oscuro
COL_SEC=D7A666 # Crema / Café
COL_BG=FFA500 # Naranja

View File

@ -0,0 +1,20 @@
# Dockerfile.dev
FROM node:22.18
# Definir variables de entorno con valores predeterminados
# ARG NODE_ENV=production
# ARG PORT=3000
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copia archivos de configuración primero para aprovechar el cache
COPY package*.json ./
# Instala dependencias
RUN npm i
# Copia el resto de la app
COPY . .
CMD ["npm", "run", "start"]

1585
services/manso/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,35 @@
{
"name": "workarround",
"version": "1.0.0",
"main": "src/index.mjs",
"scripts": {
"start": "NODE_ENV=production node ./src/index.mjs",
"dev": "NODE_ENV=development npx nodemon ./src/index.mjs",
"test": "NODE_ENV=stage node ./src/index.mjs"
},
"author": "Mateo Saldain",
"license": "ISC",
"type": "module",
"devDependencies": {
"cross-env": "^10.0.0",
"nodemon": "^3.1.10"
},
"dependencies": {
"chalk": "^5.6.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"ejs": "^3.1.10",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4",
"serve-favicon": "^2.5.1"
},
"imports": {
"#v1Router": "./src/api/v1/routes/routes.js",
"#pages": "./src/pages/pages.js",
"#db": "./src/db/poolSingleton.js"
},
"keywords": [],
"description": "Workarround para tener un MVP que llegue al verano para usarse"
}

View File

@ -0,0 +1,340 @@
// services/manso/src/api/v1/routes/routes.js
import { Router } from 'express';
import pool from '#db'; // Pool Singleton
const router = Router();
// ==========================================================
// Rutas de API v1
// ==========================================================
// ----------------------------------------------------------
// API Comandas
// ----------------------------------------------------------
router.route('/comandas').get( async (req, res, next) => {
try {
var client = await pool.getClient()
const estado = (req.query.estado || '').trim() || null;
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
const { rows } = await client.query(
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
[estado, limit]
);
res.json(rows);
} catch (e) {
next(e);
} finally {
client.release();
}
});
router.route('/comandas/:id/detalle').get( async (req, res, next) => {
try {
const client = await pool.getClient()
client.query(
`SELECT id_det_comanda, id_producto, producto_nombre,
cantidad, pre_unitario, subtotal, observaciones
FROM public.v_comandas_detalle_items
WHERE id_comanda = $1::int
ORDER BY id_det_comanda`,
[req.params.id]
)
.then(r => res.json(r.rows))
.catch(next)
client.release();
} catch (error) {
next(e);
}
});
router.route('/comandas/:id/cerrar').post( async (req, res, next) => {
try {
const client = await pool.getClient()
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await client.query(
`SELECT public.f_cerrar_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
client.release();
} catch (err) { next(err); }
});
router.route('/comandas/:id/abrir').post( async (req, res, next) => {
try {
const client = await pool.getClient()
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await client.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
client.release();
} catch (err) { next(err); }
});
// ----------------------------------------------------------
// API Productos
// ----------------------------------------------------------
// GET producto + receta
router.route('/rpc/get_producto/:id').get( async (req, res) => {
const client = await pool.getClient()
const id = Number(req.params.id);
const { rows } = await client.query('SELECT public.get_producto($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
});
// POST guardar producto + receta
router.route('/rpc/save_producto').post(async (req, res) => {
try {
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
const client = await pool.getClient()
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
const { rows } = await client.query(q, params);
res.json(rows[0] || {});
client.release();
} catch(e) {
console.error(e);
res.status(500).json({ error: 'save_producto failed' });
}
});
// ----------------------------------------------------------
// API Materias Primas
// ----------------------------------------------------------
// GET MP + proveedores
router.route('/rpc/get_materia/:id').get(async (req, res) => {
const id = Number(req.params.id);
try {
const client = await pool.getClient()
const { rows } = await client.query('SELECT public.get_materia_prima($1) AS data', [id]);
res.json(rows[0]?.data || {});
client.release();
} catch (e) {
console.error(e);
res.status(500).json({ error: 'get_materia failed' });
}
});
// SAVE MP + proveedores (array)
router.route('/rpc/save_materia').post( async (req, res) => {
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
try {
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
const { rows } = await pool.query(q, params);
res.json(rows[0] || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'save_materia failed' });
}
});
// ----------------------------------------------------------
// API Usuarios y Asistencias
// ----------------------------------------------------------
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
router.route('/rpc/find_usuarios_por_documentos').post( async (req, res) => {
try {
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
}
});
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
router.route('/rpc/import_asistencia').post( async (req, res) => {
try {
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
const origen = req.body?.origen || null;
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'import_asistencia failed' });
}
});
// Consultar datos de asistencia (raw + pares) para un usuario y rango
router.route('/rpc/asistencia_get').post( async (req, res) => {
try {
const { doc, desde, hasta } = req.body || {};
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
const { rows } = await pool.query(sql, [doc, desde, hasta]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
}
});
// Editar un registro crudo y recalcular pares
router.route('/rpc/asistencia_update_raw').post( async (req, res) => {
try {
const { id_raw, fecha, hora, modo } = req.body || {};
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
}
});
// Eliminar un registro crudo y recalcular pares
router.route('/rpc/asistencia_delete_raw').post( async (req, res) => {
try {
const { id_raw } = req.body || {};
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
const { rows } = await pool.query(sql, [id_raw]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
}
});
// ----------------------------------------------------------
// API Reportes
// ----------------------------------------------------------
// POST /api/rpc/report_tickets { year }
router.route('/rpc/report_tickets').post( async (req, res) => {
try {
const y = parseInt(req.body?.year ?? req.query?.year, 10);
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
? y
: (new Date()).getFullYear();
const { rows } = await pool.query(
'SELECT public.report_tickets_year($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_tickets error:', e);
res.status(500).json({
error: 'report_tickets failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
router.route('/rpc/report_asistencia').post( async (req, res) => {
try {
let { desde, hasta } = req.body || {};
// defaults si vienen vacíos/invalidos
const re = /^\d{4}-\d{2}-\d{2}$/;
if (!re.test(desde) || !re.test(hasta)) {
const end = new Date();
const start = new Date(end); start.setDate(end.getDate()-30);
desde = start.toISOString().slice(0,10);
hasta = end.toISOString().slice(0,10);
}
const { rows } = await pool.query(
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_asistencia error:', e);
res.status(500).json({
error: 'report_asistencia failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// ----------------------------------------------------------
// API Compras y Gastos
// ----------------------------------------------------------
// Guardar (insert/update)
router.route('/rpc/save_compra').post( async (req, res) => {
try {
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
const { rows } = await pool.query(sql, args);
res.json(rows[0]); // { id_compra, total }
} catch (e) {
console.error('save_compra error:', e);
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
}
});
// Obtener para editar
router.route('/rpc/get_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
const sql = `SELECT public.get_compra($1::int) AS data`;
const { rows } = await pool.query(sql, [id_compra]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'get_compra failed' });
}
});
// Eliminar
router.route('/rpc/delete_compra').post( async (req, res) => {
try {
const { id_compra } = req.body || {};
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
res.json({ ok: true });
} catch (e) {
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
}
});
// POST /api/rpc/report_gastos { year: 2025 }
router.route('/rpc/report_gastos').post( async (req, res) => {
try {
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
const { rows } = await pool.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});
export default router;

View File

@ -0,0 +1,46 @@
// Coneción Singleton a base de datos.
import { Pool } from 'pg';
class Database {
constructor() {
if (Database.instance) {
return Database.instance;
}
const config = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
};
this.connection = new Pool(config);
Database.instance = this;
}
async query(sql, params) {
return this.connection.query(sql,params);
}
async connect() { /* Definida solo para evitar errores */
return this.connection.connect();
}
async getClient() {
return this.connection.connect();
}
async release() {
await this.connection.end();
}
}
// const db = new Database();
// db.query('SELECT * FROM users');
const pool = new Database();
export default pool;
export { Database };

View File

@ -0,0 +1,321 @@
// ./services/manso/src/index.mjs
import 'dotenv/config';// Variables de Entorno
import favicon from 'serve-favicon'; // Favicon
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import pool from '#db'; // Pool Singleton
import v1Router from '#v1Router'; // Rutas API v1
import expressPages from '#pages'; // Rutas "/", "/dashboard", ...
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// ----------------------------------------------------------
// Variables del sistema
// ----------------------------------------------------------
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
const q = (s) => `"${String(s).replace(/"/g, '""')}"`; // Identificadores SQL -> comillas dobles y escape correcto
// ----------------------------------------------------------
// App + Motor de vistas EJS
// ----------------------------------------------------------
const app = express();
app.set('trust proxy', true);
app.set("views", path.join(__dirname, "views"));
app.set("view engine", "ejs");
app.set("layout", "layouts/main");
app.use(express.json());
app.use(express.json({ limit: '1mb' }));
app.use(express.static(path.join(__dirname, 'public'))); // Carga de archivos estaticos
app.use(expressLayouts); // Carga los layouts que usara el renderizado
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {maxAge: '1y'}));
app.use(expressPages); // Renderizado trae las paginas desde ./services/manso/src/routes/routes.js
// ----------------------------------------------------------
// Uso de API v1
// ----------------------------------------------------------
app.use("/api/v1", v1Router);
// /api/rpc/get_producto/:id
// /api/v1/rpc/get_producto/:id -> /rpc/get_producto/:id
// ----------------------------------------------------------
// Seguridad: Tablas permitidas
// ----------------------------------------------------------
const ALLOWED_TABLES = [
'roles','usuarios','usua_roles',
'categorias','productos',
'clientes','mesas',
'comandas','deta_comandas',
'proveedores','compras','deta_comp_producto',
'mate_primas','deta_comp_materias',
'prov_producto','prov_mate_prima',
'receta_producto', 'asistencia_resumen_diario',
'asistencia_intervalo', 'asistencia_detalle',
'vw_compras'
];
function ensureTable(name) {
const t = String(name || '').toLowerCase();
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
return t;
}
// ----------------------------------------------------------
// Introspección de esquema
// ----------------------------------------------------------
async function loadColumns(client, table) {
const sql = `
SELECT
c.column_name,
c.data_type,
c.is_nullable = 'YES' AS is_nullable,
c.column_default,
(SELECT EXISTS (
SELECT 1 FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
JOIN pg_index i ON i.indrelid = t.oid AND a.attnum = ANY(i.indkey)
WHERE t.relname = $1 AND i.indisprimary AND a.attname = c.column_name
)) AS is_primary,
(SELECT a.attgenerated = 's' OR a.attidentity IN ('a','d')
FROM pg_attribute a
JOIN pg_class t ON t.oid = a.attrelid
WHERE t.relname = $1 AND a.attname = c.column_name
) AS is_identity
FROM information_schema.columns c
WHERE c.table_schema='public' AND c.table_name=$1
ORDER BY c.ordinal_position
`;
const { rows } = await client.query(sql, [table]);
return rows;
}
async function loadForeignKeys(client, table) {
const sql = `
SELECT
kcu.column_name,
ccu.table_name AS foreign_table,
ccu.column_name AS foreign_column
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.table_schema='public' AND tc.table_name=$1 AND tc.constraint_type='FOREIGN KEY'
`;
const { rows } = await client.query(sql, [table]);
const map = {};
for (const r of rows) map[r.column_name] = { foreign_table: r.foreign_table, foreign_column: r.foreign_column };
return map;
}
async function loadPrimaryKey(client, table) {
const sql = `
SELECT a.attname AS column_name
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
JOIN pg_class t ON t.oid = i.indrelid
WHERE t.relname = $1 AND i.indisprimary
`;
const { rows } = await client.query(sql, [table]);
return rows.map(r => r.column_name);
}
// label column for FK options
async function pickLabelColumn(client, refTable) {
const preferred = ['nombre','raz_social','apodo','documento','correo','telefono'];
const { rows } = await client.query(
`SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema='public' AND table_name=$1
ORDER BY ordinal_position`, [refTable]
);
for (const cand of preferred) {
if (rows.find(r => r.column_name === cand)) return cand;
}
const textish = rows.find(r => /text|character varying|varchar/i.test(r.data_type));
if (textish) return textish.column_name;
return rows[0]?.column_name || 'id';
}
// ----------------------------------------------------------
// Middleware para datos globales
// ----------------------------------------------------------
app.use((req, res, next) => {
res.locals.currentPath = req.path;
res.locals.pageTitle = "SuiteCoffee";
res.locals.pageId = "";
next();
});
// ----------------------------------------------------------
// API
// ----------------------------------------------------------
app.get('/api/tables', async (_req, res) => {
res.json(ALLOWED_TABLES);
});
app.get('/api/schema/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const client = await pool.getClient();
try {
const columns = await loadColumns(client, table);
const fks = await loadForeignKeys(client, table);
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
res.json({ table, columns: enriched });
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/options/:table/:column', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const column = req.params.column;
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
const client = await pool.getClient();
try {
const fks = await loadForeignKeys(client, table);
const fk = fks[column];
if (!fk) return res.json([]);
const refTable = fk.foreign_table;
const refId = fk.foreign_column;
const labelCol = await pickLabelColumn(client, refTable);
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
const result = await client.query(sql);
res.json(result.rows);
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/table/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
const client = await pool.getClient();
try {
const pks = await loadPrimaryKey(client, table);
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
const result = await client.query(sql);
// Normalizar: siempre devolver objetos {col: valor}
const colNames = result.fields.map(f => f.name);
let rows = result.rows;
if (rows.length && Array.isArray(rows[0])) {
rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
}
res.json(rows);
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
}
});
app.post('/api/table/:table', async (req, res) => {
const table = ensureTable(req.params.table);
const payload = req.body || {};
try {
const client = await pool.getClient();
try {
const columns = await loadColumns(client, table);
const insertable = columns.filter(c =>
!c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
);
const allowedCols = new Set(insertable.map(c => c.column_name));
const cols = [];
const vals = [];
const params = [];
let idx = 1;
for (const [k, v] of Object.entries(payload)) {
if (!allowedCols.has(k)) continue;
if (!VALID_IDENT.test(k)) continue;
cols.push(q(k));
vals.push(`$${idx++}`);
params.push(v);
}
if (!cols.length) {
const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
res.status(201).json({ inserted: rows[0] });
} else {
const { rows } = await client.query(
`INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
params
);
res.status(201).json({ inserted: rows[0] });
}
} catch (e) {
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
throw e;
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
// ----------------------------------------------------------
// Verificación de conexión
// ----------------------------------------------------------
async function verificarConexion() {
try {
var client = await pool.getClient();
let res = await client.query('SELECT NOW() AS hora');
console.log(`\nConexión con la base de datos ${process.env.DB_NAME} fue exitosa.`);
console.log('Fecha y hora actual de la base de datos:\n ->', res.rows[0].hora);
} catch (error) {
console.error('Error al conectar con la base de datos al iniciar: \n ->', error.message);
console.error('Revisar credenciales y accesos de red.');
} finally{
client.release();
}
}
// ----------------------------------------------------------
// Inicio del servidor
// ----------------------------------------------------------
app.listen(PORT, () => {
console.log(`Servidor de aplicación escuchando en ${`http://localhost:${PORT}`}`);
console.log(`Comprobando accesibilidad a la db ${process.env.DB_NAME} del host ${process.env.DB_HOST} ...`);
verificarConexion();
});
// Healthcheck
app.get('/health', (_req, res) => res.status(200).json({ status: 'ok'}));

View File

@ -0,0 +1,67 @@
// services/manso/src/api/v1/routes/routes.js
import { Router } from 'express';
const router = Router();
// ----------------------------------------------------------
// Rutas de UI
// ----------------------------------------------------------
router.get('/', (req, res) => {
res.locals.pageTitle = "Inicio"; // Título de pestaña
res.locals.pageId = "home"; // Sidebar contextual
res.render("dashboard"); // Archivo .ejs a renderizar
// res.json({ ok: true, route: '/inicio' }); // Debug json
});
router.get('/dashboard', (req, res) => {
res.locals.pageTitle = "Dashboard";
res.locals.pageId = "dashboard";
res.render("dashboard");
// res.json({ ok: true, route: '/dashboard' });
});
router.get('/comandas', (req, res) => {
res.locals.pageTitle = "Comandas";
res.locals.pageId = "comandas";
res.render("comandas");
// res.json({ ok: true, route: '/comandas' });
});
router.get('/estadoComandas', (req, res) => {
res.locals.pageTitle = "Estado";
res.locals.pageId = "estadoComandas";
res.render("estadoComandas");
// res.json({ ok: true, route: '/estadoComandas' });
});
router.get('/productos', (req, res) => {
res.locals.pageTitle = "Propductos";
res.locals.pageId = "productos";
res.render("productos");
// res.json({ ok: true, route: '/productos' });
});
router.get('/usuarios', (req, res) => {
res.locals.pageTitle = "Usuarios";
res.locals.pageId = "usuarios";
res.render("usuarios");
// res.json({ ok: true, route: '/usuarios' });
});
router.get('/reportes', (req, res) => {
res.locals.pageTitle = "Reportes";
res.locals.pageId = "reportes";
res.render("reportes");
// res.json({ ok: true, route: '/reportes' });
});
router.get('/compras', (req, res) => {
res.locals.pageTitle = "Compras";
res.locals.pageId = "compras";
res.render("compras");
// res.json({ ok: true, route: '/compras' });
});
export default router;

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

View File

@ -0,0 +1,569 @@
<!-- services/manso/src/views/comandas.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">📋 Nueva Comanda</h1>
<span class="badge rounded-pill text-bg-light">/api/*</span>
</div>
<div class="row g-3">
<!-- Columna izquierda: Productos -->
<div class="col-12 col-lg-7">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Productos</strong>
<div class="ms-auto small text-muted" id="prodCount">0 ítems</div>
</div>
<div class="card-body">
<div class="row g-2 align-items-center mb-2">
<div class="col-12 col-sm">
<input id="busqueda" type="search" class="form-control" placeholder="Buscar por nombre o categoría…">
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary" id="limpiarBusqueda">Limpiar</button>
</div>
</div>
<div id="listadoProductos" class="border rounded" style="max-height:58vh; overflow:auto;">
<!-- tabla de productos renderizada por JS -->
<div class="p-3 text-muted">Cargando…</div>
</div>
</div>
</div>
</div>
<!-- Columna derecha: Detalles + Carrito -->
<div class="col-12 col-lg-5">
<div class="card shadow-sm mb-3">
<div class="card-header"><strong>Detalles</strong></div>
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-sm-6">
<label for="selMesa" class="form-label text-muted small mb-1">Mesa</label>
<select id="selMesa" class="form-select"></select>
</div>
<div class="col-12 col-sm-6">
<label for="selUsuario" class="form-label text-muted small mb-1">Usuario</label>
<select id="selUsuario" class="form-select"></select>
</div>
</div>
<div class="mt-2">
<label for="obs" class="form-label text-muted small mb-1">Observaciones</label>
<textarea id="obs" class="form-control" rows="3"></textarea>
</div>
<div class="alert alert-secondary mt-3 mb-0 small">
La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header"><strong>Carrito</strong></div>
<div class="card-body p-0" id="carritoWrap">
<div class="p-3 text-muted">Aún no agregaste productos.</div>
</div>
<div class="d-flex align-items-center gap-2 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
<div class="small"><span class="text-muted">Ítems:</span> <strong id="kpiItems">0</strong></div>
<div class="small ms-2"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
<div class="ms-auto"></div>
<button class="btn btn-outline-secondary" id="vaciar">Vaciar</button>
<button class="btn btn-primary" id="crear">Crear Comanda</button>
</div>
</div>
<div id="msg" class="mt-2 small text-muted"></div>
</div>
</div>
<!-- ====== LÓGICA ====== -->
<script>
// Helpers DOM
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
// Estado
const state = {
productos: [],
mesas: [],
usuarios: [],
categorias: [], // <--- NUEVO
carrito: [],
filtro: ''
};
function norm(s='') {
return s.toString().toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu,''); // "café" -> "cafe"
}
function isTakeaway(apodo) {
return /^takeaway$/i.test(String(apodo || '').trim());
}
function groupOrderByCatName(catName='') {
const n = norm(catName);
if (n.includes('bar')) return 1;
if (n.includes('cafe')) return 2;
if (n.includes('cafeter')) return 3;
if (n.includes('trago') || n.includes('refresc')) return 4;
return 99; // otros
}
// Genera el HTML del ticket de cocina (80mm aprox)
function buildKitchenTicketHTML(data) {
const apodo = String(data.mesa_apodo ?? '').trim();
const numero = data.mesa_numero ?? '';
const take = isTakeaway(apodo);
const mesaTxt = take ? apodo.toUpperCase() : `Mesa #${numero}${apodo ? ' · ' + apodo : ''}`;
// const isTakeaway = /Takeaway/i.test(String(data.mesa_apodo ?? '')) || /Takeaway/i.test(String(data.mesa_numero ?? ''));
const mesaClass = take ? 'bigline' : 'mesa-medium';
const obs = (data.observaciones && data.observaciones.trim()) ? data.observaciones.trim() : '';
// Productos ya vienen con su "g" (grupo numérico 1..4/99) y cantidad formateada
const items = data.productos.slice().sort((a,b)=> (a.g||99) - (b.g||99));
let productosHtml = '';
let prevG = null;
for (const p of items) {
if (prevG !== null && p.g !== prevG) {
productosHtml += `<div class="hr dotted"></div>`; // separación punteada entre grupos
}
productosHtml += `
<div class="row">
<div class="qty">x${p.cantidad}</div>
<div class="name">${p.nombre}</div>
</div>`;
prevG = p.g;
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Ticket Cocina</title>
<style>
:root {
--w: 80mm;
--fz-base: 16px;
--fz-md: 16px; /* observaciones */
--fz-item: 18px; /* filas */
--fz-xl: 26px; /* <--- NUEVO: tamaño “grande” (mesa) */
--fz-xxl: 34px; /* título (#comanda) */
--fz-sm: 12px;
}
html, body { margin:0; padding:0; }
body {
width: var(--w);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: var(--fz-base);
line-height: 1.35;
color:#000;
font-weight: 700;
}
#ticket { padding: 10px 8px; }
.center { text-align:center; }
.row { display:flex; gap:8px; margin: 4px 0; }
.row .qty { min-width: 22mm; font-size: var(--fz-item); letter-spacing:.2px; }
.row .name { flex:1; font-size: var(--fz-item); text-transform: uppercase; word-break: break-word; }
.hr { border-top: 2px dashed #000; margin: 8px 0; }
.hr.dotted { border-top: 2px dotted #000; }
.small { font-size: var(--fz-sm); }
.bigline { font-size: var(--fz-xxl); text-transform: uppercase; }
.mesa-medium { font-size: var(--fz-xl); text-transform: uppercase; }
.obs { font-size: var(--fz-md); }
.mt4{margin-top:4px}.mt8{margin-top:8px}.mb4{margin-bottom:4px}.mb8{margin-bottom:8px}
@page { size: var(--w) auto; margin: 0; }
@media print { body { width: var(--w); } }
</style>
</head>
<body>
<div id="ticket">
<!-- SIN TÍTULO -->
<div class="center bigline">#${data.id_comanda}</div>
<div class="center ${mesaClass}">${mesaTxt}</div>
<div class="small mt4">Fecha: ${data.fecha} ${data.hora}</div>
<div class="small mt4">Mozo: ${data.usuario || '—'}</div>
${obs ? `<div class="obs mt8">Obs: ${obs}</div>` : ''}
<div class="hr"></div>
${productosHtml}
<div class="hr"></div>
<div class="small">Ítems: ${data.items} · Unidades: ${data.units}</div>
<div class="center mt8 small">— fin —</div>
</div>
<script>window.onload = () => { window.focus(); window.print(); }<\/script>
</body>
</html>`;
}
// Imprime HTML usando un iframe oculto (menos bloqueos de pop-up)
function printHtmlViaIframe(html) {
return new Promise((resolve) => {
let iframe = document.getElementById('printFrame');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'printFrame';
iframe.style.position = 'fixed';
iframe.style.right = '-9999px';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
document.body.appendChild(iframe);
}
const doc = iframe.contentWindow.document;
doc.open();
doc.write(html);
doc.close();
// Salida: remover iframe después de un rato para no acumular
setTimeout(() => {
resolve();
// (si prefieres mantenerlo para reimpresiones, no lo quites)
// document.body.removeChild(iframe);
}, 1500);
});
}
// Utils
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'mt-2 small ok text-success' : 'mt-2 small err text-danger';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='mt-2 small text-muted'; }, 3500);
};
async function jget(url) {
const res = await fetch(url);
let data; try { data = await res.json(); } catch { data = null; }
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// Carga inicial
async function init() {
const [prods, mesas, usuarios, categorias] = await Promise.all([
jget('/api/table/productos?limit=1000'),
jget('/api/table/mesas?limit=1000'),
jget('/api/table/usuarios?limit=1000'),
jget('/api/table/categorias?limit=1000') // <--- NUEVO
]);
state.productos = prods.filter(p => p.activo !== false);
state.mesas = mesas;
state.usuarios = usuarios.filter(u => u.activo !== false);
state.categorias = Array.isArray(categorias) ? categorias : [];
// Mapas para buscar categoría por id de producto
state.catById = new Map(state.categorias.map(c => [c.id_categoria, (c.nombre||'').toString()]));
state.prodCatNameById = new Map(state.productos.map(p => [p.id_producto, state.catById.get(p.id_categoria)||'']));
hydrateMesas();
hydrateUsuarios();
renderProductos();
renderCarrito();
$('#busqueda').addEventListener('input', () => {
state.filtro = $('#busqueda').value.trim().toLowerCase();
renderProductos();
});
$('#limpiarBusqueda').addEventListener('click', () => {
$('#busqueda').value = '';
state.filtro = '';
renderProductos();
});
$('#vaciar').addEventListener('click', () => { state.carrito = []; renderCarrito(); });
$('#crear').addEventListener('click', crearComanda);
}
function hydrateMesas() {
const sel = $('#selMesa');
sel.innerHTML = '';
// Ordena por número de mesa (o por id si no hay número)
const rows = state.mesas
.slice()
.sort((a, b) => Number(a?.numero ?? a?.id_mesa ?? 0) - Number(b?.numero ?? b?.id_mesa ?? 0));
for (const m of rows) {
const o = document.createElement('option');
o.value = m.id_mesa;
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
sel.appendChild(o);
}
}
function hydrateUsuarios() {
const sel = $('#selUsuario');
sel.innerHTML = '';
// 🔽 Orden ascendente por id_usuario
const rows = state.usuarios
.slice()
.sort((a, b) => Number(a?.id_usuario ?? 0) - Number(b?.id_usuario ?? 0));
for (const u of rows) {
const o = document.createElement('option');
o.value = u.id_usuario;
o.textContent = `${u.nombre} ${u.apellido}`.trim();
sel.appendChild(o);
}
}
// Render productos
function renderProductos() {
let rows = state.productos.slice();
if (state.filtro) {
rows = rows.filter(p =>
(p.nombre || '').toLowerCase().includes(state.filtro) ||
String(p.id_categoria ?? '').includes(state.filtro)
);
}
$('#prodCount').textContent = `${rows.length} ítems`;
if (!rows.length) {
$('#listadoProductos').innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Precio</th>
<th style="width:210px;">Cantidad</th>
<th style="width:100px;"></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
for (const p of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.nombre}</td>
<td class="text-end">${money(p.precio)}</td>
<td>
<div class="d-flex align-items-center gap-2">
<input type="number" min="0.001" step="0.001" value="1.000" data-qty class="form-control form-control-sm" style="max-width:120px;">
<button class="btn btn-sm btn-outline-secondary" data-dec>-</button>
<button class="btn btn-sm btn-outline-secondary" data-inc>+</button>
</div>
</td>
<td><button class="btn btn-sm btn-primary" data-add>Agregar</button></td>
`;
const qty = tr.querySelector('[data-qty]');
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
tb.appendChild(tr);
}
$('#listadoProductos').innerHTML = '';
$('#listadoProductos').appendChild(tbl);
}
function addToCart(prod, cantidad) {
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
const precio = parseFloat(prod.precio);
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
renderCarrito();
}
// Render carrito
function renderCarrito() {
const wrap = $('#carritoWrap');
if (!state.carrito.length) {
wrap.innerHTML = '<div class="p-3 text-muted">Aún no agregaste productos.</div>';
$('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0);
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let items = 0, total = 0;
state.carrito.forEach((it, idx) => {
items += 1;
const sub = Number(it.pre_unitario) * Number(it.cantidad);
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${it.nombre}</td>
<td class="text-end">${money(it.pre_unitario)}</td>
<td class="text-end">
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" class="form-control form-control-sm text-end" style="max-width:120px;">
</td>
<td class="text-end">${money(sub)}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary" data-del>Quitar</button>
</td>
`;
const qty = tr.querySelector('input[type="number"]');
qty.addEventListener('change', () => {
const v = parseFloat(qty.value||'0');
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
it.cantidad = Number(v.toFixed(3));
renderCarrito();
});
tr.querySelector('[data-del]').addEventListener('click', () => {
state.carrito.splice(idx,1);
renderCarrito();
});
tb.appendChild(tr);
});
wrap.innerHTML = '';
wrap.appendChild(tbl);
$('#kpiItems').textContent = String(items);
$('#kpiTotal').textContent = money(total);
}
const fmtQty = (n) => Number(n).toFixed(3).replace(/\.?0+$/,'');
async function crearComanda() {
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
const id_mesa = parseInt($('#selMesa').value, 10);
const id_usuario = parseInt($('#selUsuario').value, 10);
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
// Snapshot del carrito ANTES de limpiar (para imprimir)
const cartSnapshot = state.carrito.map(it => ({ ...it }));
const observaciones = $('#obs').value.trim() || null;
try {
// 1) encabezado comanda
const { inserted: com } = await jpost('/api/table/comandas', {
id_usuario,
id_mesa,
estado: 'abierta',
observaciones
});
// 2) detalle
const id_comanda = com.id_comanda;
const payloads = cartSnapshot.map(it => ({
id_comanda,
id_producto: it.id_producto,
cantidad: it.cantidad,
pre_unitario: it.pre_unitario
}));
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
// 3) Datos auxiliares para ticket
const mesa = state.mesas.find(m => m.id_mesa === id_mesa) || {};
const usuario = state.usuarios.find(u => u.id_usuario === id_usuario) || {};
const now = new Date();
const fecha = now.toLocaleDateString();
const hora = now.toLocaleTimeString();
// 4) Construir e imprimir Ticket de Cocina (sin precios)
const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0);
const items = cartSnapshot.length;
// map producto -> nombre de categoría
const prodCat = state.prodCatNameById || new Map();
const productosParaTicket = cartSnapshot.map(it => ({
nombre: it.nombre,
cantidad: fmtQty(it.cantidad),
g: groupOrderByCatName(prodCat.get(it.id_producto) || '') // 1..4/99
}));
const ticketHtml = buildKitchenTicketHTML({
id_comanda,
fecha, hora,
mesa_numero: mesa?.numero,
mesa_apodo: mesa?.apodo,
usuario: `${usuario?.nombre || ''} ${usuario?.apellido || ''}`.trim(),
observaciones,
items,
units,
productos: productosParaTicket // <--- con grupos
});
await printHtmlViaIframe(ticketHtml);
// 5) Reset UI
state.carrito = [];
renderCarrito();
$('#obs').value = '';
toast(`Comanda #${id_comanda} creada e impresa`, true);
} catch (e) {
toast(e.message || 'No se pudo crear la comanda');
}
}
// // Crear comanda
// async function crearComanda() {
// if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
// const id_mesa = parseInt($('#selMesa').value, 10);
// const id_usuario = parseInt($('#selUsuario').value, 10);
// if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
// const observaciones = $('#obs').value.trim() || null;
// try {
// // 1) encabezado comanda
// const { inserted: com } = await jpost('/api/table/comandas', {
// id_usuario,
// id_mesa,
// estado: 'abierta',
// observaciones
// });
// // 2) detalle
// const id_comanda = com.id_comanda;
// const payloads = state.carrito.map(it => ({
// id_comanda,
// id_producto: it.id_producto,
// cantidad: it.cantidad,
// pre_unitario: it.pre_unitario
// }));
// await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
// state.carrito = [];
// renderCarrito();
// $('#obs').value = '';
// toast(`Comanda #${id_comanda} creada`, true);
// } catch (e) {
// toast(e.message || 'No se pudo crear la comanda');
// }
// }
// GO
init().catch(err => toast(err.message || 'Error cargando datos'));
</script>

View File

@ -0,0 +1,361 @@
<% /* Compras / Gastos */ %>
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-2">
<h3 class="mb-0">Compras / Gastos</h3>
<div class="ms-auto d-flex gap-2">
<button id="btnNueva" class="btn btn-outline-secondary btn-sm">Nueva</button>
<span id="status" class="small text-muted">—</span>
</div>
</div>
<!-- Formulario -->
<div class="card shadow-sm mb-3">
<div class="card-header"><strong id="formTitle">Nueva compra</strong></div>
<div class="card-body">
<form id="frmCompra" class="row g-3">
<input type="hidden" id="id_compra" value="">
<div class="col-12 col-md-5">
<label class="form-label">Proveedor</label>
<select id="id_proveedor" class="form-select" required></select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Fecha</label>
<input id="fec_compra" type="datetime-local" class="form-control" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Total</label>
<input id="total" type="text" class="form-control" value="$ 0" disabled>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Renglones</div>
<div>
<button type="button" id="addRow" class="btn btn-sm btn-outline-primary">Agregar renglón</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="tblDet">
<thead class="table-light">
<tr>
<th style="width:110px">Tipo</th>
<th>Ítem</th>
<th style="width:140px" class="text-end">Cantidad</th>
<th style="width:160px" class="text-end">Precio</th>
<th style="width:140px" class="text-end">Subtotal</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
<tr class="empty">
<td colspan="6" class="p-3 text-muted">Sin renglones</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button id="btnGuardar" type="submit" class="btn btn-success">Guardar</button>
<button id="btnEliminar" type="button" class="btn btn-outline-danger d-none">Eliminar</button>
</div>
</form>
</div>
</div>
<!-- Listado -->
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Compras recientes</strong>
<input id="buscar" class="form-control form-control-sm ms-auto" style="max-width:260px" placeholder="Buscar proveedor…">
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0" id="tblCompras">
<thead class="table-light">
<tr>
<th>#</th>
<th>Proveedor</th>
<th>Fecha</th>
<th class="text-end">Total</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<style>
#tblDet select, #tblDet input { min-height: 34px; }
.money { text-align: right; }
</style>
<script>
const $ = s => document.querySelector(s);
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
const z2 = n => String(n).padStart(2,'0');
const parseNum = v => (typeof v==='number') ? v : Number(String(v).replace(/[^\d.,-]/g,'').replace('.','').replace(',','.')) || 0;
function fmtMoneyInt(v, mode = 'round') {
const n = Number(v || 0);
const i = mode === 'trunc' ? Math.trunc(n) : mode === 'floor' ? Math.floor(n) : Math.round(n);
return '$ ' + i.toLocaleString('es-UY', { maximumFractionDigits: 0 });
}
const onlyDigits = s => String(s ?? '').replace(/\D+/g, '');
function wireIntInput(input, onChange) {
const sync = () => {
const n = Number(onlyDigits(input.value) || '0'); // entero
input.dataset.raw = String(n); // guardo valor crudo
input.value = n.toLocaleString('es-UY'); // muestro con miles
if (onChange) onChange(n);
};
input.addEventListener('input', () => setTimeout(sync, 0));
input.addEventListener('blur', sync);
// 1a sync
sync();
}
function getIntInput(input) {
const s = input?.dataset?.raw ?? onlyDigits(input?.value);
return Number(s || '0');
}
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
// Catálogos
let CATS = { prov:[], mat:[], prod:[] };
async function loadCatalogos(){
$('#status').textContent = 'Cargando catálogos…';
const [prov, mat, prod] = await Promise.all([
jget('/api/table/proveedores?limit=10000'),
jget('/api/table/mate_primas?limit=10000'),
jget('/api/table/productos?limit=10000')
]);
CATS.prov = prov||[]; CATS.mat = mat||[]; CATS.prod = prod||[];
const sel = $('#id_proveedor'); sel.innerHTML = '<option value="">—</option>' + CATS.prov.map(p=>`<option value="${p.id_proveedor}">${p.raz_social||p.nombre||('Prov#'+p.id_proveedor)}</option>`).join('');
$('#status').textContent = 'Listo';
}
// Renglón
function addRow(data){
const tb = $('#tblDet tbody');
tb.querySelector('.empty')?.remove();
const tr = document.createElement('tr');
const tipo = data?.tipo || 'MAT'; // MAT | PROD
const id = data?.id || '';
const cant = data?.cantidad != null ? data.cantidad : 1;
const pu = data?.precio != null ? data.precio : 0;
tr.innerHTML = `
<td>
<select class="form-select form-select-sm tipo">
<option value="MAT"${tipo==='MAT'?' selected':''}>Materia</option>
<option value="PROD"${tipo==='PROD'?' selected':''}>Producto</option>
</select>
</td>
<td>
<select class="form-select form-select-sm item"></select>
</td>
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
class="form-control form-control-sm text-end qty" value="${cant}"></td>
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
class="form-control form-control-sm text-end price" value="${pu}"></td>
<td class="text-end sub">$ 0</td>
<td><button type="button" class="btn btn-sm btn-outline-danger del">✕</button></td>
`;
tb.appendChild(tr);
// load items segun tipo
function fillItems(selTipo, selItem, selectedId){
const list = selTipo.value === 'MAT' ? CATS.mat : CATS.prod;
selItem.innerHTML = '<option value="">—</option>' + list.map(i => {
const id = selTipo.value === 'MAT' ? i.id_mat_prima : i.id_producto;
const nm = i.nombre || ('#'+id);
return `<option value="${id}">${nm}</option>`;
}).join('');
if (selectedId) selItem.value = selectedId;
}
const selTipo = tr.querySelector('.tipo');
const selItem = tr.querySelector('.item');
const qty = tr.querySelector('.qty');
const price = tr.querySelector('.price');
const subCell = tr.querySelector('.sub');
selTipo.addEventListener('change', ()=>{ fillItems(selTipo, selItem, null); updateRow(); });
[selItem, qty, price].forEach(el => el.addEventListener('input', updateRow));
tr.querySelector('.del').addEventListener('click', ()=>{ tr.remove(); recalcTotal(); if (!tb.children.length) tb.innerHTML='<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>'; });
fillItems(selTipo, selItem, id);
function updateRow(){
const s = getIntInput(qty) * getIntInput(price);
subCell.textContent = fmtMoneyInt(s);
recalcTotal();
}
wireIntInput(qty, updateRow);
wireIntInput(price, updateRow);
updateRow();
}
function recalcTotal(){
let tot = 0;
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
if (tr.classList.contains('empty')) return;
const q = getIntInput(tr.querySelector('.qty'));
const p = getIntInput(tr.querySelector('.price'));
tot += q * p;
});
$('#total').value = fmtMoneyInt(tot);
return tot;
}
function readFormToPayload(){
const id_compra = $('#id_compra').value ? Number($('#id_compra').value) : null;
const id_proveedor = Number($('#id_proveedor').value || 0);
const fec_compra = $('#fec_compra').value
? new Date($('#fec_compra').value).toISOString().slice(0,19).replace('T',' ')
: null;
const det = [];
// 👇 OJO: iteramos sobre TODAS las filas reales
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
if (tr.classList.contains('empty')) return;
const tipo = tr.querySelector('.tipo').value; // 'MAT' | 'PROD'
const id = Number(tr.querySelector('.item').value||0);
const qty = getIntInput(tr.querySelector('.qty')); // entero
const pu = getIntInput(tr.querySelector('.price')); // entero
if (id && qty>0 && pu>=0) det.push({ tipo, id, cantidad: qty, precio: pu });
});
return { id_compra, id_proveedor, fec_compra, detalles: det };
}
// Guardar / Eliminar
async function saveCompra(){
const payload = readFormToPayload();
if (!payload.id_proveedor) { alert('Seleccioná un proveedor.'); return; }
if (!payload.fec_compra) { alert('Indicá la fecha.'); return; }
if (!payload.detalles.length){ alert('Agregá al menos un renglón.'); return; }
$('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…';
try{
const res = await jpost('/api/v1/rpc/save_compra', payload);
$('#id_compra').value = res.id_compra;
$('#btnEliminar').classList.remove('d-none');
$('#formTitle').textContent = 'Editar compra #' + res.id_compra;
await loadListado();
alert('Compra guardada.');
}catch(e){
alert('Error al guardar: ' + e.message);
}finally{
$('#btnGuardar').disabled = false; $('#status').textContent = 'Listo';
}
}
async function deleteCompra(){
const id = Number($('#id_compra').value||0);
if (!id) return;
if (!confirm('¿Eliminar compra #' + id + '?')) return;
$('#btnEliminar').disabled = true;
try{
await jpost('/api/v1/rpc/delete_compra', { id_compra: id });
nuevaCompra();
await loadListado();
}catch(e){
alert('Error al eliminar: '+e.message);
}finally{
$('#btnEliminar').disabled = false;
}
}
function nuevaCompra(){
$('#formTitle').textContent = 'Nueva compra';
$('#id_compra').value = '';
$('#id_proveedor').value = '';
$('#fec_compra').value = new Date().toISOString().slice(0,16);
$('#total').value = '$ 0';
$('#btnEliminar').classList.add('d-none');
const tb = $('#tblDet tbody'); tb.innerHTML = '<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>';
}
async function cargarCompra(id){
$('#status').textContent = 'Cargando compra…';
try{
const data = await jpost('/api/v1/rpc/get_compra', { id_compra: id });
$('#id_compra').value = data.id_compra;
$('#id_proveedor').value = data.id_proveedor;
$('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16);
const tb = $('#tblDet tbody'); tb.innerHTML='';
(data.detalles||[]).forEach(d => addRow(d));
recalcTotal();
$('#btnEliminar').classList.remove('d-none');
$('#formTitle').textContent = 'Editar compra #' + id;
} catch(e){
alert('No se pudo cargar: ' + e.message);
} finally {
$('#status').textContent = 'Listo';
}
}
// Listado
async function loadListado(){
// Recomendado: vista vw_compras (más abajo)
const rows = await jget('/api/table/vw_compras?limit=200&order_by=fec_compra%20desc');
const tb = $('#tblCompras tbody');
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.id_compra}</td>
<td>${r.proveedor}</td>
<td>${(r.fec_compra||'').replace('T',' ').slice(0,16)}</td>
<td class="text-end">${fmtMoneyInt(r.total)}</td>
<td class="text-end"><button class="btn btn-sm btn-outline-primary ver" data-id="${r.id_compra}">Ver/Editar</button></td>`;
tb.appendChild(tr);
});
$('#buscar').addEventListener('input', (e)=>{
const q = e.target.value.toLowerCase();
tb.querySelectorAll('tr').forEach(tr=>{
const prov = tr.children[1]?.textContent.toLowerCase() || '';
tr.style.display = prov.includes(q) ? '' : 'none';
});
});
tb.addEventListener('click', (ev)=>{
const btn = ev.target.closest('button.ver');
if (!btn) return;
const id = Number(btn.dataset.id);
cargarCompra(id);
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// Eventos
document.getElementById('addRow').addEventListener('click', ()=> addRow());
document.getElementById('frmCompra').addEventListener('submit', (ev)=>{ ev.preventDefault(); saveCompra(); });
document.getElementById('btnEliminar').addEventListener('click', deleteCompra);
document.getElementById('btnNueva').addEventListener('click', nuevaCompra);
// Init
(async function init(){
await loadCatalogos();
nuevaCompra();
await loadListado();
})();
</script>

View File

@ -0,0 +1,487 @@
<!-- views/dashboard.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">Dashboard Operativo</h1>
<div class="d-flex align-items-center gap-2">
<button id="dashRefresh" class="btn btn-outline-secondary btn-sm">Recargar</button>
<span id="dashStatus" class="text-muted small"></span>
</div>
</div>
<!-- KPIs -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Comandas activas</div>
<div class="h3 m-0" id="kpiActivas">—</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Ventas hoy</div>
<div class="h3 m-0"><span id="kpiVentasHoy">—</span></div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Ticket promedio (hoy)</div>
<div class="h3 m-0"><span id="kpiTicketProm">—</span></div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Productos distintos (hoy)</div>
<div class="h3 m-0" id="kpiProdDist">—</div>
</div>
</div>
</div>
</div>
<!-- Gráficos -->
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Top 5 productos (hoy)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartTopProductos"></canvas>
</div>
<div class="text-muted small mt-2">Basado en detalle de comandas de hoy.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Comandas por hora (últimas 12 h)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartComandasHora"></canvas>
</div>
<div class="text-muted small mt-2">Se agrupa por hora de creación.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Estados de comandas (hoy)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartEstados"></canvas>
</div>
<div class="text-muted small mt-2">Distribución por estado.</div>
</div>
</div>
</div>
<!-- Últimas comandas -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Últimas 10 comandas</strong>
<div class="ms-auto text-muted small" id="ultAct">—</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Fecha</th>
<th>Cierre</th> <!-- NUEVO -->
<th>Estado</th>
<th class="text-end">Total</th>
<th>Acción</th> <!-- NUEVO -->
</tr>
</thead>
<tbody id="ultimasTbody">
<tr><td colspan="6" class="text-muted p-3">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer text-muted small">
Totales calculados como Σ (pre_unitario × cantidad) por comanda.
</div>
</div>
</div>
</div>
<!-- Librería para gráficos -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// ===== Utilidades =====
const $ = (s, r=document)=>r.querySelector(s);
const fmtMoney = (n)=> isNaN(n) ? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(+n);
const fmtTs = (s)=> { const d = new Date(s); return isNaN(d) ? '—' : d.toLocaleString('es-UY'); };
const setStatus = (t)=> $('#dashStatus').textContent = t || '';
const todayBounds = ()=> {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = new Date(start); end.setDate(end.getDate()+1);
return {start, end};
};
const guessKey = (obj, candidates)=> (candidates.find(k => k in obj) || null);
const toDate = (v)=> (v instanceof Date ? v : new Date(v));
const inRange = (d, a, b)=> (d>=a && d<b);
// ===== Estado =====
let charts = {};
const state = {
comandas: [],
deta: [],
productos: [],
keys: {
comFecha: null, comFechaCierre: null, comEstado: null, comId: null, // <-- agregado comFechaCierre
detIdCom: null, detPrecio: null, detCant: null,
prodId: null, prodNombre: null
}
};
// ===== Carga =====
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function loadData() {
setStatus('Cargando datos…');
const [comandas, deta, productos] = await Promise.all([
jget('/api/table/comandas?limit=2000').catch(()=>[]),
jget('/api/table/deta_comandas?limit=5000').catch(()=>[]),
jget('/api/table/productos?limit=5000').catch(()=>[])
]);
state.comandas = Array.isArray(comandas)? comandas : [];
state.deta = Array.isArray(deta)? deta : [];
state.productos= Array.isArray(productos)? productos : [];
// Descubrir claves
const c0 = state.comandas[0] || {};
// incluimos fec_creacion y fec_cierre como prioridades
state.keys.comFecha = guessKey(c0, ['fec_creacion','fecha','created_at','creado_en','ts','timestamp','hora','datetime']);
state.keys.comFechaCierre = guessKey(c0, ['fec_cierre','cierre','closed_at','fecha_cierre','ts_cierre','hora_cierre']);
state.keys.comEstado = guessKey(c0, ['estado','status']);
state.keys.comId = guessKey(c0, ['id_comanda','id','comanda_id']);
const d0 = state.deta[0] || {};
state.keys.detIdCom = guessKey(d0, ['id_comanda','comanda_id']);
state.keys.detPrecio = guessKey(d0, ['pre_unitario','precio_unitario','precio','unit_price']);
state.keys.detCant = guessKey(d0, ['cantidad','qty','cantidad_total']);
const p0 = state.productos[0] || {};
state.keys.prodId = guessKey(p0, ['id_producto','id','producto_id']);
state.keys.prodNombre = guessKey(p0, ['nombre','descripcion','titulo','name']);
renderAll();
setStatus('');
}
// ===== Cálculos =====
function isActiva(estadoRaw){
const s = String(estadoRaw||'').toLowerCase();
return ['abierta','activa','activo','open','pendiente','en curso'].some(x => s.includes(x));
}
function isAnulada(estadoRaw){
const s = String(estadoRaw||'').toLowerCase();
return ['anulada','anulado','cancelada','cancelado','void'].some(x => s.includes(x));
}
function computeKpis(){
const {comFecha, comEstado, comId} = state.keys;
const {detIdCom, detPrecio, detCant} = state.keys;
const {start, end} = todayBounds();
// activas
const activas = state.comandas.filter(c => comEstado && isActiva(c[comEstado])).length;
$('#kpiActivas').textContent = activas;
// ventas hoy
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
let totalHoy = 0, ticketsHoy = 0;
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
const total = dets.reduce((acc,d)=> acc + Number(d[detPrecio]||0) * Number(d[detCant]||0), 0);
if (total>0) { totalHoy += total; ticketsHoy++; }
}
$('#kpiVentasHoy').textContent = fmtMoney(totalHoy);
$('#kpiTicketProm').textContent = ticketsHoy ? fmtMoney(totalHoy / ticketsHoy) : '—';
// productos distintos hoy
const setProd = new Set();
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
dets.forEach(d => setProd.add(d.id_producto ?? d.producto_id ?? d[state.keys.prodId]));
}
$('#kpiProdDist').textContent = setProd.size || '0';
}
function computeTopProductosHoy(){
const {comFecha, comEstado, comId} = state.keys;
const {detIdCom, detCant, detPrecio} = state.keys;
const {prodId, prodNombre} = state.keys;
const {start, end} = todayBounds();
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
const qtyByProd = new Map(); // id -> cantidad total
const amtByProd = new Map(); // id -> importe total
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
dets.forEach(d => {
const pid = d.id_producto ?? d.producto_id ?? d[prodId];
if (pid==null) return;
const q = Number(d[detCant]||0);
const a = Number(d[detPrecio]||0) * q;
qtyByProd.set(pid, (qtyByProd.get(pid)||0)+q);
amtByProd.set(pid, (amtByProd.get(pid)||0)+a);
});
}
// id -> label
const nameById = new Map(state.productos.map(p => [p[prodId], p[prodNombre] || ('#'+p[prodId])]));
// ordenar por cantidad
const arr = [...qtyByProd.entries()]
.map(([id,qty]) => ({ id, qty, amt: amtByProd.get(id)||0, name: nameById.get(id)||('#'+id) }))
.sort((a,b)=> b.qty - a.qty)
.slice(0,5);
return arr;
}
function computeComandasPorHora12h(){
const {comFecha} = state.keys;
const now = new Date();
const buckets = [];
for (let i=11;i>=0;i--){
const h = new Date(now); h.setHours(now.getHours()-i, 0, 0, 0);
buckets.push({ label: h.getHours().toString().padStart(2,'0')+':00', ts: +h, count: 0 });
}
if (!comFecha) return buckets;
state.comandas.forEach(c => {
const d = toDate(c[comFecha]); if (isNaN(d)) return;
const diffH = Math.floor((now - d) / (60*60*1000));
if (diffH<12 && diffH>=0) {
// bucket por hora exacta
const hour = new Date(d); hour.setMinutes(0,0,0);
const idx = buckets.findIndex(b => b.ts === +hour);
if (idx>=0) buckets[idx].count++;
}
});
return buckets;
}
function computeEstadosHoy(){
const {comFecha, comEstado} = state.keys;
const {start, end} = todayBounds();
const map = new Map();
state.comandas.forEach(c=>{
const when = comFecha ? toDate(c[comFecha]) : null;
if (!when || !inRange(when, start, end)) return;
const st = (c[comEstado] ?? '—').toString().toLowerCase();
map.set(st, (map.get(st)||0)+1);
});
return [...map.entries()].map(([estado,count])=>({estado, count}));
}
// ===== Render =====
function renderAll(){
computeKpis();
// Top productos
const top = computeTopProductosHoy();
drawBar('chartTopProductos', top.map(x=>x.name), top.map(x=>x.qty));
// Comandas por hora
const porHora = computeComandasPorHora12h();
drawLine('chartComandasHora', porHora.map(x=>x.label), porHora.map(x=>x.count));
// Estados
const estados = computeEstadosHoy();
drawDoughnut('chartEstados', estados.map(x=>x.estado), estados.map(x=>x.count));
// Últimas 10
renderUltimas();
}
function renderUltimas(){
const {comFecha, comFechaCierre, comEstado, comId} = state.keys;
const {detIdCom, detPrecio, detCant} = state.keys;
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
const rows = state.comandas
.slice()
.sort((a,b)=> {
const da = comFecha ? +new Date(a[comFecha]) : 0;
const db = comFecha ? +new Date(b[comFecha]) : 0;
return db - da;
})
.slice(0,10);
const tb = $('#ultimasTbody'); tb.innerHTML = '';
let lastTs = null;
rows.forEach(c=>{
const dets = byCom.get(c[comId]) || [];
const total = dets.reduce((acc,d)=> acc + Number(d[detPrecio]||0) * Number(d[detCant]||0), 0);
const ts = comFecha ? new Date(c[comFecha]) : null;
const tsc = comFechaCierre ? new Date(c[comFechaCierre]) : null;
if (ts) lastTs = (!lastTs || ts>lastTs) ? ts : lastTs;
const activa = isActiva(c[comEstado]);
const btn = activa
? `<button class="btn btn-outline-danger btn-sm js-cerrar" data-id="${c[comId]}">Cerrar</button>`
: `<button class="btn btn-outline-success btn-sm js-abrir" data-id="${c[comId]}">Abrir</button>`;
const tr = document.createElement('tr');
tr.dataset.id = c[comId];
tr.innerHTML = `
<td>${c[comId] ?? '—'}</td>
<td>${ts ? fmtTs(ts) : '—'}</td>
<td class="c-cierre">${tsc && !isNaN(tsc) ? fmtTs(tsc) : '—'}</td>
<td class="c-estado">${c[comEstado] ?? '—'}</td>
<td class="text-end">${fmtMoney(total)}</td>
<td class="c-accion">${btn}</td>
`;
tb.appendChild(tr);
});
$('#ultAct').textContent = lastTs ? ('Actualizado: ' + fmtTs(lastTs)) : '—';
}
// ===== Charts helpers =====
function destroyChart(id){ if (charts[id]) { charts[id].destroy(); charts[id]=null; } }
function drawBar(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: 'Cantidad', data }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}} }
});
}
function drawLine(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'line',
data: { labels, datasets: [{ label: 'Comandas', data, tension:.3, fill:false }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}} }
});
}
function drawDoughnut(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'doughnut',
data: { labels, datasets: [{ data }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom'}} }
});
}
// ===== Eventos =====
$('#dashRefresh').addEventListener('click', loadData);
window.addEventListener('sc:refresh-list', loadData); // desde el sidebar "Actualizar listado"
// Abrir/Cerrar comanda (actualiza fila + estado interno + re-render KPIs/gráficos)
document.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.js-cerrar, .js-abrir');
if (!btn) return;
const id = btn.dataset.id;
const isCerrar = btn.classList.contains('js-cerrar');
const url = isCerrar ? `/api/comandas/${id}/cerrar` : `/api/comandas/${id}/abrir`;
btn.disabled = true;
try {
const r = await fetch(url, { method: 'POST' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
// Actualizar estado local
const { comId, comEstado, comFechaCierre } = state.keys;
const idx = state.comandas.findIndex(c => String(c[comId]) === String(id));
if (idx >= 0) {
state.comandas[idx][comEstado] = data.estado ?? state.comandas[idx][comEstado];
if (comFechaCierre) state.comandas[idx][comFechaCierre] = data.fec_cierre ?? state.comandas[idx][comFechaCierre];
}
// Actualizar fila visual
const tr = document.querySelector(`tr[data-id="${id}"]`);
if (tr) {
const tdEstado = tr.querySelector('.c-estado');
const tdCierre = tr.querySelector('.c-cierre');
if (tdEstado) tdEstado.textContent = data.estado ?? tdEstado.textContent;
if (tdCierre) tdCierre.textContent = data.fec_cierre ? fmtTs(data.fec_cierre) : '—';
const acc = tr.querySelector('.c-accion');
if (acc) {
acc.innerHTML = (data.estado && data.estado.toLowerCase().includes('cerr'))
? `<button class="btn btn-outline-success btn-sm js-abrir" data-id="${id}">Abrir</button>`
: `<button class="btn btn-outline-danger btn-sm js-cerrar" data-id="${id}">Cerrar</button>`;
}
}
// Recalcular KPIs y gráficos (sin “crecimiento infinito”, se destruyen antes de redibujar)
renderAll();
} catch (e) {
alert('No se pudo actualizar la comanda: ' + (e.message || 'Error'));
} finally {
btn.disabled = false;
}
});
// Go!
loadData().catch(e => setStatus(e.message || 'Error'));
// Exporta CSV con KPIs y cortes básicos
window.scExportCsv = function () {
const rows = [];
rows.push(["kpi", "valor"]);
rows.push(["comandas_activas", document.getElementById("kpiActivas").textContent.trim()]);
rows.push(["ventas_hoy", document.getElementById("kpiVentasHoy").textContent.trim()]);
rows.push(["ticket_promedio_hoy", document.getElementById("kpiTicketProm").textContent.trim()]);
rows.push(["productos_distintos_hoy", document.getElementById("kpiProdDist").textContent.trim()]);
const csv = rows.map(r => r.map(v => `"${String(v).replaceAll('"','""')}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dashboard_${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
</script>

View File

@ -0,0 +1,532 @@
<!-- services/manso/src/views/estadoComandas.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">🧾 Estado de Comandas</h1>
<a class="btn btn-sm btn-dark" href="/comandas"> Nueva comanda</a>
</div>
<div class="row g-3">
<!-- ===== Listado (izquierda) ===== -->
<div class="col-12 col-lg-7">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Listado</strong>
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" id="soloAbiertas" checked>
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
</div>
</div>
<div class="card-body">
<div class="row g-2 align-items-center mb-2">
<div class="col">
<input id="buscar" type="search" class="form-control" placeholder="Buscar por #, mesa o usuario…">
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary" id="limpiar">Limpiar</button>
</div>
</div>
<div id="lista" class="table-responsive" style="max-height:62vh; overflow:auto;">
<div class="p-3 text-muted">Cargando…</div>
</div>
</div>
</div>
</div>
<!-- ===== Detalle (derecha) ===== -->
<div class="col-12 col-lg-5">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Detalle</strong>
<span id="detalleEstado" class="badge badge-outline ms-auto">—</span>
</div>
<div class="card-body" id="detalle">
<div class="text-muted">Selecciona una comanda para ver el detalle.</div>
</div>
<div class="d-flex align-items-center gap-3 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
<div class="small"><span class="text-muted">ID:</span> <strong id="kpiId">—</strong></div>
<div class="small"><span class="text-muted">Mesa:</span> <strong id="kpiMesa">—</strong></div>
<div class="small"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
<div class="ms-auto"></div>
<button class="btn btn-outline-secondary" id="reabrir">Reabrir</button>
<button class="btn btn-primary" id="cerrar">Cerrar</button>
</div>
<div class="card-body">
<div id="msg" class="text-muted small"></div>
</div>
</div>
</div>
</div>
<script>
// ===== Helpers =====
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'text-success small' : 'text-danger small';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
};
const badgeClass = (estadoRaw) => {
const s = String(estadoRaw||'').toLowerCase();
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
return 'badge badge-outline';
};
async function jget(url){
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body){
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body ?? {}) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ===== Estado =====
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
// ===== Data =====
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/v1/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/v1/comandas?limit=300';
const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : [];
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/v1/comandas/${id}/detalle`);
state.detalle = Array.isArray(det) ? det : [];
renderDetalle();
}
// ===== UI: Lista =====
function renderLista(){
let rows = state.lista.slice();
const f = state.filtro?.trim().toLowerCase();
if (f) {
rows = rows.filter(r =>
String(r.id_comanda).includes(f) ||
String(r.mesa_numero ?? '').includes(f) ||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="text-end">Ítems</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
<td class="text-end">${r.items ?? '—'}</td>
<td class="text-end">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ===== UI: Detalle + KPIs =====
function applyHeader(r){
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = badgeClass(r.estado);
$('#detalleEstado').textContent = r.estado ?? '—';
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones (más precisos según estado)
const cerr = $('#cerrar'), reab = $('#reabrir');
const s = String(r.estado||'').toLowerCase();
if (s.includes('abier')) {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else if (s.includes('cerr')) {
cerr.disabled = true; cerr.title = 'Ya está cerrada';
reab.disabled = false; reab.title = '';
} else {
// Otros estados: permitir ambas acciones
cerr.disabled = false; cerr.title = '';
reab.disabled = false; reab.title = '';
}
}
function renderDetalle(){
const box = $('#detalle');
if (!state.detalle.length) {
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre ?? '—'}</td>
<td class="text-end">${money(r.pre_unitario)}</td>
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
<td class="text-end">${money(sub)}</td>
<td>${r.observaciones || ''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ===== Actions (usa /abrir y /cerrar) =====
async function accionComanda(accion){ // 'abrir' | 'cerrar'
if (!state.sel) return;
try {
await jpost(`/api/v1/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
// Recargar lista y re-aplicar cabecera/detalle si sigue visible
const id = state.sel;
await loadLista();
const found = state.lista.find(x => x.id_comanda === id);
if (found) {
applyHeader(found);
await loadDetalle(found.id_comanda);
} else {
// Puede desaparecer del listado si está activado "Solo abiertas" y la cerramos
state.sel = null;
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo actualizar la comanda');
}
}
// ===== Hooks con Sidebar (offcanvas) =====
window.scRefreshList = loadLista;
window.scExportCsv = function(){
const rows = state.lista.slice();
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
const csv = [header.join(",")].concat(rows.map(r => {
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
const vals = [
r.id_comanda,
r.mesa_numero ?? '',
(r.mesa_apodo ?? '').replaceAll('"','""'),
usuario.replaceAll('"','""'),
r.fec_creacion ?? '',
r.estado ?? '',
r.items ?? '',
r.total ?? ''
];
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
})).join("\n");
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
};
// ===== Init =====
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
// Ahora los botones llaman a los nuevos endpoints
$('#cerrar').addEventListener('click', () => accionComanda('cerrar'));
$('#reabrir').addEventListener('click', () => accionComanda('abrir'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script>
<!-- <script>
// ===== Helpers =====
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'text-success small' : 'text-danger small';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
};
const badgeClass = (estadoRaw) => {
const s = String(estadoRaw||'').toLowerCase();
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
return 'badge badge-outline';
};
async function jget(url){
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body){
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ===== Estado =====
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
// ===== Data =====
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/v1/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/v1/comandas?limit=300';
const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : [];
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/v1/comandas/${id}/detalle`);
state.detalle = Array.isArray(det) ? det : [];
renderDetalle();
}
// ===== UI: Lista =====
function renderLista(){
let rows = state.lista.slice();
const f = state.filtro?.trim().toLowerCase();
if (f) {
rows = rows.filter(r =>
String(r.id_comanda).includes(f) ||
String(r.mesa_numero ?? '').includes(f) ||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="text-end">Ítems</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
<td class="text-end">${r.items ?? '—'}</td>
<td class="text-end">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ===== UI: Detalle + KPIs =====
function applyHeader(r){
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = badgeClass(r.estado);
$('#detalleEstado').textContent = r.estado ?? '—';
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones
const cerr = $('#cerrar'), reab = $('#reabrir');
if ((r.estado||'').toLowerCase().includes('abier')) {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else {
cerr.disabled = false;
reab.disabled = false;
cerr.title = ''; reab.title = '';
}
}
function renderDetalle(){
const box = $('#detalle');
if (!state.detalle.length) {
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre ?? '—'}</td>
<td class="text-end">${money(r.pre_unitario)}</td>
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
<td class="text-end">${money(sub)}</td>
<td>${r.observaciones || ''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ===== Actions =====
async function setEstado(estado){
if (!state.sel) return;
try {
const { updated } = await jpost(`/api/v1/comandas/${state.sel}/estado`, { estado });
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
await loadLista();
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
else {
state.sel = null;
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo cambiar el estado');
}
}
// ===== Hooks con Sidebar (offcanvas) =====
// Permite que el botón "Actualizar" del sidebar recargue este listado
window.scRefreshList = loadLista;
// Exportación simple del listado actual
window.scExportCsv = function(){
const rows = state.lista.slice();
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
const csv = [header.join(",")].concat(rows.map(r => {
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
const vals = [
r.id_comanda,
r.mesa_numero ?? '',
(r.mesa_apodo ?? '').replaceAll('"','""'),
usuario.replaceAll('"','""'),
r.fec_creacion ?? '',
r.estado ?? '',
r.items ?? '',
r.total ?? ''
];
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
})).join("\n");
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
};
// ===== Init =====
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script> -->

View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="es">
<head>
<%- include('../partials/_head') %>
</head>
<body data-page="<%= pageId %>">
<%- include('../partials/_navbar') %>
<main class="container">
<%- body %>
</main>
<%- include('../partials/_sidebar') %>
<%- include('../partials/_footer') %>
</body>
</html>

View File

@ -0,0 +1,42 @@
<!-- /partials/_footer.html -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
/**
* (Solo si usas HTML estático)
* Carga “partials” desde elementos con [data-include="/partials/..."].
* Si usas EJS/templating, podés quitar esto.
*/
async function scLoadPartials(){
const includes = document.querySelectorAll("[data-include]");
for (const el of includes) {
const url = el.getAttribute("data-include");
try {
const res = await fetch(url, {cache:"no-store"});
el.innerHTML = await res.text();
} catch (e) {
el.innerHTML = `<div class="text-danger small">No se pudo cargar ${url}</div>`;
}
}
}
// Export util por si querés llamarlo manualmente
window.scLoadPartials = scLoadPartials;
// Eventos genéricos que el sidebar dispara (ajustá a tu lógica real)
window.addEventListener("sc:toggle-abiertas", () => {
// Ej.: togglear checkbox/estado en páginas que lo usen
const chk = document.getElementById("soloAbiertas");
if (chk) { chk.checked = !chk.checked; chk.dispatchEvent(new Event("change")); }
});
window.addEventListener("sc:export-csv", () => {
// Implementá tu export acá
if (window.scExportCsv) return window.scExportCsv();
alert("Exportar CSV: implementame 😄");
});
window.addEventListener("sc:refresh-list", () => {
if (window.scRefreshList) return window.scRefreshList();
location.reload();
});
</script>

View File

@ -0,0 +1,45 @@
<!-- /partials/_head.html -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= typeof pageTitle !== "undefined" ? pageTitle : "SuiteCoffee" %></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="icon" href="/favicon/favicon.ico" sizes="any">
<link rel="icon" href="/favicon/favicon-16x16.png" sizes="16x16">
<link rel="icon" href="/favicon/favicon-32x32.png" sizes="32x32">
<link rel="icon" href="/favicon/apple-touch-icon.png" sizes="180x180">
<link rel="icon" href="/favicon/android-chrome-512x512.png" sizes="512x512">
<link rel="icon" href="/favicon/android-chrome-192x192.png" sizes="192x192">
<link rel="manifest" href="/favicon/site.webmanifest">
<style>
:root { --navbar-h: 56px; }
body { padding-top: var(--navbar-h); background: #f7f8fb; }
.brand-mini { font-weight: 700; letter-spacing: .2px; }
/* Layout contenedor principal */
main { padding-block: 1rem 2rem; }
/* Tabla compacta */
.table-sm th, .table-sm td { padding: .5rem .6rem; }
/* Chips/etiquetas de estado */
.badge-outline { border: 1px solid #dee2e6; background: #fff; color: #495057; }
.badge-estado-abierta { border-color:#198754; color:#198754; }
.badge-estado-cerrada { border-color:#6c757d; color:#6c757d; }
.badge-estado-anulada { border-color:#dc3545; color:#dc3545; }
.badge-estado-pagada { border-color:#146c43; color:#146c43; }
/* Evita crecimiento infinito de los charts */
.chart-box {
position: relative;
height: 260px; /* altura fija base */
}
@media (min-width: 992px) {
.chart-box { height: 320px; } /* un poquito más grande en desktop */
}
.chart-box > canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important; /* ocupa todo el alto del contenedor */
}
</style>

View File

@ -0,0 +1,31 @@
<!-- /partials/_navbar.html -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom fixed-top">
<div class="container-fluid">
<a class="navbar-brand brand-mini" href="/">SuiteCoffee</a>
<!-- Links principales (colapsables en mobile) -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#scNav" aria-controls="scNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <!-- hamburguesa principal -->
</button>
<div class="collapse navbar-collapse" id="scNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0 small">
<li class="nav-item"><a class="nav-link" href="/comandas">Comandas</a></li>
<li class="nav-item"><a class="nav-link" href="/estadoComandas">Estado</a></li>
<li class="nav-item"><a class="nav-link" href="/productos">Productos</a></li>
<li class="nav-item"><a class="nav-link" href="/usuarios">Usuarios</a></li>
<li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li>
<li class="nav-item"><a class="nav-link" href="/compras">Compras</a></li>
<!-- <li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li> -->
<!-- agrega las que necesites -->
</ul>
<!-- Botón “hamburguesa” para abrir el menú contextual (sidebar derecha) -->
<button class="btn btn-outline-secondary btn-sm d-flex align-items-center" type="button"
data-bs-toggle="offcanvas" data-bs-target="#scSidebar" aria-controls="scSidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" class="me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
Opciones
</button>
</div>
</div>
</nav>

View File

@ -0,0 +1,72 @@
<!-- /partials/_sidebar.html -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="scSidebar" aria-labelledby="scSidebarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="scSidebarLabel">Opciones</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Cerrar"></button>
</div>
<div class="offcanvas-body">
<!-- Contenido se inyecta según la página actual -->
<div id="scSidebarContent" class="list-group list-group-flush small"></div>
</div>
</div>
<script>
// Map de opciones por página. Usa body[data-page] o window.scPageId.
const SC_SIDEBAR_ITEMS = {
// === ejemplos ===
"dashboard": [
{ text: "Ver reportes", href: "/reportes" },
{ text: "Actualizar", href: "#", attr: { "data-action": "refresh-list" } },
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
{ text: "Nueva comanda", href: "/comandas" },
{ text: "Ir a Estado", href: "/estadoComandas" }
],
"estadoComandas": [
{ text: " Nueva comanda", href: "/comandas" },
{ text: "Solo abiertas", href: "#", attr: { "data-action": "toggle-abiertas" } },
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
{ text: "Actualizar listado", href: "#", attr: { "data-action": "refresh-list" } },
],
"comandas": [
{ text: "Volver a Estado", href: "/estadoComandas" },
{ text: "Cargar productos", href: "/productos" },
{ text: "Mesas", href: "/mesas" },
],
"productos": [
{ text: "Nuevo producto", href: "/productos/nuevo" },
{ text: "Importar catálogo", href: "/productos/importar" },
{ text: "Reportes", href: "/reportes" },
],
"usuarios": [
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
]
};
(function initSidebar(){
const page = (document.body.dataset.page || window.scPageId || "").trim();
const items = SC_SIDEBAR_ITEMS[page] || [
{ text: "Inicio", href: "/" }
];
const box = document.getElementById("scSidebarContent");
box.innerHTML = "";
for (const it of items) {
const a = document.createElement("a");
a.className = "list-group-item list-group-item-action";
a.textContent = it.text;
a.href = it.href || "#";
if (it.attr) for (const [k,v] of Object.entries(it.attr)) a.setAttribute(k,v);
box.appendChild(a);
}
// Acciones ejemplo (opcionales). Adaptá a tus funciones reales.
box.addEventListener("click", (ev) => {
const a = ev.target.closest("a[data-action]");
if (!a) return;
ev.preventDefault();
const action = a.getAttribute("data-action");
if (action === "toggle-abiertas") window.dispatchEvent(new CustomEvent("sc:toggle-abiertas"));
if (action === "export-csv") window.dispatchEvent(new CustomEvent("sc:export-csv"));
if (action === "refresh-list") window.dispatchEvent(new CustomEvent("sc:refresh-list"));
});
})();
</script>

Some files were not shown because too many files have changed in this diff Show More