10 Commits

35 changed files with 9424 additions and 13 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# Ignorar los directorios de dependencias
node_modules/
# Ignorar los volumenes respaldados
# Ignorar los volumenes respaldados de docker compose
docker-volumes*
# Ignorar las carpetas de bases de datos
+303
View File
@@ -0,0 +1,303 @@
# SuiteCoffee — Sistema de gestión para cafeterías (Dockerizado y multiservicio)
SuiteCoffee es un sistema modular pensado para la **gestión de cafeterías** (y negocios afines), con servicios Node.js para **aplicación** y **autenticación**, bases de datos **PostgreSQL** separadas para negocio y multitenencia, y un **stack Docker Compose** que facilita levantar entornos de **desarrollo** y **producción**. Incluye herramientas auxiliares como **Nginx Proxy Manager (NPM)** y **CloudBeaver** para administrar bases de datos desde el navegador.
> Repositorio: https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git
---
## Tabla de contenidos
- [Arquitectura](#arquitectura)
- [Características principales](#características-principales)
- [Requisitos](#requisitos)
- [Inicio rápido](#inicio-rápido)
- [Variables de entorno](#variables-de-entorno)
- [Endpoints](#endpoints)
- [Estructura del proyecto](#estructura-del-proyecto)
- [Herramientas auxiliares (NPM y CloudBeaver)](#herramientas-auxiliares-npm-y-cloudbeaver)
- [Backups y restauración de volúmenes](#backups-y-restauración-de-volúmenes)
- [Comandos útiles](#comandos-útiles)
- [Licencia](#licencia)
- [Sugerencias de mejora](#sugerencias-de-mejora)
---
## Arquitectura
**Servicios principales**
- **app** (Node.js / Express): API de negocio y páginas simples para cargar y listar *roles, usuarios, categorías y productos*.
- **auth** (Node.js / Express + bcrypt): endpoints de **registro** e **inicio de sesión**.
- **db** (PostgreSQL 16): base de datos de la aplicación.
- **tenants** (PostgreSQL 16): base de datos separada para **multi-tenencia** (aislar clientes/tiendas).
**Herramientas**
- **Nginx Proxy Manager (NPM)**: reverse proxy y certificados (Lets Encrypt) para exponer servicios.
- **CloudBeaver (DBeaver)**: administración de PostgreSQL vía web.
**Redes & Volúmenes**
- Redes independientes por entorno (`suitecoffee_dev_net` / `suitecoffee_prod_net`).
- Volúmenes gestionados por Compose para persistencia: `suitecoffee-db`, `tenants-db`, etc.
### Diagrama (alto nivel)
```plantuml
@startuml
skinparam componentStyle rectangle
skinparam rectangle {
BorderColor #555
RoundCorner 10
}
actor Usuario
package "Entorno DEV/PROD" {
[app (Express)] as APP
[auth (Express + bcrypt)] as AUTH
database "db (PostgreSQL)" as DB
database "tenants (PostgreSQL)" as TENANTS
APP -down-> DB : Pool PG
APP -down-> TENANTS : Pool PG
AUTH -down-> DB : Pool PG (usuarios)
Usuario --> APP : UI / API
Usuario --> AUTH : Login/Registro
}
package "Herramientas" {
[Nginx Proxy Manager] as NPM
[CloudBeaver] as DBVR
NPM ..> APP : proxy
NPM ..> AUTH : proxy
DBVR ..> DB : admin
DBVR ..> TENANTS : admin
}
@enduml
```
---
## Características principales
- **API REST** para entidades clave (roles, usuarios, categorías y productos).
- **Autenticación básica** (registro y login) con **hash de contraseñas** (bcrypt).
- **Multitenencia** con base `tenants` separada para aislar clientes/tiendas.
- **Docker Compose v2** con entornos de **desarrollo** y **producción**.
- **Herramientas integradas** (NPM + CloudBeaver) en un `compose.tools.yaml` aparte.
- **Scripts** de **backup/restauración de volúmenes** y **gestión de entornos**.
---
## Requisitos
- **Docker** y **Docker Compose v2** (recomendado).
- **Python 3.9+** (para scripts `suitecoffee.py`, backups y utilidades).
- **Node.js 20+** (sólo si vas a ejecutar servicios Node fuera de Docker).
---
## Inicio rápido
### Opción A — Gestor interactivo (recomendado)
1. Clona el repo y entra al directorio:
```bash
git clone https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git
cd SuiteCoffee
```
2. (Opcional) Crea/copía tus archivos `.env` para **app** y **auth** en `./services/<service>/.env.development` (ver sección de variables).
3. Ejecuta el gestor:
```bash
python3 suitecoffee.py
```
- Verás un **menú** para levantar **DESARROLLO** o **PRODUCCIÓN**.
- Desde ahí también puedes **levantar/apagar** las herramientas **NPM** y **CloudBeaver**.
4. Accede:
- App (dev): suele estar disponible via NPM o directamente dentro de la red, según tu configuración.
- Páginas simples: `/roles`, `/usuarios`, `/categorias`, `/productos` (servidas por `app`).
- Salud: `/health` en `app` y `auth`.
> Consejo: primero levanta **desarrollo/producción** y luego las **herramientas** para que existan las redes externas `suitecoffee_dev_net`/`suitecoffee_prod_net` que usa `compose.tools.yaml`.
### Opción B — Comandos Docker Compose (avanzado)
- **Desarrollo**:
```bash
docker compose -f compose.yaml -f compose.dev.yaml --env-file ./services/app/.env.development --env-file ./services/auth/.env.development -p suitecoffee_dev up -d
```
- **Producción**:
```bash
docker compose -f compose.yaml -f compose.prod.yaml --env-file ./services/app/.env.production --env-file ./services/auth/.env.production -p suitecoffee_prod up -d
```
> Los puertos se **exponen** para herramientas (NPM UI `:81`, CloudBeaver `:8978`); los servicios `app` y `auth` se **exponen dentro de la red** y se publican externamente a través de NPM.
---
## Variables de entorno
Crea un archivo `.env.development` (y uno `.env.production`) en **cada servicio** (`./services/app` y `./services/auth`). Variables comunes:
```dotenv
# Servidor
PORT=4000 # puerto HTTP del servicio
NODE_ENV=development # development | production
# Base de datos
DB_HOST=db # nombre del servicio postgres (o host)
DB_LOCAL_PORT=5432 # puerto de PG al que conectarse
DB_USER=postgres
DB_PASS=postgres
DB_NAME=suitecoffee_db # para 'db' (aplicación)
TENANTS_DB_NAME=tenants_db # si el servicio necesita apuntar a 'tenants'
```
> Ajusta `DB_HOST` a `db` o `tenants` según corresponda. En desarrollo, los alias útiles son `dev-db` y `dev-tenants`; en producción: `prod-db` y `prod-tenants`.
---
## Endpoints
### Servicio **app** (negocio)
- `GET /health`
- `GET /api/roles` — lista roles
- `POST /api/roles` — crea un rol
- `GET /api/usuarios` — lista usuarios
- `POST /api/usuarios` — crea un usuario
- `GET /api/categorias` — lista categorías
- `POST /api/categorias` — crea una categoría
- `GET /api/productos` — lista productos
- `POST /api/productos` — crea un producto
- Páginas estáticas simples para probar: `/roles`, `/usuarios`, `/categorias`, `/productos`
### Servicio **auth** (autenticación)
- `GET /health`
- `POST /register` — registro de usuario (password con **bcrypt**)
- `POST /auth/login` — inicio de sesión
> **Nota**: En esta etapa los endpoints son **básicos** y pensados para desarrollo/PoC. Ver la sección *Sugerencias de mejora* para próximos pasos (JWT, autorización, etc.).
---
## Estructura del proyecto
```
SuiteCoffee/
├─ services/
│ ├─ app/
│ │ ├─ src/
│ │ │ ├─ index.js # API y páginas simples
│ │ │ └─ pages/ # roles.html, usuarios.html, categorias.html, productos.html
│ │ ├─ .env.development # variables (ejemplo)
│ │ └─ .env.production
│ └─ auth/
│ ├─ src/
│ │ └─ index.js # /register y /auth/login
│ ├─ .env.development
│ └─ .env.production
├─ compose.yaml # base (db, tenants)
├─ compose.dev.yaml # entorno desarrollo (app, auth, db, tenants)
├─ compose.prod.yaml # entorno producción (app, auth, db, tenants)
├─ compose.tools.yaml # herramientas (NPM, CloudBeaver) con redes externas
├─ suitecoffee.py # gestor interactivo (Docker Compose)
├─ backup_compose_volumes.py # backups de volúmenes Compose
└─ restore_compose_volumes.py# restauración de volúmenes Compose
```
---
## Herramientas auxiliares (NPM y CloudBeaver)
Los servicios de **herramientas** están separados para poder usarlos con **ambos entornos** (dev y prod) a la vez. Se levantan con `compose.tools.yaml` y se conectan a las **redes externas** `suitecoffee_dev_net` y `suitecoffee_prod_net`.
- **Nginx Proxy Manager (NPM)**
Puertos: `80` (HTTP), `81` (UI). Volúmenes: `npm_data`, `npm_letsencrypt`.
- **CloudBeaver**
Puerto: `8978`. Volúmenes: `dbeaver_logs`, `dbeaver_workspace`.
> Si es la primera vez, arranca un entorno (dev/prod) para que Compose cree las redes; luego levanta las herramientas:
>
> ```bash
> docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
> docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
> ```
---
## Backups y restauración de volúmenes
Este repo incluye dos utilidades:
- `backup_compose_volumes.py` — detecta volúmenes de un proyecto de Compose (por **labels** y nombres) y los exporta a `tar.gz` usando un contenedor `alpine` temporal.
- `restore_compose_volumes.py` — permite restaurar esos `tar.gz` en volúmenes (útil para migraciones y pruebas).
**Ejemplos básicos**
```bash
# Listar ayuda
python3 backup_compose_volumes.py --help
# Respaldar volúmenes asociados a "suitecoffee_dev" en ./backups
python3 backup_compose_volumes.py --project suitecoffee_dev --output ./backups
# Restaurar un archivo a un volumen
python3 restore_compose_volumes.py --archive ./backups/suitecoffee_dev_suitecoffee-db-YYYYmmddHHMMSS.tar.gz --volume suitecoffee_dev_suitecoffee-db
```
> Consejo: si migraste manualmente y ves advertencias tipo “volume ... already exists but was not created by Docker Compose”, considera marcar el volumen como `external: true` en el YAML o recrearlo para que Compose lo etiquete correctamente.
---
## Comandos útiles
```bash
# Ver estado (menú interactivo)
python3 suitecoffee.py
# Levantar DEV/PROD por menú (con o sin --force-recreate)
python3 suitecoffee.py
# Levantar herramientas (también desde menú)
docker compose -f compose.tools.yaml --profile npm -p suitecoffee up -d
docker compose -f compose.tools.yaml --profile dbeaver -p suitecoffee up -d
# Inspeccionar servicios/volúmenes que Compose detecta desde los YAML
docker compose -f compose.yaml -f compose.dev.yaml config --services
docker compose -f compose.yaml -f compose.dev.yaml config --format json | jq .volumes
```
---
## Licencia
- **ISC** (ver `package.json`).
---
## Sugerencias de mejora
- **Autenticación y seguridad**
- Emitir **JWT** en el login y proteger rutas (roles/autorización por perfil).
- Configurar **CORS** por orígenes (en dev está abierto; en prod restringir).
- Añadir **ratelimit** y **helmet** en Express.
- **Esquema de datos y migraciones**
- Añadir migraciones automatizadas (p.ej. **Prisma**, **Knex**, **Sequelize** o SQL versionado) y seeds iniciales.
- Clarificar el **modelo multitenant**: por **BD por tenant** o **schema por tenant**; documentar estrategia.
- **Calidad & DX**
- Tests (unitarios e integración) y **CI** básico.
- Validación de entrada (**zod / joi**), manejo de errores consistente y logs estructurados.
- **Docker/DevOps**
- Documentar variables `.env` completas por servicio.
- Publicar imágenes de producción y usar `IMAGE:TAG` en `compose.prod.yaml` (evitar build en servidor).
- Añadir **healthchecks** a `app`/`auth` (ya hay ejemplos comentados).
- **Frontend**
- Reemplazar páginas HTML de prueba por un **frontend** (React/Vite) o una UI admin mínima.
- **Pequeños fixes**
- En los HTML de ejemplo corregir las referencias a campos `id_rol`, `id_categoria`, etc.
- Centralizar constantes (nombres de tablas/campos) y normalizar respuestas API.
---
+664
View File
@@ -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 multiservicio)</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 multitenencia, 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 (Lets Encrypt) para exponer servicios.</li>
<li><strong>CloudBeaver (DBeaver)</strong>: administración de PostgreSQL vía web.</li>
</ul>
<p><strong>Redes &amp; 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 &quot;Entorno DEV/PROD&quot; {
[app (Express)] as APP
[auth (Express + bcrypt)] as AUTH
database &quot;db (PostgreSQL)&quot; as DB
database &quot;tenants (PostgreSQL)&quot; as TENANTS
APP -down-&gt; DB : Pool PG
APP -down-&gt; TENANTS : Pool PG
AUTH -down-&gt; DB : Pool PG (usuarios)
Usuario --&gt; APP : UI / API
Usuario --&gt; AUTH : Login/Registro
}
package &quot;Herramientas&quot; {
[Nginx Proxy Manager] as NPM
[CloudBeaver] as DBVR
NPM ..&gt; APP : proxy
NPM ..&gt; AUTH : proxy
DBVR ..&gt; DB : admin
DBVR ..&gt; 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>Multitenencia</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/&lt;service&gt;/.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>ratelimit</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 multitenant</strong>: por <strong>BD por tenant</strong> o <strong>schema por tenant</strong>; documentar estrategia.</li>
</ul>
</li>
<li><strong>Calidad &amp; 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>
+55
View File
@@ -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
+5 -5
View File
@@ -1,4 +1,4 @@
# compose.tools.yaml
# $ compose.tools.yaml
name: suitecoffee_tools
services:
@@ -14,7 +14,7 @@ services:
- dbeaver_logs:/opt/cloudbeaver/logs
- dbeaver_workspace:/opt/cloudbeaver/workspace
networks:
suitecoffee_prod_net: {}
# suitecoffee_prod_net: {}
suitecoffee_dev_net: {}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:8978 || exit 1"]
@@ -37,7 +37,7 @@ services:
- npm_data:/data
- npm_letsencrypt:/etc/letsencrypt
networks:
suitecoffee_prod_net: {}
# suitecoffee_prod_net: {}
suitecoffee_dev_net: {}
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
@@ -50,8 +50,8 @@ services:
networks:
suitecoffee_dev_net:
external: true
suitecoffee_prod_net:
external: true
# suitecoffee_prod_net:
# external: true
volumes:
npm_data:
+13
View File
@@ -4,6 +4,19 @@ name: ${COMPOSE_PROJECT_NAME:-suitecoffee}
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:
# depends_on:
# db:
+6 -7
View File
@@ -72,19 +72,18 @@ app.get('/',(req, res) => res.sendFile(path.join(__dirname, 'pages', 'index.html
app.get('/planes', async (req, res) => {
try {
const result = await pool.query(`
SELECT id, nombre, descripcion, precio
FROM plan
WHERE activo = true
ORDER BY id
`);
res.json(result.rows);
const { rows: [row] } = await pool.query(
'SELECT api.get_planes_json($1) AS data;',
[true]
);
res.type('application/json').send(row.data);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Error al cargar planes' });
}
});
app.post('/api/registro', async (req, res) => {
const {
nombre_empresa,
+20
View File
@@ -0,0 +1,20 @@
# Dockerfile.dev
FROM node:22.18
# Definir variables de entorno con valores predeterminados
# ARG NODE_ENV=production
# ARG PORT=3000
RUN apt-get update
RUN apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# Copia archivos de configuración primero para aprovechar el cache
COPY package*.json ./
# Instala dependencias
RUN npm i
# Copia el resto de la app
COPY . .
CMD ["npm", "run", "start"]
+1585
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"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",
"serve-favicon": "^2.5.1"
},
"keywords": [],
"description": "Workarround para tener un MVP que llegue al verano para usarse"
}
+842
View File
@@ -0,0 +1,842 @@
// app/src/index.js
import chalk from 'chalk'; // Colores!
import favicon from 'serve-favicon'; // Favicon
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")));
app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), {
maxAge: '1y'
}));
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {
maxAge: '1y'
}));
const url = v => !v ? "" : (v.startsWith("http") ? v : `/img/productos/${v}`);
// ----------------------------------------------------------
// 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,
};
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', 'asistencia_resumen_diario',
'asistencia_intervalo', 'vw_compras'
];
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 = "Dashboard";
res.locals.pageId = "home"; // para el sidebar contextual
res.render("dashboard");
});
app.get("/dashboard", (req, res) => {
res.locals.pageTitle = "Dashboard";
res.locals.pageId = "dashboard"; // <- importante
res.render("dashboard");
});
// app.get('/', (req, res) => {
// res.sendFile(path.join(__dirname, 'pages', 'dashboard.html'));
// });
app.get("/comandas", (req, res) => {
res.locals.pageTitle = "Comandas";
res.locals.pageId = "comandas"; // <- importante para el sidebar contextual
res.render("comandas");
});
// 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'));
// });
app.get("/productos", (req, res) => {
res.locals.pageTitle = "Productos";
res.locals.pageId = "productos";
res.render("productos");
});
app.get('/usuarios', (req, res) => {
res.locals.pageTitle = 'Usuarios';
res.locals.pageId = 'usuarios';
res.render('usuarios');
});
app.get('/reportes', (req, res) => {
res.locals.pageTitle = 'Reportes';
res.locals.pageId = 'reportes';
res.render('reportes');
});
app.get('/compras', (req, res) => {
res.locals.pageTitle = 'Compras';
res.locals.pageId = 'compras';
res.render('compras');
});
// ----------------------------------------------------------
// API
// ----------------------------------------------------------
app.get('/api/tables', async (_req, res) => {
res.json(ALLOWED_TABLES);
});
app.get('/api/schema/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const client = await getClient();
try {
const columns = await loadColumns(client, table);
const fks = await loadForeignKeys(client, table);
const enriched = columns.map(c => ({ ...c, foreign: fks[c.column_name] || null }));
res.json({ table, columns: enriched });
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/options/:table/:column', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const column = req.params.column;
if (!VALID_IDENT.test(column)) throw new Error('Columna inválida');
const client = await getClient();
try {
const fks = await loadForeignKeys(client, table);
const fk = fks[column];
if (!fk) return res.json([]);
const refTable = fk.foreign_table;
const refId = fk.foreign_column;
const labelCol = await pickLabelColumn(client, refTable);
const sql = `SELECT ${q(refId)} AS id, ${q(labelCol)} AS label FROM ${q(refTable)} ORDER BY ${q(labelCol)} LIMIT 1000`;
const result = await client.query(sql);
res.json(result.rows);
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/table/:table', async (req, res) => {
try {
const table = ensureTable(req.params.table);
const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000);
const client = await getClient();
try {
const pks = await loadPrimaryKey(client, table);
const orderBy = pks.length ? `ORDER BY ${pks.map(q).join(', ')} DESC` : '';
const sql = `SELECT * FROM ${q(table)} ${orderBy} LIMIT ${limit}`;
const result = await client.query(sql);
// Normalizar: siempre devolver objetos {col: valor}
const colNames = result.fields.map(f => f.name);
let rows = result.rows;
if (rows.length && Array.isArray(rows[0])) {
rows = rows.map(r => Object.fromEntries(r.map((v, i) => [colNames[i], v])));
}
res.json(rows);
} finally { client.release(); }
} catch (e) {
res.status(400).json({ error: e.message, code: e.code, detail: e.detail });
}
});
app.post('/api/table/:table', async (req, res) => {
const table = ensureTable(req.params.table);
const payload = req.body || {};
try {
const client = await getClient();
try {
const columns = await loadColumns(client, table);
const insertable = columns.filter(c =>
!c.is_primary && !c.is_identity && !(c.column_default || '').startsWith('nextval(')
);
const allowedCols = new Set(insertable.map(c => c.column_name));
const cols = [];
const vals = [];
const params = [];
let idx = 1;
for (const [k, v] of Object.entries(payload)) {
if (!allowedCols.has(k)) continue;
if (!VALID_IDENT.test(k)) continue;
cols.push(q(k));
vals.push(`$${idx++}`);
params.push(v);
}
if (!cols.length) {
const { rows } = await client.query(`INSERT INTO ${q(table)} DEFAULT VALUES RETURNING *`);
res.status(201).json({ inserted: rows[0] });
} else {
const { rows } = await client.query(
`INSERT INTO ${q(table)} (${cols.join(', ')}) VALUES (${vals.join(', ')}) RETURNING *`,
params
);
res.status(201).json({ inserted: rows[0] });
}
} catch (e) {
if (e.code === '23503') return res.status(400).json({ error: 'Violación de clave foránea', detail: e.detail });
if (e.code === '23505') return res.status(400).json({ error: 'Violación de unicidad', detail: e.detail });
if (e.code === '23514') return res.status(400).json({ error: 'Violación de CHECK', detail: e.detail });
if (e.code === '23502') return res.status(400).json({ error: 'Campo NOT NULL faltante', detail: e.detail });
throw e;
}
} catch (e) {
res.status(400).json({ error: e.message });
}
});
app.get('/api/comandas', async (req, res, next) => {
try {
const estado = (req.query.estado || '').trim() || null;
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
const { rows } = await pool.query(
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
[estado, limit]
);
res.json(rows);
} catch (e) { next(e); }
});
// 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)
// GET /api/comandas/:id/detalle
app.get('/api/comandas/:id/detalle', (req, res, next) =>
pool.query(
`SELECT id_det_comanda, id_producto, producto_nombre,
cantidad, pre_unitario, subtotal, observaciones
FROM public.v_comandas_detalle_items
WHERE id_comanda = $1::int
ORDER BY id_det_comanda`,
[req.params.id]
)
.then(r => res.json(r.rows))
.catch(next)
);
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
// try {
// const id = parseInt(req.params.id, 10);
// if (!Number.isInteger(id) || id <= 0) {
// return res.status(400).json({ error: 'id inválido' });
// }
// const sql = `
// SELECT
// id_det_comanda, id_producto, producto_nombre,
// cantidad, pre_unitario, subtotal, observaciones
// FROM public.v_comandas_detalle_items
// WHERE id_comanda = $1::int
// ORDER BY id_det_comanda
// `;
// const { rows } = await pool.query(sql, [id]);
// res.json(rows);
// } catch (e) { next(e); }
// });
// 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); }
// });
// Cerrar comanda (setea estado y fec_cierre en DB)
app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await pool.query(
`SELECT public.f_cerrar_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
} catch (err) { next(err); }
});
// Abrir (reabrir) comanda
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
try {
const id = Number(req.params.id);
if (!Number.isInteger(id) || id <= 0) {
return res.status(400).json({ error: 'id inválido' });
}
const { rows } = await pool.query(
`SELECT public.f_abrir_comanda($1) AS data`,
[id]
);
if (!rows.length || rows[0].data === null) {
return res.status(404).json({ error: 'Comanda no encontrada' });
}
res.json(rows[0].data);
} catch (err) { next(err); }
});
// // 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); }
// });
// GET producto + receta
app.get('/api/rpc/get_producto/:id', async (req, res) => {
const id = Number(req.params.id);
const { rows } = await pool.query('SELECT public.get_producto($1) AS data', [id]);
res.json(rows[0]?.data || {});
});
// POST guardar producto + receta
app.post('/api/rpc/save_producto', async (req, res) => {
try {
// console.debug('receta payload:', req.body?.receta); // habilitalo si lo necesitás
const q = 'SELECT public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb) AS id_producto';
const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
const { rows } = await pool.query(q, params);
res.json(rows[0] || {});
} catch(e) {
console.error(e);
res.status(500).json({ error: 'save_producto failed' });
}
});
// app.post('/api/rpc/save_producto', async (req, res) => {
// const { id_producto=null, nombre, img_producto=null, precio=0, activo=true, id_categoria=null, receta=[] } = req.body || {};
// const q = 'SELECT * FROM public.save_producto($1,$2,$3,$4,$5,$6,$7::jsonb)';
// const params = [id_producto, nombre, img_producto, precio, activo, id_categoria, JSON.stringify(receta||[])];
// const { rows } = await pool.query(q, params);
// res.json(rows[0] || {});
// });
// GET MP + proveedores
app.get('/api/rpc/get_materia/:id', async (req, res) => {
const id = Number(req.params.id);
try {
const { rows } = await pool.query('SELECT public.get_materia_prima($1) AS data', [id]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'get_materia failed' });
}
});
// SAVE MP + proveedores (array)
app.post('/api/rpc/save_materia', async (req, res) => {
const { id_mat_prima=null, nombre, unidad, activo=true, proveedores=[] } = req.body || {};
try {
const q = 'SELECT public.save_materia_prima($1,$2,$3,$4,$5::jsonb) AS id_mat_prima';
const params = [id_mat_prima, nombre, unidad, activo, JSON.stringify(proveedores||[])];
const { rows } = await pool.query(q, params);
res.json(rows[0] || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'save_materia failed' });
}
});
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
try {
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
}
});
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
app.post('/api/rpc/import_asistencia', async (req, res) => {
try {
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
const origen = req.body?.origen || null;
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'import_asistencia failed' });
}
});
// Consultar datos de asistencia (raw + pares) para un usuario y rango
app.post('/api/rpc/asistencia_get', async (req, res) => {
try {
const { doc, desde, hasta } = req.body || {};
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
const { rows } = await pool.query(sql, [doc, desde, hasta]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
}
});
// Editar un registro crudo y recalcular pares
app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
try {
const { id_raw, fecha, hora, modo } = req.body || {};
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
}
});
// Eliminar un registro crudo y recalcular pares
app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
try {
const { id_raw } = req.body || {};
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
const { rows } = await pool.query(sql, [id_raw]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
}
});
// POST /api/rpc/report_tickets { year }
app.post('/api/rpc/report_tickets', async (req, res) => {
try {
const y = parseInt(req.body?.year ?? req.query?.year, 10);
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
? y
: (new Date()).getFullYear();
const { rows } = await pool.query(
'SELECT public.report_tickets_year($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_tickets error:', e);
res.status(500).json({
error: 'report_tickets failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
app.post('/api/rpc/report_asistencia', async (req, res) => {
try {
let { desde, hasta } = req.body || {};
// defaults si vienen vacíos/invalidos
const re = /^\d{4}-\d{2}-\d{2}$/;
if (!re.test(desde) || !re.test(hasta)) {
const end = new Date();
const start = new Date(end); start.setDate(end.getDate()-30);
desde = start.toISOString().slice(0,10);
hasta = end.toISOString().slice(0,10);
}
const { rows } = await pool.query(
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_asistencia error:', e);
res.status(500).json({
error: 'report_asistencia failed',
message: e.message, detail: e.detail, where: e.where, code: e.code
});
}
});
// app.post('/api/rpc/report_asistencia', async (req,res)=>{
// try{
// const {desde, hasta} = req.body||{};
// const sql = 'SELECT * FROM public.report_asistencia($1::date,$2::date)';
// const {rows} = await pool.query(sql,[desde, hasta]);
// res.json(rows);
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'report_tickets failed' + e });
// }
// });
// app.post('/api/rpc/report_tickets', async (req, res) => {
// try {
// const { year } = req.body || {};
// const sql = 'SELECT public.report_tickets_year($1::int) AS data';
// const { rows } = await pool.query(sql, [year]);
// res.json(rows[0]?.data || {});
// } catch (e) {
// console.error(e);
// res.status(500).json({ error: 'report_tickets failed' + e });
// }
// });
// Guardar (insert/update)
app.post('/api/rpc/save_compra', async (req, res) => {
try {
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
const { rows } = await pool.query(sql, args);
res.json(rows[0]); // { id_compra, total }
} catch (e) {
console.error('save_compra error:', e);
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
}
});
// Obtener para editar
app.post('/api/rpc/get_compra', async (req, res) => {
try {
const { id_compra } = req.body || {};
const sql = `SELECT public.get_compra($1::int) AS data`;
const { rows } = await pool.query(sql, [id_compra]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'get_compra failed' });
}
});
// Eliminar
app.post('/api/rpc/delete_compra', async (req, res) => {
try {
const { id_compra } = req.body || {};
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
res.json({ ok: true });
} catch (e) {
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
}
});
// POST /api/rpc/report_gastos { year: 2025 }
app.post('/api/rpc/report_gastos', async (req, res) => {
try {
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
const { rows } = await pool.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});
// (Opcional) GET para probar rápido desde el navegador:
// /api/rpc/report_gastos?year=2025
app.get('/api/rpc/report_gastos', async (req, res) => {
try {
const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
const { rows } = await pool.query(
'SELECT public.report_gastos($1::int) AS j', [year]
);
res.json(rows[0].j);
} catch (e) {
console.error('report_gastos error:', e);
res.status(500).json({
error: 'report_gastos failed',
message: e.message, detail: e.detail, code: e.code
});
}
});
// ----------------------------------------------------------
// 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' });
});
+355
View File
@@ -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>
+293
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 1005 KiB

+558
View File
@@ -0,0 +1,558 @@
<!-- services/manso/src/views/comandas.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">📋 Nueva Comanda</h1>
<span class="badge rounded-pill text-bg-light">/api/*</span>
</div>
<div class="row g-3">
<!-- Columna izquierda: Productos -->
<div class="col-12 col-lg-7">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Productos</strong>
<div class="ms-auto small text-muted" id="prodCount">0 ítems</div>
</div>
<div class="card-body">
<div class="row g-2 align-items-center mb-2">
<div class="col-12 col-sm">
<input id="busqueda" type="search" class="form-control" placeholder="Buscar por nombre o categoría…">
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary" id="limpiarBusqueda">Limpiar</button>
</div>
</div>
<div id="listadoProductos" class="border rounded" style="max-height:58vh; overflow:auto;">
<!-- tabla de productos renderizada por JS -->
<div class="p-3 text-muted">Cargando…</div>
</div>
</div>
</div>
</div>
<!-- Columna derecha: Detalles + Carrito -->
<div class="col-12 col-lg-5">
<div class="card shadow-sm mb-3">
<div class="card-header"><strong>Detalles</strong></div>
<div class="card-body">
<div class="row g-2">
<div class="col-12 col-sm-6">
<label for="selMesa" class="form-label text-muted small mb-1">Mesa</label>
<select id="selMesa" class="form-select"></select>
</div>
<div class="col-12 col-sm-6">
<label for="selUsuario" class="form-label text-muted small mb-1">Usuario</label>
<select id="selUsuario" class="form-select"></select>
</div>
</div>
<div class="mt-2">
<label for="obs" class="form-label text-muted small mb-1">Observaciones</label>
<textarea id="obs" class="form-control" rows="3"></textarea>
</div>
<div class="alert alert-secondary mt-3 mb-0 small">
La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header"><strong>Carrito</strong></div>
<div class="card-body p-0" id="carritoWrap">
<div class="p-3 text-muted">Aún no agregaste productos.</div>
</div>
<div class="d-flex align-items-center gap-2 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
<div class="small"><span class="text-muted">Ítems:</span> <strong id="kpiItems">0</strong></div>
<div class="small ms-2"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
<div class="ms-auto"></div>
<button class="btn btn-outline-secondary" id="vaciar">Vaciar</button>
<button class="btn btn-primary" id="crear">Crear Comanda</button>
</div>
</div>
<div id="msg" class="mt-2 small text-muted"></div>
</div>
</div>
<!-- ====== LÓGICA ====== -->
<script>
// Helpers DOM
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
// Estado
const state = {
productos: [],
mesas: [],
usuarios: [],
categorias: [], // <--- NUEVO
carrito: [],
filtro: ''
};
function norm(s='') {
return s.toString().toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu,''); // "café" -> "cafe"
}
function isTakeaway(apodo) {
return /^takeaway$/i.test(String(apodo || '').trim());
}
function groupOrderByCatName(catName='') {
const n = norm(catName);
if (n.includes('bar')) return 1;
if (n.includes('cafe')) return 2;
if (n.includes('cafeter')) return 3;
if (n.includes('trago') || n.includes('refresc')) return 4;
return 99; // otros
}
// Genera el HTML del ticket de cocina (80mm aprox)
function buildKitchenTicketHTML(data) {
const apodo = String(data.mesa_apodo ?? '').trim();
const numero = data.mesa_numero ?? '';
const take = isTakeaway(apodo);
const mesaTxt = take ? apodo.toUpperCase() : `Mesa #${numero}${apodo ? ' · ' + apodo : ''}`;
// const isTakeaway = /Takeaway/i.test(String(data.mesa_apodo ?? '')) || /Takeaway/i.test(String(data.mesa_numero ?? ''));
const mesaClass = take ? 'bigline' : 'mesa-medium';
const obs = (data.observaciones && data.observaciones.trim()) ? data.observaciones.trim() : '';
// Productos ya vienen con su "g" (grupo numérico 1..4/99) y cantidad formateada
const items = data.productos.slice().sort((a,b)=> (a.g||99) - (b.g||99));
let productosHtml = '';
let prevG = null;
for (const p of items) {
if (prevG !== null && p.g !== prevG) {
productosHtml += `<div class="hr dotted"></div>`; // separación punteada entre grupos
}
productosHtml += `
<div class="row">
<div class="qty">x${p.cantidad}</div>
<div class="name">${p.nombre}</div>
</div>`;
prevG = p.g;
}
return `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Ticket Cocina</title>
<style>
:root {
--w: 80mm;
--fz-base: 16px;
--fz-md: 16px; /* observaciones */
--fz-item: 18px; /* filas */
--fz-xl: 26px; /* <--- NUEVO: tamaño “grande” (mesa) */
--fz-xxl: 34px; /* título (#comanda) */
--fz-sm: 12px;
}
html, body { margin:0; padding:0; }
body {
width: var(--w);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: var(--fz-base);
line-height: 1.35;
color:#000;
font-weight: 700;
}
#ticket { padding: 10px 8px; }
.center { text-align:center; }
.row { display:flex; gap:8px; margin: 4px 0; }
.row .qty { min-width: 22mm; font-size: var(--fz-item); letter-spacing:.2px; }
.row .name { flex:1; font-size: var(--fz-item); text-transform: uppercase; word-break: break-word; }
.hr { border-top: 2px dashed #000; margin: 8px 0; }
.hr.dotted { border-top: 2px dotted #000; }
.small { font-size: var(--fz-sm); }
.bigline { font-size: var(--fz-xxl); text-transform: uppercase; }
.mesa-medium { font-size: var(--fz-xl); text-transform: uppercase; }
.obs { font-size: var(--fz-md); }
.mt4{margin-top:4px}.mt8{margin-top:8px}.mb4{margin-bottom:4px}.mb8{margin-bottom:8px}
@page { size: var(--w) auto; margin: 0; }
@media print { body { width: var(--w); } }
</style>
</head>
<body>
<div id="ticket">
<!-- SIN TÍTULO -->
<div class="center bigline">#${data.id_comanda}</div>
<div class="center ${mesaClass}">${mesaTxt}</div>
<div class="small mt4">Fecha: ${data.fecha} ${data.hora}</div>
<div class="small mt4">Mozo: ${data.usuario || '—'}</div>
${obs ? `<div class="obs mt8">Obs: ${obs}</div>` : ''}
<div class="hr"></div>
${productosHtml}
<div class="hr"></div>
<div class="small">Ítems: ${data.items} · Unidades: ${data.units}</div>
<div class="center mt8 small">— fin —</div>
</div>
<script>window.onload = () => { window.focus(); window.print(); }<\/script>
</body>
</html>`;
}
// Imprime HTML usando un iframe oculto (menos bloqueos de pop-up)
function printHtmlViaIframe(html) {
return new Promise((resolve) => {
let iframe = document.getElementById('printFrame');
if (!iframe) {
iframe = document.createElement('iframe');
iframe.id = 'printFrame';
iframe.style.position = 'fixed';
iframe.style.right = '-9999px';
iframe.style.bottom = '0';
iframe.style.width = '0';
iframe.style.height = '0';
iframe.style.border = '0';
document.body.appendChild(iframe);
}
const doc = iframe.contentWindow.document;
doc.open();
doc.write(html);
doc.close();
// Salida: remover iframe después de un rato para no acumular
setTimeout(() => {
resolve();
// (si prefieres mantenerlo para reimpresiones, no lo quites)
// document.body.removeChild(iframe);
}, 1500);
});
}
// Utils
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'mt-2 small ok text-success' : 'mt-2 small err text-danger';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='mt-2 small text-muted'; }, 3500);
};
async function jget(url) {
const res = await fetch(url);
let data; try { data = await res.json(); } catch { data = null; }
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body) {
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// Carga inicial
async function init() {
const [prods, mesas, usuarios, categorias] = await Promise.all([
jget('/api/table/productos?limit=1000'),
jget('/api/table/mesas?limit=1000'),
jget('/api/table/usuarios?limit=1000'),
jget('/api/table/categorias?limit=1000') // <--- NUEVO
]);
state.productos = prods.filter(p => p.activo !== false);
state.mesas = mesas;
state.usuarios = usuarios.filter(u => u.activo !== false);
state.categorias = Array.isArray(categorias) ? categorias : [];
// Mapas para buscar categoría por id de producto
state.catById = new Map(state.categorias.map(c => [c.id_categoria, (c.nombre||'').toString()]));
state.prodCatNameById = new Map(state.productos.map(p => [p.id_producto, state.catById.get(p.id_categoria)||'']));
hydrateMesas();
hydrateUsuarios();
renderProductos();
renderCarrito();
$('#busqueda').addEventListener('input', () => {
state.filtro = $('#busqueda').value.trim().toLowerCase();
renderProductos();
});
$('#limpiarBusqueda').addEventListener('click', () => {
$('#busqueda').value = '';
state.filtro = '';
renderProductos();
});
$('#vaciar').addEventListener('click', () => { state.carrito = []; renderCarrito(); });
$('#crear').addEventListener('click', crearComanda);
}
function hydrateMesas() {
const sel = $('#selMesa'); sel.innerHTML = '';
for (const m of state.mesas) {
const o = document.createElement('option');
o.value = m.id_mesa;
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
sel.appendChild(o);
}
}
function hydrateUsuarios() {
const sel = $('#selUsuario'); sel.innerHTML = '';
for (const u of state.usuarios) {
const o = document.createElement('option');
o.value = u.id_usuario;
o.textContent = `${u.nombre} ${u.apellido}`.trim();
sel.appendChild(o);
}
}
// Render productos
function renderProductos() {
let rows = state.productos.slice();
if (state.filtro) {
rows = rows.filter(p =>
(p.nombre || '').toLowerCase().includes(state.filtro) ||
String(p.id_categoria ?? '').includes(state.filtro)
);
}
$('#prodCount').textContent = `${rows.length} ítems`;
if (!rows.length) {
$('#listadoProductos').innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Precio</th>
<th style="width:210px;">Cantidad</th>
<th style="width:100px;"></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
for (const p of rows) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${p.nombre}</td>
<td class="text-end">${money(p.precio)}</td>
<td>
<div class="d-flex align-items-center gap-2">
<input type="number" min="0.001" step="0.001" value="1.000" data-qty class="form-control form-control-sm" style="max-width:120px;">
<button class="btn btn-sm btn-outline-secondary" data-dec>-</button>
<button class="btn btn-sm btn-outline-secondary" data-inc>+</button>
</div>
</td>
<td><button class="btn btn-sm btn-primary" data-add>Agregar</button></td>
`;
const qty = tr.querySelector('[data-qty]');
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
tb.appendChild(tr);
}
$('#listadoProductos').innerHTML = '';
$('#listadoProductos').appendChild(tbl);
}
function addToCart(prod, cantidad) {
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
const precio = parseFloat(prod.precio);
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
renderCarrito();
}
// Render carrito
function renderCarrito() {
const wrap = $('#carritoWrap');
if (!state.carrito.length) {
wrap.innerHTML = '<div class="p-3 text-muted">Aún no agregaste productos.</div>';
$('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0);
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th></th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let items = 0, total = 0;
state.carrito.forEach((it, idx) => {
items += 1;
const sub = Number(it.pre_unitario) * Number(it.cantidad);
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${it.nombre}</td>
<td class="text-end">${money(it.pre_unitario)}</td>
<td class="text-end">
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" class="form-control form-control-sm text-end" style="max-width:120px;">
</td>
<td class="text-end">${money(sub)}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary" data-del>Quitar</button>
</td>
`;
const qty = tr.querySelector('input[type="number"]');
qty.addEventListener('change', () => {
const v = parseFloat(qty.value||'0');
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
it.cantidad = Number(v.toFixed(3));
renderCarrito();
});
tr.querySelector('[data-del]').addEventListener('click', () => {
state.carrito.splice(idx,1);
renderCarrito();
});
tb.appendChild(tr);
});
wrap.innerHTML = '';
wrap.appendChild(tbl);
$('#kpiItems').textContent = String(items);
$('#kpiTotal').textContent = money(total);
}
const fmtQty = (n) => Number(n).toFixed(3).replace(/\.?0+$/,'');
async function crearComanda() {
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
const id_mesa = parseInt($('#selMesa').value, 10);
const id_usuario = parseInt($('#selUsuario').value, 10);
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
// Snapshot del carrito ANTES de limpiar (para imprimir)
const cartSnapshot = state.carrito.map(it => ({ ...it }));
const observaciones = $('#obs').value.trim() || null;
try {
// 1) encabezado comanda
const { inserted: com } = await jpost('/api/table/comandas', {
id_usuario,
id_mesa,
estado: 'abierta',
observaciones
});
// 2) detalle
const id_comanda = com.id_comanda;
const payloads = cartSnapshot.map(it => ({
id_comanda,
id_producto: it.id_producto,
cantidad: it.cantidad,
pre_unitario: it.pre_unitario
}));
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
// 3) Datos auxiliares para ticket
const mesa = state.mesas.find(m => m.id_mesa === id_mesa) || {};
const usuario = state.usuarios.find(u => u.id_usuario === id_usuario) || {};
const now = new Date();
const fecha = now.toLocaleDateString();
const hora = now.toLocaleTimeString();
// 4) Construir e imprimir Ticket de Cocina (sin precios)
const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0);
const items = cartSnapshot.length;
// map producto -> nombre de categoría
const prodCat = state.prodCatNameById || new Map();
const productosParaTicket = cartSnapshot.map(it => ({
nombre: it.nombre,
cantidad: fmtQty(it.cantidad),
g: groupOrderByCatName(prodCat.get(it.id_producto) || '') // 1..4/99
}));
const ticketHtml = buildKitchenTicketHTML({
id_comanda,
fecha, hora,
mesa_numero: mesa?.numero,
mesa_apodo: mesa?.apodo,
usuario: `${usuario?.nombre || ''} ${usuario?.apellido || ''}`.trim(),
observaciones,
items,
units,
productos: productosParaTicket // <--- con grupos
});
await printHtmlViaIframe(ticketHtml);
// 5) Reset UI
state.carrito = [];
renderCarrito();
$('#obs').value = '';
toast(`Comanda #${id_comanda} creada e impresa`, true);
} catch (e) {
toast(e.message || 'No se pudo crear la comanda');
}
}
// // Crear comanda
// async function crearComanda() {
// if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
// const id_mesa = parseInt($('#selMesa').value, 10);
// const id_usuario = parseInt($('#selUsuario').value, 10);
// if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
// const observaciones = $('#obs').value.trim() || null;
// try {
// // 1) encabezado comanda
// const { inserted: com } = await jpost('/api/table/comandas', {
// id_usuario,
// id_mesa,
// estado: 'abierta',
// observaciones
// });
// // 2) detalle
// const id_comanda = com.id_comanda;
// const payloads = state.carrito.map(it => ({
// id_comanda,
// id_producto: it.id_producto,
// cantidad: it.cantidad,
// pre_unitario: it.pre_unitario
// }));
// await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
// state.carrito = [];
// renderCarrito();
// $('#obs').value = '';
// toast(`Comanda #${id_comanda} creada`, true);
// } catch (e) {
// toast(e.message || 'No se pudo crear la comanda');
// }
// }
// GO
init().catch(err => toast(err.message || 'Error cargando datos'));
</script>
+361
View File
@@ -0,0 +1,361 @@
<% /* Compras / Gastos */ %>
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-2">
<h3 class="mb-0">Compras / Gastos</h3>
<div class="ms-auto d-flex gap-2">
<button id="btnNueva" class="btn btn-outline-secondary btn-sm">Nueva</button>
<span id="status" class="small text-muted">—</span>
</div>
</div>
<!-- Formulario -->
<div class="card shadow-sm mb-3">
<div class="card-header"><strong id="formTitle">Nueva compra</strong></div>
<div class="card-body">
<form id="frmCompra" class="row g-3">
<input type="hidden" id="id_compra" value="">
<div class="col-12 col-md-5">
<label class="form-label">Proveedor</label>
<select id="id_proveedor" class="form-select" required></select>
</div>
<div class="col-12 col-md-3">
<label class="form-label">Fecha</label>
<input id="fec_compra" type="datetime-local" class="form-control" required>
</div>
<div class="col-12 col-md-4">
<label class="form-label">Total</label>
<input id="total" type="text" class="form-control" value="$ 0" disabled>
</div>
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Renglones</div>
<div>
<button type="button" id="addRow" class="btn btn-sm btn-outline-primary">Agregar renglón</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm align-middle" id="tblDet">
<thead class="table-light">
<tr>
<th style="width:110px">Tipo</th>
<th>Ítem</th>
<th style="width:140px" class="text-end">Cantidad</th>
<th style="width:160px" class="text-end">Precio</th>
<th style="width:140px" class="text-end">Subtotal</th>
<th style="width:60px"></th>
</tr>
</thead>
<tbody>
<tr class="empty">
<td colspan="6" class="p-3 text-muted">Sin renglones</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="col-12 d-flex gap-2">
<button id="btnGuardar" type="submit" class="btn btn-success">Guardar</button>
<button id="btnEliminar" type="button" class="btn btn-outline-danger d-none">Eliminar</button>
</div>
</form>
</div>
</div>
<!-- Listado -->
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Compras recientes</strong>
<input id="buscar" class="form-control form-control-sm ms-auto" style="max-width:260px" placeholder="Buscar proveedor…">
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0" id="tblCompras">
<thead class="table-light">
<tr>
<th>#</th>
<th>Proveedor</th>
<th>Fecha</th>
<th class="text-end">Total</th>
<th></th>
</tr>
</thead>
<tbody>
<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<style>
#tblDet select, #tblDet input { min-height: 34px; }
.money { text-align: right; }
</style>
<script>
const $ = s => document.querySelector(s);
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
const z2 = n => String(n).padStart(2,'0');
const parseNum = v => (typeof v==='number') ? v : Number(String(v).replace(/[^\d.,-]/g,'').replace('.','').replace(',','.')) || 0;
function fmtMoneyInt(v, mode = 'round') {
const n = Number(v || 0);
const i = mode === 'trunc' ? Math.trunc(n) : mode === 'floor' ? Math.floor(n) : Math.round(n);
return '$ ' + i.toLocaleString('es-UY', { maximumFractionDigits: 0 });
}
const onlyDigits = s => String(s ?? '').replace(/\D+/g, '');
function wireIntInput(input, onChange) {
const sync = () => {
const n = Number(onlyDigits(input.value) || '0'); // entero
input.dataset.raw = String(n); // guardo valor crudo
input.value = n.toLocaleString('es-UY'); // muestro con miles
if (onChange) onChange(n);
};
input.addEventListener('input', () => setTimeout(sync, 0));
input.addEventListener('blur', sync);
// 1a sync
sync();
}
function getIntInput(input) {
const s = input?.dataset?.raw ?? onlyDigits(input?.value);
return Number(s || '0');
}
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
// Catálogos
let CATS = { prov:[], mat:[], prod:[] };
async function loadCatalogos(){
$('#status').textContent = 'Cargando catálogos…';
const [prov, mat, prod] = await Promise.all([
jget('/api/table/proveedores?limit=10000'),
jget('/api/table/mate_primas?limit=10000'),
jget('/api/table/productos?limit=10000')
]);
CATS.prov = prov||[]; CATS.mat = mat||[]; CATS.prod = prod||[];
const sel = $('#id_proveedor'); sel.innerHTML = '<option value="">—</option>' + CATS.prov.map(p=>`<option value="${p.id_proveedor}">${p.raz_social||p.nombre||('Prov#'+p.id_proveedor)}</option>`).join('');
$('#status').textContent = 'Listo';
}
// Renglón
function addRow(data){
const tb = $('#tblDet tbody');
tb.querySelector('.empty')?.remove();
const tr = document.createElement('tr');
const tipo = data?.tipo || 'MAT'; // MAT | PROD
const id = data?.id || '';
const cant = data?.cantidad != null ? data.cantidad : 1;
const pu = data?.precio != null ? data.precio : 0;
tr.innerHTML = `
<td>
<select class="form-select form-select-sm tipo">
<option value="MAT"${tipo==='MAT'?' selected':''}>Materia</option>
<option value="PROD"${tipo==='PROD'?' selected':''}>Producto</option>
</select>
</td>
<td>
<select class="form-select form-select-sm item"></select>
</td>
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
class="form-control form-control-sm text-end qty" value="${cant}"></td>
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
class="form-control form-control-sm text-end price" value="${pu}"></td>
<td class="text-end sub">$ 0</td>
<td><button type="button" class="btn btn-sm btn-outline-danger del">✕</button></td>
`;
tb.appendChild(tr);
// load items segun tipo
function fillItems(selTipo, selItem, selectedId){
const list = selTipo.value === 'MAT' ? CATS.mat : CATS.prod;
selItem.innerHTML = '<option value="">—</option>' + list.map(i => {
const id = selTipo.value === 'MAT' ? i.id_mat_prima : i.id_producto;
const nm = i.nombre || ('#'+id);
return `<option value="${id}">${nm}</option>`;
}).join('');
if (selectedId) selItem.value = selectedId;
}
const selTipo = tr.querySelector('.tipo');
const selItem = tr.querySelector('.item');
const qty = tr.querySelector('.qty');
const price = tr.querySelector('.price');
const subCell = tr.querySelector('.sub');
selTipo.addEventListener('change', ()=>{ fillItems(selTipo, selItem, null); updateRow(); });
[selItem, qty, price].forEach(el => el.addEventListener('input', updateRow));
tr.querySelector('.del').addEventListener('click', ()=>{ tr.remove(); recalcTotal(); if (!tb.children.length) tb.innerHTML='<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>'; });
fillItems(selTipo, selItem, id);
function updateRow(){
const s = getIntInput(qty) * getIntInput(price);
subCell.textContent = fmtMoneyInt(s);
recalcTotal();
}
wireIntInput(qty, updateRow);
wireIntInput(price, updateRow);
updateRow();
}
function recalcTotal(){
let tot = 0;
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
if (tr.classList.contains('empty')) return;
const q = getIntInput(tr.querySelector('.qty'));
const p = getIntInput(tr.querySelector('.price'));
tot += q * p;
});
$('#total').value = fmtMoneyInt(tot);
return tot;
}
function readFormToPayload(){
const id_compra = $('#id_compra').value ? Number($('#id_compra').value) : null;
const id_proveedor = Number($('#id_proveedor').value || 0);
const fec_compra = $('#fec_compra').value
? new Date($('#fec_compra').value).toISOString().slice(0,19).replace('T',' ')
: null;
const det = [];
// 👇 OJO: iteramos sobre TODAS las filas reales
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
if (tr.classList.contains('empty')) return;
const tipo = tr.querySelector('.tipo').value; // 'MAT' | 'PROD'
const id = Number(tr.querySelector('.item').value||0);
const qty = getIntInput(tr.querySelector('.qty')); // entero
const pu = getIntInput(tr.querySelector('.price')); // entero
if (id && qty>0 && pu>=0) det.push({ tipo, id, cantidad: qty, precio: pu });
});
return { id_compra, id_proveedor, fec_compra, detalles: det };
}
// Guardar / Eliminar
async function saveCompra(){
const payload = readFormToPayload();
if (!payload.id_proveedor) { alert('Seleccioná un proveedor.'); return; }
if (!payload.fec_compra) { alert('Indicá la fecha.'); return; }
if (!payload.detalles.length){ alert('Agregá al menos un renglón.'); return; }
$('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…';
try{
const res = await jpost('/api/rpc/save_compra', payload);
$('#id_compra').value = res.id_compra;
$('#btnEliminar').classList.remove('d-none');
$('#formTitle').textContent = 'Editar compra #' + res.id_compra;
await loadListado();
alert('Compra guardada.');
}catch(e){
alert('Error al guardar: ' + e.message);
}finally{
$('#btnGuardar').disabled = false; $('#status').textContent = 'Listo';
}
}
async function deleteCompra(){
const id = Number($('#id_compra').value||0);
if (!id) return;
if (!confirm('¿Eliminar compra #' + id + '?')) return;
$('#btnEliminar').disabled = true;
try{
await jpost('/api/rpc/delete_compra', { id_compra: id });
nuevaCompra();
await loadListado();
}catch(e){
alert('Error al eliminar: '+e.message);
}finally{
$('#btnEliminar').disabled = false;
}
}
function nuevaCompra(){
$('#formTitle').textContent = 'Nueva compra';
$('#id_compra').value = '';
$('#id_proveedor').value = '';
$('#fec_compra').value = new Date().toISOString().slice(0,16);
$('#total').value = '$ 0';
$('#btnEliminar').classList.add('d-none');
const tb = $('#tblDet tbody'); tb.innerHTML = '<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>';
}
async function cargarCompra(id){
$('#status').textContent = 'Cargando compra…';
try{
const data = await jpost('/api/rpc/get_compra', { id_compra: id });
$('#id_compra').value = data.id_compra;
$('#id_proveedor').value = data.id_proveedor;
$('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16);
const tb = $('#tblDet tbody'); tb.innerHTML='';
(data.detalles||[]).forEach(d => addRow(d));
recalcTotal();
$('#btnEliminar').classList.remove('d-none');
$('#formTitle').textContent = 'Editar compra #' + id;
} catch(e){
alert('No se pudo cargar: ' + e.message);
} finally {
$('#status').textContent = 'Listo';
}
}
// Listado
async function loadListado(){
// Recomendado: vista vw_compras (más abajo)
const rows = await jget('/api/table/vw_compras?limit=200&order_by=fec_compra%20desc');
const tb = $('#tblCompras tbody');
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.id_compra}</td>
<td>${r.proveedor}</td>
<td>${(r.fec_compra||'').replace('T',' ').slice(0,16)}</td>
<td class="text-end">${fmtMoneyInt(r.total)}</td>
<td class="text-end"><button class="btn btn-sm btn-outline-primary ver" data-id="${r.id_compra}">Ver/Editar</button></td>`;
tb.appendChild(tr);
});
$('#buscar').addEventListener('input', (e)=>{
const q = e.target.value.toLowerCase();
tb.querySelectorAll('tr').forEach(tr=>{
const prov = tr.children[1]?.textContent.toLowerCase() || '';
tr.style.display = prov.includes(q) ? '' : 'none';
});
});
tb.addEventListener('click', (ev)=>{
const btn = ev.target.closest('button.ver');
if (!btn) return;
const id = Number(btn.dataset.id);
cargarCompra(id);
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// Eventos
document.getElementById('addRow').addEventListener('click', ()=> addRow());
document.getElementById('frmCompra').addEventListener('submit', (ev)=>{ ev.preventDefault(); saveCompra(); });
document.getElementById('btnEliminar').addEventListener('click', deleteCompra);
document.getElementById('btnNueva').addEventListener('click', nuevaCompra);
// Init
(async function init(){
await loadCatalogos();
nuevaCompra();
await loadListado();
})();
</script>
+487
View File
@@ -0,0 +1,487 @@
<!-- views/dashboard.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">Dashboard Operativo</h1>
<div class="d-flex align-items-center gap-2">
<button id="dashRefresh" class="btn btn-outline-secondary btn-sm">Recargar</button>
<span id="dashStatus" class="text-muted small"></span>
</div>
</div>
<!-- KPIs -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Comandas activas</div>
<div class="h3 m-0" id="kpiActivas">—</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Ventas hoy</div>
<div class="h3 m-0"><span id="kpiVentasHoy">—</span></div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Ticket promedio (hoy)</div>
<div class="h3 m-0"><span id="kpiTicketProm">—</span></div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Productos distintos (hoy)</div>
<div class="h3 m-0" id="kpiProdDist">—</div>
</div>
</div>
</div>
</div>
<!-- Gráficos -->
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Top 5 productos (hoy)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartTopProductos"></canvas>
</div>
<div class="text-muted small mt-2">Basado en detalle de comandas de hoy.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Comandas por hora (últimas 12 h)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartComandasHora"></canvas>
</div>
<div class="text-muted small mt-2">Se agrupa por hora de creación.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Estados de comandas (hoy)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartEstados"></canvas>
</div>
<div class="text-muted small mt-2">Distribución por estado.</div>
</div>
</div>
</div>
<!-- Últimas comandas -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Últimas 10 comandas</strong>
<div class="ms-auto text-muted small" id="ultAct">—</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Fecha</th>
<th>Cierre</th> <!-- NUEVO -->
<th>Estado</th>
<th class="text-end">Total</th>
<th>Acción</th> <!-- NUEVO -->
</tr>
</thead>
<tbody id="ultimasTbody">
<tr><td colspan="6" class="text-muted p-3">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer text-muted small">
Totales calculados como Σ (pre_unitario × cantidad) por comanda.
</div>
</div>
</div>
</div>
<!-- Librería para gráficos -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// ===== Utilidades =====
const $ = (s, r=document)=>r.querySelector(s);
const fmtMoney = (n)=> isNaN(n) ? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(+n);
const fmtTs = (s)=> { const d = new Date(s); return isNaN(d) ? '—' : d.toLocaleString('es-UY'); };
const setStatus = (t)=> $('#dashStatus').textContent = t || '';
const todayBounds = ()=> {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = new Date(start); end.setDate(end.getDate()+1);
return {start, end};
};
const guessKey = (obj, candidates)=> (candidates.find(k => k in obj) || null);
const toDate = (v)=> (v instanceof Date ? v : new Date(v));
const inRange = (d, a, b)=> (d>=a && d<b);
// ===== Estado =====
let charts = {};
const state = {
comandas: [],
deta: [],
productos: [],
keys: {
comFecha: null, comFechaCierre: null, comEstado: null, comId: null, // <-- agregado comFechaCierre
detIdCom: null, detPrecio: null, detCant: null,
prodId: null, prodNombre: null
}
};
// ===== Carga =====
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function loadData() {
setStatus('Cargando datos…');
const [comandas, deta, productos] = await Promise.all([
jget('/api/table/comandas?limit=2000').catch(()=>[]),
jget('/api/table/deta_comandas?limit=5000').catch(()=>[]),
jget('/api/table/productos?limit=5000').catch(()=>[])
]);
state.comandas = Array.isArray(comandas)? comandas : [];
state.deta = Array.isArray(deta)? deta : [];
state.productos= Array.isArray(productos)? productos : [];
// Descubrir claves
const c0 = state.comandas[0] || {};
// incluimos fec_creacion y fec_cierre como prioridades
state.keys.comFecha = guessKey(c0, ['fec_creacion','fecha','created_at','creado_en','ts','timestamp','hora','datetime']);
state.keys.comFechaCierre = guessKey(c0, ['fec_cierre','cierre','closed_at','fecha_cierre','ts_cierre','hora_cierre']);
state.keys.comEstado = guessKey(c0, ['estado','status']);
state.keys.comId = guessKey(c0, ['id_comanda','id','comanda_id']);
const d0 = state.deta[0] || {};
state.keys.detIdCom = guessKey(d0, ['id_comanda','comanda_id']);
state.keys.detPrecio = guessKey(d0, ['pre_unitario','precio_unitario','precio','unit_price']);
state.keys.detCant = guessKey(d0, ['cantidad','qty','cantidad_total']);
const p0 = state.productos[0] || {};
state.keys.prodId = guessKey(p0, ['id_producto','id','producto_id']);
state.keys.prodNombre = guessKey(p0, ['nombre','descripcion','titulo','name']);
renderAll();
setStatus('');
}
// ===== Cálculos =====
function isActiva(estadoRaw){
const s = String(estadoRaw||'').toLowerCase();
return ['abierta','activa','activo','open','pendiente','en curso'].some(x => s.includes(x));
}
function isAnulada(estadoRaw){
const s = String(estadoRaw||'').toLowerCase();
return ['anulada','anulado','cancelada','cancelado','void'].some(x => s.includes(x));
}
function computeKpis(){
const {comFecha, comEstado, comId} = state.keys;
const {detIdCom, detPrecio, detCant} = state.keys;
const {start, end} = todayBounds();
// activas
const activas = state.comandas.filter(c => comEstado && isActiva(c[comEstado])).length;
$('#kpiActivas').textContent = activas;
// ventas hoy
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
let totalHoy = 0, ticketsHoy = 0;
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
const total = dets.reduce((acc,d)=> acc + Number(d[detPrecio]||0) * Number(d[detCant]||0), 0);
if (total>0) { totalHoy += total; ticketsHoy++; }
}
$('#kpiVentasHoy').textContent = fmtMoney(totalHoy);
$('#kpiTicketProm').textContent = ticketsHoy ? fmtMoney(totalHoy / ticketsHoy) : '—';
// productos distintos hoy
const setProd = new Set();
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
dets.forEach(d => setProd.add(d.id_producto ?? d.producto_id ?? d[state.keys.prodId]));
}
$('#kpiProdDist').textContent = setProd.size || '0';
}
function computeTopProductosHoy(){
const {comFecha, comEstado, comId} = state.keys;
const {detIdCom, detCant, detPrecio} = state.keys;
const {prodId, prodNombre} = state.keys;
const {start, end} = todayBounds();
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
const qtyByProd = new Map(); // id -> cantidad total
const amtByProd = new Map(); // id -> importe total
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
dets.forEach(d => {
const pid = d.id_producto ?? d.producto_id ?? d[prodId];
if (pid==null) return;
const q = Number(d[detCant]||0);
const a = Number(d[detPrecio]||0) * q;
qtyByProd.set(pid, (qtyByProd.get(pid)||0)+q);
amtByProd.set(pid, (amtByProd.get(pid)||0)+a);
});
}
// id -> label
const nameById = new Map(state.productos.map(p => [p[prodId], p[prodNombre] || ('#'+p[prodId])]));
// ordenar por cantidad
const arr = [...qtyByProd.entries()]
.map(([id,qty]) => ({ id, qty, amt: amtByProd.get(id)||0, name: nameById.get(id)||('#'+id) }))
.sort((a,b)=> b.qty - a.qty)
.slice(0,5);
return arr;
}
function computeComandasPorHora12h(){
const {comFecha} = state.keys;
const now = new Date();
const buckets = [];
for (let i=11;i>=0;i--){
const h = new Date(now); h.setHours(now.getHours()-i, 0, 0, 0);
buckets.push({ label: h.getHours().toString().padStart(2,'0')+':00', ts: +h, count: 0 });
}
if (!comFecha) return buckets;
state.comandas.forEach(c => {
const d = toDate(c[comFecha]); if (isNaN(d)) return;
const diffH = Math.floor((now - d) / (60*60*1000));
if (diffH<12 && diffH>=0) {
// bucket por hora exacta
const hour = new Date(d); hour.setMinutes(0,0,0);
const idx = buckets.findIndex(b => b.ts === +hour);
if (idx>=0) buckets[idx].count++;
}
});
return buckets;
}
function computeEstadosHoy(){
const {comFecha, comEstado} = state.keys;
const {start, end} = todayBounds();
const map = new Map();
state.comandas.forEach(c=>{
const when = comFecha ? toDate(c[comFecha]) : null;
if (!when || !inRange(when, start, end)) return;
const st = (c[comEstado] ?? '—').toString().toLowerCase();
map.set(st, (map.get(st)||0)+1);
});
return [...map.entries()].map(([estado,count])=>({estado, count}));
}
// ===== Render =====
function renderAll(){
computeKpis();
// Top productos
const top = computeTopProductosHoy();
drawBar('chartTopProductos', top.map(x=>x.name), top.map(x=>x.qty));
// Comandas por hora
const porHora = computeComandasPorHora12h();
drawLine('chartComandasHora', porHora.map(x=>x.label), porHora.map(x=>x.count));
// Estados
const estados = computeEstadosHoy();
drawDoughnut('chartEstados', estados.map(x=>x.estado), estados.map(x=>x.count));
// Últimas 10
renderUltimas();
}
function renderUltimas(){
const {comFecha, comFechaCierre, comEstado, comId} = state.keys;
const {detIdCom, detPrecio, detCant} = state.keys;
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
const rows = state.comandas
.slice()
.sort((a,b)=> {
const da = comFecha ? +new Date(a[comFecha]) : 0;
const db = comFecha ? +new Date(b[comFecha]) : 0;
return db - da;
})
.slice(0,10);
const tb = $('#ultimasTbody'); tb.innerHTML = '';
let lastTs = null;
rows.forEach(c=>{
const dets = byCom.get(c[comId]) || [];
const total = dets.reduce((acc,d)=> acc + Number(d[detPrecio]||0) * Number(d[detCant]||0), 0);
const ts = comFecha ? new Date(c[comFecha]) : null;
const tsc = comFechaCierre ? new Date(c[comFechaCierre]) : null;
if (ts) lastTs = (!lastTs || ts>lastTs) ? ts : lastTs;
const activa = isActiva(c[comEstado]);
const btn = activa
? `<button class="btn btn-outline-danger btn-sm js-cerrar" data-id="${c[comId]}">Cerrar</button>`
: `<button class="btn btn-outline-success btn-sm js-abrir" data-id="${c[comId]}">Abrir</button>`;
const tr = document.createElement('tr');
tr.dataset.id = c[comId];
tr.innerHTML = `
<td>${c[comId] ?? '—'}</td>
<td>${ts ? fmtTs(ts) : '—'}</td>
<td class="c-cierre">${tsc && !isNaN(tsc) ? fmtTs(tsc) : '—'}</td>
<td class="c-estado">${c[comEstado] ?? '—'}</td>
<td class="text-end">${fmtMoney(total)}</td>
<td class="c-accion">${btn}</td>
`;
tb.appendChild(tr);
});
$('#ultAct').textContent = lastTs ? ('Actualizado: ' + fmtTs(lastTs)) : '—';
}
// ===== Charts helpers =====
function destroyChart(id){ if (charts[id]) { charts[id].destroy(); charts[id]=null; } }
function drawBar(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: 'Cantidad', data }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}} }
});
}
function drawLine(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'line',
data: { labels, datasets: [{ label: 'Comandas', data, tension:.3, fill:false }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}} }
});
}
function drawDoughnut(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'doughnut',
data: { labels, datasets: [{ data }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom'}} }
});
}
// ===== Eventos =====
$('#dashRefresh').addEventListener('click', loadData);
window.addEventListener('sc:refresh-list', loadData); // desde el sidebar "Actualizar listado"
// Abrir/Cerrar comanda (actualiza fila + estado interno + re-render KPIs/gráficos)
document.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.js-cerrar, .js-abrir');
if (!btn) return;
const id = btn.dataset.id;
const isCerrar = btn.classList.contains('js-cerrar');
const url = isCerrar ? `/api/comandas/${id}/cerrar` : `/api/comandas/${id}/abrir`;
btn.disabled = true;
try {
const r = await fetch(url, { method: 'POST' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
// Actualizar estado local
const { comId, comEstado, comFechaCierre } = state.keys;
const idx = state.comandas.findIndex(c => String(c[comId]) === String(id));
if (idx >= 0) {
state.comandas[idx][comEstado] = data.estado ?? state.comandas[idx][comEstado];
if (comFechaCierre) state.comandas[idx][comFechaCierre] = data.fec_cierre ?? state.comandas[idx][comFechaCierre];
}
// Actualizar fila visual
const tr = document.querySelector(`tr[data-id="${id}"]`);
if (tr) {
const tdEstado = tr.querySelector('.c-estado');
const tdCierre = tr.querySelector('.c-cierre');
if (tdEstado) tdEstado.textContent = data.estado ?? tdEstado.textContent;
if (tdCierre) tdCierre.textContent = data.fec_cierre ? fmtTs(data.fec_cierre) : '—';
const acc = tr.querySelector('.c-accion');
if (acc) {
acc.innerHTML = (data.estado && data.estado.toLowerCase().includes('cerr'))
? `<button class="btn btn-outline-success btn-sm js-abrir" data-id="${id}">Abrir</button>`
: `<button class="btn btn-outline-danger btn-sm js-cerrar" data-id="${id}">Cerrar</button>`;
}
}
// Recalcular KPIs y gráficos (sin “crecimiento infinito”, se destruyen antes de redibujar)
renderAll();
} catch (e) {
alert('No se pudo actualizar la comanda: ' + (e.message || 'Error'));
} finally {
btn.disabled = false;
}
});
// Go!
loadData().catch(e => setStatus(e.message || 'Error'));
// Exporta CSV con KPIs y cortes básicos
window.scExportCsv = function () {
const rows = [];
rows.push(["kpi", "valor"]);
rows.push(["comandas_activas", document.getElementById("kpiActivas").textContent.trim()]);
rows.push(["ventas_hoy", document.getElementById("kpiVentasHoy").textContent.trim()]);
rows.push(["ticket_promedio_hoy", document.getElementById("kpiTicketProm").textContent.trim()]);
rows.push(["productos_distintos_hoy", document.getElementById("kpiProdDist").textContent.trim()]);
const csv = rows.map(r => r.map(v => `"${String(v).replaceAll('"','""')}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dashboard_${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
</script>
+532
View File
@@ -0,0 +1,532 @@
<!-- services/manso/src/views/estadoComandas.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">🧾 Estado de Comandas</h1>
<a class="btn btn-sm btn-dark" href="/comandas"> Nueva comanda</a>
</div>
<div class="row g-3">
<!-- ===== Listado (izquierda) ===== -->
<div class="col-12 col-lg-7">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Listado</strong>
<div class="form-check form-switch ms-auto">
<input class="form-check-input" type="checkbox" id="soloAbiertas" checked>
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
</div>
</div>
<div class="card-body">
<div class="row g-2 align-items-center mb-2">
<div class="col">
<input id="buscar" type="search" class="form-control" placeholder="Buscar por #, mesa o usuario…">
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary" id="limpiar">Limpiar</button>
</div>
</div>
<div id="lista" class="table-responsive" style="max-height:62vh; overflow:auto;">
<div class="p-3 text-muted">Cargando…</div>
</div>
</div>
</div>
</div>
<!-- ===== Detalle (derecha) ===== -->
<div class="col-12 col-lg-5">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Detalle</strong>
<span id="detalleEstado" class="badge badge-outline ms-auto">—</span>
</div>
<div class="card-body" id="detalle">
<div class="text-muted">Selecciona una comanda para ver el detalle.</div>
</div>
<div class="d-flex align-items-center gap-3 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
<div class="small"><span class="text-muted">ID:</span> <strong id="kpiId">—</strong></div>
<div class="small"><span class="text-muted">Mesa:</span> <strong id="kpiMesa">—</strong></div>
<div class="small"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
<div class="ms-auto"></div>
<button class="btn btn-outline-secondary" id="reabrir">Reabrir</button>
<button class="btn btn-primary" id="cerrar">Cerrar</button>
</div>
<div class="card-body">
<div id="msg" class="text-muted small"></div>
</div>
</div>
</div>
</div>
<script>
// ===== Helpers =====
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'text-success small' : 'text-danger small';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
};
const badgeClass = (estadoRaw) => {
const s = String(estadoRaw||'').toLowerCase();
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
return 'badge badge-outline';
};
async function jget(url){
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body){
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body ?? {}) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ===== Estado =====
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
// ===== Data =====
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : [];
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
state.detalle = Array.isArray(det) ? det : [];
renderDetalle();
}
// ===== UI: Lista =====
function renderLista(){
let rows = state.lista.slice();
const f = state.filtro?.trim().toLowerCase();
if (f) {
rows = rows.filter(r =>
String(r.id_comanda).includes(f) ||
String(r.mesa_numero ?? '').includes(f) ||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="text-end">Ítems</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
<td class="text-end">${r.items ?? '—'}</td>
<td class="text-end">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ===== UI: Detalle + KPIs =====
function applyHeader(r){
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = badgeClass(r.estado);
$('#detalleEstado').textContent = r.estado ?? '—';
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones (más precisos según estado)
const cerr = $('#cerrar'), reab = $('#reabrir');
const s = String(r.estado||'').toLowerCase();
if (s.includes('abier')) {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else if (s.includes('cerr')) {
cerr.disabled = true; cerr.title = 'Ya está cerrada';
reab.disabled = false; reab.title = '';
} else {
// Otros estados: permitir ambas acciones
cerr.disabled = false; cerr.title = '';
reab.disabled = false; reab.title = '';
}
}
function renderDetalle(){
const box = $('#detalle');
if (!state.detalle.length) {
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre ?? '—'}</td>
<td class="text-end">${money(r.pre_unitario)}</td>
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
<td class="text-end">${money(sub)}</td>
<td>${r.observaciones || ''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ===== Actions (usa /abrir y /cerrar) =====
async function accionComanda(accion){ // 'abrir' | 'cerrar'
if (!state.sel) return;
try {
await jpost(`/api/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
// Recargar lista y re-aplicar cabecera/detalle si sigue visible
const id = state.sel;
await loadLista();
const found = state.lista.find(x => x.id_comanda === id);
if (found) {
applyHeader(found);
await loadDetalle(found.id_comanda);
} else {
// Puede desaparecer del listado si está activado "Solo abiertas" y la cerramos
state.sel = null;
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo actualizar la comanda');
}
}
// ===== Hooks con Sidebar (offcanvas) =====
window.scRefreshList = loadLista;
window.scExportCsv = function(){
const rows = state.lista.slice();
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
const csv = [header.join(",")].concat(rows.map(r => {
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
const vals = [
r.id_comanda,
r.mesa_numero ?? '',
(r.mesa_apodo ?? '').replaceAll('"','""'),
usuario.replaceAll('"','""'),
r.fec_creacion ?? '',
r.estado ?? '',
r.items ?? '',
r.total ?? ''
];
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
})).join("\n");
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
};
// ===== Init =====
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
// Ahora los botones llaman a los nuevos endpoints
$('#cerrar').addEventListener('click', () => accionComanda('cerrar'));
$('#reabrir').addEventListener('click', () => accionComanda('abrir'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script>
<!-- <script>
// ===== Helpers =====
const $ = (s, r=document) => r.querySelector(s);
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
const el = $('#msg');
el.className = ok ? 'text-success small' : 'text-danger small';
el.textContent = msg;
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
};
const badgeClass = (estadoRaw) => {
const s = String(estadoRaw||'').toLowerCase();
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
return 'badge badge-outline';
};
async function jget(url){
const res = await fetch(url);
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
async function jpost(url, body){
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
const data = await res.json().catch(()=>null);
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
return data;
}
// ===== Estado =====
const state = {
filtro: '',
soloAbiertas: true,
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
sel: null, // id seleccionado
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
};
// ===== Data =====
async function loadLista() {
const estado = state.soloAbiertas ? 'abierta' : '';
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
const rows = await jget(url);
state.lista = Array.isArray(rows) ? rows : [];
renderLista();
}
async function loadDetalle(id) {
const det = await jget(`/api/comandas/${id}/detalle`);
state.detalle = Array.isArray(det) ? det : [];
renderDetalle();
}
// ===== UI: Lista =====
function renderLista(){
let rows = state.lista.slice();
const f = state.filtro?.trim().toLowerCase();
if (f) {
rows = rows.filter(r =>
String(r.id_comanda).includes(f) ||
String(r.mesa_numero ?? '').includes(f) ||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
);
}
const box = $('#lista');
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>#</th>
<th>Mesa</th>
<th>Usuario</th>
<th>Fecha</th>
<th>Estado</th>
<th class="text-end">Ítems</th>
<th class="text-end">Total</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
rows.forEach(r => {
const tr = document.createElement('tr');
tr.style.cursor = 'pointer';
tr.innerHTML = `
<td>${r.id_comanda}</td>
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
<td class="text-end">${r.items ?? '—'}</td>
<td class="text-end">${money(r.total ?? 0)}</td>
`;
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
}
// ===== UI: Detalle + KPIs =====
function applyHeader(r){
$('#kpiId').textContent = r.id_comanda ?? '—';
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
$('#detalleEstado').className = badgeClass(r.estado);
$('#detalleEstado').textContent = r.estado ?? '—';
$('#kpiTotal').textContent = money(r.total ?? 0);
// Botones
const cerr = $('#cerrar'), reab = $('#reabrir');
if ((r.estado||'').toLowerCase().includes('abier')) {
cerr.disabled = false; cerr.title = '';
reab.disabled = true; reab.title = 'Ya está abierta';
} else {
cerr.disabled = false;
reab.disabled = false;
cerr.title = ''; reab.title = '';
}
}
function renderDetalle(){
const box = $('#detalle');
if (!state.detalle.length) {
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
return;
}
const tbl = document.createElement('table');
tbl.className = 'table table-sm align-middle mb-0';
tbl.innerHTML = `
<thead class="table-light">
<tr>
<th>Producto</th>
<th class="text-end">Unitario</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Subtotal</th>
<th>Observaciones</th>
</tr>
</thead>
<tbody></tbody>
`;
const tb = tbl.querySelector('tbody');
let total = 0;
state.detalle.forEach(r => {
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
total += sub;
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.producto_nombre ?? '—'}</td>
<td class="text-end">${money(r.pre_unitario)}</td>
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
<td class="text-end">${money(sub)}</td>
<td>${r.observaciones || ''}</td>
`;
tb.appendChild(tr);
});
box.innerHTML = '';
box.appendChild(tbl);
$('#kpiTotal').textContent = money(total);
}
// ===== Actions =====
async function setEstado(estado){
if (!state.sel) return;
try {
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
await loadLista();
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
else {
state.sel = null;
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
}
} catch (e) {
toast(e.message || 'No se pudo cambiar el estado');
}
}
// ===== Hooks con Sidebar (offcanvas) =====
// Permite que el botón "Actualizar" del sidebar recargue este listado
window.scRefreshList = loadLista;
// Exportación simple del listado actual
window.scExportCsv = function(){
const rows = state.lista.slice();
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
const csv = [header.join(",")].concat(rows.map(r => {
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
const vals = [
r.id_comanda,
r.mesa_numero ?? '',
(r.mesa_apodo ?? '').replaceAll('"','""'),
usuario.replaceAll('"','""'),
r.fec_creacion ?? '',
r.estado ?? '',
r.items ?? '',
r.total ?? ''
];
return vals.map(v => `"${String(v).replaceAll('"','""')}"`).join(",");
})).join("\n");
const blob = new Blob([csv], {type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
};
// ===== Init =====
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script> -->
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="es">
<head>
<%- include('../partials/_head') %>
</head>
<body data-page="<%= pageId %>">
<%- include('../partials/_navbar') %>
<main class="container">
<%- body %>
</main>
<%- include('../partials/_sidebar') %>
<%- include('../partials/_footer') %>
</body>
</html>
@@ -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,45 @@
<!-- /partials/_head.html -->
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= typeof pageTitle !== "undefined" ? pageTitle : "SuiteCoffee" %></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="icon" href="/favicon/favicon.ico" sizes="any">
<link rel="icon" href="/favicon/favicon-16x16.png" sizes="16x16">
<link rel="icon" href="/favicon/favicon-32x32.png" sizes="32x32">
<link rel="icon" href="/favicon/apple-touch-icon.png" sizes="180x180">
<link rel="icon" href="/favicon/android-chrome-512x512.png" sizes="512x512">
<link rel="icon" href="/favicon/android-chrome-192x192.png" sizes="192x192">
<link rel="manifest" href="/favicon/site.webmanifest">
<style>
:root { --navbar-h: 56px; }
body { padding-top: var(--navbar-h); background: #f7f8fb; }
.brand-mini { font-weight: 700; letter-spacing: .2px; }
/* Layout contenedor principal */
main { padding-block: 1rem 2rem; }
/* Tabla compacta */
.table-sm th, .table-sm td { padding: .5rem .6rem; }
/* Chips/etiquetas de estado */
.badge-outline { border: 1px solid #dee2e6; background: #fff; color: #495057; }
.badge-estado-abierta { border-color:#198754; color:#198754; }
.badge-estado-cerrada { border-color:#6c757d; color:#6c757d; }
.badge-estado-anulada { border-color:#dc3545; color:#dc3545; }
.badge-estado-pagada { border-color:#146c43; color:#146c43; }
/* Evita crecimiento infinito de los charts */
.chart-box {
position: relative;
height: 260px; /* altura fija base */
}
@media (min-width: 992px) {
.chart-box { height: 320px; } /* un poquito más grande en desktop */
}
.chart-box > canvas {
position: absolute;
inset: 0;
width: 100% !important;
height: 100% !important; /* ocupa todo el alto del contenedor */
}
</style>
@@ -0,0 +1,31 @@
<!-- /partials/_navbar.html -->
<nav class="navbar navbar-expand-lg navbar-light bg-white border-bottom fixed-top">
<div class="container-fluid">
<a class="navbar-brand brand-mini" href="/">SuiteCoffee</a>
<!-- Links principales (colapsables en mobile) -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#scNav" aria-controls="scNav" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span> <!-- hamburguesa principal -->
</button>
<div class="collapse navbar-collapse" id="scNav">
<ul class="navbar-nav me-auto mb-2 mb-lg-0 small">
<li class="nav-item"><a class="nav-link" href="/comandas">Comandas</a></li>
<li class="nav-item"><a class="nav-link" href="/estadoComandas">Estado</a></li>
<li class="nav-item"><a class="nav-link" href="/productos">Productos</a></li>
<li class="nav-item"><a class="nav-link" href="/usuarios">Usuarios</a></li>
<li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li>
<li class="nav-item"><a class="nav-link" href="/compras">Compras</a></li>
<!-- <li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li> -->
<!-- agrega las que necesites -->
</ul>
<!-- Botón “hamburguesa” para abrir el menú contextual (sidebar derecha) -->
<button class="btn btn-outline-secondary btn-sm d-flex align-items-center" type="button"
data-bs-toggle="offcanvas" data-bs-target="#scSidebar" aria-controls="scSidebar">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" class="me-1" viewBox="0 0 24 24" fill="none" stroke="currentColor"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
Opciones
</button>
</div>
</div>
</nav>
@@ -0,0 +1,72 @@
<!-- /partials/_sidebar.html -->
<div class="offcanvas offcanvas-end" tabindex="-1" id="scSidebar" aria-labelledby="scSidebarLabel">
<div class="offcanvas-header">
<h5 class="offcanvas-title" id="scSidebarLabel">Opciones</h5>
<button type="button" class="btn-close text-reset" data-bs-dismiss="offcanvas" aria-label="Cerrar"></button>
</div>
<div class="offcanvas-body">
<!-- Contenido se inyecta según la página actual -->
<div id="scSidebarContent" class="list-group list-group-flush small"></div>
</div>
</div>
<script>
// Map de opciones por página. Usa body[data-page] o window.scPageId.
const SC_SIDEBAR_ITEMS = {
// === ejemplos ===
"dashboard": [
{ text: "Ver reportes", href: "/reportes" },
{ text: "Actualizar", href: "#", attr: { "data-action": "refresh-list" } },
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
{ text: "Nueva comanda", href: "/comandas" },
{ text: "Ir a Estado", href: "/estadoComandas" }
],
"estadoComandas": [
{ text: " Nueva comanda", href: "/comandas" },
{ text: "Solo abiertas", href: "#", attr: { "data-action": "toggle-abiertas" } },
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
{ text: "Actualizar listado", href: "#", attr: { "data-action": "refresh-list" } },
],
"comandas": [
{ text: "Volver a Estado", href: "/estadoComandas" },
{ text: "Cargar productos", href: "/productos" },
{ text: "Mesas", href: "/mesas" },
],
"productos": [
{ text: "Nuevo producto", href: "/productos/nuevo" },
{ text: "Importar catálogo", href: "/productos/importar" },
{ text: "Reportes", href: "/reportes" },
],
"usuarios": [
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
]
};
(function initSidebar(){
const page = (document.body.dataset.page || window.scPageId || "").trim();
const items = SC_SIDEBAR_ITEMS[page] || [
{ text: "Inicio", href: "/" }
];
const box = document.getElementById("scSidebarContent");
box.innerHTML = "";
for (const it of items) {
const a = document.createElement("a");
a.className = "list-group-item list-group-item-action";
a.textContent = it.text;
a.href = it.href || "#";
if (it.attr) for (const [k,v] of Object.entries(it.attr)) a.setAttribute(k,v);
box.appendChild(a);
}
// Acciones ejemplo (opcionales). Adaptá a tus funciones reales.
box.addEventListener("click", (ev) => {
const a = ev.target.closest("a[data-action]");
if (!a) return;
ev.preventDefault();
const action = a.getAttribute("data-action");
if (action === "toggle-abiertas") window.dispatchEvent(new CustomEvent("sc:toggle-abiertas"));
if (action === "export-csv") window.dispatchEvent(new CustomEvent("sc:export-csv"));
if (action === "refresh-list") window.dispatchEvent(new CustomEvent("sc:refresh-list"));
});
})();
</script>
+559
View File
@@ -0,0 +1,559 @@
<!-- services/manso/src/views/productos.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">🛒 Productos</h1>
<div class="d-flex gap-2">
<button id="btnNuevo" class="btn btn-outline-secondary btn-sm">Nuevo</button>
<button id="btnGuardar" class="btn btn-primary btn-sm">Guardar</button>
<button class="btn btn-outline-dark btn-sm" data-bs-toggle="collapse" data-bs-target="#mpWrap" aria-expanded="false">Materias primas</button>
</div>
</div>
<div class="row g-3">
<!-- ===== Listado ===== -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Listado</strong>
<div class="ms-auto d-flex gap-2">
<input id="q" type="search" class="form-control form-control-sm" placeholder="Buscar…">
<button id="btnLimpiar" class="btn btn-outline-secondary btn-sm">Limpiar</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height: 65vh; overflow: auto;">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Nombre</th>
<th class="text-end">Precio</th>
<th>Activo</th>
<th>Categoría</th>
</tr>
</thead>
<tbody id="tbProductos">
<tr><td colspan="5" class="p-3 text-muted">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- ===== Edición / Alta ===== -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Ficha</strong></div>
<div class="card-body">
<input type="hidden" id="id_producto">
<div class="row g-3">
<div class="col-12">
<label class="form-label small text-muted mb-1" for="nombre">Nombre</label>
<input id="nombre" class="form-control" autocomplete="off">
</div>
<div class="col-12 col-sm-6">
<label class="form-label small text-muted mb-1" for="precio">Precio</label>
<input id="precio" type="number" step="0.01" min="0" class="form-control">
</div>
<div class="col-12 col-sm-6">
<label class="form-label small text-muted mb-1" for="id_categoria">Categoría</label>
<select id="id_categoria" class="form-select"></select>
</div>
<div class="col-12">
<label class="form-label small text-muted mb-1" for="img_producto">Imagen (URL)</label>
<input id="img_producto" class="form-control" placeholder="img_producto.png">
<div class="mt-2">
<img id="preview" src="" alt="" class="img-thumbnail d-none" style="max-height: 140px;">
</div>
</div>
<div class="col-12">
<div class="form-check">
<input id="activo" class="form-check-input" type="checkbox" checked>
<label for="activo" class="form-check-label">Activo</label>
</div>
</div>
</div>
</div>
</div>
<!-- ===== Receta ===== -->
<div class="card shadow-sm mt-3">
<div class="card-header d-flex align-items-center">
<strong>Receta (materias primas por unidad)</strong>
<button id="btnAddIng" class="btn btn-outline-secondary btn-sm ms-auto">Agregar ingrediente</button>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 55%;">Materia prima</th>
<th class="text-end" style="width: 25%;">Cantidad</th>
<th style="width: 20%;"></th>
</tr>
</thead>
<tbody id="tbReceta">
<tr><td colspan="3" class="p-3 text-muted">Sin ingredientes.</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer d-flex align-items-center small text-muted">
Cantidades en la unidad definida por cada materia prima.
<span id="msg" class="ms-auto"></span>
</div>
</div>
</div>
</div>
<!-- ====== GESTIÓN DE MATERIAS PRIMAS (OCULTO POR DEFECTO) ====== -->
<div class="collapse mt-4" id="mpWrap">
<div class="d-flex align-items-center justify-content-between mb-2">
<h2 class="h5 m-0">⚙️ Materias primas</h2>
<div class="d-flex gap-2">
<button id="mpNuevo" class="btn btn-outline-secondary btn-sm">Nuevo</button>
<button id="mpGuardar" class="btn btn-primary btn-sm">Guardar</button>
</div>
</div>
<div class="row g-3">
<!-- Listado MP -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Listado</strong>
<div class="ms-auto d-flex gap-2">
<input id="mpQ" type="search" class="form-control form-control-sm" placeholder="Buscar…">
<button id="mpLimpiar" class="btn btn-outline-secondary btn-sm">Limpiar</button>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive" style="max-height:60vh;overflow:auto;">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Nombre</th>
<th>Unidad</th>
<th>Activo</th>
</tr>
</thead>
<tbody id="mpTb">
<tr><td colspan="4" class="p-3 text-muted">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Ficha MP -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Ficha</strong></div>
<div class="card-body">
<input type="hidden" id="mp_id_mat_prima">
<div class="row g-3">
<div class="col-12">
<label class="form-label small text-muted mb-1" for="mp_nombre">Nombre</label>
<input id="mp_nombre" class="form-control" autocomplete="off">
</div>
<div class="col-12 col-sm-6">
<label class="form-label small text-muted mb-1" for="mp_unidad">Unidad</label>
<input id="mp_unidad" class="form-control" placeholder="ej: gr, ml, u.">
</div>
<div class="col-12 col-sm-6 d-flex align-items-end">
<div class="form-check">
<input id="mp_activo" class="form-check-input" type="checkbox" checked>
<label class="form-check-label" for="mp_activo">Activo</label>
</div>
</div>
<div class="col-12">
<label class="form-label small text-muted mb-1" for="mp_proveedores">Proveedores (asignación)</label>
<select id="mp_proveedores" class="form-select" multiple></select>
<div class="form-text">Mantén presionadas Ctrl/⌘ para seleccionar varios.</div>
</div>
</div>
</div>
<div class="card-footer small text-muted d-flex">
<span id="mpMsg"></span>
</div>
</div>
</div>
</div>
</div>
<script>
// ===== Helpers =====
const $ = (s, r=document)=>r.querySelector(s);
const $$ = (s, r=document)=>Array.from(r.querySelectorAll(s));
const money = (n)=> isNaN(n) ? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(+n);
const toast = (t, ok=false)=> { const el=$('#msg'); el.className = 'ms-auto ' + (ok?'text-success':'text-danger'); el.textContent=t; setTimeout(()=>{ el.textContent=''; el.className='ms-auto text-muted'; }, 3000); };
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b)}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
// ===== Estado =====
const state = {
productos: [],
categorias: [],
materias: [],
receta: [], // [{id_mat_prima, nombre, unidad, qty_por_unidad}]
filtro: '',
selId: null
};
// ===== Carga inicial =====
async function init(){
const [prods, cats, mats] = await Promise.all([
jget('/api/table/productos?limit=2000'),
jget('/api/table/categorias?limit=2000').catch(()=>[]),
jget('/api/table/mate_primas?limit=2000')
]);
state.productos = Array.isArray(prods)? prods : [];
state.categorias = Array.isArray(cats)? cats : [];
state.catById = new Map(state.categorias.map(c => [c.id_categoria, c.nombre]));
state.materias = Array.isArray(mats)? mats : [];
hydrateCategorias();
renderLista();
clearForm();
}
function hydrateCategorias(){
const sel = $('#id_categoria'); sel.innerHTML='';
sel.appendChild(new Option('(sin categoría)', '', true, true));
state.categorias.forEach(c => sel.appendChild(new Option(c.nombre || ('#'+c.id_categoria), c.id_categoria)));
}
// ===== Listado =====
const catName = (id) => state?.catById?.get(id) ?? (id ? `#${id}` : '');
function renderLista(){
const tb = $('#tbProductos');
let rows = state.productos.slice();
const f = state.filtro.trim().toLowerCase();
if (f) rows = rows.filter(p => (p.nombre||'').toLowerCase().includes(f) || String(p.id_producto).includes(f));
if (!rows.length) { tb.innerHTML = '<tr><td colspan="5" class="p-3 text-muted">Sin resultados.</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(p => {
const tr = document.createElement('tr');
tr.style.cursor='pointer';
tr.innerHTML = `
<td>${p.id_producto}</td>
<td>${p.nombre||'—'}</td>
<td class="text-end">${money(p.precio)}</td>
<td>${p.activo ? 'Sí' : 'No'}</td>
<td>${catName(p.id_categoria)}</td>
`;
tr.addEventListener('click', ()=> loadProducto(p.id_producto));
tb.appendChild(tr);
});
}
// ===== Ficha =====
function clearForm(){
state.selId = null;
$('#id_producto').value = '';
$('#nombre').value = '';
$('#precio').value = '';
$('#id_categoria').value = '';
$('#img_producto').value = '';
$('#preview').src = ''; $('#preview').classList.add('d-none');
$('#activo').checked = true;
state.receta = [];
renderReceta();
}
async function loadProducto(id){
try {
// Usamos la función SQL (RPC) para traer producto + receta en un solo tiro
const data = await jget(`/api/rpc/get_producto/${id}`);
const p = data.producto || {};
const r = Array.isArray(data.receta) ? data.receta : [];
state.selId = p.id_producto;
$('#id_producto').value = p.id_producto ?? '';
$('#nombre').value = p.nombre ?? '';
$('#precio').value = p.precio ?? '';
$('#id_categoria').value = p.id_categoria ?? '';
$('#img_producto').value = p.img_producto ?? '';
if (p.img_producto) { $('#preview').src = p.img_producto; $('#preview').classList.remove('d-none'); } else { $('#preview').src=''; $('#preview').classList.add('d-none'); }
$('#activo').checked = (p.activo !== false);
// receta
state.receta = r.map(x => ({
id_mat_prima: x.id_mat_prima,
nombre: x.nombre ?? (state.materias.find(m=>m.id_mat_prima===x.id_mat_prima)?.nombre || ('#'+x.id_mat_prima)),
unidad: x.unidad ?? (state.materias.find(m=>m.id_mat_prima===x.id_mat_prima)?.unidad || ''),
qty_por_unidad: Number(x.qty_por_unidad||0)
}));
renderReceta();
} catch(e) {
toast(e.message || 'No se pudo cargar el producto');
}
}
// ===== Receta (UI) =====
function renderReceta(){
const tb = $('#tbReceta');
if (!state.receta.length) { tb.innerHTML = '<tr><td colspan="3" class="p-3 text-muted">Sin ingredientes.</td></tr>'; return; }
tb.innerHTML = '';
state.receta.forEach((it, idx) => {
const tr = document.createElement('tr');
const sel = document.createElement('select');
sel.className = 'form-select form-select-sm';
state.materias.forEach(m => sel.appendChild(new Option(`${m.nombre} (${m.unidad||'-'})`, m.id_mat_prima, false, m.id_mat_prima===it.id_mat_prima)));
sel.addEventListener('change', () => {
const val = parseInt(sel.value, 10);
if (!Number.isInteger(val) || val <= 0) { // si algo raro
const first = state.materias[0];
it.id_mat_prima = Number(first?.id_mat_prima || 0);
} else {
it.id_mat_prima = val;
}
const m = state.materias.find(x => x.id_mat_prima === it.id_mat_prima);
it.nombre = m?.nombre || '';
it.unidad = m?.unidad || '';
});
const qty = document.createElement('input');
qty.type = 'number'; qty.min='0.001'; qty.step='0.001'; qty.value = (it.qty_por_unidad||0).toFixed(3);
qty.className = 'form-control form-control-sm text-end';
qty.addEventListener('change', ()=> it.qty_por_unidad = Number(qty.value||0));
const del = document.createElement('button');
del.className = 'btn btn-outline-secondary btn-sm';
del.textContent = 'Quitar';
del.addEventListener('click', ()=> { state.receta.splice(idx,1); renderReceta(); });
const td1 = document.createElement('td'); td1.appendChild(sel);
const td2 = document.createElement('td'); td2.className='text-end'; td2.appendChild(qty);
const td3 = document.createElement('td'); td3.appendChild(del);
tr.append(td1,td2,td3);
tb.appendChild(tr);
});
}
function addIngrediente(){
if (!state.materias.length) {
toast('Primero cargá materias primas', false);
return;
}
const m = state.materias[0];
state.receta.push({
id_mat_prima: Number(m.id_mat_prima), // siempre número válido
nombre: m.nombre || '',
unidad: m.unidad || '',
qty_por_unidad: 1.000
});
renderReceta();
}
// ===== Guardar (INSERT/UPDATE + receta) vía función SQL =====
async function guardar(){
try {
const cleanedReceta = state.receta
.map(r => ({
id: parseInt(r.id_mat_prima, 10),
qty: Number(r.qty_por_unidad)
}))
.filter(x => Number.isInteger(x.id) && x.id > 0 && Number.isFinite(x.qty) && x.qty > 0)
.map(x => ({ id_mat_prima: x.id, qty_por_unidad: +x.qty.toFixed(3) }));
const payload = {
id_producto: $('#id_producto').value ? Number($('#id_producto').value) : null,
nombre: $('#nombre').value.trim(),
img_producto: $('#img_producto').value.trim() || null,
precio: Number($('#precio').value || 0),
activo: $('#activo').checked,
id_categoria: $('#id_categoria').value ? Number($('#id_categoria').value) : null,
receta: cleanedReceta
};
if (cleanedReceta.length !== state.receta.length) {
toast('Se ignoraron ingredientes inválidos (id o cantidad).', false);
}
if (!payload.nombre) { toast('Nombre requerido'); return; }
if (!(payload.precio >= 0)) { toast('Precio inválido'); return; }
const { id_producto } = await jpost('/api/rpc/save_producto', payload);
toast(`Guardado #${id_producto}`, true);
// refrescar listado y reabrir seleccionado
state.productos = await jget('/api/table/productos?limit=2000');
renderLista();
await loadProducto(id_producto);
} catch (e) {
toast(e.message || 'No se pudo guardar');
}
}
// ===== Eventos =====
$('#q').addEventListener('input', ()=> { state.filtro = $('#q').value||''; renderLista(); });
$('#btnLimpiar').addEventListener('click', ()=> { $('#q').value=''; state.filtro=''; renderLista(); });
$('#btnNuevo').addEventListener('click', clearForm);
$('#btnAddIng').addEventListener('click', addIngrediente);
$('#btnGuardar').addEventListener('click', guardar);
$('#img_producto').addEventListener('input', ()=> {
const v=$('#img_producto').value.trim();
if (v) { $('#preview').src=v; $('#preview').classList.remove('d-none'); } else { $('#preview').src=''; $('#preview').classList.add('d-none'); }
});
// Hooks con sidebar (opcional)
window.scRefreshList = async function(){ state.productos = await jget('/api/table/productos?limit=2000'); renderLista(); };
window.scExportCsv = function(){
const rows = state.productos.slice();
const head = ["id_producto","nombre","precio","activo","id_categoria"];
const csv = [head.join(",")].concat(rows.map(r => {
const vals = [r.id_producto,r.nombre,(r.precio??''),(r.activo??''),(r.id_categoria??'')];
return vals.map(v => `"${String(v??'').replaceAll('"','""')}"`).join(",");
})).join("\n");
const blob = new Blob([csv],{type:"text/csv;charset=utf-8"});
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {href:url, download:`productos_${new Date().toISOString().slice(0,10)}.csv`});
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
};
/* ========= EXTENSIÓN: MATERIAS PRIMAS ========= */
// 1) ampliar el estado global existente:
state.proveedores = state.proveedores || [];
state.mpFiltro = '';
state.mpSelId = null;
state.mpAsignados = []; // array de id_proveedor seleccionados para la MP
// 2) cargar proveedores también en init()
const __oldInit = init;
init = async function() {
const [provs] = await Promise.all([
jget('/api/table/proveedores?limit=5000').catch(()=>[])
]);
state.proveedores = Array.isArray(provs) ? provs : [];
await __oldInit(); // llama a tu init original (productos + categorías + materias)
hydrateMpProveedoresOptions(); // por si abres el panel de MP
};
// helpers UI MP
function mpToast(t, ok=false){ const el=$('#mpMsg'); el.className = ok?'text-success':'text-danger'; el.textContent=t; setTimeout(()=>{el.textContent=''; el.className='';}, 3000); }
function hydrateMpProveedoresOptions(selectedIds=[]) {
const sel = $('#mp_proveedores'); if (!sel) return;
sel.innerHTML = '';
state.proveedores.forEach(p => {
const opt = new Option(p.raz_social || ('#'+p.id_proveedor), p.id_proveedor, false, selectedIds.includes(p.id_proveedor));
sel.appendChild(opt);
});
}
// 3) listado MP
function renderMpLista() {
const tb = $('#mpTb'); if (!tb) return;
let rows = state.materias.slice();
const f = (state.mpFiltro||'').trim().toLowerCase();
if (f) rows = rows.filter(m => (m.nombre||'').toLowerCase().includes(f) || String(m.id_mat_prima).includes(f));
if (!rows.length) { tb.innerHTML = '<tr><td colspan="4" class="p-3 text-muted">Sin resultados.</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(m => {
const tr = document.createElement('tr');
tr.style.cursor='pointer';
tr.innerHTML = `
<td>${m.id_mat_prima}</td>
<td>${m.nombre||'—'}</td>
<td>${m.unidad||'—'}</td>
<td>${m.activo ? 'Sí' : 'No'}</td>
`;
tr.addEventListener('click', ()=> loadMp(m.id_mat_prima));
tb.appendChild(tr);
});
}
// 4) limpiar ficha MP
function clearMpForm() {
state.mpSelId = null;
$('#mp_id_mat_prima').value = '';
$('#mp_nombre').value = '';
$('#mp_unidad').value = '';
$('#mp_activo').checked = true;
state.mpAsignados = [];
hydrateMpProveedoresOptions([]);
}
// 5) cargar MP + proveedores asignados (via función SQL)
async function loadMp(id) {
try {
const data = await jget(`/api/rpc/get_materia/${id}`); // { materia: {...}, proveedores: [...] }
const m = data.materia || {};
const provs = Array.isArray(data.proveedores) ? data.proveedores : [];
state.mpSelId = m.id_mat_prima;
$('#mp_id_mat_prima').value = m.id_mat_prima ?? '';
$('#mp_nombre').value = m.nombre ?? '';
$('#mp_unidad').value = m.unidad ?? '';
$('#mp_activo').checked = (m.activo !== false);
state.mpAsignados = provs.map(x => x.id_proveedor);
hydrateMpProveedoresOptions(state.mpAsignados);
} catch (e) {
mpToast(e.message || 'No se pudo cargar la materia prima');
}
}
// 6) guardar MP (insert/update) y sincronizar proveedores (JSONB)
async function saveMp() {
try {
const payload = {
id_mat_prima: $('#mp_id_mat_prima').value ? Number($('#mp_id_mat_prima').value) : null,
nombre: $('#mp_nombre').value.trim(),
unidad: $('#mp_unidad').value.trim(),
activo: $('#mp_activo').checked,
proveedores: Array.from($('#mp_proveedores').selectedOptions).map(o => Number(o.value))
};
if (!payload.nombre) { mpToast('Nombre requerido'); return; }
const r = await jpost('/api/rpc/save_materia', payload); // => { id_mat_prima }
mpToast(`Guardado #${r.id_mat_prima}`, true);
// refrescar listas globales
state.materias = await jget('/api/table/mate_primas?limit=5000');
renderMpLista();
hydrateCategorias(); // no hace falta, pero mantenemos consistencia si dependiera de MPs
// refrescar selects de receta del producto (por si se usa en receta)
renderReceta(); // tu función existente reusará state.materias
await loadMp(r.id_mat_prima);
} catch (e) {
mpToast(e.message || 'No se pudo guardar');
}
}
// 7) listeners MP
document.getElementById('mpQ')?.addEventListener('input', ()=> { state.mpFiltro = $('#mpQ').value||''; renderMpLista(); });
document.getElementById('mpLimpiar')?.addEventListener('click', ()=> { $('#mpQ').value=''; state.mpFiltro=''; renderMpLista(); });
document.getElementById('mpNuevo')?.addEventListener('click', clearMpForm);
document.getElementById('mpGuardar')?.addEventListener('click', saveMp);
// 8) cuando se despliega el panel MP por primera vez, renderizar listado
document.getElementById('mpWrap')?.addEventListener('shown.bs.collapse', ()=> renderMpLista());
function imgUrl(v){
if (!v) return '';
return v.startsWith('http') ? v : `/img/productos/${v}`;
}
$('#img_producto').addEventListener('input', ()=>{
const v = $('#img_producto').value.trim();
const src = imgUrl(v);
if (src) { $('#preview').src = src; $('#preview').classList.remove('d-none'); }
else { $('#preview').src = ''; $('#preview').classList.add('d-none'); }
});
// Go
init().catch(e => toast(e.message||'Error cargando datos'));
</script>
+836
View File
@@ -0,0 +1,836 @@
<% /* Reportes - Asistencias, Tickets y Gastos */ %>
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-2">
<h3 class="mb-0">Reportes</h3>
<span class="ms-auto small text-muted" id="repStatus">—</span>
</div>
<!-- Filtros -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Asistencias · Rango</label>
<div class="row g-2">
<div class="col-6 col-md-4"><input id="asistDesde" type="date" class="form-control"></div>
<div class="col-6 col-md-4"><input id="asistHasta" type="date" class="form-control"></div>
<div class="col-12 col-md-4 d-grid d-md-block">
<button id="btnAsistCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnAsistExcel" class="btn btn-outline-success me-2">Excel</button>
<button id="btnAsistPDF" class="btn btn-outline-secondary">PDF</button>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Año (Tickets / Gastos)</label>
<div class="row g-2">
<div class="col-6 col-md-4"><input id="anualYear" type="number" min="2000" step="1" class="form-control"></div>
<div class="col-6 col-md-8 d-grid d-md-block">
<button id="btnAnualCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnAnualExcel" class="btn btn-outline-success me-2">Excel (Comparativo)</button>
<button id="btnAnualPDF" class="btn btn-outline-secondary">PDF (Comparativo)</button>
</div>
</div>
</div>
</div>
<div class="small text-muted mt-2">
Los Excel se generan como CSV. Los PDF se generan con “Imprimir área” del navegador.
</div>
</div>
</div>
<!-- ASISTENCIA: Resumen diario (últimos 30 días) -->
<div class="card shadow-sm mb-3" id="PRINT_ASIST">
<div class="card-header d-flex align-items-center">
<strong>Asistencia — Resumen diario (últimos 30 días)</strong>
<span class="ms-auto small text-muted" id="resumeCount">Cargando…</span>
</div>
<div class="card-body">
<div id="resumenCards" class="row g-3"></div>
</div>
</div>
<!-- Tickets -->
<div class="card shadow-sm mb-3" id="PRINT_TICKETS">
<div class="card-header d-flex align-items-center">
<strong>Ventas (Tickets)</strong>
<span class="ms-auto small text-muted" id="ticketsInfo">—</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Resumen del año</div>
<div class="small text-muted" id="ticketsYearTitle">—</div>
</div>
<div class="row text-center">
<div class="col-4"><div class="small text-muted">Tickets YTD</div><div class="fs-5 fw-semibold" id="tYtd">—</div></div>
<div class="col-4"><div class="small text-muted">Promedio</div><div class="fs-5 fw-semibold" id="tAvg">—</div></div>
<div class="col-4"><div class="small text-muted">Ingresos YTD</div><div class="fs-5 fw-semibold" id="tToDate">—</div></div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Tickets por mes</div>
<div class="small text-muted">Cantidad</div>
</div>
<div id="ticketsChart" style="height:140px;"></div>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr><th>Mes</th><th class="text-end">Tickets</th><th class="text-end">Importe</th><th class="text-end">Ticket promedio</th></tr>
</thead>
<tbody id="tbTickets"><tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Gastos detallados (filtrable por mes/año) -->
<div class="card shadow-sm mb-3" id="PRINT_GASTOS_DET">
<div class="card-header">
<div class="row g-2 align-items-center">
<div class="col-12 col-lg-4 d-flex align-items-center">
<strong class="me-2">Gastos detallados — </strong>
<span id="gdetTitle" class="text-muted small">mes anterior</span>
</div>
<div class="col-12 col-lg-4">
<div class="row g-2">
<div class="col-7">
<select id="gdetMes" class="form-select">
<option value="1">Enero</option>
<option value="2">Febrero</option>
<option value="3">Marzo</option>
<option value="4">Abril</option>
<option value="5">Mayo</option>
<option value="6">Junio</option>
<option value="7">Julio</option>
<option value="8">Agosto</option>
<option value="9">Setiembre</option>
<option value="10">Octubre</option>
<option value="11">Noviembre</option>
<option value="12">Diciembre</option>
</select>
</div>
<div class="col-5">
<input id="gdetAnio" type="number" min="2000" step="1" class="form-control" placeholder="Año">
</div>
</div>
</div>
<div class="col-12 col-lg-4 d-grid d-md-block text-lg-end">
<button id="btnGdetCargar" class="btn btn-primary btn-sm me-2">Cargar</button>
<button id="btnGdetExcel" class="btn btn-outline-success btn-sm me-2">Excel</button>
<button id="btnGdetPDF" class="btn btn-outline-secondary btn-sm">PDF</button>
</div>
</div>
</div>
<div class="card-body">
<div class="row g-3 mb-2">
<div class="col-12">
<div class="d-flex gap-2 flex-wrap">
<span class="badge bg-primary-subtle border text-primary">Total: <span id="gdetTotal">—</span></span>
<span class="badge bg-secondary-subtle border text-secondary">Compras: <span id="gdetCompras">—</span></span>
<span class="badge bg-info-subtle border text-info">Renglones: <span id="gdetRows">—</span></span>
<span class="badge bg-light border text-muted ms-auto" id="gdetInfo">—</span>
</div>
</div>
</div>
<div class="table-responsive table-scroll" id="gdetScroll">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>Fecha</th>
<th>Proveedor</th>
<th>Tipo</th>
<th>Ítem</th>
<th class="text-end">Cantidad</th>
<th class="text-end">Precio</th>
<th class="text-end">Subtotal</th>
</tr>
</thead>
<tbody id="tbGdet">
<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Comparativo Ventas vs Gastos -->
<div class="card shadow-sm" id="PRINT_COMP">
<div class="card-header d-flex align-items-center">
<strong>Comparativo: Ventas vs Gastos</strong>
<span class="ms-auto small text-muted" id="compInfo">—</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="row text-center">
<div class="col-4"><div class="small text-muted">Ingresos YTD</div><div class="fs-5 fw-semibold" id="cmpSales">—</div></div>
<div class="col-4"><div class="small text-muted">Gastos YTD</div><div class="fs-5 fw-semibold" id="cmpCost">—</div></div>
<div class="col-4"><div class="small text-muted">Resultado</div><div class="fs-5 fw-semibold" id="cmpDiff">—</div></div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Mensual (UYU)</div>
<div class="small text-muted" id="compYearTitle">—</div>
</div>
<div id="compChart" style="height:160px;"></div>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr><th>Mes</th><th class="text-end">Ingresos</th><th class="text-end">Gastos</th><th class="text-end">Resultado</th></tr>
</thead>
<tbody id="tbComp"><tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr></tbody>
</table>
</div>
</div>
</div>
<div class="d-flex gap-2 mt-2">
<button id="btnCompExcel" class="btn btn-outline-success btn-sm">Excel</button>
<button id="btnCompPDF" class="btn btn-outline-secondary btn-sm">PDF</button>
</div>
</div>
</div>
</div>
<style>
.spark rect:hover { filter: brightness(0.9); }
.emp-card { border:1px solid #e9ecef; border-radius:.75rem; padding:12px; }
.emp-meta .badge { background:#f8f9fa; color:#212529; border:1px solid #e9ecef; }
.spark-wrap { width:100%; height:80px; }
.spark { width:100%; height:100%; }
.spark text { font-size:10px; fill:#6c757d; }
.spark rect:hover { filter: brightness(.9); }
@media print {
body * { visibility: hidden !important; }
#PRINT_ASIST, #PRINT_ASIST *,
#PRINT_TICKETS, #PRINT_TICKETS *,
#PRINT_GASTOS_DET, #PRINT_GASTOS_DET *,
#PRINT_COMP, #PRINT_COMP * { visibility: visible !important; }
#PRINT_ASIST, #PRINT_TICKETS, #PRINT_GASTOS_DET, #PRINT_COMP { position:absolute; left:0; top:0; width:100%; }
}
#PRINT_GASTOS_DET { --gdet-h: 48vh; } /* ~mitad de la pantalla */
@media (min-width: 992px){ #PRINT_GASTOS_DET { --gdet-h: 420px; } } /* desktop fijo */
/* Scroll vertical con encabezado fijo */
#PRINT_GASTOS_DET .table-scroll{
max-height: var(--gdet-h);
overflow: auto; /* vertical + horizontal si hace falta */
}
#PRINT_GASTOS_DET .table-scroll thead th{
position: sticky;
top: 0;
z-index: 2;
background: var(--bs-table-bg, #fff);
}
#PRINT_GASTOS_DET .table-scroll tbody tr:last-child td{
border-bottom: 0;
}
/* Al imprimir, expandir todo (sin scroll) */
@media print{
#PRINT_GASTOS_DET .table-scroll{
max-height: none !important;
overflow: visible !important;
}
}
</style>
<script>
/* ===== Helpers ===== */
const $ = s => document.querySelector(s);
const z2 = n => String(n).padStart(2,'0');
const fmtMoney = v => (v==null? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(Number(v)));
const fmtInt = v => Math.round(Number(v||0));
const fmtHM = mins => { const h=Math.floor(mins/60); const m=Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
const ymd = s => String(s||'').slice(0,10);
const monthNames = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
const MONTH_NAMES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Setiembre','Octubre','Noviembre','Diciembre'];
async function jget(url){ const r=await fetch(url); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||`${r.status}`); return j; }
async function jpost(url, body){ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||`${r.status}`); return j; }
function getSelectedMonthYear() {
const m = parseInt(document.querySelector('#gdetMes')?.value, 10);
const y = parseInt(document.querySelector('#gdetAnio')?.value, 10);
const now = new Date();
const month = (Number.isFinite(m) && m>=1 && m<=12) ? m : (now.getMonth()+1);
const year = (Number.isFinite(y) && y>=2000 && y<=2100) ? y : now.getFullYear();
return {month, year};
}
function monthRange(month, year) {
// month: 1..12
const from = new Date(year, month-1, 1, 0,0,0,0);
const to = new Date(year, month, 0, 23,59,59,999);
return {
desdeISO: from.toISOString(),
hastaISO: to.toISOString(),
titulo: `${MONTH_NAMES[month-1]} ${year}`,
spanTxt: `${from.toLocaleDateString('es-UY')} - ${to.toLocaleDateString('es-UY')}`
};
}
function toCSV(rows, headers){
const esc = v => v==null? '' : (typeof v==='number'? String(v) : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g,'""')}"` : String(v));
const cols = headers && headers.length? headers : Object.keys(rows?.[0]||{});
const out = []; if(headers) out.push(cols.join(','));
for(const r of (rows||[])) out.push(cols.map(c=>esc(r[c])).join(','));
return out.join('\r\n');
}
function downloadText(name, text){ const blob=new Blob([text],{type:'text/csv;charset=utf-8;'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name; a.click(); URL.revokeObjectURL(a.href); }
function printArea(){ window.print(); }
/* === Mini SVGs === */
function barsSVG(series){ // [{label, value}]
const W=560,H=120,P=10,G=6;
const n=series.length||1, max=Math.max(1,...series.map(d=>Number(d.value||0)));
const bw=Math.max(6,Math.floor((W-P*2-G*(n-1))/n)); let x=P, bars='';
series.forEach(d=>{ const vh=Math.round((Number(d.value||0)/max)*(H-P-26)); const y=H-20-vh;
bars+=`<g><rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="3" ry="3" fill="#0d6efd"><title>${d.label} · ${fmtMoney(d.value)}</title></rect><text x="${x+bw/2}" y="${H-6}" text-anchor="middle">${d.label}</text></g>`; x+=bw+G; });
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none"><line x1="${P}" y1="${H-20}" x2="${W-P}" y2="${H-20}" stroke="#adb5bd"/></svg>` .replace('</svg>', bars+'</svg>');
}
function barsCompareSVG(a,b){ // a=ventas, b=gastos: arrays [{label,value}]
const W=560,H=160,P=10,G=8,PAIR=2,INNER=3;
const n=a.length, max=Math.max(1,...a.map(d=>Number(d.value||0)),...b.map(d=>Number(d.value||0)));
const bw=Math.max(5,Math.floor((W-P*2-G*(n-1))/(n*PAIR)));
let x=P, g=''; for(let i=0;i<n;i++){
const av=Number(a[i].value||0), bv=Number(b[i].value||0);
const ah=Math.round((av/max)*(H-P-26)), bh=Math.round((bv/max)*(H-P-26));
const ay=H-20-ah, by=H-20-bh;
g+=`<g><rect x="${x}" y="${ay}" width="${bw}" height="${ah}" rx="3" ry="3" fill="#198754"><title>${a[i].label} · Ventas ${fmtMoney(av)}</title></rect></g>`;
x+=bw+INNER;
g+=`<g><rect x="${x}" y="${by}" width="${bw}" height="${bh}" rx="3" ry="3" fill="#dc3545"><title>${b[i].label} · Gastos ${fmtMoney(bv)}</title></rect><text x="${x-bw/2}" y="${H-6}" text-anchor="middle">${a[i].label}</text></g>`;
x+=bw+G;
}
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none"><line x1="${P}" y1="${H-20}" x2="${W-P}" y2="${H-20}" stroke="#adb5bd"/></svg>` .replace('</svg>', g+'</svg>');
}
/* ===== Asistencias ===== */
let cacheAsist=[];
async function fetchAsistencias(desde,hasta){
try { return await jpost('/api/rpc/report_asistencia', { desde, hasta }); }
catch { const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`; return await jget(url); }
}
function renderAsistTabla(rows){
const tb=$('#tbAsist'); if(!rows?.length){ tb.innerHTML='<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML=''; rows.forEach(r=>{ const tr=document.createElement('tr'); tr.innerHTML=`
<td>${r.documento||'—'}</td><td>${r.nombre||'—'}</td><td>${r.apellido||'—'}</td><td>${r.fecha||'—'}</td>
<td class="text-end">${r.desde_hora||'—'}</td><td class="text-end">${r.hasta_hora||'—'}</td>
<td class="text-end">${fmtHM(Number(r.dur_min||0))}</td>`; tb.appendChild(tr); });
}
async function loadAsist(){
let d = $('#asistDesde')?.value;
let h = $('#asistHasta')?.value;
// fallback: últimos 30 días
if (!d || !h){
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate() - 30);
d = start.toISOString().slice(0,10);
h = end.toISOString().slice(0,10);
if ($('#asistDesde')) $('#asistDesde').value = d;
if ($('#asistHasta')) $('#asistHasta').value = h;
}
$('#repStatus').textContent = 'Cargando asistencias…';
cacheAsist = await jpost('/api/rpc/report_asistencia', { desde: d, hasta: h })
.catch(async ()=>{
// fallback a tabla genérica si el RPC no está
const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(d)}&hasta=${encodeURIComponent(h)}&limit=10000`;
return await jget(url);
});
renderAsistTabla(cacheAsist||[]);
const minsTot = (cacheAsist||[]).reduce((s,r)=>s+Number(r.dur_min||0),0);
$('#asistInfo').textContent = `${cacheAsist?.length||0} registros · ${fmtHM(minsTot)}`;
$('#repStatus').textContent = 'Listo';
}
function asistBarsSVG(series /* [{x:'YYYY-MM-DD', h:Number}] */, maxH = null) {
const W=520, H=80, PAD=6, GAP=3;
const n = series.length || 1;
const max = maxH ?? Math.max(1, ...series.map(d => d.h || 0));
const bw = Math.max(2, Math.floor((W - PAD*2 - GAP*(n-1)) / n));
let x = PAD, bars = '';
series.forEach(d => {
const vh = max ? Math.round((d.h / max) * (H - PAD*2)) : 0;
const y = H - PAD - vh;
const label = `${d.x} · ${fmtHM((d.h||0)*60)}`;
bars += `<rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="2" ry="2" data-x="${d.x}" data-h="${d.h??0}"><title>${label}</title></rect>`;
x += bw + GAP;
});
const axis = `<line x1="${PAD}" y1="${H-PAD}" x2="${W-PAD}" y2="${H-PAD}" stroke="#adb5bd" stroke-width="1"/>`;
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="spark"><style>rect{fill:#0d6efd}</style>${axis}${bars}</svg>`;
}
// Render de tarjetas por empleado (idéntico a usuarios.ejs)
function asistRenderCards(grouped) {
const cont = $('#resumenCards');
if (!cont) return;
cont.innerHTML = '';
for (const [key, data] of grouped.entries()) {
const { doc, nombre, apellido, rows } = data;
rows.sort((a,b)=> a.fecha.localeCompare(b.fecha));
const series = rows.map(r => ({ x: r.fecha, h: Number(r.horas)||0 }));
const totalH = series.reduce((s,d)=> s + d.h, 0);
const dias = series.length;
const avgH = dias ? totalH / dias : 0;
const pairs = rows.reduce((s,r)=> s + (Number(r.pares)||0), 0);
const last = series.at(-1) || {x:'',h:0};
const svg = asistBarsSVG(series);
const col = document.createElement('div');
col.className = 'col-12 col-md-6 col-lg-4';
col.innerHTML = `
<div class="emp-card h-100">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<div class="fw-semibold">${nombre||''} ${apellido||''}</div>
<div class="text-muted small">${doc}</div>
</div>
<div class="text-end">
<div class="small text-muted">Total</div>
<div class="fs-5 fw-semibold">${fmtHM(totalH*60)}</div>
</div>
</div>
<div class="spark-wrap mb-1">${svg}</div>
<div class="small text-muted legend">Pasá el mouse por una barra…</div>
<div class="d-flex flex-wrap gap-1 emp-meta mt-2">
<span class="badge">Días: ${dias}</span>
<span class="badge">Prom: ${fmtHM(avgH*60)}</span>
<span class="badge">Pares: ${pairs}</span>
<span class="badge">Último: ${fmtHM((last.h||0)*60)} ${last.x?`(${last.x})`:''}</span>
</div>
</div>`;
cont.appendChild(col);
}
const badge = $('#resumeCount'); if (badge) badge.textContent = `${grouped.size} empleado(s)`;
}
// Leyenda al sobrevolar barras
const cardsRoot = $('#resumenCards');
if (cardsRoot){
cardsRoot.addEventListener('mouseover', (e)=>{
const r = e.target;
if (!(r instanceof SVGRectElement)) return;
const card = r.closest('.emp-card');
const legend = card?.querySelector('.legend');
if (!legend) return;
const x = r.getAttribute('data-x')||'';
const h = Number(r.getAttribute('data-h')||0);
legend.textContent = `${x} · ${fmtHM(h*60)}`;
});
}
// Loader: trae la vista asistencia_resumen_diario y arma tarjetas (30 días)
async function asistLoadResumenDiario30d(){
const badge = $('#resumeCount'); if (badge) badge.textContent = 'Cargando…';
try{
const rows = await jget('/api/table/asistencia_resumen_diario?limit=5000').catch(()=>[]);
const today = new Date(); const cut = new Date(today); cut.setDate(today.getDate()-30);
const byKey = new Map();
for (const r of (rows||[])) {
const fStr = ymd(r.fecha); const fDt = new Date(fStr);
if (!(fDt >= cut)) continue;
const key = `${r.documento}::${r.nombre||''}::${r.apellido||''}`;
if (!byKey.has(key)) byKey.set(key, { doc:r.documento, nombre:r.nombre||'', apellido:r.apellido||'', rows:[] });
byKey.get(key).rows.push({
fecha: fStr,
horas: Number(r.horas_dia ?? r.horas ?? (r.minutos_dia||0)/60),
pares: Number(r.pares_dia ?? r.pares ?? 0)
});
}
asistRenderCards(byKey);
if (badge) badge.textContent = 'Listo';
}catch(e){
if (badge) badge.textContent = 'Error';
console.error('asistLoadResumenDiario30d:', e);
}
}
// Auto-carga al abrir reportes (si la card está en el DOM)
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('PRINT_ASIST')) {
asistLoadResumenDiario30d();
}
});
/* ===== Tickets (ventas) ===== */
let cacheTickets=null;
function getYearSafe(val){
const y = parseInt(val, 10);
return Number.isFinite(y) && y >= 2000 && y <= 2100
? y
: new Date().getFullYear();
}
async function fetchTickets(year){
const y = getYearSafe(year);
return await jpost('/api/rpc/report_tickets', { year: y });
}
function renderTickets(data){
const months=data?.months||[]; $('#ticketsYearTitle').textContent=data?.year||'—';
$('#tYtd').textContent=months.reduce((s,m)=>s+Number(m.cant||0),0);
$('#tAvg').textContent=fmtMoney(data?.avg||data?.avg_ticket||0);
$('#tToDate').textContent=fmtMoney(data?.to_date||0);
$('#ticketsChart').innerHTML=barsSVG(months.map(m=>({label:m.nombre||m.mes,value:Number(m.cant||0)})));
const tb=$('#tbTickets'); if(!months.length){ tb.innerHTML='<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>'; } else {
tb.innerHTML=''; months.forEach(m=>{ const tr=document.createElement('tr'); tr.innerHTML=`
<td>${m.nombre||m.mes}</td><td class="text-end">${m.cant||0}</td>
<td class="text-end">${fmtMoney(m.importe||0)}</td><td class="text-end">${fmtMoney(m.avg||0)}</td>`; tb.appendChild(tr); });
}
$('#ticketsInfo').textContent=`${months.length} meses`;
}
/* ===== Gastos ===== */
let cacheGastos=null; // {year, months:[{mes,nombre,importe}], total, avg}
async function fetchGastos(year){
// 1) Intentar RPC
try { return await jpost('/api/rpc/report_gastos', { year }); } catch {}
// 2) Fallback: traer compras y agrupar en el cliente
const rows = await jget('/api/table/compras?limit=10000&order_by=fec_compra%20asc').catch(()=>[]);
const months = Array.from({length:12},(_,i)=>({mes:i+1,nombre:monthNames[i],importe:0}));
let total=0;
(rows||[]).forEach(r=>{
const d=new Date(r.fec_compra||r.fec||r.fecha); if(!d.getFullYear) return;
if (d.getFullYear() !== Number(year)) return;
const m=d.getMonth(); const t=Number(r.total||0);
months[m].importe += t; total += t;
});
const avg = months.reduce((s,m)=>s+m.importe,0)/12;
return { year, months, total, avg };
}
function renderGastos(data){
// siempre cacheo para el comparativo
cacheGastos = data || { year: new Date().getFullYear(), months: [], total: 0, avg: 0 };
const months = cacheGastos.months || [];
// elementos de la antigua card (pueden NO existir)
const yTitle = document.querySelector('#gastosYearTitle');
const toDate = document.querySelector('#gToDate');
const avgEl = document.querySelector('#gAvg');
const chart = document.querySelector('#gastosChart');
const tb = document.querySelector('#tbGastos');
const info = document.querySelector('#gastosInfo');
// si NO existe ninguno, significa que ya no está la card de Gastos ⇒ solo mantener cache y salir
if (!yTitle && !toDate && !avgEl && !chart && !tb && !info) return;
// a partir de acá, escribir solo si el elemento existe
if (yTitle) yTitle.textContent = cacheGastos.year ?? '—';
if (toDate) toDate.textContent = fmtMoney(cacheGastos.total || 0);
if (avgEl) avgEl.textContent = fmtMoney(cacheGastos.avg || 0);
if (chart) {
chart.innerHTML = barsSVG(months.map(m => ({
label: m.nombre || m.mes, value: Number(m.importe || 0)
})));
}
if (tb) {
if (!months.length) {
tb.innerHTML = '<tr><td colspan="2" class="p-3 text-muted">Sin datos</td></tr>';
} else {
tb.innerHTML = '';
months.forEach(m => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${m.nombre || m.mes}</td>
<td class="text-end">${fmtMoney(m.importe || 0)}</td>`;
tb.appendChild(tr);
});
}
}
if (info) info.textContent = `${months.length} meses`;
}
/* ===== Comparativo ===== */
function renderComparativo(){
if(!cacheTickets?.months || !cacheGastos?.months) return;
const y = cacheTickets.year || cacheGastos.year; $('#compYearTitle').textContent=y;
const ventas = Array.from({length:12},(_,i)=>Number(cacheTickets.months.find(m=>(m.mes||monthNames.indexOf(m.nombre)+1)===i+1)?.importe||0));
const gastos = Array.from({length:12},(_,i)=>Number(cacheGastos.months[i]?.importe||0));
const seriesA = ventas.map((v,i)=>({label:monthNames[i], value:v}));
const seriesB = gastos.map((v,i)=>({label:monthNames[i], value:v}));
$('#compChart').innerHTML = barsCompareSVG(seriesA, seriesB);
const tb=$('#tbComp'); tb.innerHTML='';
let ySales=0,yCost=0;
for(let i=0;i<12;i++){
const s=ventas[i]||0, g=gastos[i]||0, d=s-g; ySales+=s; yCost+=g;
const tr=document.createElement('tr'); tr.innerHTML=`
<td>${monthNames[i]}</td>
<td class="text-end">${fmtMoney(s)}</td>
<td class="text-end">${fmtMoney(g)}</td>
<td class="text-end ${d>=0?'text-success':'text-danger'}">${fmtMoney(d)}</td>`;
tb.appendChild(tr);
}
$('#cmpSales').textContent = fmtMoney(ySales);
$('#cmpCost').textContent = fmtMoney(yCost);
$('#cmpDiff').textContent = fmtMoney(ySales - yCost);
$('#compInfo').textContent = '12 meses';
}
/* ===== Exportaciones ===== */
function exportAsistCSV(){
if(!cacheAsist?.length) return;
const headers=['Documento','Nombre','Apellido','Fecha','Desde','Hasta','Duración(min)'];
const rows = cacheAsist.map(r=>({Documento:r.documento||'',Nombre:r.nombre||'',Apellido:r.apellido||'',Fecha:r.fecha||'',Desde:r.desde_hora||'',Hasta:r.hasta_hora||'','Duración(min)':Number(r.dur_min||0)}));
downloadText(`asistencias_${$('#asistDesde').value}_${$('#asistHasta').value}.csv`, toCSV(rows,headers));
}
function exportTicketsCSV(){
if(!cacheTickets?.months?.length) return;
const toInt=v=>fmtInt(v);
const headers=['Año','Mes','Tickets','Importe','Ticket promedio'];
const rows=cacheTickets.months.map(m=>({'Año':cacheTickets.year,'Mes':m.nombre||m.mes,'Tickets':Number(m.cant||0),'Importe':toInt(m.importe),'Ticket promedio':toInt(m.avg)}));
downloadText(`tickets_${cacheTickets.year}.csv`, toCSV(rows,headers));
}
function exportGastosCSV(){
if(!cacheGastos?.months?.length) return;
const toInt=v=>fmtInt(v);
const headers=['Año','Mes','Gasto'];
const rows=cacheGastos.months.map(m=>({'Año':cacheGastos.year,'Mes':m.nombre,'Gasto':toInt(m.importe)}));
downloadText(`gastos_${cacheGastos.year}.csv`, toCSV(rows,headers));
}
function exportCompCSV(){
if(!cacheGastos?.months || !cacheTickets?.months) return;
const headers=['Mes','Ingresos','Gastos','Resultado'];
const rows=monthNames.map((nm,i)=>{ const s=Number(cacheTickets.months[i]?.importe||0), g=Number(cacheGastos.months[i]?.importe||0); return {Mes:nm,Ingresos:fmtInt(s),Gastos:fmtInt(g),Resultado:fmtInt(s-g)}; });
downloadText(`comparativo_${cacheTickets.year||cacheGastos.year}.csv`, toCSV(rows,headers));
}
/* ===== Gastos detallados (mes anterior) ===== */
let cacheGastosDet = [];
let cacheGdetMeta = null;
async function loadGastosDetallado(optMonth, optYear){
// 1) rango según select (o params)
const {month, year} = (Number.isFinite(optMonth) && Number.isFinite(optYear))
? {month: optMonth, year: optYear}
: getSelectedMonthYear();
const {desdeISO, hastaISO, titulo, spanTxt} = monthRange(month, year);
cacheGdetMeta = { desdeISO, hastaISO, month, year };
// 2) traer tablas base
const [compras, provs, detMats, detProds, mates, prods] = await Promise.all([
jget('/api/table/compras?limit=10000&order_by=fec_compra%20desc').catch(()=>[]),
jget('/api/table/proveedores?limit=10000').catch(()=>[]),
jget('/api/table/deta_comp_materias?limit=100000').catch(()=>[]),
jget('/api/table/deta_comp_producto?limit=100000').catch(()=>[]),
jget('/api/table/mate_primas?limit=10000').catch(()=>[]),
jget('/api/table/productos?limit=10000').catch(()=>[]),
]);
// 3) filtro por rango seleccionado
const from = new Date(desdeISO), to = new Date(hastaISO);
const comprasMes = (compras||[]).filter(c=>{
const d = new Date(c.fec_compra || c.fecha || c.fec);
return d >= from && d <= to;
});
const ids = new Set(comprasMes.map(c=>c.id_compra));
// 4) mapas auxiliares
const provById = Object.fromEntries((provs||[]).map(p=>[p.id_proveedor, p.raz_social||p.rut||`#${p.id_proveedor}`]));
const matName = Object.fromEntries((mates||[]).map(x=>[x.id_mat_prima, x.nombre]));
const prodName = Object.fromEntries((prods||[]).map(x=>[x.id_producto, x.nombre]));
const mapCompra = Object.fromEntries(comprasMes.map(c=>[c.id_compra, c]));
// 5) construir filas
const filas = [];
(detMats||[]).forEach(d=>{
if(!ids.has(d.id_compra)) return;
const c = mapCompra[d.id_compra]; const fecha = new Date(c.fec_compra||c.fecha||c.fec);
const prov = provById[c.id_proveedor] || '—';
const qty = Number(d.cantidad||0);
const pu = Number(d.pre_unitario||0);
filas.push({
fecha, fecha_txt: fecha.toLocaleDateString('es-UY'),
proveedor: prov, tipo: 'Materia', item: (matName[d.id_mat_prima] || `#${d.id_mat_prima}`),
cantidad: qty, precio: pu, subtotal: qty*pu
});
});
(detProds||[]).forEach(d=>{
if(!ids.has(d.id_compra)) return;
const c = mapCompra[d.id_compra]; const fecha = new Date(c.fec_compra||c.fecha||c.fec);
const prov = provById[c.id_proveedor] || '—';
const qty = Number(d.cantidad||0);
const pu = Number(d.pre_unitario||0);
filas.push({
fecha, fecha_txt: fecha.toLocaleDateString('es-UY'),
proveedor: prov, tipo: 'Producto', item: (prodName[d.id_producto] || `#${d.id_producto}`),
cantidad: qty, precio: pu, subtotal: qty*pu
});
});
filas.sort((a,b)=> b.fecha - a.fecha);
cacheGastosDet = filas;
// 6) render
document.querySelector('#gdetTitle')?.replaceChildren(document.createTextNode(titulo));
document.querySelector('#gdetInfo') ?.replaceChildren(document.createTextNode(spanTxt));
const tb = document.querySelector('#tbGdet');
if(!filas.length){ tb.innerHTML = '<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; }
else{
tb.innerHTML = '';
filas.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.fecha_txt}</td>
<td>${r.proveedor}</td>
<td>${r.tipo}</td>
<td>${r.item}</td>
<td class="text-end">${r.cantidad.toLocaleString('es-UY')}</td>
<td class="text-end">${new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(r.precio)}</td>
<td class="text-end">${new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(r.subtotal)}</td>`;
tb.appendChild(tr);
});
}
const total = filas.reduce((s,r)=>s+r.subtotal,0);
document.querySelector('#gdetTotal') ?.replaceChildren(document.createTextNode(new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(total)));
document.querySelector('#gdetCompras')?.replaceChildren(document.createTextNode(comprasMes.length.toLocaleString('es-UY')));
document.querySelector('#gdetRows') ?.replaceChildren(document.createTextNode(filas.length.toLocaleString('es-UY')));
}
function exportGdetCSV(){
if(!cacheGastosDet?.length) return;
const headers = ['Fecha','Proveedor','Tipo','Ítem','Cantidad','Precio','Subtotal'];
const rows = cacheGastosDet.map(r=>({
'Fecha': r.fecha_txt,
'Proveedor': r.proveedor,
'Tipo': r.tipo,
'Ítem': r.item,
'Cantidad': r.cantidad,
'Precio': Math.round(r.precio),
'Subtotal': Math.round(r.subtotal)
}));
const title = document.querySelector('#gdetTitle')?.textContent?.replace(/\s+/g,'_') || 'mes';
downloadText(`gastos_detalle_${title}.csv`, toCSV(rows, headers));
}
const onPDFGdet = ()=>printArea('PRINT_GASTOS_DET');
const onPDFAsist=()=>printArea('PRINT_ASIST');
const onPDFTicket=()=>printArea('PRINT_TICKETS');
const onPDFGastos=()=>printArea('PRINT_GASTOS');
const onPDFComp=()=>printArea('PRINT_COMP');
/* ===== Eventos ===== */
const btnGdetCargar = document.querySelector('#btnGdetCargar');
if (btnGdetCargar) btnGdetCargar.addEventListener('click', ()=> loadGastosDetallado());
// por UX: recargar al cambiar mes/año
document.querySelector('#gdetMes') ?.addEventListener('change', ()=> loadGastosDetallado());
document.querySelector('#gdetAnio')?.addEventListener('change', ()=> loadGastosDetallado());
document.querySelector('#btnGdetExcel')?.addEventListener('click', exportGdetCSV);
document.querySelector('#btnGdetPDF') ?.addEventListener('click', onPDFGdet);
$('#btnAsistCargar').addEventListener('click', loadAsist);
$('#btnAsistExcel'). addEventListener('click', exportAsistCSV);
$('#btnAsistPDF'). addEventListener('click', onPDFAsist);
$('#btnAnualExcel').addEventListener('click', exportCompCSV);
$('#btnAnualPDF'). addEventListener('click', onPDFComp);
/* ✅ Enlaza los botones de la sección comparativo */
$('#btnCompExcel').addEventListener('click', exportCompCSV);
$('#btnCompPDF'). addEventListener('click', onPDFComp);
/* ✅ Botones de la nueva sección de gastos detallados */
$('#btnGdetExcel').addEventListener('click', exportGdetCSV);
$('#btnGdetPDF'). addEventListener('click', onPDFGdet);
$('#btnAnualCargar').addEventListener('click', async ()=>{
const y=Number($('#anualYear').value);
$('#repStatus').textContent='Cargando ventas y gastos…';
cacheTickets = await fetchTickets(y).catch(()=>null);
if (cacheTickets) renderTickets(cacheTickets);
cacheGastos = await fetchGastos(y).catch(()=>null);
if (cacheGastos) renderGastos(cacheGastos);
if (cacheTickets && cacheGastos) renderComparativo();
$('#repStatus').textContent='Listo';
});
$('#btnAnualExcel').addEventListener('click', exportCompCSV);
$('#btnAnualPDF'). addEventListener('click', onPDFComp);
/* ===== Defaults al cargar ===== */
(function init(){
const today = new Date();
const y = today.getFullYear();
if (!$('#anualYear').value) $('#anualYear').value = y;
if (!$('#asistDesde').value || !$('#asistHasta').value){
const end = new Date();
const start = new Date(end);
start.setDate(end.getDate()-30);
$('#asistDesde').value = start.toISOString().slice(0,10);
$('#asistHasta').value = end.toISOString().slice(0,10);
}
loadAsist().catch(()=>{});
(async()=>{
cacheTickets = await fetchTickets($('#anualYear').value).catch(()=>null);
if (cacheTickets) renderTickets(cacheTickets);
cacheGastos = await fetchGastos($('#anualYear').value).catch(()=>null);
if (cacheGastos) renderGastos(cacheGastos);
if (cacheTickets && cacheGastos) renderComparativo();
$('#repStatus').textContent = 'Listo';
})();
})();
(function presetMesAnterior(){
const now = new Date();
const prev = new Date(now.getFullYear(), now.getMonth()-1, 1);
const mesSel = document.querySelector('#gdetMes');
const anioIn = document.querySelector('#gdetAnio');
if (mesSel && !mesSel.value) mesSel.value = String(prev.getMonth()+1);
if (anioIn && !anioIn.value) anioIn.value = String(prev.getFullYear());
// primera carga
loadGastosDetallado(prev.getMonth()+1, prev.getFullYear()).catch(()=>{});
})();
</script>
+402
View File
@@ -0,0 +1,402 @@
<% /* Reportes - Asistencias y Tickets (Comandas) */ %>
<div class="container-fluid py-3">
<div class="d-flex align-items-center mb-2">
<h3 class="mb-0">Reportes</h3>
<span class="ms-auto small text-muted" id="repStatus">—</span>
</div>
<!-- Filtros -->
<div class="card shadow-sm mb-3">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Asistencias · Rango</label>
<div class="row g-2">
<div class="col-6 col-md-4">
<input id="asistDesde" type="date" class="form-control">
</div>
<div class="col-6 col-md-4">
<input id="asistHasta" type="date" class="form-control">
</div>
<div class="col-12 col-md-4 d-grid d-md-block">
<button id="btnAsistCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnAsistExcel" class="btn btn-outline-success me-2">Excel</button>
<button id="btnAsistPDF" class="btn btn-outline-secondary">PDF</button>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<label class="form-label mb-1">Tickets (Comandas) · Año</label>
<div class="row g-2">
<div class="col-6 col-md-4">
<input id="ticketsYear" type="number" min="2000" step="1" class="form-control">
</div>
<div class="col-6 col-md-8 d-grid d-md-block">
<button id="btnTicketsCargar" class="btn btn-primary me-2">Cargar</button>
<button id="btnTicketsExcel" class="btn btn-outline-success me-2">Excel</button>
<button id="btnTicketsPDF" class="btn btn-outline-secondary">PDF</button>
</div>
</div>
</div>
</div>
<div class="small text-muted mt-2">
Los archivos Excel se generan como CSV (compatibles). Los PDF se generan con “Imprimir área” del navegador.
</div>
</div>
</div>
<!-- Reporte Asistencias -->
<div class="card shadow-sm mb-3" id="PRINT_ASIST">
<div class="card-header d-flex align-items-center">
<strong>Asistencias</strong>
<span class="ms-auto small text-muted" id="asistInfo">—</span>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Documento</th>
<th>Nombre</th>
<th>Apellido</th>
<th>Fecha</th>
<th class="text-end">Desde</th>
<th class="text-end">Hasta</th>
<th class="text-end">Duración</th>
</tr>
</thead>
<tbody id="tbAsist">
<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Reporte Tickets -->
<div class="card shadow-sm" id="PRINT_TICKETS">
<div class="card-header d-flex align-items-center">
<strong>Tickets</strong>
<span class="ms-auto small text-muted" id="ticketsInfo">—</span>
</div>
<div class="card-body">
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Resumen del año</div>
<div class="small text-muted" id="ticketsYearTitle">—</div>
</div>
<div class="row text-center">
<div class="col-4">
<div class="small text-muted">Tickets YTD</div>
<div class="fs-5 fw-semibold" id="tYtd">—</div>
</div>
<div class="col-4">
<div class="small text-muted">Promedio</div>
<div class="fs-5 fw-semibold" id="tAvg">—</div>
</div>
<div class="col-4">
<div class="small text-muted">Hasta la fecha</div>
<div class="fs-5 fw-semibold" id="tToDate">—</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="emp-card p-3 border rounded">
<div class="d-flex justify-content-between align-items-center mb-2">
<div class="fw-semibold">Tickets por mes</div>
<div class="small text-muted">Cantidad</div>
</div>
<div class="spark-wrap" id="ticketsChart" style="height:140px;"></div>
</div>
</div>
<div class="col-12">
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead class="table-light">
<tr>
<th>Mes</th>
<th class="text-end">Tickets</th>
<th class="text-end">Importe</th>
<th class="text-end">Ticket promedio</th>
</tr>
</thead>
<tbody id="tbTickets">
<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.spark rect:hover { filter: brightness(0.9); }
@media print {
body * { visibility: hidden !important; }
#PRINT_ASIST, #PRINT_ASIST *,
#PRINT_TICKETS, #PRINT_TICKETS * { visibility: visible !important; }
#PRINT_ASIST, #PRINT_TICKETS { position: absolute; left:0; top:0; width:100%; }
}
</style>
<script>
/* =========================
Helpers reutilizables
========================= */
const $ = s => document.querySelector(s);
const z2 = n => String(n).padStart(2,'0');
const fmtMoney = v => (v==null? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(Number(v)));
const fmtHM = mins => { const h = Math.floor(mins/60); const m = Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
// GET JSON simple
async function jget(url){
const r = await fetch(url);
const j = await r.json().catch(()=>null);
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
return j;
}
// POST JSON simple
async function jpost(url, body){
const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
const j = await r.json().catch(()=>null);
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
return j;
}
// CSV (Excel-friendly)
function toCSV(rows, headers){
const esc = v => {
if (v == null) return '';
if (typeof v === 'number') return String(v); // números sin comillas
const s = String(v);
return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s;
};
const cols = headers && headers.length ? headers : Object.keys(rows?.[0] || {});
const lines = [];
if (headers) lines.push(cols.join(','));
for (const r of (rows || [])) lines.push(cols.map(c => esc(r[c])).join(','));
return lines.join('\r\n');
}
function downloadText(filename, text){
const blob = new Blob([text], {type:'text/csv;charset=utf-8;'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
a.click();
URL.revokeObjectURL(a.href);
}
// Print solo área
function printArea(id){
// cambiamos el hash para que @media print muestre el área; luego invocamos print
const el = document.getElementById(id);
if (!el) return;
window.print();
}
// SVG barras simple (sin librerías)
function barsSVG(series /* [{label:'Ene', value:Number}] */){
const W=560, H=120, PAD=10, GAP=6;
const n = series.length||1;
const max = Math.max(1, ...series.map(d=>d.value||0));
const bw = Math.max(6, Math.floor((W-PAD*2-GAP*(n-1))/n));
let x = PAD;
let bars = '';
series.forEach((d,i)=>{
const vh = Math.round((d.value/max)*(H-PAD-26)); // 26px para etiquetas
const y = H-20 - vh;
const title = `${d.label} · ${d.value}`;
bars += `<g>
<rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="3" ry="3" class="bar">
<title>${title}</title>
</rect>
<text x="${x + bw/2}" y="${H-6}" text-anchor="middle">${d.label}</text>
</g>`;
x += bw + GAP;
});
const css = `.bar{fill:#0d6efd}`;
const axis = `<line x1="${PAD}" y1="${H-20}" x2="${W-PAD}" y2="${H-20}" stroke="#adb5bd" stroke-width="1"/>`;
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none">
<style>${css}</style>
${axis}
${bars}
</svg>`;
}
/* =========================
Data access (enchufable)
=========================
Estas funciones llaman RPCs del server, que a su vez deben
delegar en funciones SQL. Si aún no existen, ver más abajo
el bloque "Sugerencia de funciones SQL".
*/
async function fetchAsistencias(desde, hasta){
// endpoint recomendado (RPC):
// POST /api/rpc/report_asistencia { desde, hasta }
// Retorna [{documento,nombre,apellido,fecha,desde_hora,hasta_hora,dur_min}]
try {
return await jpost('/api/rpc/report_asistencia', { desde, hasta });
} catch {
// fallback (si aún no tienes RPC): lee la vista "asistencia_detalle" hipotética
const url = `/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`;
return await jget(url);
}
}
async function fetchTickets(year){
// endpoint recomendado (RPC):
// POST /api/rpc/report_tickets { year }
// Retorna: { year, total_ytd, avg_ticket, to_date, months:[{mes:1..12, nombre:'Ene', cant, importe, avg}] }
return await jpost('/api/rpc/report_tickets', { year });
}
/* =========================
Render Asistencias
========================= */
let cacheAsist = [];
function renderAsistTabla(rows){
const tb = $('#tbAsist');
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
rows.forEach(r=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.documento||'—'}</td>
<td>${r.nombre||'—'}</td>
<td>${r.apellido||'—'}</td>
<td>${r.fecha||'—'}</td>
<td class="text-end">${r.desde_hora||'—'}</td>
<td class="text-end">${r.hasta_hora||'—'}</td>
<td class="text-end">${fmtHM(Number(r.dur_min||0))}</td>
`;
tb.appendChild(tr);
});
}
async function loadAsist(){
const d = $('#asistDesde').value;
const h = $('#asistHasta').value;
$('#repStatus').textContent = 'Cargando asistencias…';
const rows = await fetchAsistencias(d,h);
cacheAsist = rows||[];
renderAsistTabla(cacheAsist);
const minsTot = cacheAsist.reduce((s,r)=> s + Number(r.dur_min||0), 0);
$('#asistInfo').textContent = `${cacheAsist.length} registros · ${fmtHM(minsTot)}`;
$('#repStatus').textContent = 'Listo';
}
/* =========================
Render Tickets
========================= */
let cacheTickets = null;
function renderTickets(data){
const months = data?.months||[];
$('#ticketsYearTitle').textContent = data?.year || '—';
$('#tYtd').textContent = months.reduce((s,m)=> s + Number(m.cant||0), 0);
$('#tAvg').textContent = fmtMoney(data?.avg_ticket ?? 0);
$('#tToDate').textContent = data?.to_date != null ? fmtMoney(data.to_date) : '—';
const series = months.map(m=>({ label:m.nombre||m.mes, value:Number(m.cant||0) }));
$('#ticketsChart').innerHTML = barsSVG(series);
const tb = $('#tbTickets');
if (!months.length){ tb.innerHTML = '<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>'; return; }
tb.innerHTML = '';
months.forEach(m=>{
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${m.nombre||m.mes}</td>
<td class="text-end">${m.cant||0}</td>
<td class="text-end">${fmtMoney(m.importe||0)}</td>
<td class="text-end">${fmtMoney(m.avg||0)}</td>
`;
tb.appendChild(tr);
});
$('#ticketsInfo').textContent = `${months.length} meses`;
}
async function loadTickets(){
const y = Number($('#ticketsYear').value);
$('#repStatus').textContent = 'Cargando tickets…';
const data = await fetchTickets(y);
cacheTickets = data;
renderTickets(cacheTickets);
$('#repStatus').textContent = 'Listo';
}
/* =========================
Excel (CSV) & PDF
========================= */
function exportAsistCSV(){
if (!cacheAsist?.length) return;
const headers = ['Documento','Nombre','Apellido','Fecha','Desde','Hasta','Duración(min)'];
const rows = cacheAsist.map(r=>({
Documento:r.documento||'',
Nombre:r.nombre||'',
Apellido:r.apellido||'',
Fecha:r.fecha||'',
Desde:r.desde_hora||'',
Hasta:r.hasta_hora||'',
'Duración(min)':Number(r.dur_min||0)
}));
const csv = toCSV(rows, headers);
downloadText(`asistencias_${$('#asistDesde').value}_${$('#asistHasta').value}.csv`, csv);
}
function exportTicketsCSV(){
if (!cacheTickets?.months?.length) return;
const toInt = v => Math.round(Number(v || 0)); // sin decimales
const headers = ['Año','Mes','Tickets','Importe','Ticket promedio'];
const rows = cacheTickets.months.map(m => ({
'Año': cacheTickets.year,
'Mes': m.nombre || m.mes,
'Tickets': Number(m.cant || 0),
'Importe': toInt(m.importe), // ← entero
'Ticket promedio': toInt(m.avg) // ← entero
}));
const csv = toCSV(rows, headers);
downloadText(`tickets_${cacheTickets.year}.csv`, csv);
}
// PDF vía print-area del navegador
const onPDFAsist = () => printArea('PRINT_ASIST');
const onPDFTicket = () => printArea('PRINT_TICKETS');
/* =========================
Eventos + defaults
========================= */
document.getElementById('btnAsistCargar').addEventListener('click', loadAsist);
document.getElementById('btnTicketsCargar').addEventListener('click', loadTickets);
document.getElementById('btnAsistExcel').addEventListener('click', exportAsistCSV);
document.getElementById('btnTicketsExcel').addEventListener('click', exportTicketsCSV);
document.getElementById('btnAsistPDF').addEventListener('click', onPDFAsist);
document.getElementById('btnTicketsPDF').addEventListener('click', onPDFTicket);
// Defaults: último mes y año actual
(function initDefaults(){
const today = new Date();
const y = today.getFullYear();
const hasta = today.toISOString().slice(0,10);
const d = new Date(today); d.setMonth(d.getMonth()-1);
const desde = d.toISOString().slice(0,10);
$('#asistDesde').value = desde;
$('#asistHasta').value = hasta;
$('#ticketsYear').value = y;
// carga inicial
loadAsist().catch(()=>{});
loadTickets().catch(()=>{});
})();
</script>
File diff suppressed because it is too large Load Diff