feat: Add initial setup page and functionality for admin account creation

- Created setup.html for the initial configuration of the admin account.
- Implemented setup.js to handle form submission and validation.
- Added logo images for branding.
- Introduced storage.js for API data handling (load, save, remove).
- Added styles.css for consistent styling across the application.
This commit is contained in:
Marco Gallegos
2025-08-25 08:01:30 -06:00
parent 9c497bc414
commit 4a841917f8
29 changed files with 4163 additions and 3216 deletions

View File

View File

@@ -22,7 +22,7 @@ RUN addgroup -S app && adduser -S app -G app \
USER app USER app
# 8. Exponer el puerto # 8. Exponer el puerto
EXPOSE 3000 EXPOSE 3111
# 9. Comando de inicio # 9. Comando de inicio
CMD ["npm", "start"] CMD ["npm", "start"]

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2025 Marco Gallegos Copyright (c) 2025 marcogll
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,32 +0,0 @@
# AP-POS — Aplicación de Punto de Venta
Esta es una aplicación de punto de venta (POS) robusta y moderna, diseñada para ser simple, multiusuario y fácil de desplegar. Permite registrar clientes, gestionar ventas y usuarios, e imprimir recibos.
---
## Arquitectura y Tecnologías
* **Frontend**: Single-Page Application (SPA) con **HTML5, CSS3 y JavaScript (Vanilla)**.
* **Backend**: Servidor ligero con **Node.js y Express.js** que provee una API RESTful.
* **Base de Datos**: **SQLite** (`ap-pos.db`), que hace la aplicación portable y fácil de respaldar.
* **Contenerización**: Lista para desplegar con **Docker**.
## Características Principales
* **Gestión de Ventas:** Crea nuevos movimientos (ventas, pagos) y genera recibos imprimibles.
* **Base de Datos de Clientes:** Administra una lista de clientes con su información de contacto.
* **Sistema de Roles Multi-usuario:**
- **Administrador:** Tiene acceso a todas las funciones, incluyendo un dashboard de estadísticas, la configuración del negocio y la gestión de usuarios.
- **Usuario:** Rol de vendedor con acceso limitado a la creación de ventas y gestión de clientes.
* **Dashboard (Solo Admin):** Visualiza estadísticas clave como ingresos totales, número de servicios y un gráfico de ingresos por tipo de servicio.
* **Exportación de Datos:** Exporta todos los movimientos a un archivo CSV.
* **Persistencia de Datos:** Toda la información se guarda en una base de datos SQLite.
## Cómo Empezar
Para instrucciones detalladas sobre cómo instalar, ejecutar y desplegar la aplicación (tanto de forma local como con Docker), por favor consulta el archivo `README.md` dentro de la carpeta `ap-pos`.
```bash
cd ap-pos
cat README.md
```

Binary file not shown.

View File

