Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a31b411437 | |||
| b4c5d2af4f | |||
| 69f5860b7f | |||
| 5d078f3932 | |||
| 237a5427dd | |||
| 80778c0ed9 | |||
| 8522d02170 | |||
| cbcea72848 | |||
| 25876e733b | |||
| 93ac1db5f1 | |||
| c9b4b4871d | |||
| 9c5219863b | |||
| ce3d01a180 | |||
| 57dbd5b1fa | |||
| 44d1adecdc | |||
| 09610df995 | |||
| 922da441eb | |||
| f7962f894d |
37
.env.development
Normal 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
@ -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
@ -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
@ -0,0 +1,303 @@
|
||||
# SuiteCoffee — Sistema de gestión para cafeterías (Dockerizado y multi‑servicio)
|
||||
|
||||
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 multi‑tenencia, 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 (Let’s 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).
|
||||
- **Multi‑tenencia** 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 **rate‑limit** 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 multi‑tenant**: 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
|
After Width: | Height: | Size: 717 KiB |
|
After Width: | Height: | Size: 717 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_BM_1.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
13
authentik-media/suitecoffee-logo/SuiteCoffee_BN_1.svg
Normal 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 |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TB_1.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TM_1.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
BIN
authentik-media/suitecoffee-logo/SuiteCoffee_TN_1.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 528 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 488 B |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
authentik-media/suitecoffee-logo/favicon_io/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
@ -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"}
|
||||
129
compose.dev.yaml
@ -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
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
74
compose.yaml
@ -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
@ -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
38
services/app/.env.development
Normal 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
|
||||
22
services/app/.env.production
Normal 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
|
||||
1103
services/app/package-lock.json
generated
@ -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
@ -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
@ -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
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
@ -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" });
|
||||
});
|
||||
37
services/app/src/middlewares/tenant.js
Normal 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();
|
||||
// }
|
||||
@ -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>Sí</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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
BIN
services/app/src/public/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
services/app/src/public/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
services/app/src/public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
services/app/src/public/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 488 B |
BIN
services/app/src/public/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
services/app/src/public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
services/app/src/public/favicon/site.webmanifest
Normal 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"}
|
||||
BIN
services/app/src/public/img/productos/img_producto.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
76
services/app/src/utilities/cargaEnVista.js
Normal 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';
|
||||
}
|
||||
558
services/app/src/views/comandas.ejs
Normal 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>
|
||||
361
services/app/src/views/compras.ejs
Normal 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>
|
||||
487
services/app/src/views/dashboard.ejs
Normal 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>
|
||||
532
services/app/src/views/estadoComandas.ejs
Normal 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> -->
|
||||
158
services/app/src/views/inicio.ejs
Normal 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>
|
||||
130
services/app/src/views/inicio.ejs.bak
Normal 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>
|
||||
16
services/app/src/views/layouts/main.ejs
Normal 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>
|
||||
164
services/app/src/views/login.ejs
Normal 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>
|
||||
42
services/app/src/views/partials/_footer.ejs
Normal 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>
|
||||
45
services/app/src/views/partials/_head.ejs
Normal 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>
|
||||
31
services/app/src/views/partials/_navbar.ejs
Normal 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>
|
||||
72
services/app/src/views/partials/_sidebar.ejs
Normal 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>
|
||||
559
services/app/src/views/productos.ejs
Normal 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>
|
||||
836
services/app/src/views/reportes.ejs
Normal 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>
|
||||
402
services/app/src/views/reportes.ejs.bak
Normal 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>
|
||||
1030
services/app/src/views/usuarios.ejs
Normal file
44
services/auth/.env.development
Normal 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/
|
||||
22
services/auth/.env.production
Normal 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
|
||||
967
services/auth/package-lock.json
generated
@ -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
@ -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);
|
||||
3071
services/auth/src/db/dumpl_manso_250905.sql
Normal file
2239
services/auth/src/db/initTenant.sql
Normal file
2267
services/auth/src/db/initTenant_v2.sql
Normal file
2284
services/auth/src/db/initTenant_v3.sql
Normal 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();
|
||||
});
|
||||
19
services/manso/.env.development
Normal 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
|
||||
20
services/manso/.env.production
Normal 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
|
||||
20
services/manso/Dockerfile.production
Normal 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
35
services/manso/package.json
Normal 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"
|
||||
}
|
||||
340
services/manso/src/api/v1/routes/routes.js
Normal 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;
|
||||
46
services/manso/src/db/poolSingleton.js
Normal 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 };
|
||||
321
services/manso/src/index.mjs
Normal 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'}));
|
||||
67
services/manso/src/pages/pages.js
Normal 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;
|
||||
BIN
services/manso/src/public/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
services/manso/src/public/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 528 KiB |
BIN
services/manso/src/public/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
services/manso/src/public/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 488 B |
BIN
services/manso/src/public/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
services/manso/src/public/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
1
services/manso/src/public/favicon/site.webmanifest
Normal 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"}
|
||||
BIN
services/manso/src/public/img/productos/img_producto.png
Normal file
|
After Width: | Height: | Size: 1005 KiB |
569
services/manso/src/views/comandas.ejs
Normal 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>
|
||||
361
services/manso/src/views/compras.ejs
Normal 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>
|
||||
487
services/manso/src/views/dashboard.ejs
Normal 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>
|
||||
532
services/manso/src/views/estadoComandas.ejs
Normal 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> -->
|
||||
16
services/manso/src/views/layouts/main.ejs
Normal 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>
|
||||
42
services/manso/src/views/partials/_footer.ejs
Normal 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>
|
||||
45
services/manso/src/views/partials/_head.ejs
Normal 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>
|
||||
31
services/manso/src/views/partials/_navbar.ejs
Normal 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>
|
||||
72
services/manso/src/views/partials/_sidebar.ejs
Normal 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>
|
||||