Compare commits

..

2 Commits

Author SHA1 Message Date
8522d02170 Intento de integrar Authentik 2025-09-05 01:33:52 +00:00
cbcea72848 Importación de feature/registration 2025-09-05 00:45:16 +00:00
39 changed files with 7119 additions and 1501 deletions

View File

@ -1,6 +1,6 @@
# 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.
SuiteCoffee es un sistema modular pensado para la **gestión de cafeterías** (y negocios afines), con servicios Node.js para **aplicación** y Authentik **autenticación**, bases de datos **PostgreSQL** separadas para negocio y multitenencia, y un **stack Docker Compose** que facilita levantar entornos de **desarrollo** y **producción**. Incluye herramientas auxiliares como **Nginx Proxy Manager (NPM)** y **CloudBeaver** para administrar bases de datos desde el navegador.
> Repositorio: https://gitea.mateosaldain.uy/msaldain/SuiteCoffee.git

View File

@ -1,664 +0,0 @@
<!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>

View File

@ -63,10 +63,90 @@ services:
networks:
net:
aliases: [dev-tenants]
#################
### Authentik ###
#################
# --- Authentik db (solo interno)
authentik-db:
# image: postgres:16-alpine
environment:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: ${AUTHENTIK_DB_PASS}
# healthcheck:
# test: ["CMD-SHELL", "pg_isready -U authentik -d authentik"]
# interval: 10s
# timeout: 3s
# retries: 10
volumes:
- authentik-db:/var/lib/postgresql/data
networks:
net:
aliases: [ak-db]
# restart: unless-stopped
# --- Authentik Redis (solo interno)
authentik-redis:
# image: redis:7-alpine
command: ["redis-server", "--save", "", "--appendonly", "no"]
networks:
net:
aliases: [ak-redis]
# restart: unless-stopped
# --- Authentik Server (sin puertos públicos)
authentik:
# image: ghcr.io/goauthentik/server:latest
# depends_on:
# authentik-db: { condition: service_healthy }
# authentik-redis: { condition: service_started }
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_DEBUG: "false"
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
AUTHENTIK_REDIS__HOST: authentik-redis
# Opcional: bootstrap automático del admin
AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
# expose:
# - "9000" # HTTP interno
# - "9443" # HTTPS interno
networks:
net:
aliases: [authentik]
# restart: unless-stopped
# Habilitá ESTO SOLO si querés abrir la UI local:
profiles: ["ak-ui"]
ports:
- 9000:9000
- 9443:9443
# --- Authentik Worker
authentik-worker:
# image: ghcr.io/goauthentik/server:latest
command: worker
depends_on:
authentik-db: { condition: service_healthy }
authentik-redis: { condition: service_started }
environment:
AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
AUTHENTIK_POSTGRESQL__HOST: authentik-db
AUTHENTIK_POSTGRESQL__USER: authentik
AUTHENTIK_POSTGRESQL__NAME: authentik
AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASS}
AUTHENTIK_REDIS__HOST: authentik-redis
networks:
net:
aliases: [ak-work]
volumes:
tenants-db:
suitecoffee-db:
authentik-db:
networks:
net:

View File

@ -6,6 +6,11 @@ services:
manso:
image: node:20-bookworm
depends_on:
db:
condition: service_healthy
tenants:
condition: service_healthy
expose:
- ${APP_LOCAL_PORT}
working_dir: /app
@ -20,7 +25,15 @@ services:
networks:
net:
aliases: [manso]
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:${APP_DOCKER_PORT}/health || exit 1"]
interval: 10s
timeout: 3s
retries: 10
start_period: 20s
command: npm run dev
profiles: [manso]
restart: unless-stopped
db:
image: postgres:16

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -18,10 +18,12 @@
"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"
"pg-format": "^1.0.4",
"serve-favicon": "^2.5.1"
},
"keywords": [],
"description": ""

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -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>

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>

View File