@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 marcogll
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,124 +0,0 @@
# Ale Ponce | AlMa - Sistema de Punto de Venta
Este es un sistema de punto de venta (POS) simple y moderno basado en la web, diseñado para gestionar clientes, ventas y recibos de forma eficiente.
## Características Principales
- **Gestión de Ventas:** Crea nuevos movimientos (ventas, pagos) y genera recibos imprimibles.
- **Base de Datos de Clientes:** Administra una lista de clientes con su información de contacto.
- **Sistema de Roles:**
- **Administrador:** Tiene acceso a todas las funciones, incluyendo un dashboard de estadísticas, la configuración del negocio y la gestión de usuarios.
- **Usuario:** Rol de vendedor con acceso limitado a la creación de ventas y gestión de clientes.
- **Dashboard (Solo Admin):** Visualiza estadísticas clave como ingresos totales, número de servicios y un gráfico de ingresos por tipo de servicio.
- **Exportación de Datos:** Exporta todos los movimientos a un archivo CSV.
- **Persistencia de Datos:** Toda la información se guarda en una base de datos SQLite (`ap-pos.db`).
- **Listo para Docker:** Incluye un `Dockerfile` para una fácil contenerización y despliegue.
## Cómo Ejecutar la Aplicación
### Prerrequisitos
- [Node.js](https://nodejs.org/) (versión 18 o superior)
- [npm](https://www.npmjs.com/) (generalmente se instala con Node.js)
### Pasos para la Ejecución
1. **Clonar el Repositorio:**
```bash
git clone https://github.com/marcogll/ap_pos.git
cd ap_pos/ap-pos
```
2. **Instalar Dependencias:**
Navega a la carpeta `ap-pos` y ejecuta el siguiente comando para instalar los paquetes necesarios:
```bash
npm install
```
3. **Iniciar el Servidor:**
Una vez instaladas las dependencias, inicia el servidor con:
```bash
npm start
```
El servidor se ejecutará en `http://localhost:3000`.
4. **Acceder a la Aplicación:**
Abre tu navegador web y ve a `http://localhost:3000`.
5. **Credenciales por Defecto:**
- **Usuario:** `admin`
- **Contraseña:** `password`
**¡Importante!** Se recomienda cambiar la contraseña del administrador en la pestaña de "Configuración" después del primer inicio de sesión.
## Cómo Usar con Docker
1. **Construir la Imagen de Docker:**
Desde la carpeta `ap-pos`, ejecuta:
```bash
docker build -t ap-pos-app .
```
2. **Ejecutar el Contenedor:**
Para ejecutar la aplicación en un contenedor, usa el siguiente comando. Esto mapeará el puerto 3000 y montará un volumen para que la base de datos persista fuera del contenedor, en una nueva carpeta `data` que se creará en tu directorio actual.
```bash
docker run -p 3000:3000 -v $(pwd)/data:/usr/src/app/data ap-pos-app
```
*Nota: La primera vez que ejecutes esto, se creará un directorio `data` en tu carpeta actual para almacenar `ap-pos.db`.*
## Autores
- **Gemini**
- **Marco G.**
---
## Diagnóstico de Impresión (CUPS en Linux)
Si tienes problemas para imprimir recibos en un entorno Linux, sigue estos pasos en la terminal de la computadora donde la impresora está conectada para diagnosticar el problema.
### Paso 1: Verificar el Estado de la Impresora
Este comando lista todas las impresoras configuradas en el sistema y muestra su estado. Reemplaza `TICKETS` con el nombre real de tu impresora.
```bash
lpstat -p -d
```
**Qué buscar:**
- Deberías ver una línea como `printer TICKETS is idle...`.
- Si tu impresora no aparece en la lista, no está instalada o CUPS no la detecta.
- Anota el nombre exacto de la impresora, ya que lo necesitarás para los siguientes pasos y para la configuración de la aplicación.
### Paso 2: Enviar una Página de Prueba Directa
Esto permite verificar si el sistema de impresión (CUPS) puede comunicarse con la impresora, ignorando cualquier problema de la aplicación.
1. **Crea un archivo de texto de prueba:**
```bash
echo "Prueba de impresión para la impresora TICKETS" > /tmp/prueba.txt
```
2. **Envía el archivo a imprimir:** (Recuerda usar el nombre exacto de tu impresora)
```bash
lp -d TICKETS /tmp/prueba.txt
```
**¿Se imprimió la página?**
- **Sí:** El sistema de impresión funciona correctamente. El problema probablemente está en la configuración de la aplicación. Asegúrate de que esté usando el nombre correcto de la impresora.
- **No:** El problema está en la comunicación entre CUPS y la impresora. Continúa al siguiente paso.
### Paso 3: Revisar la Cola y los Registros de Errores
Si la página de prueba no se imprimió, estos comandos pueden darte más pistas.
1. **Revisar la cola de impresión:**
```bash
lpq -a
```
*Esto te mostrará si el trabajo está atascado en la cola.*
2. **Consultar el registro de errores de CUPS:**
```bash
tail -n 20 /var/log/cups/error_log
```
*Busca mensajes de error recientes que mencionen el nombre de tu impresora, "filter failed", "driver" o problemas de conexión.*

View File

@@ -1,24 +0,0 @@
cat > /home/marco/Documents/code/ap_pos/ap-pos/ecosystem.config.js <<'EOF'
module.exports = {
apps: [
{
name: "ap-pos",
script: "server.js",
cwd: "/home/marco/Documents/code/ap_pos/ap-pos",
watch: true,
watch_delay: 2000,
ignore_watch: [
"node_modules",
"logs",
"*.log",
"*.sqlite",
"*.db",
".git"
],
env: {
NODE_ENV: "production"
}
}
]
}
EOF

2279
ap-pos/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,488 +0,0 @@
const express = require('express');
const session = require('express-session');
const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const path = require('path');
const bcrypt = require('bcryptjs');
const app = express();
const port = 3000;
// --- MIDDLEWARE ---
app.use(cors());
app.use(express.json());
app.use(express.static(__dirname)); // Servir archivos estáticos como CSS, JS, etc.
// Cargar una clave secreta desde variables de entorno o usar una por defecto (solo para desarrollo)
const SESSION_SECRET = process.env.SESSION_SECRET || 'your-very-secret-key-change-it';
const IN_PROD = process.env.NODE_ENV === 'production';
// Session Middleware
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: IN_PROD, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 } // `secure: true` en producción con HTTPS
}));
// --- DATABASE INITIALIZATION ---
// Usar un path absoluto para asegurar que la DB siempre se cree en la carpeta del proyecto.
const dbPath = path.join(__dirname, 'ap-pos.db');
console.log(`Connecting to database at: ${dbPath}`);
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error(err.message);
}
console.log('Connected to the database.');
});
// --- AUTHENTICATION LOGIC ---
const SALT_ROUNDS = 10;
// Crear tabla de usuarios y usuario admin por defecto si no existen
db.serialize(() => {
// Añadir columna 'role' si no existe
db.run("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding role column to users table:", err.message);
}
});
db.run("ALTER TABLE users ADD COLUMN name TEXT", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding name column to users table:", err.message);
}
});
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
password TEXT,
role TEXT DEFAULT 'user',
name TEXT
)`, (err) => {
if (err) return;
// Solo intentar insertar si la tabla fue creada o ya existía
const adminUsername = 'admin';
const defaultPassword = 'password';
const defaultName = 'Admin'; // Nombre por defecto para el admin
db.get('SELECT * FROM users WHERE username = ?', [adminUsername], (err, row) => {
if (err) return;
if (!row) {
bcrypt.hash(defaultPassword, SALT_ROUNDS, (err, hash) => {
if (err) return;
// Insertar admin con su rol y nombre
db.run('INSERT INTO users (username, password, role, name) VALUES (?, ?, ?, ?)', [adminUsername, hash, 'admin', defaultName], (err) => {
if (!err) {
console.log(`Default user '${adminUsername}' created with name '${defaultName}', password '${defaultPassword}' and role 'admin'. Please change it.`);
}
});
});
} else {
// Si el usuario admin ya existe, asegurarse de que tenga el rol de admin
db.run("UPDATE users SET role = 'admin' WHERE username = ?", [adminUsername]);
}
});
});
// Tablas existentes
db.run(`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)`);
db.run(`CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
nombre TEXT,
telefono TEXT,
genero TEXT,
cumpleaños TEXT,
consentimiento INTEGER,
esOncologico INTEGER,
oncologoAprueba INTEGER,
nombreMedico TEXT,
telefonoMedico TEXT,
cedulaMedico TEXT,
pruebaAprobacion INTEGER
)`);
db.run(`CREATE TABLE IF NOT EXISTS movements (id TEXT PRIMARY KEY, folio TEXT, fechaISO TEXT, clienteId TEXT, tipo TEXT, subtipo TEXT, monto REAL, metodo TEXT, concepto TEXT, staff TEXT, notas TEXT, fechaCita TEXT, horaCita TEXT, FOREIGN KEY (clienteId) REFERENCES clients (id))`);
});
// Middleware para verificar si el usuario está autenticado
const isAuthenticated = (req, res, next) => {
if (req.session.userId) {
next();
} else {
// Para peticiones de API, devolver un error 401
if (req.path.startsWith('/api/')) {
return res.status(401).json({ error: 'Not authenticated' });
}
// Para otras peticiones, redirigir al login
res.redirect('/login.html');
}
};
// Middleware para verificar si el usuario es admin
const isAdmin = (req, res, next) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
db.get('SELECT role FROM users WHERE id = ?', [req.session.userId], (err, user) => {
if (err || !user) {
return res.status(403).json({ error: 'Forbidden' });
}
if (user.role === 'admin') {
next();
} else {
return res.status(403).json({ error: 'Forbidden: Admins only' });
}
});
};
// --- AUTH API ROUTES ---
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
if (err || !user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
bcrypt.compare(password, user.password, (err, isMatch) => {
if (err || !isMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.session.userId = user.id;
req.session.role = user.role; // Guardar rol en la sesión
req.session.name = user.name; // Guardar nombre en la sesión
res.json({ message: 'Login successful', role: user.role, name: user.name });
});
});
});
app.post('/api/logout', (req, res) => {
req.session.destroy(err => {
if (err) {
return res.status(500).json({ error: 'Could not log out' });
}
res.clearCookie('connect.sid'); // Limpiar la cookie de sesión
res.json({ message: 'Logout successful' });
});
});
// Endpoint para verificar el estado de la autenticación en el frontend
app.get('/api/check-auth', (req, res) => {
if (req.session.userId) {
res.json({ isAuthenticated: true, role: req.session.role, name: req.session.name });
} else {
res.json({ isAuthenticated: false });
}
});
// --- PROTECTED APPLICATION ROUTES ---
// La ruta principal ahora está protegida
app.get('/', isAuthenticated, (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
// Proteger todas las rutas de la API
const apiRouter = express.Router();
apiRouter.use(isAuthenticated);
// --- Settings ---
apiRouter.get('/settings', (req, res) => {
db.get("SELECT value FROM settings WHERE key = 'settings'", (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(row ? JSON.parse(row.value) : {});
});
});
apiRouter.post('/settings', (req, res) => {
const { settings } = req.body;
const value = JSON.stringify(settings);
db.run(`INSERT OR REPLACE INTO settings (key, value) VALUES ('settings', ?)`, [value], function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({ message: 'Settings saved' });
});
});
// --- Clients ---
apiRouter.get('/clients', (req, res) => {
db.all("SELECT * FROM clients", [], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows);
});
});
apiRouter.post('/clients', (req, res) => {
const { client } = req.body;
const {
id, nombre, telefono, genero, cumpleaños, consentimiento,
esOncologico, oncologoAprueba, nombreMedico, telefonoMedico, cedulaMedico, pruebaAprobacion
} = client;
db.run(`INSERT OR REPLACE INTO clients (
id, nombre, telefono, genero, cumpleaños, consentimiento,
esOncologico, oncologoAprueba, nombreMedico, telefonoMedico, cedulaMedico, pruebaAprobacion
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
id, nombre, telefono, genero, cumpleaños, consentimiento,
esOncologico, oncologoAprueba, nombreMedico, telefonoMedico, cedulaMedico, pruebaAprobacion
], function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({ id });
});
});
apiRouter.delete('/clients/:id', (req, res) => {
const { id } = req.params;
db.run(`DELETE FROM clients WHERE id = ?`, id, function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({ message: 'Client deleted' });
});
});
// --- Movements ---
apiRouter.get('/movements', (req, res) => {
db.all("SELECT * FROM movements ORDER BY fechaISO DESC", [], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows);
});
});
apiRouter.post('/movements', (req, res) => {
const { movement } = req.body;
const { id, folio, fechaISO, clienteId, tipo, subtipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita } = movement;
db.run(`INSERT INTO movements (id, folio, fechaISO, clienteId, tipo, subtipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, folio, fechaISO, clienteId, tipo, subtipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita], function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({ id });
});
});
apiRouter.delete('/movements/:id', (req, res) => {
const { id } = req.params;
db.run(`DELETE FROM movements WHERE id = ?`, id, function(err) {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json({ message: 'Movement deleted' });
});
});
// --- Client History ---
apiRouter.get('/clients/:id/history', (req, res) => {
const { id } = req.params;
db.all("SELECT * FROM movements WHERE clienteId = ? ORDER BY fechaISO DESC", [id], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows);
});
});
// Registrar el router de la API protegida
app.use('/api', apiRouter);
// --- User Management (Admin) ---
// Proteger estas rutas para que solo los admins puedan usarlas
apiRouter.get('/users', isAdmin, (req, res) => {
db.all("SELECT id, username, role, name FROM users", [], (err, rows) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(rows);
});
});
apiRouter.post('/users', isAdmin, (req, res) => {
const { username, password, role, name } = req.body;
if (!username || !password || !role || !name) {
return res.status(400).json({ error: 'Username, password, name, and role are required' });
}
if (role !== 'admin' && role !== 'user') {
return res.status(400).json({ error: 'Invalid role' });
}
bcrypt.hash(password, SALT_ROUNDS, (err, hash) => {
if (err) {
return res.status(500).json({ error: 'Error hashing password' });
}
db.run('INSERT INTO users (username, password, role, name) VALUES (?, ?, ?, ?)', [username, hash, role, name], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username already exists' });
}
return res.status(500).json({ error: err.message });
}
res.status(201).json({ id: this.lastID, username, role, name });
});
});
});
// Nueva ruta para actualizar un usuario (solo admin)
apiRouter.put('/users/:id', isAdmin, (req, res) => {
const { id } = req.params;
const { username, role, name } = req.body;
if (!username || !role || !name) {
return res.status(400).json({ error: 'Username, role, and name are required' });
}
db.run('UPDATE users SET username = ?, role = ?, name = ? WHERE id = ?', [username, role, name, id], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username already exists' });
}
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ message: 'User updated successfully' });
});
});
apiRouter.delete('/users/:id', isAdmin, (req, res) => {
const { id } = req.params;
// Prevenir que el admin se elimine a sí mismo
if (parseInt(id, 10) === req.session.userId) {
return res.status(400).json({ error: "You cannot delete your own account." });
}
db.run(`DELETE FROM users WHERE id = ?`, id, function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) {
return res.status(404).json({ error: "User not found" });
}
res.json({ message: 'User deleted' });
});
});
// --- Current User Settings ---
apiRouter.get('/user', (req, res) => {
db.get("SELECT id, username, role, name FROM users WHERE id = ?", [req.session.userId], (err, row) => {
if (err) {
res.status(500).json({ error: err.message });
return;
}
res.json(row);
});
});
apiRouter.post('/user', (req, res) => {
const { username, password, name } = req.body;
if (!username || !name) {
return res.status(400).json({ error: 'Username and name are required' });
}
if (password) {
// Si se proporciona una nueva contraseña, hashearla y actualizar todo
bcrypt.hash(password, SALT_ROUNDS, (err, hash) => {
if (err) {
return res.status(500).json({ error: 'Error hashing password' });
}
db.run('UPDATE users SET username = ?, password = ?, name = ? WHERE id = ?', [username, hash, name, req.session.userId], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ message: 'User credentials updated successfully' });
});
});
} else {
// Si no se proporciona contraseña, solo actualizar el nombre de usuario y el nombre
db.run('UPDATE users SET username = ?, name = ? WHERE id = ?', [username, name, req.session.userId], function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ message: 'Username and name updated successfully' });
});
}
});
// --- Dashboard Route (Admin Only) ---
apiRouter.get('/dashboard', isAdmin, (req, res) => {
const queries = {
totalIncome: "SELECT SUM(monto) as total FROM movements",
totalMovements: "SELECT COUNT(*) as total FROM movements",
incomeByService: "SELECT tipo, SUM(monto) as total FROM movements GROUP BY tipo",
incomeByPaymentMethod: "SELECT metodo, SUM(monto) as total FROM movements WHERE metodo IS NOT NULL AND metodo != '' GROUP BY metodo",
upcomingAppointments: `
SELECT m.id, m.folio, m.fechaCita, m.horaCita, c.nombre as clienteNombre
FROM movements m
JOIN clients c ON m.clienteId = c.id
WHERE m.fechaCita IS NOT NULL AND m.fechaCita >= date('now')
ORDER BY m.fechaCita ASC, m.horaCita ASC
LIMIT 5`
};
const results = {};
const promises = Object.keys(queries).map(key => {
return new Promise((resolve, reject) => {
const query = queries[key];
// Usar db.all para consultas que devuelven múltiples filas
const method = ['incomeByService', 'incomeByPaymentMethod', 'upcomingAppointments'].includes(key) ? 'all' : 'get';
db[method](query, [], (err, result) => {
if (err) {
return reject(err);
}
if (method === 'get') {
resolve({ key, value: result ? result.total : 0 });
} else {
resolve({ key, value: result });
}
});
});
});
Promise.all(promises)
.then(allResults => {
allResults.forEach(result => {
results[result.key] = result.value;
});
res.json(results);
})
.catch(err => {
res.status(500).json({ error: err.message });
});
});
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});

View File

@@ -1,6 +1,21 @@
import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js'; import { load, save, remove, KEY_DATA, KEY_SETTINGS, KEY_CLIENTS } from './storage.js';
import { renderTicketAndPrint } from './print.js'; import { renderTicketAndPrint } from './print.js';
// --- UTILITIES ---
function escapeHTML(str) {
if (str === null || str === undefined) {
return '';
}
return str.toString()
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
const APP_VERSION = '1.0.0';
// --- ESTADO Y DATOS --- // --- ESTADO Y DATOS ---
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
negocio: 'Ale Ponce', negocio: 'Ale Ponce',
@@ -19,9 +34,11 @@ let settings = {};
let movements = []; let movements = [];
let clients = []; let clients = [];
let users = []; let users = [];
let products = [];
let incomeChart = null; let incomeChart = null;
let paymentMethodChart = null; let paymentMethodChart = null;
let currentUser = {}; let currentUser = {};
let currentClientId = null;
// --- DOM ELEMENTS --- // --- DOM ELEMENTS ---
const formSettings = document.getElementById('formSettings'); const formSettings = document.getElementById('formSettings');
@@ -34,7 +51,10 @@ const tblClientsBody = document.getElementById('tblClients')?.querySelector('tbo
const clientDatalist = document.getElementById('client-list'); const clientDatalist = document.getElementById('client-list');
const formCredentials = document.getElementById('formCredentials'); const formCredentials = document.getElementById('formCredentials');
const formAddUser = document.getElementById('formAddUser'); const formAddUser = document.getElementById('formAddUser');
const tblUsersBody = document.getElementById('tblUsers')?.querySelector('tbody'); const tblUsersBody = document.getElementById('tblUsers')?.querySelector('tbody');
const tblServicesBody = document.getElementById('tblServices')?.querySelector('tbody');
const tblCoursesBody = document.getElementById('tblCourses')?.querySelector('tbody');
const formProduct = document.getElementById('formProduct');
const appointmentsList = document.getElementById('upcoming-appointments-list'); const appointmentsList = document.getElementById('upcoming-appointments-list');
let isDashboardLoading = false; let isDashboardLoading = false;
@@ -186,6 +206,7 @@ async function deleteClient(id) {
clients = clients.filter(c => c.id !== id); clients = clients.filter(c => c.id !== id);
renderClientsTable(); renderClientsTable();
updateClientDatalist(); updateClientDatalist();
clearClientRecord();
} }
} }
@@ -228,19 +249,33 @@ function renderTable() {
tblMovesBody.innerHTML = ''; tblMovesBody.innerHTML = '';
movements.forEach(mov => { movements.forEach(mov => {
const client = clients.find(c => c.id === mov.clienteId); const client = clients.find(c => c.id === mov.clienteId);
const tr = document.createElement('tr'); const tr = tblMovesBody.insertRow();
const fechaCita = mov.fechaCita ? new Date(mov.fechaCita + 'T00:00:00').toLocaleDateString('es-MX') : ''; const fechaCita = mov.fechaCita ? new Date(mov.fechaCita + 'T00:00:00').toLocaleDateString('es-MX') : '';
const tipoServicio = mov.subtipo ? `${mov.tipo} (${mov.subtipo})` : mov.tipo; const tipoServicio = mov.subtipo ? `${escapeHTML(mov.tipo)} (${escapeHTML(mov.subtipo)})` : escapeHTML(mov.tipo);
tr.innerHTML = `
<td><a href="#" class="action-btn" data-id="${mov.id}" data-action="reprint">${mov.folio}</a></td> const folioCell = tr.insertCell();
<td>${new Date(mov.fechaISO).toLocaleDateString('es-MX')}</td> const folioLink = document.createElement('a');
<td>${fechaCita} ${mov.horaCita || ''}</td> folioLink.href = '#';
<td>${client ? client.nombre : 'Cliente Eliminado'}</td> folioLink.className = 'action-btn';
<td>${tipoServicio}</td> folioLink.dataset.id = mov.id;
<td>${Number(mov.monto).toFixed(2)}</td> folioLink.dataset.action = 'reprint';
<td><button class="action-btn" data-id="${mov.id}" data-action="delete">Eliminar</button></td> folioLink.textContent = mov.folio;
`; folioCell.appendChild(folioLink);
tblMovesBody.appendChild(tr);
tr.insertCell().textContent = new Date(mov.fechaISO).toLocaleDateString('es-MX');
tr.insertCell().textContent = `${fechaCita} ${mov.horaCita || ''}`;
tr.insertCell().textContent = client ? escapeHTML(client.nombre) : 'Cliente Eliminado';
tr.insertCell().textContent = tipoServicio;
tr.insertCell().textContent = Number(mov.monto).toFixed(2);
const actionsCell = tr.insertCell();
const deleteButton = document.createElement('button');
deleteButton.className = 'action-btn';
deleteButton.dataset.id = mov.id;
deleteButton.dataset.action = 'delete';
deleteButton.textContent = 'Eliminar';
actionsCell.appendChild(deleteButton);
}); });
} }
@@ -248,19 +283,13 @@ function renderClientsTable(clientList = clients) {
if (!tblClientsBody) return; if (!tblClientsBody) return;
tblClientsBody.innerHTML = ''; tblClientsBody.innerHTML = '';
clientList.forEach(c => { clientList.forEach(c => {
const tr = document.createElement('tr'); const tr = tblClientsBody.insertRow();
tr.dataset.id = c.id; // Importante para la función de expandir tr.dataset.id = c.id;
tr.style.cursor = 'pointer'; // Indicar que la fila es clickeable if (c.id === currentClientId) {
tr.innerHTML = ` tr.classList.add('selected');
<td>${c.nombre}</td> }
<td>${c.telefono || ''}</td> tr.insertCell().textContent = escapeHTML(c.nombre);
<td>${c.esOncologico ? 'Sí' : 'No'}</td> tr.insertCell().textContent = escapeHTML(c.telefono || '');
<td>
<button class="action-btn" data-id="${c.id}" data-action="edit-client">Editar</button>
<button class="action-btn" data-id="${c.id}" data-action="delete-client">Eliminar</button>
</td>
`;
tblClientsBody.appendChild(tr);
}); });
} }
@@ -268,17 +297,59 @@ function renderUsersTable() {
if (!tblUsersBody) return; if (!tblUsersBody) return;
tblUsersBody.innerHTML = ''; tblUsersBody.innerHTML = '';
users.forEach(u => { users.forEach(u => {
const tr = document.createElement('tr'); const tr = tblUsersBody.insertRow();
tr.innerHTML = ` tr.insertCell().textContent = escapeHTML(u.name);
<td>${u.name}</td> tr.insertCell().textContent = escapeHTML(u.username);
<td>${u.username}</td> tr.insertCell().textContent = u.role === 'admin' ? 'Administrador' : 'Usuario';
<td>${u.role === 'admin' ? 'Administrador' : 'Usuario'}</td>
<td> const actionsCell = tr.insertCell();
<button class="action-btn" data-id="${u.id}" data-action="edit-user">Editar</button> const editButton = document.createElement('button');
${u.id !== currentUser.id ? `<button class="action-btn" data-id="${u.id}" data-action="delete-user">Eliminar</button>` : ''} editButton.className = 'action-btn';
</td> editButton.dataset.id = u.id;
`; editButton.dataset.action = 'edit-user';
tblUsersBody.appendChild(tr); editButton.textContent = 'Editar';
actionsCell.appendChild(editButton);
if (u.id !== currentUser.id) {
const deleteButton = document.createElement('button');
deleteButton.className = 'action-btn';
deleteButton.dataset.id = u.id;
deleteButton.dataset.action = 'delete-user';
deleteButton.textContent = 'Eliminar';
actionsCell.appendChild(deleteButton);
}
});
}
function renderProductTables() {
const tblServicesBody = document.getElementById('tblServices')?.querySelector('tbody');
const tblCoursesBody = document.getElementById('tblCourses')?.querySelector('tbody');
if (!tblServicesBody || !tblCoursesBody) return;
tblServicesBody.innerHTML = '';
tblCoursesBody.innerHTML = '';
products.forEach(p => {
const tableBody = p.type === 'service' ? tblServicesBody : tblCoursesBody;
const tr = tableBody.insertRow();
tr.insertCell().textContent = escapeHTML(p.name);
tr.insertCell().textContent = Number(p.price || 0).toFixed(2);
const actionsCell = tr.insertCell();
const editButton = document.createElement('button');
editButton.className = 'action-btn';
editButton.dataset.id = p.id;
editButton.dataset.action = 'edit-product';
editButton.textContent = 'Editar';
actionsCell.appendChild(editButton);
const deleteButton = document.createElement('button');
deleteButton.className = 'action-btn';
deleteButton.dataset.id = p.id;
deleteButton.dataset.action = 'delete-product';
deleteButton.textContent = 'Eliminar';
actionsCell.appendChild(deleteButton);
}); });
} }
@@ -292,6 +363,19 @@ function updateClientDatalist() {
}); });
} }
function populateArticuloDropdown(category) {
const articuloSelect = document.getElementById('m-articulo');
if (!articuloSelect) return;
articuloSelect.innerHTML = '';
const items = products.filter(p => p.type === category);
items.forEach(i => {
const option = document.createElement('option');
option.value = i.name;
option.textContent = i.name;
articuloSelect.appendChild(option);
});
}
// --- MANEJADORES DE EVENTOS --- // --- MANEJADORES DE EVENTOS ---
async function handleSaveSettings(e) { async function handleSaveSettings(e) {
@@ -412,6 +496,156 @@ async function deleteUser(id) {
} }
} }
async function handleAddOrUpdateProduct(e) {
e.preventDefault();
const id = document.getElementById('p-id').value;
const name = document.getElementById('p-name').value;
const type = document.getElementById('p-type').value;
const price = document.getElementById('p-price').value;
const isUpdate = !!id;
const url = isUpdate ? `/api/products/${id}` : '/api/products';
const method = isUpdate ? 'PUT' : 'POST';
const body = { name, type, price };
try {
const response = await fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const result = await response.json();
if (response.ok) {
alert(`Producto ${isUpdate ? 'actualizado' : 'creado'} exitosamente.`);
if (isUpdate) {
const index = products.findIndex(p => p.id === parseInt(id));
if (index > -1) {
products[index] = { ...products[index], name, type, price };
}
} else {
products.push(result);
}
renderProductTables();
formProduct.reset();
document.getElementById('p-id').value = '';
} else {
alert(`Error: ${result.error}`);
}
} catch (error) {
alert('Error de conexión al guardar el producto.');
}
}
async function deleteProduct(id) {
if (confirm('¿Estás seguro de que quieres eliminar este producto?')) {
try {
const response = await fetch(`/api/products/${id}`, { method: 'DELETE' });
if (response.ok) {
products = products.filter(p => p.id !== id);
renderProductTables();
} else {
const error = await response.json();
alert(`Error: ${error.error}`);
}
} catch (error) {
alert('Error de conexión al eliminar el producto.');
}
}
}
function showAddCourseModal(clientId) {
const courses = products.filter(p => p.type === 'course');
const courseOptions = courses.map(c => `<option value="${c.id}">${escapeHTML(c.name)}</option>`).join('');
const modalHTML = `
<div id="course-modal" class="modal">
<div class="modal-content">
<span class="close-button">&times;</span>
<h2>Registrar Curso a Cliente</h2>
<form id="formAddCourseToClient">
<div class="form-grid-single">
<label>Curso:</label>
<select id="course-id" required>${courseOptions}</select>
<label>Fecha del Curso:</label>
<input type="date" id="course-date" />
<label>Score General:</label>
<input type="text" id="course-score" />
<div class="checkbox-container">
<input type="checkbox" id="course-presencial" />
<label for="course-presencial">¿Completó curso presencial?</label>
</div>
<div class="checkbox-container">
<input type="checkbox" id="course-online" />
<label for="course-online">¿Completó curso online?</label>
</div>
<div class="checkbox-container">
<input type="checkbox" id="course-practicas" />
<label for="course-practicas">¿Realizó prácticas?</label>
</div>
<div class="checkbox-container">
<input type="checkbox" id="course-certificacion" />
<label for="course-certificacion">¿Obtuvo certificación?</label>
</div>
</div>
<div class="form-actions">
<button type="submit">Guardar Curso</button>
</div>
</form>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
const modal = document.getElementById('course-modal');
const closeButton = modal.querySelector('.close-button');
const form = modal.querySelector('#formAddCourseToClient');
const closeModal = () => modal.remove();
closeButton.onclick = closeModal;
window.onclick = (event) => {
if (event.target == modal) {
closeModal();
}
};
form.onsubmit = async (e) => {
e.preventDefault();
const courseData = {
course_id: document.getElementById('course-id').value,
fecha_curso: document.getElementById('course-date').value,
score_general: document.getElementById('course-score').value,
completo_presencial: document.getElementById('course-presencial').checked ? 1 : 0,
completo_online: document.getElementById('course-online').checked ? 1 : 0,
realizo_practicas: document.getElementById('course-practicas').checked ? 1 : 0,
obtuvo_certificacion: document.getElementById('course-certificacion').checked ? 1 : 0,
};
try {
const response = await fetch(`/api/clients/${clientId}/courses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(courseData)
});
if (response.ok) {
alert('Curso registrado exitosamente.');
closeModal();
showClientRecord(clientId);
} else {
const error = await response.json();
alert(`Error: ${error.error}`);
}
} catch (error) {
alert('Error de conexión al registrar el curso.');
}
};
}
async function handleNewMovement(e) { async function handleNewMovement(e) {
e.preventDefault(); e.preventDefault();
@@ -436,23 +670,16 @@ async function handleNewMovement(e) {
} }
} }
const tipoServicio = document.getElementById('m-tipo').value;
const subtipoContainer = document.getElementById('m-subtipo-container');
let subtipo = '';
if (!subtipoContainer.classList.contains('hidden')) {
subtipo = document.getElementById('m-subtipo').value;
}
const newMovement = { const newMovement = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
folio: generateFolio(), folio: generateFolio(),
fechaISO: new Date().toISOString(), fechaISO: new Date().toISOString(),
clienteId: client.id, clienteId: client.id,
tipo: tipoServicio, tipo: document.getElementById('m-categoria').value,
subtipo: subtipo, subtipo: '',
monto: Number(monto.toFixed(2)), monto: Number(monto.toFixed(2)),
metodo: document.getElementById('m-metodo').value, metodo: document.getElementById('m-metodo').value,
concepto: document.getElementById('m-concepto').value, concepto: document.getElementById('m-articulo').value,
staff: currentUser.name, // Usar el nombre del usuario actual staff: currentUser.name, // Usar el nombre del usuario actual
notas: document.getElementById('m-notas').value, notas: document.getElementById('m-notas').value,
fechaCita: document.getElementById('m-fecha-cita').value, fechaCita: document.getElementById('m-fecha-cita').value,
@@ -466,43 +693,129 @@ async function handleNewMovement(e) {
subtipoContainer.classList.add('hidden'); subtipoContainer.classList.add('hidden');
} }
async function toggleClientHistory(row, client) { function exportClientHistoryCSV(client, history) {
const historyRowId = `history-for-${client.id}`; const headers = 'Folio,Fecha,Servicio,Monto';
const existingHistoryRow = document.getElementById(historyRowId); const rows = history.map(mov => {
const fecha = new Date(mov.fechaISO).toLocaleDateString('es-MX');
const servicio = mov.subtipo ? `${mov.tipo} (${mov.subtipo})` : mov.tipo;
return [
mov.folio,
fecha,
`"${servicio}"`,
Number(mov.monto).toFixed(2)
].join(',');
});
if (existingHistoryRow) { const csvContent = `data:text/csv;charset=utf-8,${headers}\n${rows.join('\n')}`;
existingHistoryRow.remove(); const encodedUri = encodeURI(csvContent);
return; const link = document.createElement('a');
} link.setAttribute('href', encodedUri);
link.setAttribute('download', `historial-${client.nombre.replace(/\s+/g, '_')}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
try { async function showClientRecord(clientId) {
const response = await fetch(`/api/clients/${client.id}/history`); currentClientId = clientId;
const history = await response.json(); const client = clients.find(c => c.id === clientId);
if (!client) {
const historyRow = document.createElement('tr'); clearClientRecord();
historyRow.id = historyRowId; return;
historyRow.className = 'client-history-row';
let historyHtml = '<h4>Historial de Servicios</h4>';
if (history.length > 0) {
historyHtml += '<table><thead><tr><th>Fecha</th><th>Servicio</th><th>Monto</th></tr></thead><tbody>';
history.forEach(mov => {
const fecha = new Date(mov.fechaISO).toLocaleDateString('es-MX');
const servicio = mov.subtipo ? `${mov.tipo} (${mov.subtipo})` : mov.tipo;
historyHtml += `<tr><td>${fecha}</td><td>${servicio}</td><td>${Number(mov.monto).toFixed(2)}</td></tr>`;
});
historyHtml += '</tbody></table>';
} else {
historyHtml += '<p>No hay historial de servicios para este cliente.</p>';
} }
historyRow.innerHTML = `<td colspan="4"><div class="client-history-content">${historyHtml}</div></td>`; renderClientsTable(clients.filter(c => c.nombre.toLowerCase().includes(document.getElementById('search-client').value.toLowerCase())));
row.after(historyRow);
} catch (error) { const clientRecordContent = document.getElementById('client-record-content');
console.error('Error al cargar el historial del cliente:', error); const clientRecordPlaceholder = document.getElementById('client-record-placeholder');
alert('No se pudo cargar el historial.'); const clientDetails = document.getElementById('client-details');
} const clientHistoryTableBody = document.getElementById('client-history-table').querySelector('tbody');
const clientCoursesContainer = document.getElementById('client-courses-history-container');
// Sanitize client details before rendering
clientDetails.innerHTML = `
<p><strong>Nombre:</strong> ${escapeHTML(client.nombre)}</p>
<p><strong>Teléfono:</strong> ${escapeHTML(client.telefono || 'N/A')}</p>
<p><strong>Cumpleaños:</strong> ${escapeHTML(client.cumpleaños ? new Date(client.cumpleaños + 'T00:00:00').toLocaleDateString('es-MX') : 'N/A')}</p>
<p><strong>Género:</strong> ${escapeHTML(client.genero || 'N/A')}</p>
<p><strong>Oncológico:</strong> ${client.esOncologico ? 'Sí' : 'No'}</p>
`;
try {
const [historyResponse, coursesResponse] = await Promise.all([
fetch(`/api/clients/${client.id}/history`),
fetch(`/api/clients/${client.id}/courses`)
]);
const history = await historyResponse.json();
const courses = await coursesResponse.json();
clientHistoryTableBody.innerHTML = '';
if (history.length > 0) {
history.forEach(mov => {
const tr = clientHistoryTableBody.insertRow();
const fecha = new Date(mov.fechaISO).toLocaleDateString('es-MX');
const servicio = mov.subtipo ? `${escapeHTML(mov.tipo)} (${escapeHTML(mov.subtipo)})` : escapeHTML(mov.tipo);
tr.insertCell().textContent = mov.folio;
tr.insertCell().textContent = fecha;
tr.insertCell().textContent = servicio;
tr.insertCell().textContent = Number(mov.monto).toFixed(2);
});
} else {
clientHistoryTableBody.innerHTML = '<tr><td colspan="4">No hay historial de servicios.</td></tr>';
}
clientCoursesContainer.innerHTML = '';
if (courses.length > 0) {
const coursesTable = document.createElement('table');
coursesTable.innerHTML = `
<thead>
<tr>
<th>Curso</th>
<th>Fecha</th>
<th>Score</th>
<th>Presencial</th>
<th>Online</th>
<th>Prácticas</th>
<th>Certificación</th>
</tr>
</thead>
<tbody>
${courses.map(course => `
<tr>
<td>${escapeHTML(course.course_name)}</td>
<td>${escapeHTML(course.fecha_curso)}</td>
<td>${escapeHTML(course.score_general)}</td>
<td>${course.completo_presencial ? 'Sí' : 'No'}</td>
<td>${course.completo_online ? 'Sí' : 'No'}</td>
<td>${course.realizo_practicas ? 'Sí' : 'No'}</td>
<td>${course.obtuvo_certificacion ? 'Sí' : 'No'}</td>
</tr>
`).join('')}
</tbody>
`;
clientCoursesContainer.appendChild(coursesTable);
} else {
clientCoursesContainer.innerHTML = '<p>No hay cursos registrados para este cliente.</p>';
}
} catch (error) {
console.error('Error al cargar el historial del cliente:', error);
clientHistoryTableBody.innerHTML = '<tr><td colspan="4">Error al cargar historial.</td></tr>';
clientCoursesContainer.innerHTML = '<p>Error al cargar historial de cursos.</p>';
}
clientRecordContent.classList.remove('hidden');
clientRecordPlaceholder.classList.add('hidden');
}
function clearClientRecord() {
currentClientId = null;
const clientRecordContent = document.getElementById('client-record-content');
const clientRecordPlaceholder = document.getElementById('client-record-placeholder');
clientRecordContent.classList.add('hidden');
clientRecordPlaceholder.classList.remove('hidden');
renderClientsTable();
} }
function handleTableClick(e) { function handleTableClick(e) {
@@ -510,12 +823,20 @@ function handleTableClick(e) {
const row = target.closest('tr'); const row = target.closest('tr');
if (!row) return; if (!row) return;
const tableId = row.closest('table')?.id;
if (tableId === 'tblClients') {
const clientId = row.dataset.id;
showClientRecord(clientId);
return;
}
const actionBtn = target.closest('.action-btn'); const actionBtn = target.closest('.action-btn');
if (actionBtn) { if (actionBtn) {
e.preventDefault(); e.preventDefault();
const id = actionBtn.dataset.id; const id = actionBtn.dataset.id;
const action = actionBtn.dataset.action; const action = actionBtn.dataset.action;
if (action === 'reprint' || action === 'delete') { if (action === 'reprint' || action === 'delete') {
const movement = movements.find(m => m.id === id); const movement = movements.find(m => m.id === id);
if (movement) { if (movement) {
@@ -526,51 +847,28 @@ function handleTableClick(e) {
deleteMovement(id); deleteMovement(id);
} }
} }
} else if (action === 'edit-client' || action === 'delete-client') {
const client = clients.find(c => c.id === id);
if (client) {
if (action === 'edit-client') {
document.getElementById('c-id').value = client.id;
document.getElementById('c-nombre').value = client.nombre;
document.getElementById('c-telefono').value = client.telefono || '';
document.getElementById('c-genero').value = client.genero || '';
document.getElementById('c-cumple').value = client.cumpleaños;
document.getElementById('c-consent').checked = client.consentimiento;
const esOncologicoCheckbox = document.getElementById('c-esOncologico');
const oncologicoFields = document.getElementById('oncologico-fields');
esOncologicoCheckbox.checked = client.esOncologico;
oncologicoFields.classList.toggle('hidden', !client.esOncologico);
document.getElementById('c-oncologoAprueba').checked = client.oncologoAprueba;
document.getElementById('c-nombreMedico').value = client.nombreMedico || '';
document.getElementById('c-telefonoMedico').value = client.telefonoMedico || '';
document.getElementById('c-cedulaMedico').value = client.cedulaMedico || '';
document.getElementById('c-pruebaAprobacion').checked = client.pruebaAprobacion;
} else if (action === 'delete-client') {
deleteClient(id);
}
}
} else if (action === 'edit-user') { } else if (action === 'edit-user') {
const user = users.find(u => u.id === parseInt(id)); const user = users.find(u => u.id === parseInt(id));
if (user) { if (user) {
document.getElementById('u-id').value = user.id; document.getElementById('u-id').value = user.id;
document.getElementById('u-name').value = user.name; document.getElementById('u-name').value = user.name;
document.getElementById('u-username').value = user.username; document.getElementById('u-username').value = user.username;
document.getElementById('u-role').value = user.role; document.getElementById('u-role').value = user.role;
document.getElementById('u-password').value = ''; document.getElementById('u-password').value = '';
document.getElementById('u-password').placeholder = 'Dejar en blanco para no cambiar'; document.getElementById('u-password').placeholder = 'Dejar en blanco para no cambiar';
} }
} else if (action === 'delete-user') { } else if (action === 'delete-user') {
deleteUser(parseInt(id, 10)); deleteUser(parseInt(id, 10));
} } else if (action === 'edit-product') {
} else if (row.parentElement.id === 'tblClientsBody') { const product = products.find(p => p.id === parseInt(id));
// Si se hace clic en cualquier parte de la fila del cliente (que no sea un botón) if (product) {
const clientId = row.dataset.id; document.getElementById('p-id').value = product.id;
const client = clients.find(c => c.id === clientId); document.getElementById('p-name').value = product.name;
if (client) { document.getElementById('p-type').value = product.type;
toggleClientHistory(row, client); document.getElementById('p-price').value = product.price;
}
} else if (action === 'delete-product') {
deleteProduct(parseInt(id, 10));
} }
} }
} }
@@ -578,6 +876,35 @@ function handleTableClick(e) {
async function handleClientForm(e) { async function handleClientForm(e) {
e.preventDefault(); e.preventDefault();
await saveClient(); await saveClient();
// Después de guardar, cambiar a la pestaña de consulta
activateClientSubTab('sub-tab-consult');
}
function activateClientSubTab(subTabId) {
if (!subTabId) return;
// Desactivar todas las sub-pestañas y contenidos de clientes
document.querySelectorAll('#tab-clients .sub-tab-link').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('#tab-clients .sub-tab-content').forEach(content => content.classList.remove('active'));
// Activar la sub-pestaña y el contenido correctos
const tabButton = document.querySelector(`[data-subtab="${subTabId}"]`);
const tabContent = document.getElementById(subTabId);
if (tabButton) {
tabButton.classList.add('active');
}
if (tabContent) {
tabContent.classList.add('active');
}
}
function handleClientTabChange(e) {
const subTabButton = e.target.closest('.sub-tab-link');
if (!subTabButton) return;
e.preventDefault();
const subTabId = subTabButton.dataset.subtab;
activateClientSubTab(subTabId);
} }
function activateTab(tabId) { function activateTab(tabId) {
@@ -647,9 +974,14 @@ function activateTab(tabId) {
function handleTabChange(e) { function handleTabChange(e) {
const tabButton = e.target.closest('.tab-link'); const tabButton = e.target.closest('.tab-link');
if (!tabButton) return; if (!tabButton) return;
e.preventDefault();
const tabId = tabButton.dataset.tab; // Solo prevenir el comportamiento por defecto si es un botón para cambiar de pestaña
activateTab(tabId); if (tabButton.dataset.tab) {
e.preventDefault();
const tabId = tabButton.dataset.tab;
activateTab(tabId);
}
// Si no tiene data-tab (es un enlace normal como el de Clientes), no hacer nada y permitir la navegación.
} }
function handleTestTicket() { function handleTestTicket() {
@@ -675,35 +1007,49 @@ function handleTestTicket() {
} }
function setupUIForRole(role) { function setupUIForRole(role) {
const dashboardTab = document.querySelector('[data-tab="tab-dashboard"]'); const dashboardTab = document.querySelector('[data-tab="dashboard"]');
const settingsTab = document.querySelector('[data-tab="tab-settings"]'); const settingsTab = document.querySelector('[data-tab="settings"]');
const userManagementSection = document.getElementById('user-management-section'); const userManagementSection = document.getElementById('user-management-section');
const staffInput = document.getElementById('m-staff'); const staffInput = document.getElementById('m-staff');
if (role === 'admin') { if (role === 'admin') {
// El admin puede ver todo if (dashboardTab) dashboardTab.style.display = 'block';
dashboardTab.style.display = 'block'; if (settingsTab) settingsTab.style.display = 'block';
settingsTab.style.display = 'block'; if (userManagementSection) userManagementSection.style.display = 'block';
userManagementSection.style.display = 'block';
// Cargar la lista de usuarios para el admin fetch('/api/users')
fetch('/api/users').then(res => res.json()).then(data => { .then(res => {
users = data; if (!res.ok) throw new Error('Failed to fetch users list');
renderUsersTable(); return res.json();
}); })
.then(data => {
users = data;
renderUsersTable();
})
.catch(err => console.error(err));
} else { } else {
// El usuario normal tiene vistas ocultas if (dashboardTab) dashboardTab.style.display = 'none';
dashboardTab.style.display = 'none'; if (settingsTab) settingsTab.style.display = 'none';
settingsTab.style.display = 'none'; if (userManagementSection) userManagementSection.style.display = 'none';
userManagementSection.style.display = 'none';
} }
// Deshabilitar el campo "Atendió" para todos, ya que se autocompleta
if (staffInput) { if (staffInput) {
staffInput.disabled = true; staffInput.disabled = true;
} }
} }
function populateFooter() {
const dateElement = document.getElementById('footer-date');
const versionElement = document.getElementById('footer-version');
if (dateElement) {
dateElement.textContent = new Date().toLocaleDateString('es-MX', { year: 'numeric', month: 'long', day: 'numeric' });
}
if (versionElement) {
versionElement.textContent = `Versión ${APP_VERSION}`;
}
}
// --- INICIALIZACIÓN --- // --- INICIALIZACIÓN ---
@@ -740,19 +1086,23 @@ async function initializeApp() {
const tabs = document.querySelector('.tabs'); const tabs = document.querySelector('.tabs');
const btnLogout = document.getElementById('btnLogout'); const btnLogout = document.getElementById('btnLogout');
const btnCancelEditUser = document.getElementById('btnCancelEditUser'); const btnCancelEditUser = document.getElementById('btnCancelEditUser');
const searchClientInput = document.getElementById('search-client');
const tipoServicioSelect = document.getElementById('m-tipo'); const tipoServicioSelect = document.getElementById('m-tipo');
const clientSubTabs = document.querySelector('#tab-clients .sub-tabs');
formSettings?.addEventListener('submit', handleSaveSettings); formSettings?.addEventListener('submit', handleSaveSettings);
formCredentials?.addEventListener('submit', handleSaveCredentials); formCredentials?.addEventListener('submit', handleSaveCredentials);
formMove?.addEventListener('submit', handleNewMovement); formMove?.addEventListener('submit', handleNewMovement);
tblMovesBody?.addEventListener('click', handleTableClick); tblMovesBody?.addEventListener('click', handleTableClick);
tblClientsBody?.addEventListener('click', handleTableClick); tblClientsBody?.addEventListener('click', handleTableClick);
tblServicesBody?.addEventListener('click', handleTableClick);
tblCoursesBody?.addEventListener('click', handleTableClick);
appointmentsList?.addEventListener('click', handleTableClick); appointmentsList?.addEventListener('click', handleTableClick);
btnExport?.addEventListener('click', exportCSV); btnExport?.addEventListener('click', exportCSV);
btnTestTicket?.addEventListener('click', handleTestTicket); btnTestTicket?.addEventListener('click', handleTestTicket);
formClient?.addEventListener('submit', handleClientForm); formClient?.addEventListener('submit', handleClientForm);
formProduct?.addEventListener('submit', handleAddOrUpdateProduct);
tabs?.addEventListener('click', handleTabChange); tabs?.addEventListener('click', handleTabChange);
clientSubTabs?.addEventListener('click', handleClientTabChange);
if (currentUser.role === 'admin') { if (currentUser.role === 'admin') {
formAddUser?.addEventListener('submit', handleAddOrUpdateUser); formAddUser?.addEventListener('submit', handleAddOrUpdateUser);
@@ -782,46 +1132,115 @@ async function initializeApp() {
document.getElementById('u-password').placeholder = 'Contraseña'; document.getElementById('u-password').placeholder = 'Contraseña';
}); });
const searchClientInput = document.getElementById('search-client');
searchClientInput?.addEventListener('input', (e) => { searchClientInput?.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase(); const searchTerm = e.target.value.toLowerCase();
const filteredClients = clients.filter(c => c.nombre.toLowerCase().includes(searchTerm)); const filteredClients = clients.filter(c =>
c.nombre.toLowerCase().includes(searchTerm) ||
c.telefono?.toLowerCase().includes(searchTerm)
);
renderClientsTable(filteredClients); renderClientsTable(filteredClients);
}); });
const categoriaSelect = document.getElementById('m-categoria');
categoriaSelect?.addEventListener('change', (e) => {
populateArticuloDropdown(e.target.value);
});
tipoServicioSelect?.addEventListener('change', (e) => { tipoServicioSelect?.addEventListener('change', (e) => {
const subtipoContainer = document.getElementById('m-subtipo-container'); const subtipoContainer = document.getElementById('m-subtipo-container');
const servicesWithSubtype = ['Microblading', 'Lashes', 'Nail Art', 'Lash Lifting']; const servicesWithSubtype = ['Microblading', 'Lashes', 'Nail Art', 'Lash Lifting'];
subtipoContainer.classList.toggle('hidden', !servicesWithSubtype.includes(e.target.value)); subtipoContainer.classList.toggle('hidden', !servicesWithSubtype.includes(e.target.value));
}); });
document.getElementById('btn-edit-client')?.addEventListener('click', () => {
if (!currentClientId) return;
const client = clients.find(c => c.id === currentClientId);
if (client) {
document.getElementById('c-id').value = client.id;
document.getElementById('c-nombre').value = client.nombre;
document.getElementById('c-telefono').value = client.telefono || '';
document.getElementById('c-genero').value = client.genero || '';
document.getElementById('c-cumple').value = client.cumpleaños;
document.getElementById('c-consent').checked = client.consentimiento;
const esOncologicoCheckbox = document.getElementById('c-esOncologico');
const oncologicoFields = document.getElementById('oncologico-fields');
esOncologicoCheckbox.checked = client.esOncologico;
oncologicoFields.classList.toggle('hidden', !client.esOncologico);
document.getElementById('c-oncologoAprueba').checked = client.oncologoAprueba;
document.getElementById('c-nombreMedico').value = client.nombreMedico || '';
document.getElementById('c-telefonoMedico').value = client.telefonoMedico || '';
document.getElementById('c-cedulaMedico').value = client.cedulaMedico || '';
document.getElementById('c-pruebaAprobacion').checked = client.pruebaAprobacion;
activateClientSubTab('sub-tab-register');
}
});
document.getElementById('btn-delete-client')?.addEventListener('click', () => {
if (!currentClientId) return;
deleteClient(currentClientId);
});
document.getElementById('btnAddCourseToClient')?.addEventListener('click', () => {
const clientId = document.getElementById('c-id').value;
if (!clientId) {
alert('Por favor, primero guarda el cliente antes de añadir un curso.');
return;
}
showAddCourseModal(clientId);
});
// 4. Cargar el resto de los datos de la aplicación. // 4. Cargar el resto de los datos de la aplicación.
Promise.all([ Promise.all([
load(KEY_SETTINGS, DEFAULT_SETTINGS), load(KEY_SETTINGS, DEFAULT_SETTINGS),
load(KEY_DATA, []), load(KEY_DATA, []),
load(KEY_CLIENTS, []), load(KEY_CLIENTS, []),
fetch('/api/products').then(res => res.json()),
]).then(values => { ]).then(values => {
[settings, movements, clients] = values; console.log('Initial data loaded:', values);
[settings, movements, clients, products] = values;
console.log('Rendering settings...');
renderSettings(); renderSettings();
console.log('Rendering movements table...');
renderTable(); renderTable();
console.log('Rendering clients table...');
renderClientsTable(); renderClientsTable();
console.log('Rendering products table...');
renderProductTables();
console.log('Updating client datalist...');
updateClientDatalist(); updateClientDatalist();
// Initial population of the articulo dropdown
populateArticuloDropdown(document.getElementById('m-categoria').value);
if (currentUser) { if (currentUser) {
console.log('Setting user info in form...');
document.getElementById('s-name').value = currentUser.name || ''; document.getElementById('s-name').value = currentUser.name || '';
document.getElementById('s-username').value = currentUser.username; document.getElementById('s-username').value = currentUser.username;
document.getElementById('m-staff').value = currentUser.name || ''; document.getElementById('m-staff').value = currentUser.name || '';
} }
// 5. Configurar la UI y activar la pestaña inicial correcta. console.log('Setting up UI for role...');
setupUIForRole(currentUser.role); setupUIForRole(currentUser.role);
console.log('Activating initial tab...');
if (currentUser.role === 'admin') { if (currentUser.role === 'admin') {
activateTab('tab-dashboard'); activateTab('tab-dashboard');
} else { } else {
activateTab('tab-movements'); activateTab('tab-movements');
} }
console.log('Activating client sub-tab...');
activateClientSubTab('sub-tab-consult');
console.log('Clearing client record...');
clearClientRecord();
console.log('Populating footer...');
populateFooter();
console.log('Initialization complete.');
}).catch(error => { }).catch(error => {
console.error('CRITICAL: Failed to load initial data.', error); console.error('CRITICAL: Failed to load initial data.', error);
alert('Error Crítico: No se pudieron cargar los datos del servidor.'); alert('Error Crítico: No se pudieron cargar los datos del servidor.');
@@ -829,4 +1248,4 @@ async function initializeApp() {
} }
document.addEventListener('DOMContentLoaded', initializeApp); document.addEventListener('DOMContentLoaded', initializeApp);

252
clients.js Normal file
View File

@@ -0,0 +1,252 @@
import { load, save, remove, KEY_CLIENTS } from './storage.js';
let clients = [];
// --- DOM ELEMENTS ---
const formClient = document.getElementById('formClient');
const tblClientsBody = document.getElementById('tblClients')?.querySelector('tbody');
const searchClientInput = document.getElementById('search-client');
// --- LÓGICA DE NEGOCIO ---
async function saveClient(clientData) {
let clientToSave;
let isUpdate = false;
if (clientData) {
clientToSave = clientData;
} else {
isUpdate = !!document.getElementById('c-id').value;
const id = isUpdate ? document.getElementById('c-id').value : crypto.randomUUID();
clientToSave = {
id: id,
nombre: document.getElementById('c-nombre').value,
telefono: document.getElementById('c-telefono').value,
genero: document.getElementById('c-genero').value,
cumpleaños: document.getElementById('c-cumple').value,
consentimiento: document.getElementById('c-consent').checked,
esOncologico: document.getElementById('c-esOncologico').checked,
oncologoAprueba: document.getElementById('c-oncologoAprueba').checked,
nombreMedico: document.getElementById('c-nombreMedico').value,
telefonoMedico: document.getElementById('c-telefonoMedico').value,
cedulaMedico: document.getElementById('c-cedulaMedico').value,
pruebaAprobacion: document.getElementById('c-pruebaAprobacion').checked,
};
}
await save('clients', { client: clientToSave });
if (isUpdate) {
const index = clients.findIndex(c => c.id === clientToSave.id);
if (index > -1) clients[index] = clientToSave;
} else {
clients.unshift(clientToSave);
}
renderClientsTable();
if (!clientData) {
document.getElementById('formClient').reset();
document.getElementById('c-id').value = '';
document.getElementById('oncologico-fields').classList.add('hidden');
}
}
async function deleteClient(id) {
if (confirm('¿Estás seguro de que quieres eliminar este cliente? Se conservarán sus recibos históricos.')) {
await remove(KEY_CLIENTS, id);
clients = clients.filter(c => c.id !== id);
renderClientsTable();
}
}
function exportClientHistoryCSV(client, history) {
const headers = 'Folio,Fecha,Servicio,Monto';
const rows = history.map(mov => {
const fecha = new Date(mov.fechaISO).toLocaleDateString('es-MX');
const servicio = mov.subtipo ? `${mov.tipo} (${mov.subtipo})` : mov.tipo;
return [
mov.folio,
fecha,
`"${servicio}"`, // Corrected: escaped inner quotes for CSV compatibility
Number(mov.monto).toFixed(2)
].join(',');
});
const csvContent = `data:text/csv;charset=utf-8,${headers}\n${rows.join('\n')}`;
const encodedUri = encodeURI(csvContent);
const link = document.createElement('a');
link.setAttribute('href', encodedUri);
link.setAttribute('download', `historial-${client.nombre.replace(/\s+/g, '_')}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
async function toggleClientHistory(row, client) {
const historyRowId = `history-for-${client.id}`;
const existingHistoryRow = document.getElementById(historyRowId);
if (existingHistoryRow) {
existingHistoryRow.remove();
return;
}
try {
const response = await fetch(`/api/clients/${client.id}/history`);
const history = await response.json();
const historyRow = document.createElement('tr');
historyRow.id = historyRowId;
historyRow.className = 'client-history-row';
let historyHtml = '
<div class="client-history-header">
<h4>Historial de Servicios</h4>
<button class="action-btn" id="btn-export-history-' + client˚.id + '">Exportar CSV</button>
</div>
';
if (history.length > 0) {
historyHtml += '<table><thead><tr><th>Folio</th><th>Fecha</th><th>Servicio</th><th>Monto</th></tr></thead><tbody>';
history.forEach(mov => {
const fecha = new Date(mov.fechaISO).toLocaleDateString('es-MX');
const servicio = mov.subtipo ? `${mov.tipo} (${mov.subtipo})` : mov.tipo;
historyHtml += `<tr><td>${mov.folio}</td><td>${fecha}</td><td>${servicio}</td><td>${Number(mov.monto).toFixed(2)}</td></tr>`;
});
historyHtml += '</tbody></table>';
} else {
historyHtml += '<p>No hay historial de servicios para este cliente.</p>';
}
historyRow.innerHTML = `<td colspan="4"><div class="client-history-content">${historyHtml}</div></td>`;
row.after(historyRow);
const exportButton = document.getElementById(`btn-export-history-${client.id}`);
if (exportButton) {
exportButton.addEventListener('click', (e) => {
e.stopPropagation();
exportClientHistoryCSV(client, history);
});
}
} catch (error) {
console.error('Error al cargar el historial del cliente:', error);
alert('No se pudo cargar el historial.');
}
}
// --- RENDERIZADO ---
function renderClientsTable(clientList = clients) {
if (!tblClientsBody) return;
tblClientsBody.innerHTML = '';
clientList.forEach(c => {
const tr = document.createElement('tr');
tr.dataset.id = c.id;
tr.innerHTML = '
<td>' + c.nombre + '</td>
<td>' + (c.telefono || '') + '</td>
<td>' + (c.esOncologico ? 'Sí' : 'No') + '</td>
<td>
<button class="action-btn" data-id="' + c.id + '" data-action="view-history">Historial</button>
<button class="action-btn" data-id="' + c.id + '" data-action="edit-client">Editar</button>
<button class="action-btn" data-id="' + c.id + '" data-action="delete-client">Eliminar</button>
</td>
';
tblClientsBody.appendChild(tr);
});
}
// --- MANEJADORES DE EVENTOS ---
function handleTableClick(e) {
const target = e.target;
const row = target.closest('tr');
if (!row) return;
const actionBtn = target.closest('.action-btn');
if (actionBtn) {
e.preventDefault();
const id = actionBtn.dataset.id;
const action = actionBtn.dataset.action;
const client = clients.find(c => c.id === id);
if (!client) return;
if (action === 'view-history') {
toggleClientHistory(row, client);
} else if (action === 'edit-client') {
document.getElementById('c-id').value = client.id;
document.getElementById('c-nombre').value = client.nombre;
document.getElementById('c-telefono').value = client.telefono || '';
document.getElementById('c-genero').value = client.genero || '';
document.getElementById('c-cumple').value = client.cumpleaños;
document.getElementById('c-consent').checked = client.consentimiento;
const esOncologicoCheckbox = document.getElementById('c-esOncologico');
const oncologicoFields = document.getElementById('oncologico-fields');
esOncologicoCheckbox.checked = client.esOncologico;
oncologicoFields.classList.toggle('hidden', !client.esOncologico);
document.getElementById('c-oncologoAprueba').checked = client.oncologoAprueba;
document.getElementById('c-nombreMedico').value = client.nombreMedico || '';
document.getElementById('c-telefonoMedico').value = client.telefonoMedico || '';
document.getElementById('c-cedulaMedico').value = client.cedulaMedico || '';
document.getElementById('c-pruebaAprobacion').checked = client.pruebaAprobacion;
} else if (action === 'delete-client') {
deleteClient(id);
}
}
}
async function handleClientForm(e) {
e.preventDefault();
await saveClient();
}
// --- INICIALIZACIÓN ---
async function initializeClientsPage() {
// 1. Verificar autenticación
try {
const response = await fetch('/api/check-auth');
const auth = await response.json();
if (!auth.isAuthenticated) {
window.location.href = '/login.html';
return;
}
} catch (error) {
console.error('Error de autenticación', error);
window.location.href = '/login.html';
return;
}
// 2. Cargar clientes
clients = await load(KEY_CLIENTS, []);
renderClientsTable();
// 3. Añadir manejadores de eventos
formClient?.addEventListener('submit', handleClientForm);
tblClientsBody?.addEventListener('click', handleTableClick);
document.getElementById('btnCancelEditClient')?.addEventListener('click', () => {
formClient.reset();
document.getElementById('c-id').value = '';
document.getElementById('oncologico-fields').classList.add('hidden');
});
document.getElementById('c-esOncologico')?.addEventListener('change', (e) => {
const oncologicoFields = document.getElementById('oncologico-fields');
oncologicoFields.classList.toggle('hidden', !e.target.checked);
});
searchClientInput?.addEventListener('input', (e) => {
const searchTerm = e.target.value.toLowerCase();
const filteredClients = clients.filter(c => c.nombre.toLowerCase().includes(searchTerm));
renderClientsTable(filteredClients);
});
}
document.addEventListener('DOMContentLoaded', initializeClientsPage);

12
dev-tasks.md Normal file
View File

@@ -0,0 +1,12 @@
# Tareas de Desarrollo
## Mejoras UI/UX
* **Redimensionamiento del Logotipo Principal:** Reducir el tamaño de `src/logo.png` (logotipo principal) al 35% de sus dimensiones actuales. - **HECHO**
* **Corrección del Logotipo del Pie de Página:** Investigar y corregir el problema que impide que `src/logo_dev.png` se cargue en el pie de página. - **PENDIENTE**
* **Acción:** Verificar la existencia e integridad del archivo `src/logo_dev.png`.
* **Refinamiento de la Barra de Navegación:** - **HECHO**
* Para las pestañas inactivas, mostrar solo el icono (eliminar texto).
* Asegurar que las pestañas inactivas tengan un fondo transparente.
* La pestaña activa debe seguir mostrando tanto el texto como el icono.
* **Problema de Transparencia:** Abordar el problema general de transparencia que afectaba a la interfaz de usuario en general. - **HECHO**

View File

@@ -4,7 +4,7 @@ services:
container_name: ap-pos container_name: ap-pos
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3111:3111"
environment: environment:
NODE_ENV: production NODE_ENV: production
volumes: volumes:

View File

@@ -12,7 +12,7 @@
<link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@500;600&family=Open+Sans:wght@400;600&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Material+Icons+Outlined" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Material+Icons+Outlined" rel="stylesheet">
<!-- Styles --> <!-- Styles -->
<link rel="stylesheet" href="styles.css?v=1.2" /> <link rel="stylesheet" href="styles.css?v=1.4" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head> </head>
<body> <body>
@@ -22,16 +22,19 @@
<img src="src/logo.png" alt="Ale Ponce" class="header-logo"> <img src="src/logo.png" alt="Ale Ponce" class="header-logo">
<nav class="tabs"> <nav class="tabs">
<button type="button" class="tab-link" data-tab="tab-dashboard"> <button type="button" class="tab-link" data-tab="tab-dashboard">
<span class="material-icons-outlined">dashboard</span>Dashboard <span class="material-icons-outlined">dashboard</span><span>Dashboard</span>
</button> </button>
<button type="button" class="tab-link" data-tab="tab-movements"> <button type="button" class="tab-link" data-tab="tab-movements">
<span class="material-icons-outlined">receipt_long</span>Recibos <span class="material-icons-outlined">receipt_long</span><span>Ventas</span>
</button> </button>
<button type="button" class="tab-link" data-tab="tab-clients"> <button type="button" class="tab-link" data-tab="tab-clients">
<span class="material-icons-outlined">groups</span>Clientes <span class="material-icons-outlined">groups</span><span>Clientes</span>
</button>
<button type="button" class="tab-link" data-tab="tab-products">
<span class="material-icons-outlined">inventory_2</span><span>Productos</span>
</button> </button>
<button type="button" class="tab-link" data-tab="tab-settings"> <button type="button" class="tab-link" data-tab="tab-settings">
<span class="material-icons-outlined">settings</span>Configuración <span class="material-icons-outlined">settings</span><span>Configuración</span>
</button> </button>
</nav> </nav>
<button type="button" id="btnLogout" class="btn-icon btn-danger"> <button type="button" id="btnLogout" class="btn-icon btn-danger">
@@ -83,13 +86,13 @@
<input type="text" id="m-cliente" list="client-list" required autocomplete="off" /> <input type="text" id="m-cliente" list="client-list" required autocomplete="off" />
<datalist id="client-list"></datalist> <datalist id="client-list"></datalist>
</div> </div>
<label>Servicio:</label> <label>Categoría:</label>
<select id="m-tipo" required> <select id="m-categoria" required>
<option value="Microblading">Microblading</option> <option value="service">Servicio</option>
<option value="Lashes">Lashes</option> <option value="course">Curso</option>
<option value="Nail Art">Nail Art</option> </select>
<option value="Lash Lifting">Lash Lifting</option> <label>Artículo:</label>
<option value="Pago">Pago</option> <select id="m-articulo" required>
</select> </select>
<div id="m-subtipo-container" class="hidden"> <div id="m-subtipo-container" class="hidden">
<label>Subtipo:</label> <label>Subtipo:</label>
@@ -147,92 +150,205 @@
<!-- Pestaña de Clientes --> <!-- Pestaña de Clientes -->
<div id="tab-clients" class="tab-content"> <div id="tab-clients" class="tab-content">
<div class="section"> <div class="sub-tabs">
<h2>Administrar Clientes</h2> <button type="button" class="sub-tab-link active" data-subtab="sub-tab-register">Registro de Cliente</button>
<form id="formClient"> <button type="button" class="sub-tab-link" data-subtab="sub-tab-consult">Consulta de Clientes</button>
<input type="hidden" id="c-id" /> </div>
<div class="form-grid-single">
<label for="c-nombre">Nombre:</label>
<input type="text" id="c-nombre" required />
<label for="c-telefono">Teléfono:</label> <!-- Sub-Pestaña de Registro -->
<input type="tel" id="c-telefono" /> <div id="sub-tab-register" class="sub-tab-content active">
<div class="section">
<label for="c-genero">Género:</label> <h2>Registrar Nuevo Cliente</h2>
<select id="c-genero"> <form id="formClient">
<option value="">-- Seleccionar --</option> <input type="hidden" id="c-id" />
<option value="Mujer">Mujer</option>
<option value="Hombre">Hombre</option>
</select>
<label for="c-cumple">Cumpleaños:</label>
<input type="date" id="c-cumple" />
<div class="checkbox-container">
<input type="checkbox" id="c-consent" />
<label for="c-consent">¿Consentimiento médico informado completo?</label>
</div>
<div class="checkbox-container">
<input type="checkbox" id="c-esOncologico" />
<label for="c-esOncologico">¿Es paciente oncológico?</label>
</div>
</div>
<!-- Campos condicionales para paciente oncológico -->
<div id="oncologico-fields" class="sub-section hidden">
<h3>Información Oncológica</h3>
<div class="form-grid-single"> <div class="form-grid-single">
<div class="checkbox-container"> <label for="c-nombre">Nombre:</label>
<input type="checkbox" id="c-oncologoAprueba" /> <input type="text" id="c-nombre" required />
<label for="c-oncologoAprueba">¿Oncólogo aprueba procedimiento?</label>
</div>
<label for="c-nombreMedico">Nombre del Médico:</label> <label for="c-telefono">Teléfono:</label>
<input type="text" id="c-nombreMedico" /> <input type="tel" id="c-telefono" />
<label for="c-telefonoMedico">Teléfono del Médico:</label> <label for="c-genero">Género:</label>
<input type="tel" id="c-telefonoMedico" /> <select id="c-genero">
<option value="">-- Seleccionar --</option>
<option value="Mujer">Mujer</option>
<option value="Hombre">Hombre</option>
</select>
<label for="c-cedulaMedico">Cédula Profesional:</label> <label for="c-cumple">Cumpleaños:</label>
<input type="text" id="c-cedulaMedico" /> <input type="date" id="c-cumple" />
<div class="checkbox-container">
<input type="checkbox" id="c-pruebaAprobacion" />
<label for="c-pruebaAprobacion">¿Presenta prueba de aprobación?</label>
</div>
<p class="data-location-info"> <div class="checkbox-container">
El consentimiento del médico debe ser presentado físicamente antes de la evaluación. <input type="checkbox" id="c-consent" />
</p> <label for="c-consent">¿Consentimiento médico informado completo?</label>
</div>
<div class="checkbox-container">
<input type="checkbox" id="c-esOncologico" />
<label for="c-esOncologico">¿Es paciente oncológico?</label>
</div>
</div>
<!-- Campos condicionales para paciente oncológico -->
<div id="oncologico-fields" class="sub-section hidden">
<h3>Información Oncológica</h3>
<div class="form-grid-single">
<div class="checkbox-container">
<input type="checkbox" id="c-oncologoAprueba" />
<label for="c-oncologoAprueba">¿Oncólogo aprueba procedimiento?</label>
</div>
<label for="c-nombreMedico">Nombre del Médico:</label>
<input type="text" id="c-nombreMedico" />
<label for="c-telefonoMedico">Teléfono del Médico:</label>
<input type="tel" id="c-telefonoMedico" />
<label for="c-cedulaMedico">Cédula Profesional:</label>
<input type="text" id="c-cedulaMedico" />
<div class="checkbox-container">
<input type="checkbox" id="c-pruebaAprobacion" />
<label for="c-pruebaAprobacion">¿Presenta prueba de aprobación?</label>
</div>
<p class="data-location-info">
El consentimiento del médico debe ser presentado físicamente antes de la evaluación.
</p>
</div>
</div>
<div id="client-courses-section" class="sub-section">
<h3>Cursos Registrados</h3>
<div id="client-courses-list"></div>
<button type="button" id="btnAddCourseToClient" class="btn-secondary">Registrar Curso</button>
</div>
<div class="form-actions-single">
<button type="submit">Guardar Cliente</button>
<button type="reset" id="btnCancelEditClient" class="btn-danger">Limpiar</button>
</div>
</form>
</div>
</div>
<!-- Sub-Pestaña de Consulta -->
<div id="sub-tab-consult" class="sub-tab-content">
<div class="section">
<div class="consult-grid">
<div class="client-list-container">
<h2>Lista de Clientes</h2>
<input type="text" id="search-client" placeholder="Buscar por nombre o teléfono..." />
<div class="table-wrapper">
<table id="tblClients">
<thead>
<tr>
<th>Nombre</th>
<th>Teléfono</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div id="client-record-container" class="client-record-container">
<h2>Expediente del Cliente</h2>
<div id="client-record-content" class="hidden">
<div id="client-details"></div>
<h3>Historial de Servicios</h3>
<div id="client-history-table-container" class="table-wrapper">
<table id="client-history-table">
<thead>
<tr>
<th>Folio</th>
<th>Fecha</th>
<th>Servicio</th>
<th>Monto</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<h3>Historial de Cursos</h3>
<div id="client-courses-history-container" class="table-wrapper">
<!-- Course history will be rendered here -->
</div>
<div class="form-actions-single">
<button id="btn-edit-client" class="btn-secondary">Editar Cliente</button>
<button id="btn-delete-client" class="btn-danger">Eliminar Cliente</button>
</div>
</div>
<div id="client-record-placeholder">
<p>Selecciona un cliente de la lista para ver su expediente.</p>
</div>
</div> </div>
</div> </div>
</div>
</div>
</div>
<div class="form-actions-single"> <!-- Pestaña de Productos -->
<button type="submit">Guardar Cliente</button> <div id="tab-products" class="tab-content">
<button type="reset" id="btnCancelEditClient" class="btn-danger">Limpiar</button> <div class="section">
</div> <h2>Gestión de Productos y Cursos</h2>
</form> <div class="sub-section">
</div> <h3>Añadir/Editar</h3>
<div class="section"> <form id="formProduct">
<h2>Lista de Clientes</h2> <input type="hidden" id="p-id" />
<div class="form-grid-single"> <div class="form-grid">
<input type="text" id="search-client" placeholder="Buscar cliente por nombre..." /> <label>Nombre:</label>
<input type="text" id="p-name" required />
<label>Tipo:</label>
<select id="p-type" required>
<option value="service">Servicio</option>
<option value="course">Curso</option>
</select>
<label>Precio (MXN):</label>
<input type="number" id="p-price" step="0.01" min="0" />
</div>
<div class="form-actions">
<button type="submit">Guardar</button>
<button type="reset" id="btnCancelEditProduct" class="btn-danger">Cancelar</button>
</div>
</form>
</div>
<hr class="section-divider">
<div class="sub-section">
<h3>Servicios</h3>
<div class="table-wrapper">
<table id="tblServices">
<thead>
<tr>
<th>Nombre</th>
<th>Precio</th>
<th>Acciones</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<hr class="section-divider">
<div class="sub-section">
<h3>Cursos</h3>
<div class="table-wrapper">
<table id="tblCourses">
<thead>
<tr>
<th>Nombre</th>
<th>Precio</th>
<th>Acciones</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div> </div>
<div class="table-wrapper">
<table id="tblClients">
<thead>
<tr>
<th>Nombre</th>
<th>Teléfono</th>
<th>Oncológico</th>
<th>Acciones</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
</div> </div>
<!-- Pestaña de Configuración --> <!-- Pestaña de Configuración -->
@@ -326,9 +442,21 @@
</div> </div>
</main> </main>
<footer class="main-footer">
<div class="footer-logos">
<img src="src/logo_dev.png" alt="Marco Gallegos">
<img src="src/logo_gemini.png" alt="Google Gemini">
</div>
<div class="footer-info">
<p>Marco Gallegos | Creado con Google Gemini ®</p>
<p id="footer-date"></p>
<p id="footer-version"></p>
</div>
</footer>
<div id="printArea" class="no-print"></div> <div id="printArea" class="no-print"></div>
<script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/qrcode@1/build/qrcode.min.js"></script>
<script type="module" src="app.js?v=1.3"></script> <script type="module" src="app.js?v=1.3"></script>
</body> </body>
</html> </html>

View File

@@ -20,6 +20,7 @@
box-shadow: 0 4px 10px rgba(0,0,0,0.1); box-shadow: 0 4px 10px rgba(0,0,0,0.1);
width: 100%; width: 100%;
max-width: 400px; max-width: 400px;
text-align: center;
} }
.login-container h1 { .login-container h1 {
text-align: center; text-align: center;
@@ -29,9 +30,10 @@
.form-grid { .form-grid {
display: grid; display: grid;
gap: 1rem; gap: 1rem;
text-align: left;
} }
#error-message { #error-message {
color: var(--color-danger); color: #dc3545;
text-align: center; text-align: center;
margin-top: 1rem; margin-top: 1rem;
display: none; display: none;
@@ -40,6 +42,7 @@
</head> </head>
<body> <body>
<main class="login-container"> <main class="login-container">
<img src="src/logo.png" alt="Logo" style="max-width: 150px; margin-bottom: 1.5rem;">
<h1>Iniciar Sesión</h1> <h1>Iniciar Sesión</h1>
<form id="loginForm"> <form id="loginForm">
<div class="form-grid"> <div class="form-grid">
@@ -48,7 +51,7 @@
<label for="password">Contraseña:</label> <label for="password">Contraseña:</label>
<input type="password" id="password" required autocomplete="current-password" /> <input type="password" id="password" required autocomplete="current-password" />
</div> </div>
<div class="form-actions" style="margin-top: 1.5rem;"> <div class="form-actions" style="margin-top: 1.5rem; justify-content: center;">
<button type="submit">Entrar</button> <button type="submit">Entrar</button>
</div> </div>
</form> </form>
@@ -56,4 +59,4 @@
</main> </main>
<script type="module" src="login.js"></script> <script type="module" src="login.js"></script>
</body> </body>
</html> </html>

2277
package-lock.json generated

File diff suppressed because it is too large Load Diff

531
server.js Normal file
View File

@@ -0,0 +1,531 @@
const express = require('express');
const session = require('express-session');
const sqlite3 = require('sqlite3').verbose();
const cors = require('cors');
const path = require('path');
const bcrypt = require('bcryptjs');
const app = express();
const port = 3111;
// --- MIDDLEWARE ---
app.use(cors());
app.use(express.json());
// Cargar una clave secreta desde variables de entorno o usar una por defecto (solo para desarrollo)
const SESSION_SECRET = process.env.SESSION_SECRET || 'your-very-secret-key-change-it';
const IN_PROD = process.env.NODE_ENV === 'production';
// Session Middleware
app.use(session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: IN_PROD, httpOnly: true, maxAge: 24 * 60 * 60 * 1000 } // `secure: true` en producción con HTTPS
}));
// --- DATABASE INITIALIZATION ---
// Usar un path absoluto para asegurar que la DB siempre se cree en la carpeta del proyecto.
const dbPath = path.join(__dirname, 'ap-pos.db');
console.log(`Connecting to database at: ${dbPath}`);
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error(err.message);
}
console.log('Connected to the database.');
initializeApplication(); // Iniciar la aplicación después de conectar a la DB
});
// --- AUTHENTICATION LOGIC ---
const SALT_ROUNDS = 10;
let needsSetup = false;
function initializeApplication() {
db.serialize(() => {
// Crear tabla de usuarios si no existe
db.run(`CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE,
password TEXT,
role TEXT DEFAULT 'user',
name TEXT
)`, (err) => {
if (err) {
console.error("Error creating users table:", err.message);
startServer(); // Iniciar el servidor incluso si hay error para no colgar el proceso
return;
}
// Asegurar que las columnas 'role' y 'name' existan
db.run("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'user'", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding role column:", err.message);
}
});
db.run("ALTER TABLE users ADD COLUMN name TEXT", (err) => {
if (err && !err.message.includes('duplicate column name')) {
console.error("Error adding name column:", err.message);
}
});
// Verificar si hay usuarios
db.get('SELECT COUNT(id) as count FROM users', (err, row) => {
if (err) {
console.error("Error checking for users:", err.message);
} else {
if (row.count === 0) {
console.log("No users found. Application needs setup.");
needsSetup = true;
} else {
console.log(`${row.count} user(s) found. Setup is not required.`);
}
}
// Crear otras tablas después de la verificación de usuarios
db.run(`CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT)`);
db.run(`CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY, nombre TEXT, telefono TEXT, genero TEXT, cumpleaños TEXT,
consentimiento INTEGER, esOncologico INTEGER, oncologoAprueba INTEGER, nombreMedico TEXT,
telefonoMedico TEXT, cedulaMedico TEXT, pruebaAprobacion INTEGER
)`);
db.run(`CREATE TABLE IF NOT EXISTS movements (
id TEXT PRIMARY KEY, folio TEXT, fechaISO TEXT, clienteId TEXT, tipo TEXT, subtipo TEXT,
monto REAL, metodo TEXT, concepto TEXT, staff TEXT, notas TEXT, fechaCita TEXT, horaCita TEXT,
FOREIGN KEY (clienteId) REFERENCES clients (id)
)`);
// --- Tablas de Cursos y Productos ---
db.run(`CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
type TEXT NOT NULL, -- 'service' or 'course'
price REAL
)`, (err) => {
if (err) {
console.error("Error creating products table:", err.message);
} else {
// Insertar cursos iniciales si no existen
const courses = ['Vanity Lashes', 'Vanity Brows'];
courses.forEach(course => {
db.get("SELECT id FROM products WHERE name = ? AND type = 'course'", [course], (err, row) => {
if (!row) {
db.run("INSERT INTO products (name, type, price) VALUES (?, 'course', 0)", [course]);
}
});
});
}
});
db.run(`CREATE TABLE IF NOT EXISTS client_courses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
course_id INTEGER NOT NULL,
fecha_curso TEXT,
completo_presencial INTEGER DEFAULT 0,
completo_online INTEGER DEFAULT 0,
realizo_practicas INTEGER DEFAULT 0,
obtuvo_certificacion INTEGER DEFAULT 0,
score_general TEXT,
FOREIGN KEY (client_id) REFERENCES clients (id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES products (id) ON DELETE CASCADE
)`);
// Una vez completada toda la inicialización de la DB, iniciar el servidor
startServer();
});
});
});
}
function startServer() {
// --- SETUP & AUTH MIDDLEWARE ---
// Middleware para manejar la redirección a la página de configuración
const checkSetup = (req, res, next) => {
const allowedPaths = ['/setup.html', '/setup.js', '/api/setup', '/styles.css', '/src/logo.png', '/api/check-auth'];
if (needsSetup && !allowedPaths.includes(req.path)) {
return res.redirect('/setup.html');
}
next();
};
// Aplicar el middleware de configuración a todas las rutas
app.use(checkSetup);
// Servir archivos estáticos DESPUÉS del middleware de setup
app.use(express.static(__dirname));
// Middleware para verificar si el usuario está autenticado
const isAuthenticated = (req, res, next) => {
if (req.session.userId) {
return next();
}
// Para las rutas de la API, devolver un error 401 en lugar de redirigir.
if (req.originalUrl.startsWith('/api/')) {
return res.status(401).json({ error: 'Not authenticated' });
}
// Para las rutas de la UI, redirigir al login si no hay sesión.
if (!needsSetup) {
return res.redirect('/login.html');
}
next();
};
// Middleware para verificar si el usuario es admin
const isAdmin = (req, res, next) => {
if (!req.session.userId) {
return res.status(401).json({ error: 'Not authenticated' });
}
db.get('SELECT role FROM users WHERE id = ?', [req.session.userId], (err, user) => {
if (err || !user) {
return res.status(403).json({ error: 'Forbidden' });
}
if (user.role === 'admin') {
next();
} else {
return res.status(403).json({ error: 'Forbidden: Admins only' });
}
});
};
// --- API ROUTES ---
app.post('/api/setup', (req, res) => {
if (!needsSetup) {
return res.status(403).json({ error: 'Setup has already been completed.' });
}
const { name, username, password } = req.body;
if (!name || !username || !password) {
return res.status(400).json({ error: 'Name, username, and password are required' });
}
bcrypt.hash(password, SALT_ROUNDS, (err, hash) => {
if (err) {
return res.status(500).json({ error: 'Error hashing password' });
}
db.run('INSERT INTO users (username, password, role, name) VALUES (?, ?, ?, ?)', [username, hash, 'admin', name], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) {
return res.status(409).json({ error: 'Username already exists' });
}
return res.status(500).json({ error: err.message });
}
console.log("Administrator account created. Setup is now complete.");
needsSetup = false;
res.status(201).json({ message: 'Admin user created successfully' });
});
});
});
app.post('/api/login', (req, res) => {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password are required' });
}
db.get('SELECT * FROM users WHERE username = ?', [username], (err, user) => {
if (err || !user) {
return res.status(401).json({ error: 'Invalid credentials' });
}
bcrypt.compare(password, user.password, (err, isMatch) => {
if (err || !isMatch) {
return res.status(401).json({ error: 'Invalid credentials' });
}
req.session.userId = user.id;
req.session.role = user.role;
req.session.name = user.name;
res.json({ message: 'Login successful', role: user.role, name: user.name });
});
});
});
app.post('/api/logout', (req, res) => {
req.session.destroy(err => {
if (err) {
return res.status(500).json({ error: 'Could not log out' });
}
res.clearCookie('connect.sid');
res.json({ message: 'Logout successful' });
});
});
app.get('/api/check-auth', (req, res) => {
if (req.session.userId) {
res.json({ isAuthenticated: true, role: req.session.role, name: req.session.name });
} else {
res.json({ isAuthenticated: false });
}
});
// --- PROTECTED ROUTES ---
app.get('/', isAuthenticated, (req, res) => {
res.sendFile(path.join(__dirname, 'index.html'));
});
const apiRouter = express.Router();
apiRouter.use(isAuthenticated);
// --- Settings ---
apiRouter.get('/settings', (req, res) => {
db.get("SELECT value FROM settings WHERE key = 'settings'", (err, row) => {
if (err) return res.status(500).json({ error: err.message });
res.json(row ? JSON.parse(row.value) : {});
});
});
apiRouter.post('/settings', (req, res) => {
const { settings } = req.body;
const value = JSON.stringify(settings);
db.run(`INSERT OR REPLACE INTO settings (key, value) VALUES ('settings', ?)`, [value], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Settings saved' });
});
});
// --- Clients ---
apiRouter.get('/clients', (req, res) => {
db.all("SELECT * FROM clients", [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
apiRouter.post('/clients', (req, res) => {
const { client } = req.body;
db.run(`INSERT OR REPLACE INTO clients (id, nombre, telefono, genero, cumpleaños, consentimiento, esOncologico, oncologoAprueba, nombreMedico, telefonoMedico, cedulaMedico, pruebaAprobacion) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[client.id, client.nombre, client.telefono, client.genero, client.cumpleaños, client.consentimiento, client.esOncologico, client.oncologoAprueba, client.nombreMedico, client.telefonoMedico, client.cedulaMedico, client.pruebaAprobacion], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ id: client.id });
});
});
apiRouter.delete('/clients/:id', (req, res) => {
db.run(`DELETE FROM clients WHERE id = ?`, req.params.id, function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Client deleted' });
});
});
// --- Movements ---
apiRouter.get('/movements', (req, res) => {
db.all("SELECT * FROM movements ORDER BY fechaISO DESC", [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
apiRouter.post('/movements', (req, res) => {
const { movement } = req.body;
db.run(`INSERT INTO movements (id, folio, fechaISO, clienteId, tipo, subtipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[movement.id, movement.folio, movement.fechaISO, movement.clienteId, movement.tipo, movement.subtipo, movement.monto, movement.metodo, movement.concepto, movement.staff, movement.notas, movement.fechaCita, movement.horaCita], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ id: movement.id });
});
});
apiRouter.delete('/movements/:id', (req, res) => {
db.run(`DELETE FROM movements WHERE id = ?`, req.params.id, function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Movement deleted' });
});
});
// --- Client History ---
apiRouter.get('/clients/:id/history', (req, res) => {
db.all("SELECT * FROM movements WHERE clienteId = ? ORDER BY fechaISO DESC", [req.params.id], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
// --- Product/Course Management ---
apiRouter.get('/products', (req, res) => {
db.all("SELECT * FROM products ORDER BY type, name", [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
apiRouter.post('/products', isAdmin, (req, res) => {
const { name, type, price } = req.body;
if (!name || !type) return res.status(400).json({ error: 'Name and type are required' });
db.run(`INSERT INTO products (name, type, price) VALUES (?, ?, ?)`,
[name, type, price || 0], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json({ id: this.lastID, name, type, price });
});
});
apiRouter.put('/products/:id', isAdmin, (req, res) => {
const { name, type, price } = req.body;
if (!name || !type) return res.status(400).json({ error: 'Name and type are required' });
db.run(`UPDATE products SET name = ?, type = ?, price = ? WHERE id = ?`,
[name, type, price || 0, req.params.id], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Product updated' });
});
});
apiRouter.delete('/products/:id', isAdmin, (req, res) => {
db.run(`DELETE FROM products WHERE id = ?`, req.params.id, function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Product deleted' });
});
});
// --- Client-Course Management ---
apiRouter.get('/clients/:id/courses', (req, res) => {
const sql = `
SELECT cc.*, p.name as course_name
FROM client_courses cc
JOIN products p ON cc.course_id = p.id
WHERE cc.client_id = ?
`;
db.all(sql, [req.params.id], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
apiRouter.post('/clients/:id/courses', (req, res) => {
const { course_id, fecha_curso, completo_presencial, completo_online, realizo_practicas, obtuvo_certificacion, score_general } = req.body;
db.run(`INSERT INTO client_courses (client_id, course_id, fecha_curso, completo_presencial, completo_online, realizo_practicas, obtuvo_certificacion, score_general) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[req.params.id, course_id, fecha_curso, completo_presencial, completo_online, realizo_practicas, obtuvo_certificacion, score_general],
function(err) {
if (err) return res.status(500).json({ error: err.message });
res.status(201).json({ id: this.lastID });
}
);
});
// --- User Management (Admin) ---
apiRouter.get('/users', isAdmin, (req, res) => {
db.all("SELECT id, username, role, name FROM users", [], (err, rows) => {
if (err) return res.status(500).json({ error: err.message });
res.json(rows);
});
});
apiRouter.post('/users', isAdmin, (req, res) => {
const { username, password, role, name } = req.body;
if (!username || !password || !role || !name) return res.status(400).json({ error: 'All fields are required' });
if (role !== 'admin' && role !== 'user') return res.status(400).json({ error: 'Invalid role' });
bcrypt.hash(password, SALT_ROUNDS, (err, hash) => {
if (err) return res.status(500).json({ error: 'Error hashing password' });
db.run('INSERT INTO users (username, password, role, name) VALUES (?, ?, ?, ?)', [username, hash, role, name], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) return res.status(409).json({ error: 'Username already exists' });
return res.status(500).json({ error: err.message });
}
res.status(201).json({ id: this.lastID, username, role, name });
});
});
});
apiRouter.put('/users/:id', isAdmin, (req, res) => {
const { id } = req.params;
const { username, role, name } = req.body;
if (!username || !role || !name) return res.status(400).json({ error: 'Username, role, and name are required' });
db.run('UPDATE users SET username = ?, role = ?, name = ? WHERE id = ?', [username, role, name, id], function(err) {
if (err) {
if (err.message.includes('UNIQUE constraint failed')) return res.status(409).json({ error: 'Username already exists' });
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) return res.status(404).json({ error: 'User not found' });
res.json({ message: 'User updated successfully' });
});
});
apiRouter.delete('/users/:id', isAdmin, (req, res) => {
if (parseInt(req.params.id, 10) === req.session.userId) {
return res.status(400).json({ error: "You cannot delete your own account." });
}
db.run(`DELETE FROM users WHERE id = ?`, req.params.id, function(err) {
if (err) return res.status(500).json({ error: err.message });
if (this.changes === 0) return res.status(404).json({ error: "User not found" });
res.json({ message: 'User deleted' });
});
});
// --- Current User Settings ---
apiRouter.get('/user', isAuthenticated, (req, res) => {
db.get("SELECT id, username, role, name FROM users WHERE id = ?", [req.session.userId], (err, row) => {
if (err) return res.status(500).json({ error: err.message });
res.json(row);
});
});
apiRouter.post('/user', isAuthenticated, (req, res) => {
const { username, password, name } = req.body;
if (!username || !name) return res.status(400).json({ error: 'Username and name are required' });
if (password) {
bcrypt.hash(password, SALT_ROUNDS, (err, hash) => {
if (err) return res.status(500).json({ error: 'Error hashing password' });
db.run('UPDATE users SET username = ?, password = ?, name = ? WHERE id = ?', [username, hash, name, req.session.userId], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'User credentials updated successfully' });
});
});
} else {
db.run('UPDATE users SET username = ?, name = ? WHERE id = ?', [username, name, req.session.userId], function(err) {
if (err) return res.status(500).json({ error: err.message });
res.json({ message: 'Username and name updated successfully' });
});
}
});
// --- Dashboard Route (Admin Only) ---
apiRouter.get('/dashboard', isAdmin, (req, res) => {
const queries = {
totalIncome: "SELECT SUM(monto) as total FROM movements",
totalMovements: "SELECT COUNT(*) as total FROM movements",
incomeByService: "SELECT tipo, SUM(monto) as total FROM movements GROUP BY tipo",
incomeByPaymentMethod: "SELECT metodo, SUM(monto) as total FROM movements WHERE metodo IS NOT NULL AND metodo != '''' GROUP BY metodo",
upcomingAppointments: `
SELECT m.id, m.folio, m.fechaCita, m.horaCita, c.nombre as clienteNombre
FROM movements m
JOIN clients c ON m.clienteId = c.id
WHERE m.fechaCita IS NOT NULL AND m.fechaCita >= date('now')
ORDER BY m.fechaCita ASC, m.horaCita ASC
LIMIT 5`
};
const promises = Object.keys(queries).map(key => {
return new Promise((resolve, reject) => {
const query = queries[key];
const method = ['incomeByService', 'incomeByPaymentMethod', 'upcomingAppointments'].includes(key) ? 'all' : 'get';
db[method](query, [], (err, result) => {
if (err) return reject(err);
if (method === 'get') {
resolve({ key, value: result ? result.total : 0 });
} else {
resolve({ key, value: result });
}
});
});
});
Promise.all(promises)
.then(allResults => {
const results = {};
allResults.forEach(result => {
results[result.key] = result.value;
});
res.json(results);
})
.catch(err => {
res.status(500).json({ error: err.message });
});
});
app.use('/api', apiRouter);
app.listen(port, () => {
console.log(`Server running at http://localhost:${port}`);
});
}

