Compare commits
4 Commits
main
..
44d1adecdc
| Author | SHA1 | Date | |
|---|---|---|---|
| 44d1adecdc | |||
| 09610df995 | |||
| 922da441eb | |||
| f7962f894d |
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Ignorar los directorios de dependencias
|
# Ignorar los directorios de dependencias
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Ignorar los volumenes respaldados
|
# Ignorar los volumenes respaldados de docker compose
|
||||||
docker-volumes*
|
docker-volumes*
|
||||||
|
|
||||||
# Ignorar las carpetas de bases de datos
|
# Ignorar las carpetas de bases de datos
|
||||||
|
|||||||
@@ -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 **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.
|
||||||
|
|
||||||
|
---
|
||||||
+664
@@ -0,0 +1,664 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>README.md</title>
|
||||||
|
<meta http-equiv="Content-type" content="text/html;charset=UTF-8">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* https://github.com/microsoft/vscode/blob/master/extensions/markdown-language-features/media/markdown.css */
|
||||||
|
/*---------------------------------------------------------------------------------------------
|
||||||
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||||
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||||
|
*--------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--vscode-markdown-font-family, -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Ubuntu", "Droid Sans", sans-serif);
|
||||||
|
font-size: var(--vscode-markdown-font-size, 14px);
|
||||||
|
padding: 0 26px;
|
||||||
|
line-height: var(--vscode-markdown-line-height, 22px);
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-csp-warning {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
color: white;
|
||||||
|
margin: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: sans-serif;
|
||||||
|
background-color:#444444;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 6px;
|
||||||
|
box-shadow: 1px 1px 1px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
#code-csp-warning:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
background-color:#007acc;
|
||||||
|
box-shadow: 2px 2px 2px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.scrollBeyondLastLine {
|
||||||
|
margin-bottom: calc(100vh - 22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.showEditorSelection .code-line {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.showEditorSelection .code-active-line:before,
|
||||||
|
body.showEditorSelection .code-line:hover:before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -12px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.showEditorSelection li.code-active-line:before,
|
||||||
|
body.showEditorSelection li.code-line:hover:before {
|
||||||
|
left: -30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-light.showEditorSelection .code-active-line:before {
|
||||||
|
border-left: 3px solid rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-light.showEditorSelection .code-line:hover:before {
|
||||||
|
border-left: 3px solid rgba(0, 0, 0, 0.40);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-light.showEditorSelection .code-line .code-line:hover:before {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-dark.showEditorSelection .code-active-line:before {
|
||||||
|
border-left: 3px solid rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-dark.showEditorSelection .code-line:hover:before {
|
||||||
|
border-left: 3px solid rgba(255, 255, 255, 0.60);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-dark.showEditorSelection .code-line .code-line:hover:before {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-high-contrast.showEditorSelection .code-active-line:before {
|
||||||
|
border-left: 3px solid rgba(255, 160, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-high-contrast.showEditorSelection .code-line:hover:before {
|
||||||
|
border-left: 3px solid rgba(255, 160, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-high-contrast.showEditorSelection .code-line .code-line:hover:before {
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:focus,
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: 1px solid -webkit-focus-ring-color;
|
||||||
|
outline-offset: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
height: 2px;
|
||||||
|
border-bottom: 2px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
padding-bottom: 0.3em;
|
||||||
|
line-height: 1.2;
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
table > thead > tr > th {
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
table > thead > tr > th,
|
||||||
|
table > thead > tr > td,
|
||||||
|
table > tbody > tr > th,
|
||||||
|
table > tbody > tr > td {
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
table > tbody > tr + tr > td {
|
||||||
|
border-top: 1px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0 7px 0 5px;
|
||||||
|
padding: 0 16px 0 10px;
|
||||||
|
border-left-width: 5px;
|
||||||
|
border-left-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: Menlo, Monaco, Consolas, "Droid Sans Mono", "Courier New", monospace, "Droid Sans Fallback";
|
||||||
|
font-size: 1em;
|
||||||
|
line-height: 1.357em;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.wordWrap pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre:not(.hljs),
|
||||||
|
pre.hljs code > div {
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
color: var(--vscode-editor-foreground);
|
||||||
|
tab-size: 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Theming */
|
||||||
|
|
||||||
|
.vscode-light pre {
|
||||||
|
background-color: rgba(220, 220, 220, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-dark pre {
|
||||||
|
background-color: rgba(10, 10, 10, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-high-contrast pre {
|
||||||
|
background-color: rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-high-contrast h1 {
|
||||||
|
border-color: rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-light table > thead > tr > th {
|
||||||
|
border-color: rgba(0, 0, 0, 0.69);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-dark table > thead > tr > th {
|
||||||
|
border-color: rgba(255, 255, 255, 0.69);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-light h1,
|
||||||
|
.vscode-light hr,
|
||||||
|
.vscode-light table > tbody > tr + tr > td {
|
||||||
|
border-color: rgba(0, 0, 0, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vscode-dark h1,
|
||||||
|
.vscode-dark hr,
|
||||||
|
.vscode-dark table > tbody > tr + tr > td {
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Tomorrow Theme */
|
||||||
|
/* http://jmblog.github.com/color-themes-for-google-code-highlightjs */
|
||||||
|
/* Original theme - https://github.com/chriskempson/tomorrow-theme */
|
||||||
|
|
||||||
|
/* Tomorrow Comment */
|
||||||
|
.hljs-comment,
|
||||||
|
.hljs-quote {
|
||||||
|
color: #8e908c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tomorrow Red */
|
||||||
|
.hljs-variable,
|
||||||
|
.hljs-template-variable,
|
||||||
|
.hljs-tag,
|
||||||
|
.hljs-name,
|
||||||
|
.hljs-selector-id,
|
||||||
|
.hljs-selector-class,
|
||||||
|
.hljs-regexp,
|
||||||
|
.hljs-deletion {
|
||||||
|
color: #c82829;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tomorrow Orange */
|
||||||
|
.hljs-number,
|
||||||
|
.hljs-built_in,
|
||||||
|
.hljs-builtin-name,
|
||||||
|
.hljs-literal,
|
||||||
|
.hljs-type,
|
||||||
|
.hljs-params,
|
||||||
|
.hljs-meta,
|
||||||
|
.hljs-link {
|
||||||
|
color: #f5871f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tomorrow Yellow */
|
||||||
|
.hljs-attribute {
|
||||||
|
color: #eab700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tomorrow Green */
|
||||||
|
.hljs-string,
|
||||||
|
.hljs-symbol,
|
||||||
|
.hljs-bullet,
|
||||||
|
.hljs-addition {
|
||||||
|
color: #718c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tomorrow Blue */
|
||||||
|
.hljs-title,
|
||||||
|
.hljs-section {
|
||||||
|
color: #4271ae;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tomorrow Purple */
|
||||||
|
.hljs-keyword,
|
||||||
|
.hljs-selector-tag {
|
||||||
|
color: #8959a8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
color: #4d4d4c;
|
||||||
|
padding: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-emphasis {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hljs-strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* Markdown PDF CSS
|
||||||
|
*/
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe WPC", "Segoe UI", "Ubuntu", "Droid Sans", sans-serif, "Meiryo";
|
||||||
|
padding: 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background-color: #f8f8f8;
|
||||||
|
border: 1px solid #cccccc;
|
||||||
|
border-radius: 3px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre:not(.hljs) {
|
||||||
|
padding: 23px;
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
background: rgba(127, 127, 127, 0.1);
|
||||||
|
border-color: rgba(0, 122, 204, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
height: 1.4em;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* for inline code */
|
||||||
|
:not(pre):not(.hljs) > code {
|
||||||
|
color: #C9AE75; /* Change the old color so it seems less like an error */
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Page Break : use <div class="page"/> to insert page break
|
||||||
|
-------------------------------------------------------- */
|
||||||
|
.page {
|
||||||
|
page-break-after: always;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/mermaid/dist/mermaid.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: true,
|
||||||
|
theme: document.body.classList.contains('vscode-dark') || document.body.classList.contains('vscode-high-contrast')
|
||||||
|
? 'dark'
|
||||||
|
: 'default'
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<h1 id="suitecoffee--sistema-de-gesti%C3%B3n-para-cafeter%C3%ADas-dockerizado-y-multi%E2%80%91servicio">SuiteCoffee — Sistema de gestión para cafeterías (Dockerizado y multi‑servicio)</h1>
|
||||||
|
<p>SuiteCoffee es un sistema modular pensado para la <strong>gestión de cafeterías</strong> (y negocios afines), con servicios Node.js para <strong>aplicación</strong> y <strong>autenticación</strong>, bases de datos <strong>PostgreSQL</strong> separadas para negocio y multi‑tenencia, y un <strong>stack Docker Compose</strong> que facilita levantar entornos de <strong>desarrollo</strong> y <strong>producción</strong>. Incluye herramientas auxiliares como <strong>Nginx Proxy Manager (NPM)</strong> y <strong>CloudBeaver</strong> para administrar bases de datos desde el navegador.</p>
|
||||||
|
<blockquote>
|
||||||
|
<p>Repositorio: https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git</p>
|
||||||
|
</blockquote>
|
||||||
|
<hr>
|
||||||
|
<h2 id="tabla-de-contenidos">Tabla de contenidos</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="#arquitectura">Arquitectura</a></li>
|
||||||
|
<li><a href="#caracter%C3%ADsticas-principales">Características principales</a></li>
|
||||||
|
<li><a href="#requisitos">Requisitos</a></li>
|
||||||
|
<li><a href="#inicio-r%C3%A1pido">Inicio rápido</a></li>
|
||||||
|
<li><a href="#variables-de-entorno">Variables de entorno</a></li>
|
||||||
|
<li><a href="#endpoints">Endpoints</a></li>
|
||||||
|
<li><a href="#estructura-del-proyecto">Estructura del proyecto</a></li>
|
||||||
|
<li><a href="#herramientas-auxiliares-npm-y-cloudbeaver">Herramientas auxiliares (NPM y CloudBeaver)</a></li>
|
||||||
|
<li><a href="#backups-y-restauraci%C3%B3n-de-vol%C3%BAmenes">Backups y restauración de volúmenes</a></li>
|
||||||
|
<li><a href="#comandos-%C3%BAtiles">Comandos útiles</a></li>
|
||||||
|
<li><a href="#licencia">Licencia</a></li>
|
||||||
|
<li><a href="#sugerencias-de-mejora">Sugerencias de mejora</a></li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h2 id="arquitectura">Arquitectura</h2>
|
||||||
|
<p><strong>Servicios principales</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>app</strong> (Node.js / Express): API de negocio y páginas simples para cargar y listar <em>roles, usuarios, categorías y productos</em>.</li>
|
||||||
|
<li><strong>auth</strong> (Node.js / Express + bcrypt): endpoints de <strong>registro</strong> e <strong>inicio de sesión</strong>.</li>
|
||||||
|
<li><strong>db</strong> (PostgreSQL 16): base de datos de la aplicación.</li>
|
||||||
|
<li><strong>tenants</strong> (PostgreSQL 16): base de datos separada para <strong>multi-tenencia</strong> (aislar clientes/tiendas).</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Herramientas</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Nginx Proxy Manager (NPM)</strong>: reverse proxy y certificados (Let’s Encrypt) para exponer servicios.</li>
|
||||||
|
<li><strong>CloudBeaver (DBeaver)</strong>: administración de PostgreSQL vía web.</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Redes & Volúmenes</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Redes independientes por entorno (<code>suitecoffee_dev_net</code> / <code>suitecoffee_prod_net</code>).</li>
|
||||||
|
<li>Volúmenes gestionados por Compose para persistencia: <code>suitecoffee-db</code>, <code>tenants-db</code>, etc.</li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="diagrama-alto-nivel">Diagrama (alto nivel)</h3>
|
||||||
|
<pre class="hljs"><code><div>@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
|
||||||
|
</div></code></pre>
|
||||||
|
<hr>
|
||||||
|
<h2 id="caracter%C3%ADsticas-principales">Características principales</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>API REST</strong> para entidades clave (roles, usuarios, categorías y productos).</li>
|
||||||
|
<li><strong>Autenticación básica</strong> (registro y login) con <strong>hash de contraseñas</strong> (bcrypt).</li>
|
||||||
|
<li><strong>Multi‑tenencia</strong> con base <code>tenants</code> separada para aislar clientes/tiendas.</li>
|
||||||
|
<li><strong>Docker Compose v2</strong> con entornos de <strong>desarrollo</strong> y <strong>producción</strong>.</li>
|
||||||
|
<li><strong>Herramientas integradas</strong> (NPM + CloudBeaver) en un <code>compose.tools.yaml</code> aparte.</li>
|
||||||
|
<li><strong>Scripts</strong> de <strong>backup/restauración de volúmenes</strong> y <strong>gestión de entornos</strong>.</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h2 id="requisitos">Requisitos</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Docker</strong> y <strong>Docker Compose v2</strong> (recomendado).</li>
|
||||||
|
<li><strong>Python 3.9+</strong> (para scripts <code>suitecoffee.py</code>, backups y utilidades).</li>
|
||||||
|
<li><strong>Node.js 20+</strong> (sólo si vas a ejecutar servicios Node fuera de Docker).</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h2 id="inicio-r%C3%A1pido">Inicio rápido</h2>
|
||||||
|
<h3 id="opci%C3%B3n-a--gestor-interactivo-recomendado">Opción A — Gestor interactivo (recomendado)</h3>
|
||||||
|
<ol>
|
||||||
|
<li>Clona el repo y entra al directorio:<pre class="hljs"><code><div>git <span class="hljs-built_in">clone</span> https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git
|
||||||
|
<span class="hljs-built_in">cd</span> SuiteCoffee
|
||||||
|
</div></code></pre>
|
||||||
|
</li>
|
||||||
|
<li>(Opcional) Crea/copía tus archivos <code>.env</code> para <strong>app</strong> y <strong>auth</strong> en <code>./services/<service>/.env.development</code> (ver sección de variables).</li>
|
||||||
|
<li>Ejecuta el gestor:<pre class="hljs"><code><div>python3 suitecoffee.py
|
||||||
|
</div></code></pre>
|
||||||
|
<ul>
|
||||||
|
<li>Verás un <strong>menú</strong> para levantar <strong>DESARROLLO</strong> o <strong>PRODUCCIÓN</strong>.</li>
|
||||||
|
<li>Desde ahí también puedes <strong>levantar/apagar</strong> las herramientas <strong>NPM</strong> y <strong>CloudBeaver</strong>.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li>Accede:
|
||||||
|
<ul>
|
||||||
|
<li>App (dev): suele estar disponible via NPM o directamente dentro de la red, según tu configuración.</li>
|
||||||
|
<li>Páginas simples: <code>/roles</code>, <code>/usuarios</code>, <code>/categorias</code>, <code>/productos</code> (servidas por <code>app</code>).</li>
|
||||||
|
<li>Salud: <code>/health</code> en <code>app</code> y <code>auth</code>.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<blockquote>
|
||||||
|
<p>Consejo: primero levanta <strong>desarrollo/producción</strong> y luego las <strong>herramientas</strong> para que existan las redes externas <code>suitecoffee_dev_net</code>/<code>suitecoffee_prod_net</code> que usa <code>compose.tools.yaml</code>.</p>
|
||||||
|
</blockquote>
|
||||||
|
<h3 id="opci%C3%B3n-b--comandos-docker-compose-avanzado">Opción B — Comandos Docker Compose (avanzado)</h3>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<p><strong>Desarrollo</strong>:</p>
|
||||||
|
<pre class="hljs"><code><div>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
|
||||||
|
</div></code></pre>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<p><strong>Producción</strong>:</p>
|
||||||
|
<pre class="hljs"><code><div>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
|
||||||
|
</div></code></pre>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<blockquote>
|
||||||
|
<p>Los puertos se <strong>exponen</strong> para herramientas (NPM UI <code>:81</code>, CloudBeaver <code>:8978</code>); los servicios <code>app</code> y <code>auth</code> se <strong>exponen dentro de la red</strong> y se publican externamente a través de NPM.</p>
|
||||||
|
</blockquote>
|
||||||
|
<hr>
|
||||||
|
<h2 id="variables-de-entorno">Variables de entorno</h2>
|
||||||
|
<p>Crea un archivo <code>.env.development</code> (y uno <code>.env.production</code>) en <strong>cada servicio</strong> (<code>./services/app</code> y <code>./services/auth</code>). Variables comunes:</p>
|
||||||
|
<pre class="hljs"><code><div># 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'
|
||||||
|
</div></code></pre>
|
||||||
|
<blockquote>
|
||||||
|
<p>Ajusta <code>DB_HOST</code> a <code>db</code> o <code>tenants</code> según corresponda. En desarrollo, los alias útiles son <code>dev-db</code> y <code>dev-tenants</code>; en producción: <code>prod-db</code> y <code>prod-tenants</code>.</p>
|
||||||
|
</blockquote>
|
||||||
|
<hr>
|
||||||
|
<h2 id="endpoints">Endpoints</h2>
|
||||||
|
<h3 id="servicio-app-negocio">Servicio <strong>app</strong> (negocio)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>GET /health</code></li>
|
||||||
|
<li><code>GET /api/roles</code> — lista roles</li>
|
||||||
|
<li><code>POST /api/roles</code> — crea un rol</li>
|
||||||
|
<li><code>GET /api/usuarios</code> — lista usuarios</li>
|
||||||
|
<li><code>POST /api/usuarios</code> — crea un usuario</li>
|
||||||
|
<li><code>GET /api/categorias</code> — lista categorías</li>
|
||||||
|
<li><code>POST /api/categorias</code> — crea una categoría</li>
|
||||||
|
<li><code>GET /api/productos</code> — lista productos</li>
|
||||||
|
<li><code>POST /api/productos</code> — crea un producto</li>
|
||||||
|
<li>Páginas estáticas simples para probar: <code>/roles</code>, <code>/usuarios</code>, <code>/categorias</code>, <code>/productos</code></li>
|
||||||
|
</ul>
|
||||||
|
<h3 id="servicio-auth-autenticaci%C3%B3n">Servicio <strong>auth</strong> (autenticación)</h3>
|
||||||
|
<ul>
|
||||||
|
<li><code>GET /health</code></li>
|
||||||
|
<li><code>POST /register</code> — registro de usuario (password con <strong>bcrypt</strong>)</li>
|
||||||
|
<li><code>POST /auth/login</code> — inicio de sesión</li>
|
||||||
|
</ul>
|
||||||
|
<blockquote>
|
||||||
|
<p><strong>Nota</strong>: En esta etapa los endpoints son <strong>básicos</strong> y pensados para desarrollo/PoC. Ver la sección <em>Sugerencias de mejora</em> para próximos pasos (JWT, autorización, etc.).</p>
|
||||||
|
</blockquote>
|
||||||
|
<hr>
|
||||||
|
<h2 id="estructura-del-proyecto">Estructura del proyecto</h2>
|
||||||
|
<pre class="hljs"><code><div>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
|
||||||
|
</div></code></pre>
|
||||||
|
<hr>
|
||||||
|
<h2 id="herramientas-auxiliares-npm-y-cloudbeaver">Herramientas auxiliares (NPM y CloudBeaver)</h2>
|
||||||
|
<p>Los servicios de <strong>herramientas</strong> están separados para poder usarlos con <strong>ambos entornos</strong> (dev y prod) a la vez. Se levantan con <code>compose.tools.yaml</code> y se conectan a las <strong>redes externas</strong> <code>suitecoffee_dev_net</code> y <code>suitecoffee_prod_net</code>.</p>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Nginx Proxy Manager (NPM)</strong><br>
|
||||||
|
Puertos: <code>80</code> (HTTP), <code>81</code> (UI). Volúmenes: <code>npm_data</code>, <code>npm_letsencrypt</code>.</li>
|
||||||
|
<li><strong>CloudBeaver</strong><br>
|
||||||
|
Puerto: <code>8978</code>. Volúmenes: <code>dbeaver_logs</code>, <code>dbeaver_workspace</code>.</li>
|
||||||
|
</ul>
|
||||||
|
<blockquote>
|
||||||
|
<p>Si es la primera vez, arranca un entorno (dev/prod) para que Compose cree las redes; luego levanta las herramientas:</p>
|
||||||
|
<pre class="hljs"><code><div>docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
|
||||||
|
docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
|
||||||
|
</div></code></pre>
|
||||||
|
</blockquote>
|
||||||
|
<hr>
|
||||||
|
<h2 id="backups-y-restauraci%C3%B3n-de-vol%C3%BAmenes">Backups y restauración de volúmenes</h2>
|
||||||
|
<p>Este repo incluye dos utilidades:</p>
|
||||||
|
<ul>
|
||||||
|
<li><code>backup_compose_volumes.py</code> — detecta volúmenes de un proyecto de Compose (por <strong>labels</strong> y nombres) y los exporta a <code>tar.gz</code> usando un contenedor <code>alpine</code> temporal.</li>
|
||||||
|
<li><code>restore_compose_volumes.py</code> — permite restaurar esos <code>tar.gz</code> en volúmenes (útil para migraciones y pruebas).</li>
|
||||||
|
</ul>
|
||||||
|
<p><strong>Ejemplos básicos</strong></p>
|
||||||
|
<pre class="hljs"><code><div><span class="hljs-comment"># Listar ayuda</span>
|
||||||
|
python3 backup_compose_volumes.py --<span class="hljs-built_in">help</span>
|
||||||
|
|
||||||
|
<span class="hljs-comment"># Respaldar volúmenes asociados a "suitecoffee_dev" en ./backups</span>
|
||||||
|
python3 backup_compose_volumes.py --project suitecoffee_dev --output ./backups
|
||||||
|
|
||||||
|
<span class="hljs-comment"># Restaurar un archivo a un volumen</span>
|
||||||
|
python3 restore_compose_volumes.py --archive ./backups/suitecoffee_dev_suitecoffee-db-YYYYmmddHHMMSS.tar.gz --volume suitecoffee_dev_suitecoffee-db
|
||||||
|
</div></code></pre>
|
||||||
|
<blockquote>
|
||||||
|
<p>Consejo: si migraste manualmente y ves advertencias tipo “volume ... already exists but was not created by Docker Compose”, considera marcar el volumen como <code>external: true</code> en el YAML o recrearlo para que Compose lo etiquete correctamente.</p>
|
||||||
|
</blockquote>
|
||||||
|
<hr>
|
||||||
|
<h2 id="comandos-%C3%BAtiles">Comandos útiles</h2>
|
||||||
|
<pre class="hljs"><code><div><span class="hljs-comment"># Ver estado (menú interactivo)</span>
|
||||||
|
python3 suitecoffee.py
|
||||||
|
|
||||||
|
<span class="hljs-comment"># Levantar DEV/PROD por menú (con o sin --force-recreate)</span>
|
||||||
|
python3 suitecoffee.py
|
||||||
|
|
||||||
|
<span class="hljs-comment"># Levantar herramientas (también desde menú)</span>
|
||||||
|
docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
|
||||||
|
docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
|
||||||
|
|
||||||
|
<span class="hljs-comment"># Inspeccionar servicios/volúmenes que Compose detecta desde los YAML</span>
|
||||||
|
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
|
||||||
|
</div></code></pre>
|
||||||
|
<hr>
|
||||||
|
<h2 id="licencia">Licencia</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>ISC</strong> (ver <code>package.json</code>).</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
<h2 id="sugerencias-de-mejora">Sugerencias de mejora</h2>
|
||||||
|
<ul>
|
||||||
|
<li><strong>Autenticación y seguridad</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Emitir <strong>JWT</strong> en el login y proteger rutas (roles/autorización por perfil).</li>
|
||||||
|
<li>Configurar <strong>CORS</strong> por orígenes (en dev está abierto; en prod restringir).</li>
|
||||||
|
<li>Añadir <strong>rate‑limit</strong> y <strong>helmet</strong> en Express.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Esquema de datos y migraciones</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Añadir migraciones automatizadas (p.ej. <strong>Prisma</strong>, <strong>Knex</strong>, <strong>Sequelize</strong> o SQL versionado) y seeds iniciales.</li>
|
||||||
|
<li>Clarificar el <strong>modelo multi‑tenant</strong>: por <strong>BD por tenant</strong> o <strong>schema por tenant</strong>; documentar estrategia.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Calidad & DX</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Tests (unitarios e integración) y <strong>CI</strong> básico.</li>
|
||||||
|
<li>Validación de entrada (<strong>zod / joi</strong>), manejo de errores consistente y logs estructurados.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Docker/DevOps</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Documentar variables <code>.env</code> completas por servicio.</li>
|
||||||
|
<li>Publicar imágenes de producción y usar <code>IMAGE:TAG</code> en <code>compose.prod.yaml</code> (evitar build en servidor).</li>
|
||||||
|
<li>Añadir <strong>healthchecks</strong> a <code>app</code>/<code>auth</code> (ya hay ejemplos comentados).</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Frontend</strong>
|
||||||
|
<ul>
|
||||||
|
<li>Reemplazar páginas HTML de prueba por un <strong>frontend</strong> (React/Vite) o una UI admin mínima.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li><strong>Pequeños fixes</strong>
|
||||||
|
<ul>
|
||||||
|
<li>En los HTML de ejemplo corregir las referencias a campos <code>id_rol</code>, <code>id_categoria</code>, etc.</li>
|
||||||
|
<li>Centralizar constantes (nombres de tablas/campos) y normalizar respuestas API.</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# docker-compose.overrride.yml
|
||||||
|
# Docker Comose para entorno de desarrollo o development.
|
||||||
|
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
manso:
|
||||||
|
image: node:20-bookworm
|
||||||
|
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]
|
||||||
|
command: npm run dev
|
||||||
|
|
||||||
|
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
|
||||||
@@ -4,6 +4,19 @@ name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
|
manso:
|
||||||
|
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:
|
# app:
|
||||||
# depends_on:
|
# depends_on:
|
||||||
# db:
|
# db:
|
||||||
|
|||||||
@@ -72,19 +72,18 @@ app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html
|
|||||||
|
|
||||||
app.get('/planes', async (req, res) => {
|
app.get('/planes', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const result = await pool.query(`
|
const { rows: [row] } = await pool.query(
|
||||||
SELECT id, nombre, descripcion, precio
|
'SELECT api.get_planes_json($1) AS data;',
|
||||||
FROM plan
|
[true]
|
||||||
WHERE activo = true
|
);
|
||||||
ORDER BY id
|
res.type('application/json').send(row.data);
|
||||||
`);
|
|
||||||
res.json(result.rows);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Error al cargar planes' });
|
res.status(500).json({ error: 'Error al cargar planes' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
app.post('/api/registro', async (req, res) => {
|
app.post('/api/registro', async (req, res) => {
|
||||||
const {
|
const {
|
||||||
nombre_empresa,
|
nombre_empresa,
|
||||||
|
|||||||
@@ -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"]
|
||||||
Generated
+1559
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "workarround",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "src/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "NODE_ENV=production node ./src/index.js",
|
||||||
|
"dev": "NODE_ENV=development npx nodemon ./src/index.js",
|
||||||
|
"test": "NODE_ENV=stage node ./src/index.js"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"description": "Workarround para tener un MVP que llegue al verano para usarse"
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
// 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';
|
||||||
|
|
||||||
|
// Rutas
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
// Variables de Entorno
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
// Cargar .env según entorno
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env.development') });
|
||||||
|
} else if (process.env.NODE_ENV === 'test') {
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env.test') });
|
||||||
|
} else if (process.env.NODE_ENV === 'production') {
|
||||||
|
dotenv.config({ path: path.resolve(__dirname, '../.env.production') });
|
||||||
|
} else {
|
||||||
|
dotenv.config(); // .env por defecto
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// App
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
const app = express();
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
app.use(express.static(path.join(__dirname, 'pages')));
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Motor de vistas EJS
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
app.set("views", path.join(__dirname, "views"));
|
||||||
|
app.set("view engine", "ejs");
|
||||||
|
app.use(expressLayouts);
|
||||||
|
app.set("layout", "layouts/main");
|
||||||
|
|
||||||
|
// Archivos estáticos
|
||||||
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 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 ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||||
|
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||||
|
max: 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = new Pool(dbConfig);
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 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'
|
||||||
|
];
|
||||||
|
|
||||||
|
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||||
|
|
||||||
|
// Identificadores SQL -> comillas dobles y escape correcto
|
||||||
|
const q = (s) => `"${String(s).replace(/"/g, '""')}"`;
|
||||||
|
|
||||||
|
function ensureTable(name) {
|
||||||
|
const t = String(name || '').toLowerCase();
|
||||||
|
if (!ALLOWED_TABLES.includes(t)) throw new Error('Tabla no permitida');
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getClient() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 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();
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Rutas de UI
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.locals.pageTitle = "Inicio";
|
||||||
|
res.locals.pageId = "home";
|
||||||
|
res.render("estadoComandas");
|
||||||
|
});
|
||||||
|
|
||||||
|
// app.get('/', (req, res) => {
|
||||||
|
// res.sendFile(path.join(__dirname, 'pages', 'dashboard.html'));
|
||||||
|
// });
|
||||||
|
|
||||||
|
app.get('/comandas', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'pages', 'comandas.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/estadoComandas", (req, res) => {
|
||||||
|
res.locals.pageTitle = "Estado de Comandas";
|
||||||
|
res.locals.pageId = "estadoComandas";
|
||||||
|
res.render("estadoComandas");
|
||||||
|
});
|
||||||
|
|
||||||
|
// app.get('/estadoComandas', (req, res) => {
|
||||||
|
// res.sendFile(path.join(__dirname, 'pages', 'estadoComandas.html'));
|
||||||
|
// });
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listado (con join) y totales por comanda
|
||||||
|
app.get('/api/comandas', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const estado = (req.query.estado || '').trim();
|
||||||
|
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||||
|
const params = [];
|
||||||
|
let where = '';
|
||||||
|
if (estado) { params.push(estado); where = `WHERE c.estado = $${params.length}`; }
|
||||||
|
params.push(limit);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
WITH items AS (
|
||||||
|
SELECT d.id_comanda,
|
||||||
|
COUNT(*) AS items,
|
||||||
|
SUM(d.cantidad * d.pre_unitario) AS total
|
||||||
|
FROM deta_comandas d
|
||||||
|
GROUP BY d.id_comanda
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.id_comanda, c.fec_creacion, c.estado, c.observaciones,
|
||||||
|
u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido,
|
||||||
|
m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo,
|
||||||
|
COALESCE(i.items, 0) AS items,
|
||||||
|
COALESCE(i.total, 0) AS total
|
||||||
|
FROM comandas c
|
||||||
|
JOIN usuarios u ON u.id_usuario = c.id_usuario
|
||||||
|
JOIN mesas m ON m.id_mesa = c.id_mesa
|
||||||
|
LEFT JOIN items i ON i.id_comanda = c.id_comanda
|
||||||
|
${where}
|
||||||
|
ORDER BY c.id_comanda DESC
|
||||||
|
LIMIT $${params.length}
|
||||||
|
`;
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
const { rows } = await client.query(sql, params);
|
||||||
|
res.json(rows);
|
||||||
|
} finally { client.release(); }
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Detalle de una comanda (con nombres de productos)
|
||||||
|
app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
if (!id) return res.status(400).json({ error: 'id inválido' });
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT d.id_det_comanda, d.id_producto, p.nombre AS producto_nombre,
|
||||||
|
d.cantidad, d.pre_unitario, (d.cantidad * d.pre_unitario) AS subtotal,
|
||||||
|
d.observaciones
|
||||||
|
FROM deta_comandas d
|
||||||
|
JOIN productos p ON p.id_producto = d.id_producto
|
||||||
|
WHERE d.id_comanda = $1
|
||||||
|
ORDER BY d.id_det_comanda
|
||||||
|
`;
|
||||||
|
const { rows } = await pool.query(sql, [id]);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cambiar estado (abrir/cerrar)
|
||||||
|
app.post('/api/comandas/:id/estado', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const id = parseInt(req.params.id, 10);
|
||||||
|
let { estado } = req.body || {};
|
||||||
|
if (!id) return res.status(400).json({ error: 'id inválido' });
|
||||||
|
|
||||||
|
const allowed = new Set(['abierta','cerrada','pagada','anulada']);
|
||||||
|
if (!allowed.has(estado)) return res.status(400).json({ error: 'estado inválido' });
|
||||||
|
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`UPDATE comandas SET estado = $2 WHERE id_comanda = $1 RETURNING *`,
|
||||||
|
[id, estado]
|
||||||
|
);
|
||||||
|
if (!rows.length) return res.status(404).json({ error: 'comanda no encontrada' });
|
||||||
|
res.json({ updated: rows[0] });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// 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();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al conectar con la base de datos al iniciar:', error.message);
|
||||||
|
console.error('Revisar credenciales y accesos de red.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
// Inicio del servidor
|
||||||
|
// ----------------------------------------------------------
|
||||||
|
|
||||||
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 3000;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`Servidor de aplicación escuchando en ${chalk.yellow(`http://localhost:${PORT}`)}`);
|
||||||
|
console.log(chalk.grey(`Comprobando accesibilidad a la db ${chalk.white(process.env.DB_NAME)} del host ${chalk.white(process.env.DB_HOST)} ...`));
|
||||||
|
verificarConexion();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Healthcheck
|
||||||
|
app.get('/health', async (_req, res) => {
|
||||||
|
res.status(200).json({ status: 'ok' });
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Comandas</title>
|
||||||
|
<style>
|
||||||
|
:root { --gap: 12px; --radius: 10px; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
|
||||||
|
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
|
||||||
|
header h1 { margin:0; font-size:16px; font-weight:600; }
|
||||||
|
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 1.15fr 0.85fr; gap: var(--gap); }
|
||||||
|
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
|
||||||
|
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
|
||||||
|
.card .bd { padding:14px; }
|
||||||
|
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
|
||||||
|
.grid { display:grid; gap:10px; }
|
||||||
|
.grid.cols-2 { grid-template-columns: 1fr 1fr; }
|
||||||
|
.muted { color:#666; }
|
||||||
|
select, input, textarea, button { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
|
||||||
|
select:focus, input:focus, textarea:focus { outline:none; border-color:#999; }
|
||||||
|
input[type="number"] { width: 100%; }
|
||||||
|
textarea { width:100%; min-height: 68px; resize: vertical; }
|
||||||
|
button { cursor: pointer; }
|
||||||
|
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; }
|
||||||
|
.btn.primary { background:#111; border-color:#111; color:#fff; }
|
||||||
|
.btn.ghost { background:#fff; }
|
||||||
|
.btn.small { padding:6px 8px; font-size: 13px; }
|
||||||
|
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||||
|
.toolbar { display:flex; gap:10px; align-items:center; }
|
||||||
|
.spacer { flex:1 }
|
||||||
|
.search { display:flex; gap:8px; }
|
||||||
|
.search input { flex:1; }
|
||||||
|
table { width:100%; border-collapse: collapse; }
|
||||||
|
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||||
|
th, td { padding:8px 10px; border-bottom:1px solid #eee; vertical-align: middle; }
|
||||||
|
.qty { display:flex; align-items:center; gap:6px; }
|
||||||
|
.qty input { width: 90px; }
|
||||||
|
.right { text-align:right; }
|
||||||
|
.total { font-size: 22px; font-weight: 700; }
|
||||||
|
.notice { padding:10px; border-radius:8px; border:1px solid #e7e7ef; background:#fafafa; }
|
||||||
|
.ok { color:#0a7d28; }
|
||||||
|
.err { color:#b00020; }
|
||||||
|
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
|
||||||
|
.kpi { display:flex; gap:6px; align-items: baseline; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>📋 Nueva Comanda</h1>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="pill muted">/api/*</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Panel izquierdo: productos -->
|
||||||
|
<section class="card" id="panelProductos">
|
||||||
|
<div class="hd">
|
||||||
|
<strong>Productos</strong>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<span class="muted" id="prodCount">0 ítems</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bd">
|
||||||
|
<div class="row search" style="margin-bottom:10px;">
|
||||||
|
<input id="busqueda" type="search" placeholder="Buscar por nombre o categoría…"/>
|
||||||
|
<button class="btn" id="limpiarBusqueda">Limpiar</button>
|
||||||
|
</div>
|
||||||
|
<div id="listadoProductos" style="max-height: 58vh; overflow:auto;">
|
||||||
|
<!-- tabla productos -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Panel derecho: datos + carrito + crear -->
|
||||||
|
<section class="card" id="panelComanda">
|
||||||
|
<div class="hd"><strong>Detalles</strong></div>
|
||||||
|
<div class="bd grid" style="gap:14px;">
|
||||||
|
<div class="grid cols-2">
|
||||||
|
<div>
|
||||||
|
<label class="muted">Mesa</label>
|
||||||
|
<select id="selMesa"></select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="muted">Usuario</label>
|
||||||
|
<select id="selUsuario"></select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="muted">Observaciones</label>
|
||||||
|
<textarea id="obs"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice muted">La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="hd"><strong>Carrito</strong></div>
|
||||||
|
<div class="bd" id="carritoWrap">
|
||||||
|
<div class="muted">Aún no agregaste productos.</div>
|
||||||
|
</div>
|
||||||
|
<div class="sticky-footer">
|
||||||
|
<div class="kpi"><span class="muted">Ítems:</span><strong id="kpiItems">0</strong></div>
|
||||||
|
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button class="btn ghost" id="vaciar">Vaciar</button>
|
||||||
|
<button class="btn primary" id="crear">Crear Comanda</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="msg" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (s, r=document) => r.querySelector(s);
|
||||||
|
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
productos: [],
|
||||||
|
mesas: [],
|
||||||
|
usuarios: [],
|
||||||
|
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}]
|
||||||
|
filtro: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------- 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 ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Load data ----------
|
||||||
|
async function init() {
|
||||||
|
// productos, mesas, usuarios
|
||||||
|
const [prods, mesas, usuarios] = await Promise.all([
|
||||||
|
jget('/api/table/productos?limit=1000'),
|
||||||
|
jget('/api/table/mesas?limit=1000'),
|
||||||
|
jget('/api/table/usuarios?limit=1000')
|
||||||
|
]);
|
||||||
|
|
||||||
|
state.productos = prods.filter(p => p.activo !== false); // si existe activo=false, filtrarlo
|
||||||
|
state.mesas = mesas;
|
||||||
|
state.usuarios = usuarios.filter(u => u.activo !== false);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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="muted">Sin resultados.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tbl = document.createElement('table');
|
||||||
|
tbl.innerHTML = `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Producto</th>
|
||||||
|
<th class="right">Precio</th>
|
||||||
|
<th style="width:180px;">Cantidad</th>
|
||||||
|
<th style="width:90px;"></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="right">${money(p.precio)}</td>
|
||||||
|
<td>
|
||||||
|
<div class="qty">
|
||||||
|
<input type="number" min="0.001" step="0.001" value="1.000" data-qty />
|
||||||
|
<button class="btn small" data-dec>-</button>
|
||||||
|
<button class="btn small" data-inc>+</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><button class="btn primary small" 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Carrito ----------
|
||||||
|
function renderCarrito() {
|
||||||
|
const wrap = $('#carritoWrap');
|
||||||
|
if (!state.carrito.length) { wrap.innerHTML = '<div class="muted">Aún no agregaste productos.</div>'; $('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0); return; }
|
||||||
|
|
||||||
|
const tbl = document.createElement('table');
|
||||||
|
tbl.innerHTML = `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Producto</th>
|
||||||
|
<th class="right">Unitario</th>
|
||||||
|
<th class="right">Cantidad</th>
|
||||||
|
<th class="right">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="right">${money(it.pre_unitario)}</td>
|
||||||
|
<td class="right">
|
||||||
|
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" style="width:120px"/>
|
||||||
|
</td>
|
||||||
|
<td class="right">${money(sub)}</td>
|
||||||
|
<td class="right">
|
||||||
|
<button class="btn small" 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 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 (estado por defecto: 'abierta'; fecha la pone la DB)
|
||||||
|
const { inserted: com } = await jpost('/api/table/comandas', {
|
||||||
|
id_usuario,
|
||||||
|
id_mesa,
|
||||||
|
estado: 'abierta',
|
||||||
|
observaciones
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2) detalle (una inserción por renglón)
|
||||||
|
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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Dashboard</title>
|
||||||
|
<style>
|
||||||
|
:root { --radius: 10px; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji'; background:#f6f7fb; color:#111; }
|
||||||
|
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e8e8ef; padding:16px 20px; display:flex; gap:12px; align-items:center; z-index:1;}
|
||||||
|
header h1 { margin:0; font-size:18px; font-weight:600;}
|
||||||
|
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
|
||||||
|
.card { background:#fff; border:1px solid #e8e8ef; border-radius: var(--radius); padding:16px; }
|
||||||
|
.row { display:flex; gap:16px; align-items: center; flex-wrap:wrap; }
|
||||||
|
select, input, button, textarea { font: inherit; padding:10px; border-radius:8px; border:1px solid #d7d7e0; background:#fff; }
|
||||||
|
select:focus, input:focus, textarea:focus { outline: none; border-color:#888; }
|
||||||
|
button { cursor:pointer; }
|
||||||
|
button.primary { background:#111; color:#fff; border-color:#111; }
|
||||||
|
table { width:100%; border-collapse: collapse; }
|
||||||
|
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||||
|
th, td { padding:10px 12px; border-bottom: 1px solid #eee; vertical-align: top; }
|
||||||
|
.muted { color:#666; }
|
||||||
|
.tabs { display:flex; gap:6px; margin-top:12px; }
|
||||||
|
.tab { padding:8px 10px; border:1px solid #e0e0ea; border-bottom:none; background:#fafafa; border-top-left-radius:8px; border-top-right-radius:8px; cursor:pointer; font-size:14px; }
|
||||||
|
.tab.active { background:#fff; border-color:#e0e0ea; }
|
||||||
|
.panel { border:1px solid #e0e0ea; border-radius: 0 8px 8px 8px; padding:16px; background:#fff; }
|
||||||
|
.grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(220px,1fr)); gap:12px; }
|
||||||
|
.help { font-size:12px; color:#777; margin-top:6px; }
|
||||||
|
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||||
|
.toolbar { display:flex; gap:10px; align-items:center; }
|
||||||
|
.spacer { flex:1 }
|
||||||
|
.error { color:#b00020; }
|
||||||
|
.success { color:#0a7d28; }
|
||||||
|
.sr-only{ position:absolute; width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;}
|
||||||
|
details summary { cursor:pointer; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>Dashboard</h1>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<span class="pill muted">/api/*</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="card">
|
||||||
|
<div class="row" style="margin-bottom:12px;">
|
||||||
|
<label for="tableSelect" class="sr-only">Tabla</label>
|
||||||
|
<select id="tableSelect"></select>
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<button id="refreshBtn">Recargar</button>
|
||||||
|
<span id="status" class="muted"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="datos">Datos</button>
|
||||||
|
<button class="tab" data-tab="nuevo">Nuevo</button>
|
||||||
|
<button class="tab" data-tab="esquema">Esquema</button>
|
||||||
|
</div>
|
||||||
|
<section class="panel" id="panel-datos">
|
||||||
|
<div class="help">Mostrando hasta <span id="limitInfo">100</span> filas.</div>
|
||||||
|
<div id="tableContainer" style="overflow:auto;"></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel" id="panel-nuevo" hidden>
|
||||||
|
<form id="insertForm" class="grid"></form>
|
||||||
|
<div class="row" style="margin-top:10px;">
|
||||||
|
<div class="spacer"></div>
|
||||||
|
<button id="insertBtn" class="primary">Insertar</button>
|
||||||
|
</div>
|
||||||
|
<div id="insertMsg" class="help"></div>
|
||||||
|
</section>
|
||||||
|
<section class="panel" id="panel-esquema" hidden>
|
||||||
|
<pre id="schemaPre" style="white-space:pre-wrap;"></pre>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<details style="margin-top:16px;">
|
||||||
|
<summary>Endpoints</summary>
|
||||||
|
<div class="help">GET /api/tables • GET /api/schema/:tabla • GET /api/table/:tabla?limit=100 • POST /api/table/:tabla</div>
|
||||||
|
</details>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (s, r=document) => r.querySelector(s);
|
||||||
|
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||||
|
const state = { tables: [], table: null, schema: null, limit: 100 };
|
||||||
|
|
||||||
|
// Tabs
|
||||||
|
$$('.tab').forEach(t => t.addEventListener('click', () => {
|
||||||
|
$$('.tab').forEach(x => x.classList.remove('active'));
|
||||||
|
t.classList.add('active');
|
||||||
|
const tab = t.dataset.tab;
|
||||||
|
$('#panel-datos').hidden = tab !== 'datos';
|
||||||
|
$('#panel-nuevo').hidden = tab !== 'nuevo';
|
||||||
|
$('#panel-esquema').hidden = tab !== 'esquema';
|
||||||
|
}));
|
||||||
|
|
||||||
|
$('#refreshBtn').addEventListener('click', () => {
|
||||||
|
if (state.table) {
|
||||||
|
loadSchema(state.table);
|
||||||
|
loadData(state.table);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
setStatus('Cargando tablas…');
|
||||||
|
const res = await fetch('/api/tables');
|
||||||
|
const tables = await res.json();
|
||||||
|
state.tables = tables;
|
||||||
|
const sel = $('#tableSelect');
|
||||||
|
sel.innerHTML = '';
|
||||||
|
tables.forEach(name => {
|
||||||
|
const o = document.createElement('option');
|
||||||
|
o.value = name; o.textContent = name;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
sel.addEventListener('change', () => selectTable(sel.value));
|
||||||
|
if (tables.length) {
|
||||||
|
selectTable(tables[0]);
|
||||||
|
} else {
|
||||||
|
setStatus('No hay tablas disponibles.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectTable(tbl) {
|
||||||
|
state.table = tbl;
|
||||||
|
await loadSchema(tbl);
|
||||||
|
await loadData(tbl);
|
||||||
|
buildForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSchema(tbl) {
|
||||||
|
const res = await fetch(`/api/schema/${tbl}`);
|
||||||
|
state.schema = await res.json();
|
||||||
|
$('#schemaPre').textContent = JSON.stringify(state.schema, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData(tbl) {
|
||||||
|
setStatus('Cargando datos…');
|
||||||
|
const res = await fetch(`/api/table/${tbl}?limit=${state.limit}`);
|
||||||
|
const data = await res.json();
|
||||||
|
$('#limitInfo').textContent = String(state.limit);
|
||||||
|
renderTable(data);
|
||||||
|
clearStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(rows) {
|
||||||
|
const c = $('#tableContainer');
|
||||||
|
c.innerHTML = '';
|
||||||
|
if (!rows.length) { c.innerHTML = '<div class="muted">Sin datos.</div>'; return; }
|
||||||
|
const headers = Object.keys(rows[0]);
|
||||||
|
const table = document.createElement('table');
|
||||||
|
table.innerHTML = `
|
||||||
|
<thead><tr>${headers.map(h => '<th>'+h+'</th>').join('')}</tr></thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
`;
|
||||||
|
const tbody = table.querySelector('tbody');
|
||||||
|
for (const row of rows) {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = headers.map(h => '<td>'+formatCell(row[h])+'</td>').join('');
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
}
|
||||||
|
c.appendChild(table);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCell(v) {
|
||||||
|
if (v === null || v === undefined) return '<span class="muted">NULL</span>';
|
||||||
|
if (typeof v === 'boolean') return v ? '✓' : '—';
|
||||||
|
if (typeof v === 'string' && v.match(/^\\d{4}-\\d{2}-\\d{2}/)) return new Date(v).toLocaleString();
|
||||||
|
return String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildForm() {
|
||||||
|
const form = $('#insertForm');
|
||||||
|
form.innerHTML = '';
|
||||||
|
if (!state.schema) return;
|
||||||
|
for (const col of state.schema.columns) {
|
||||||
|
// Omitir PK auto y columnas generadas
|
||||||
|
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
const id = 'f_'+col.column_name;
|
||||||
|
wrap.innerHTML = `
|
||||||
|
<label for="${id}" class="muted">${col.column_name} <span class="muted">${col.data_type}</span> ${col.is_nullable ? '' : '<span class="pill">requerido</span>'}</label>
|
||||||
|
<div data-input></div>
|
||||||
|
${col.column_default ? '<div class="help">DEFAULT: '+col.column_default+'</div>' : ''}
|
||||||
|
`;
|
||||||
|
const holder = wrap.querySelector('[data-input]');
|
||||||
|
|
||||||
|
if (col.foreign) {
|
||||||
|
const sel = document.createElement('select');
|
||||||
|
sel.id = id;
|
||||||
|
holder.appendChild(sel);
|
||||||
|
hydrateOptions(sel, state.schema.table, col.column_name);
|
||||||
|
} else if (col.data_type.includes('boolean')) {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.id = id; inp.type = 'checkbox';
|
||||||
|
holder.appendChild(inp);
|
||||||
|
} else if (col.data_type.includes('timestamp')) {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.id = id; inp.type = 'datetime-local'; inp.required = !col.is_nullable && !col.column_default;
|
||||||
|
holder.appendChild(inp);
|
||||||
|
} else if (col.data_type.includes('date')) {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.id = id; inp.type = 'date'; inp.required = !col.is_nullable && !col.column_default;
|
||||||
|
holder.appendChild(inp);
|
||||||
|
} else if (col.data_type.match(/numeric|integer|real|double/)) {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.id = id; inp.type = 'number'; inp.step = 'any'; inp.required = !col.is_nullable && !col.column_default;
|
||||||
|
holder.appendChild(inp);
|
||||||
|
} else if (col.data_type.includes('text') || col.data_type.includes('character')) {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
|
||||||
|
holder.appendChild(inp);
|
||||||
|
} else {
|
||||||
|
const inp = document.createElement('input');
|
||||||
|
inp.id = id; inp.type = 'text'; inp.required = !col.is_nullable && !col.column_default;
|
||||||
|
holder.appendChild(inp);
|
||||||
|
}
|
||||||
|
form.appendChild(wrap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hydrateOptions(selectEl, table, column) {
|
||||||
|
selectEl.innerHTML = '<option value="">Cargando…</option>';
|
||||||
|
const res = await fetch(`/api/options/${table}/${column}`);
|
||||||
|
const opts = await res.json();
|
||||||
|
selectEl.innerHTML = '<option value="">Seleccione…</option>' + opts.map(o => `<option value="${o.id}">${o.label}</option>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#insertBtn').addEventListener('click', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!state.table) return;
|
||||||
|
const payload = {};
|
||||||
|
for (const col of state.schema.columns) {
|
||||||
|
if (col.is_primary || col.is_identity || (col.column_default || '').startsWith('nextval(')) continue;
|
||||||
|
const id = 'f_'+col.column_name;
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) continue;
|
||||||
|
|
||||||
|
let val = null;
|
||||||
|
if (el.type === 'checkbox') {
|
||||||
|
val = el.checked;
|
||||||
|
} else if (el.type === 'datetime-local' && el.value) {
|
||||||
|
// Convertir a ISO
|
||||||
|
val = new Date(el.value).toISOString().slice(0,19).replace('T',' ');
|
||||||
|
} else if (el.tagName === 'SELECT') {
|
||||||
|
val = el.value ? (isNaN(el.value) ? el.value : Number(el.value)) : null;
|
||||||
|
} else if (el.type === 'number') {
|
||||||
|
val = el.value === '' ? null : Number(el.value);
|
||||||
|
} else {
|
||||||
|
val = el.value === '' ? null : el.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (val === null && !col.is_nullable && !col.column_default) {
|
||||||
|
showInsertMsg('Completa: '+col.column_name, true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (val !== null) payload[col.column_name] = val;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/table/${state.table}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type':'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error || 'Error');
|
||||||
|
showInsertMsg('Insertado correctamente (id: '+(data.inserted?.id || '?')+')', false);
|
||||||
|
// Reset form
|
||||||
|
$('#insertForm').reset?.();
|
||||||
|
await loadData(state.table);
|
||||||
|
} catch (e) {
|
||||||
|
showInsertMsg(e.message, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showInsertMsg(msg, isError=false) {
|
||||||
|
const m = $('#insertMsg');
|
||||||
|
m.className = 'help ' + (isError ? 'error' : 'success');
|
||||||
|
m.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text) { $('#status').textContent = text; }
|
||||||
|
function clearStatus() { setStatus(''); }
|
||||||
|
|
||||||
|
// Start
|
||||||
|
init();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<!-- pages/estadoComandas.html -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>Estado de Comandas</title>
|
||||||
|
<style>
|
||||||
|
:root { --gap: 12px; --radius: 10px; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, 'Helvetica Neue', Arial; background:#f6f7fb; color:#111; }
|
||||||
|
header { position: sticky; top: 0; background:#fff; border-bottom:1px solid #e7e7ef; padding:14px 18px; display:flex; gap:10px; align-items:center; z-index:2; }
|
||||||
|
header h1 { margin:0; font-size:16px; font-weight:600; }
|
||||||
|
main { padding: 18px; max-width: 1200px; margin: 0 auto; display:grid; grid-template-columns: 0.9fr 1.1fr; gap: var(--gap); }
|
||||||
|
.card { background:#fff; border:1px solid #e7e7ef; border-radius: var(--radius); }
|
||||||
|
.card .hd { padding:12px 14px; border-bottom:1px solid #eee; display:flex; align-items:center; gap:10px; }
|
||||||
|
.card .bd { padding:14px; }
|
||||||
|
.row { display:flex; gap:10px; align-items:center; flex-wrap: wrap; }
|
||||||
|
.grid { display:grid; gap:10px; }
|
||||||
|
.muted { color:#666; }
|
||||||
|
.pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid #e0e0ea; background:#fafafa; display:inline-block; }
|
||||||
|
.list { max-height: 70vh; overflow:auto; }
|
||||||
|
.list table { width:100%; border-collapse: collapse; }
|
||||||
|
.list th, .list td { padding:8px 10px; border-bottom:1px solid #eee; }
|
||||||
|
.list thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||||
|
.right { text-align:right; }
|
||||||
|
.btn { padding:10px 12px; border-radius:8px; border:1px solid #dadbe4; background:#fafafa; cursor:pointer; }
|
||||||
|
.btn.primary { background:#111; color:#fff; border-color:#111; }
|
||||||
|
.btn.danger { background:#b00020; color:#fff; border-color:#b00020; }
|
||||||
|
.btn.small { font-size: 13px; padding:6px 8px; }
|
||||||
|
select, input, textarea { font: inherit; padding:10px; border:1px solid #dadbe4; border-radius:8px; background:#fff; }
|
||||||
|
.kpi { display:flex; gap:6px; align-items: baseline; }
|
||||||
|
.sticky-footer { position: sticky; bottom: 0; background:#fff; padding:12px 14px; border-top:1px solid #eee; display:flex; gap:10px; align-items:center; }
|
||||||
|
.ok { color:#0a7d28; }
|
||||||
|
.err { color:#b00020; }
|
||||||
|
.tag { font-size:12px; padding:2px 8px; border-radius:6px; border:1px solid #e7e7ef; background:#fafafa; }
|
||||||
|
.tag.abierta { border-color:#0a7d28; color:#0a7d28; }
|
||||||
|
.tag.cerrada { border-color:#555; color:#555; }
|
||||||
|
.tag.pagada { border-color:#1b5e20; color:#1b5e20; }
|
||||||
|
.tag.anulada { border-color:#b00020; color:#b00020; }
|
||||||
|
table { width:100%; border-collapse: collapse; }
|
||||||
|
th, td { padding:8px 10px; border-bottom:1px solid #eee; }
|
||||||
|
thead th { text-align:left; font-size:12px; text-transform: uppercase; letter-spacing:.04em; color:#555; background:#fafafa; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🧾 Estado de Comandas</h1>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<a class="pill" href="/comandas">➕ Nueva comanda</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- Izquierda: listado -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="hd">
|
||||||
|
<strong>Listado</strong>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<label class="muted" style="display:flex; gap:6px; align-items:center;">
|
||||||
|
<input id="soloAbiertas" type="checkbox" checked />
|
||||||
|
Solo abiertas
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="bd">
|
||||||
|
<div class="row" style="margin-bottom:10px;">
|
||||||
|
<input id="buscar" type="search" placeholder="Buscar por #, mesa o usuario…" style="flex:1"/>
|
||||||
|
<button class="btn" id="limpiar">Limpiar</button>
|
||||||
|
</div>
|
||||||
|
<div class="list" id="lista"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Derecha: detalle -->
|
||||||
|
<section class="card">
|
||||||
|
<div class="hd">
|
||||||
|
<strong>Detalle</strong>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<span id="detalleEstado" class="tag">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="bd" id="detalle">
|
||||||
|
<div class="muted">Selecciona una comanda para ver el detalle.</div>
|
||||||
|
</div>
|
||||||
|
<div class="sticky-footer">
|
||||||
|
<div class="kpi"><span class="muted">ID:</span><strong id="kpiId">—</strong></div>
|
||||||
|
<div class="kpi" style="margin-left:8px;"><span class="muted">Mesa:</span><strong id="kpiMesa">—</strong></div>
|
||||||
|
<div class="kpi" style="margin-left:8px;"><span class="muted">Total:</span><strong id="kpiTotal">$ 0.00</strong></div>
|
||||||
|
<div style="flex:1"></div>
|
||||||
|
<button class="btn" id="reabrir">Reabrir</button>
|
||||||
|
<button class="btn primary" id="cerrar">Cerrar</button>
|
||||||
|
</div>
|
||||||
|
<div class="bd">
|
||||||
|
<div id="msg" class="muted"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (s, r=document) => r.querySelector(s);
|
||||||
|
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
filtro: '',
|
||||||
|
soloAbiertas: true,
|
||||||
|
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, observaciones }]
|
||||||
|
sel: null, // id seleccionado
|
||||||
|
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ? 'ok' : 'err'; el.textContent = msg; setTimeout(()=>{ el.textContent=''; el.className='muted'; }, 3500); };
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------- 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 = rows;
|
||||||
|
renderLista();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadDetalle(id) {
|
||||||
|
const det = await jget(`/api/comandas/${id}/detalle`);
|
||||||
|
state.detalle = det;
|
||||||
|
renderDetalle();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------- UI: Lista -----------
|
||||||
|
function renderLista() {
|
||||||
|
let rows = state.lista.slice();
|
||||||
|
const f = state.filtro;
|
||||||
|
if (f) {
|
||||||
|
const k = f.toLowerCase();
|
||||||
|
rows = rows.filter(r =>
|
||||||
|
String(r.id_comanda).includes(k) ||
|
||||||
|
(String(r.mesa_numero ?? '').includes(k)) ||
|
||||||
|
((`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(k))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const box = $('#lista');
|
||||||
|
if (!rows.length) { box.innerHTML = '<div class="muted">Sin resultados.</div>'; return; }
|
||||||
|
|
||||||
|
const tbl = document.createElement('table');
|
||||||
|
tbl.innerHTML = `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Mesa</th>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th class="right">Items</th>
|
||||||
|
<th class="right">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 || ''}</td>
|
||||||
|
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
|
||||||
|
<td>${new Date(r.fec_creacion).toLocaleString()}</td>
|
||||||
|
<td><span class="tag ${r.estado}">${r.estado}</span></td>
|
||||||
|
<td class="right">${r.items ?? '—'}</td>
|
||||||
|
<td class="right">${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 -----------
|
||||||
|
function applyHeader(r) {
|
||||||
|
$('#kpiId').textContent = r.id_comanda ?? '—';
|
||||||
|
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
|
||||||
|
$('#detalleEstado').className = `tag ${r.estado}`;
|
||||||
|
$('#detalleEstado').textContent = r.estado;
|
||||||
|
$('#kpiTotal').textContent = money(r.total ?? 0);
|
||||||
|
|
||||||
|
// Botones según estado
|
||||||
|
const cerr = $('#cerrar'), reab = $('#reabrir');
|
||||||
|
if (r.estado === 'abierta') {
|
||||||
|
cerr.disabled = false; cerr.title = '';
|
||||||
|
reab.disabled = true; reab.title = 'Ya está abierta';
|
||||||
|
} else {
|
||||||
|
cerr.disabled = false; // permitir cerrar (idempotente/override)
|
||||||
|
reab.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetalle() {
|
||||||
|
const box = $('#detalle');
|
||||||
|
if (!state.detalle.length) { box.innerHTML = '<div class="muted">Sin detalle.</div>'; return; }
|
||||||
|
|
||||||
|
const tbl = document.createElement('table');
|
||||||
|
tbl.innerHTML = `
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Producto</th>
|
||||||
|
<th class="right">Unitario</th>
|
||||||
|
<th class="right">Cantidad</th>
|
||||||
|
<th class="right">Subtotal</th>
|
||||||
|
<th>Observaciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
`;
|
||||||
|
const tb = tbl.querySelector('tbody');
|
||||||
|
|
||||||
|
let total = 0;
|
||||||
|
state.detalle.forEach(r => {
|
||||||
|
total += Number(r.subtotal||0);
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${r.producto_nombre}</td>
|
||||||
|
<td class="right">${money(r.pre_unitario)}</td>
|
||||||
|
<td class="right">${Number(r.cantidad).toFixed(3)}</td>
|
||||||
|
<td class="right">${money(r.subtotal)}</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();
|
||||||
|
// mantener seleccionada si sigue existiendo en filtro
|
||||||
|
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="muted">Selecciona una comanda para ver el detalle.</div>';
|
||||||
|
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'tag';
|
||||||
|
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast(e.message || 'No se pudo cambiar el estado');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------- 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>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||||
|
<h1 class="h4 m-0">Estado de Comandas</h1>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="soloAbiertas">
|
||||||
|
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- tu tabla/listado acá -->
|
||||||
|
<table class="table table-sm table-bordered">
|
||||||
|
<thead><tr><th>ID</th><th>Mesa</th><th>Estado</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>1</td><td>5</td>
|
||||||
|
<td><span class="badge badge-outline badge-estado-abierta">Abierta</span></td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!-- /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">
|
||||||
|
|
||||||
|
<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; }
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!-- /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="/mesas">Mesas</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>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
<!-- /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 ===
|
||||||
|
"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" },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
(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>
|
||||||
Reference in New Issue
Block a user