@ -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>

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1005 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -11,10 +11,12 @@
"dependencies": {
"bcrypt": "^5.1.1",
"chalk": "^5.6.0",
"cookie-session": "^2.0.0",
"cors": "^2.8.5",
"dotenv": "^17.2.1",
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"openid-client": "^5.6.5",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
},
@ -335,6 +337,30 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-session": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.1.tgz",
"integrity": "sha512-ji3kym/XZaFVew1+tIZk5ZLp9Z/fLv9rK1aZmpug0FsgE7Cu3ZDrUdRo7FT9vFjMYfNimrrUHJzywDwT7XEFlg==",
"license": "MIT",
"dependencies": {
"cookies": "0.9.1",
"debug": "3.2.7",
"on-headers": "~1.1.0",
"safe-buffer": "5.2.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cookie-session/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
@ -344,6 +370,19 @@
"node": ">=6.6.0"
}
},
"node_modules/cookies": {
"version": "0.9.1",
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"keygrip": "~1.1.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
@ -967,6 +1006,39 @@
"dev": true,
"license": "ISC"
},
"node_modules/jose": {
"version": "4.15.9",
"resolved": "https://registry.npmjs.org/jose/-/jose-4.15.9.tgz",
"integrity": "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/keygrip": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",
"integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==",
"license": "MIT",
"dependencies": {
"tsscmp": "1.0.6"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/lru-cache": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz",
"integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
@ -1217,6 +1289,15 @@
"node": ">=0.10.0"
}
},
"node_modules/object-hash": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-2.2.0.tgz",
"integrity": "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@ -1229,6 +1310,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oidc-token-hash": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.1.1.tgz",
"integrity": "sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g==",
"license": "MIT",
"engines": {
"node": "^10.13.0 || >=12.0.0"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -1241,6 +1331,15 @@
"node": ">= 0.8"
}
},
"node_modules/on-headers": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
"integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@ -1250,6 +1349,21 @@
"wrappy": "1"
}
},
"node_modules/openid-client": {
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
"integrity": "sha512-jDBPgSVfTnkIh71Hg9pRvtJc6wTwqjRkN88+gCFtYWrlP4Yx2Dsrow8uPi3qLr/aeymPF3o2+dS+wOpglK04ew==",
"license": "MIT",
"dependencies": {
"jose": "^4.15.9",
"lru-cache": "^6.0.0",
"object-hash": "^2.2.0",
"oidc-token-hash": "^5.0.3"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@ -1877,6 +1991,15 @@
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tsscmp": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz",
"integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==",
"license": "MIT",
"engines": {
"node": ">=0.6.x"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",

View File

@ -22,7 +22,9 @@
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
"pg-format": "^1.0.4",
"openid-client": "^5.6.5",
"cookie-session": "^2.0.0"
},
"keywords": [],
"description": ""

View File

@ -6,6 +6,9 @@ import cors from 'cors';
import { Pool } from 'pg';
import bcrypt from'bcrypt';
import { Issuer, generators } from 'openid-client';
import cookieSession from 'cookie-session';
// Rutas
import path from 'path';
import { fileURLToPath } from 'url';
@ -39,6 +42,14 @@ app.set('trust proxy', true);
app.use(express.static(path.join(__dirname, 'pages')));
app.use(cookieSession({
name: 'sid',
secret: process.env.SESSION_SECRET,
httpOnly: true,
sameSite: 'lax',
secure: false // en prod detrás de https: true
}));
// Configuración de conexión PostgreSQL
const dbConfig = {
@ -65,6 +76,20 @@ async function verificarConexion() {
}
}
// Descubrimiento OIDC (una sola vez)
let oidcClient;
async function getClient() {
if (oidcClient) return oidcClient;
const ISSUER = process.env.OIDC_ISSUER_INTERNAL; // ej: http://authentik:9000/application/o/suitecoffee/
const issuer = await Issuer.discover(`${ISSUER}.well-known/openid-configuration`);
oidcClient = new issuer.Client({
client_id: process.env.OIDC_CLIENT_ID,
client_secret: process.env.OIDC_CLIENT_SECRET,
redirect_uris: [`${process.env.BASE_URL}${process.env.OIDC_REDIRECT_PATH}`],
response_types: ['code']
});
return oidcClient;
}
// === Servir páginas estáticas ===
@ -178,6 +203,58 @@ app.post('/api/login', async (req, res) => {
});
// --- login: redirige a Authentik con PKCE
app.get('/auth/login', async (req, res) => {
const client = await getClient();
const state = generators.state();
const code_verifier = generators.codeVerifier();
const code_challenge = generators.codeChallenge(code_verifier);
req.session.state = state;
req.session.code_verifier = code_verifier;
const authUrl = client.authorizationUrl({
scope: 'openid profile email',
state,
code_challenge,
code_challenge_method: 'S256'
});
res.redirect(authUrl);
});
// --- callback: intercambia code por tokens y guarda sesión mínima
app.get(process.env.OIDC_REDIRECT_PATH || '/auth/callback', async (req, res) => {
const client = await getClient();
const { state, code } = req.query;
if (!state || state !== req.session.state) {
return res.status(400).send('state inválido');
}
const params = { state, code, code_verifier: req.session.code_verifier };
const tokenSet = await client.callback(`${process.env.BASE_URL}${process.env.OIDC_REDIRECT_PATH}`, params, { state });
// Guarda lo que necesites para pruebas (id_token y claims)
req.session.user = tokenSet.claims();
req.session.id_token = tokenSet.id_token;
req.session.access_token = tokenSet.access_token;
// Redirigí a donde quieras (página de bienvenida)
res.redirect('/auth/me');
});
// --- ver quién soy (para probar)
app.get('/auth/me', (req, res) => {
if (!req.session?.user) return res.status(401).json({ error: 'no autenticado' });
res.json({ user: req.session.user });
});
// --- logout simple (borra cookie)
app.post('/auth/logout', (req, res) => {
req.session = null;
res.status(204).end();
});
// Colores personalizados
let primaryColor = chalk.hex('#'+`${process.env.COL_PRI}`);
let secondaryColor = chalk.hex('#'+`${process.env.COL_SEC}`);