78
setup.html Normal file
View File

@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Configuración Inicial - AP POS</title>
<link rel="stylesheet" href="styles.css">
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
background-color: #f4f4f9;
}
.login-container {
padding: 2rem;
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 10px rgba(0,0,0,0.1);
width: 100%;
max-width: 400px;
}
.login-container h1 {
text-align: center;
margin-bottom: 1rem;
color: #333;
}
.login-container p {
text-align: center;
margin-bottom: 1.5rem;
color: #666;
}
.form-grid {
display: grid;
gap: 1rem;
}
label {
font-weight: 600;
color: #495057;
font-size: 14px;
text-align: left;
}
#error-message {
color: #dc3545;
text-align: center;
margin-top: 1rem;
font-weight: 600;
}
</style>
</head>
<body>
<main class="login-container">
<h1>Configuración Inicial</h1>
<p>Este es el primer inicio. Por favor, crea tu cuenta de administrador.</p>
<form id="setupForm">
<div class="form-grid">
<label for="fullName">Nombre Completo:</label>
<input type="text" id="fullName" name="fullName" required placeholder="Ej: Juanita Lopez">
<label for="username">Nombre de Usuario:</label>
<input type="text" id="username" name="username" value="admin" readonly required>
<label for="password">Contraseña:</label>
<input type="password" id="password" name="password" required>
<label for="confirmPassword">Confirmar Contraseña:</label>
<input type="password" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="form-actions" style="margin-top: 1.5rem; justify-content: center;">
<button type="submit">Crear Administrador</button>
</div>
</form>
<p id="error-message"></p>
</main>
<script src="setup.js"></script>
</body>
</html>

36
setup.js Normal file
View File

@@ -0,0 +1,36 @@
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const name = document.getElementById('fullName').value;
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
const errorMessage = document.getElementById('error-message');
if (password !== confirmPassword) {
errorMessage.textContent = 'Las contraseñas no coinciden.';
return;
}
try {
const response = await fetch('/api/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ name, username, password })
});
const result = await response.json();
if (response.ok) {
alert('Cuenta de administrador creada exitosamente. Ahora serás redirigido a la página de inicio de sesión.');
window.location.href = '/login.html';
} else {
errorMessage.textContent = result.error || 'Ocurrió un error al crear la cuenta.';
}
} catch (error) {
errorMessage.textContent = 'Error de conexión con el servidor.';
console.error('Error en el setup:', error);
}
});

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
src/logo_dev.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

BIN
src/logo_gemini.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,4 +1,4 @@
const API_URL = 'http://localhost:3000/api'; const API_URL = 'http://localhost:3111/api';
export const KEY_DATA = 'movements'; export const KEY_DATA = 'movements';
export const KEY_SETTINGS = 'settings'; export const KEY_SETTINGS = 'settings';

View File

@@ -18,6 +18,8 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
.container { .container {
max-width: 800px; max-width: 800px;
margin: 30px auto; margin: 30px auto;
@@ -277,9 +279,9 @@ button.action-btn {
} }
.header-logo { .header-logo {
height: 50px; max-width: 20%;
width: auto; height: auto;
margin-right: 2rem; padding-right: 50px;
} }
.tabs { .tabs {
@@ -312,6 +314,18 @@ button.action-btn {
border-bottom-color: #343a40; border-bottom-color: #343a40;
} }
.tab-link.active span:not(.material-icons-outlined) {
display: inline-block;
}
.tab-link:not(.active) span:not(.material-icons-outlined) {
display: none;
}
.tab-link:not(.active) {
color: rgba(108, 117, 125, 0.5);
}
.tab-content { .tab-content {
display: none; display: none;
} }
@@ -320,6 +334,42 @@ button.action-btn {
display: block; display: block;
} }
/* Table Styles */
table {
width: 100%;
border-collapse: collapse;
margin: 0;
}
table th, table td {
border-bottom: 1px solid #dee2e6;
padding: 12px 15px;
text-align: left;
white-space: nowrap;
}
table th {
background-color: #000;
color: #fff;
font-family: 'Montserrat', sans-serif;
font-weight: 500;
border-bottom-width: 2px;
}
table tbody tr:nth-child(even) {
background-color: #f8f9fa;
}
table tbody tr:hover {
background-color: #e9ecef;
}
.section-divider {
border: 0;
border-top: 1px solid #dee2e6;
margin: 40px 0;
}
/* Dashboard Styles */ /* Dashboard Styles */
.dashboard-stats { .dashboard-stats {
display: grid; display: grid;
@@ -502,3 +552,136 @@ button.action-btn {
.main-footer-credits p { .main-footer-credits p {
margin: 0 0 5px 0; margin: 0 0 5px 0;
} }
/* --- Estilos para Sub-Pestañas --- */
.sub-tabs {
display: flex;
border-bottom: 1px solid #dee2e6;
margin-bottom: 25px;
}
.sub-tab-link {
padding: 10px 20px;
cursor: pointer;
background: none;
border: none;
font-family: 'Montserrat', sans-serif;
font-weight: 500;
font-size: 16px;
color: #6c757d;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
transition: color 0.2s, border-color 0.2s;
}
.sub-tab-link:hover {
color: #343a40;
}
.sub-tab-link.active {
color: #343a40;
border-bottom-color: #343a40;
}
.sub-tab-content {
display: none;
}
.sub-tab-content.active {
display: block;
}
.client-history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.client-history-header h4 {
margin: 0;
}
#btn-export-history {
background-color: #17a2b8;
}
#btn-export-history:hover {
background-color: #138496;
}
/* --- Nuevos estilos para consulta de clientes --- */
.consult-grid {
display: grid;
grid-template-columns: 300px 1fr;
gap: 30px;
}
.client-list-container h2, .client-record-container h2 {
font-size: 20px;
}
.client-list-container .table-wrapper {
max-height: 400px;
overflow-y: auto;
}
#tblClients tbody tr {
cursor: pointer;
}
#tblClients tbody tr:hover {
background-color: #f1f3f5;
}
#tblClients tbody tr.selected {
background-color: #e9ecef;
}
.client-record-container {
border-left: 1px solid #dee2e6;
padding-left: 30px;
}
#client-record-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #6c757d;
text-align: center;
}
#client-details p {
margin: 0 0 10px 0;
}
#client-details strong {
display: inline-block;
width: 100px;
}
.main-footer {
text-align: center;
margin-top: 40px;
padding-top: 20px;
border-top: 1px solid #dee2e6;
color: #6c757d;
font-size: 12px;
}
.footer-logos {
display: flex;
justify-content: center;
align-items: center;
gap: 20px;
margin-bottom: 10px;
}
.footer-logos img {
height: 40px; /* Reducido para mejor proporción */
width: auto; /* Añadido para mantener la proporción */
max-height: 40px; /* Añadido para evitar distorsión */
}
.footer-info p {
margin: 5px 0;
}