mirror of
https://github.com/marcogll/ap_pos.git
synced 2026-01-13 21:25:16 +00:00
feat: Implementar roles de usuario y gestión de administradores
Se introduce un sistema completo de roles de usuario (admin, user) para controlar el acceso a las diferentes funcionalidades de la aplicación.
Funcionalidades y Cambios:
- Se añade la columna 'role' a la tabla de usuarios en la base de datos.
- El login ahora devuelve el rol del usuario y la sesión lo almacena.
- El Dashboard y la pestaña de Configuración ahora solo son visibles para los administradores.
- Los administradores tienen una nueva sección en "Configuración" para añadir y eliminar otros usuarios.
- Se implementan endpoints de API seguros ('/api/users') para la gestión de usuarios, accesibles solo por administradores.
- Se corrige un error que impedía la navegación entre pestañas y la interactividad general.
- Se soluciona un error de renderizado del gráfico del dashboard que causaba una sensación de "bucle".
- Se actualiza el README con instrucciones detalladas de instalación, uso y despliegue con Docker.
- Se añaden archivos Dockerfile y .dockerignore para la contenerización.
This commit is contained in:
7
ap-pos/.dockerignore
Normal file
7
ap-pos/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Evitar que los módulos locales y los archivos de git se copien a la imagen
|
||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Excluir la base de datos de desarrollo para que se pueda usar un volumen en su lugar
|
||||||
|
ap-pos.db
|
||||||
20
ap-pos/Dockerfile
Normal file
20
ap-pos/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Usar una imagen base de Node.js ligera
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
# Establecer el directorio de trabajo dentro del contenedor
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Copiar los archivos de definición de paquetes y dependencias
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Instalar las dependencias de producción
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Copiar el resto de los archivos de la aplicación
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Exponer el puerto en el que corre la aplicación
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
# Definir el comando para iniciar la aplicación
|
||||||
|
CMD [ "node", "server.js" ]
|
||||||
@@ -1,7 +1,71 @@
|
|||||||
# AP-POS WebApp
|
# Ale Ponce | AlMa - Sistema de Punto de Venta
|
||||||
|
|
||||||
Este es un sistema de punto de venta simple basado en la web.
|
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.
|
||||||
|
|
||||||
## Futuras Implementaciones
|
## Características Principales
|
||||||
|
|
||||||
Se tiene la intención de que esta aplicación se pueda ejecutar en un contenedor de Docker. Además, se buscará que la aplicación tenga la capacidad de interactuar con una impresora de tickets conectada vía USB en un entorno de macOS.
|
- **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 (si aplica):**
|
||||||
|
```bash
|
||||||
|
git clone <url-del-repositorio>
|
||||||
|
cd ap-webapp/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.
|
||||||
|
```bash
|
||||||
|
docker run -p 3000:3000 -v $(pwd)/data:/usr/src/app ap-pos-app
|
||||||
|
```
|
||||||
|
*Nota: El comando anterior crea un directorio `data` en tu carpeta actual para almacenar `ap-pos.db`.*
|
||||||
|
|
||||||
|
## Autores
|
||||||
|
- **Gemini**
|
||||||
|
- **Marco G.**
|
||||||
204
ap-pos/app.js
204
ap-pos/app.js
@@ -18,7 +18,9 @@ const DEFAULT_SETTINGS = {
|
|||||||
let settings = {};
|
let settings = {};
|
||||||
let movements = [];
|
let movements = [];
|
||||||
let clients = [];
|
let clients = [];
|
||||||
|
let users = [];
|
||||||
let incomeChart = null;
|
let incomeChart = null;
|
||||||
|
let currentUser = {};
|
||||||
|
|
||||||
// --- DOM ELEMENTS ---
|
// --- DOM ELEMENTS ---
|
||||||
const formSettings = document.getElementById('formSettings');
|
const formSettings = document.getElementById('formSettings');
|
||||||
@@ -30,13 +32,23 @@ const formClient = document.getElementById('formClient');
|
|||||||
const tblClientsBody = document.getElementById('tblClients')?.querySelector('tbody');
|
const tblClientsBody = document.getElementById('tblClients')?.querySelector('tbody');
|
||||||
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 tblUsersBody = document.getElementById('tblUsers')?.querySelector('tbody');
|
||||||
|
|
||||||
// --- LÓGICA DE NEGOCIO ---
|
// --- LÓGICA DE NEGOCIO ---
|
||||||
|
|
||||||
async function loadDashboardData() {
|
async function loadDashboardData() {
|
||||||
|
// Solo admins pueden cargar esto
|
||||||
|
if (currentUser.role !== 'admin') return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/dashboard');
|
const response = await fetch('/api/dashboard');
|
||||||
if (!response.ok) throw new Error('Failed to fetch dashboard data');
|
if (!response.ok) {
|
||||||
|
if (response.status === 403) {
|
||||||
|
console.warn('Access to dashboard denied.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Failed to fetch dashboard data');
|
||||||
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
// Update stat cards
|
// Update stat cards
|
||||||
@@ -50,14 +62,7 @@ async function loadDashboardData() {
|
|||||||
datasets: [{
|
datasets: [{
|
||||||
label: 'Ingresos por Servicio',
|
label: 'Ingresos por Servicio',
|
||||||
data: data.incomeByService.map(item => item.total),
|
data: data.incomeByService.map(item => item.total),
|
||||||
backgroundColor: [
|
backgroundColor: ['#FF6384', '#36A2EB', '#FFCE56', '#4BC0C0', '#9966FF', '#FF9F40'],
|
||||||
'#FF6384',
|
|
||||||
'#36A2EB',
|
|
||||||
'#FFCE56',
|
|
||||||
'#4BC0C0',
|
|
||||||
'#9966FF',
|
|
||||||
'#FF9F40'
|
|
||||||
],
|
|
||||||
}]
|
}]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,6 +76,7 @@ async function loadDashboardData() {
|
|||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
|
animation: false // Desactivar la animación para evitar la sensación de "bucle"
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -78,7 +84,6 @@ async function loadDashboardData() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function generateFolio() {
|
function generateFolio() {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
let result = '';
|
let result = '';
|
||||||
@@ -224,6 +229,22 @@ function renderClientsTable() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderUsersTable() {
|
||||||
|
if (!tblUsersBody) return;
|
||||||
|
tblUsersBody.innerHTML = '';
|
||||||
|
users.forEach(u => {
|
||||||
|
const tr = document.createElement('tr');
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>${u.username}</td>
|
||||||
|
<td>${u.role === 'admin' ? 'Administrador' : 'Usuario'}</td>
|
||||||
|
<td>
|
||||||
|
${u.id !== currentUser.id ? `<button class="action-btn" data-id="${u.id}" data-action="delete-user">Eliminar</button>` : ''}
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
tblUsersBody.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function updateClientDatalist() {
|
function updateClientDatalist() {
|
||||||
if (!clientDatalist) return;
|
if (!clientDatalist) return;
|
||||||
clientDatalist.innerHTML = '';
|
clientDatalist.innerHTML = '';
|
||||||
@@ -283,6 +304,52 @@ async function handleSaveCredentials(e) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleAddUser(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const username = document.getElementById('u-username').value;
|
||||||
|
const password = document.getElementById('u-password').value;
|
||||||
|
const role = document.getElementById('u-role').value;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/users', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username, password, role })
|
||||||
|
});
|
||||||
|
|
||||||
|
const newUser = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Usuario creado exitosamente.');
|
||||||
|
users.push(newUser);
|
||||||
|
renderUsersTable();
|
||||||
|
formAddUser.reset();
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${newUser.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error de conexión al crear usuario.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUser(id) {
|
||||||
|
if (confirm('¿Estás seguro de que quieres eliminar este usuario?')) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/${id}`, { method: 'DELETE' });
|
||||||
|
if (response.ok) {
|
||||||
|
users = users.filter(u => u.id !== id);
|
||||||
|
renderUsersTable();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert(`Error: ${error.error}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error de conexión al eliminar usuario.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function handleNewMovement(e) {
|
async function handleNewMovement(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const form = e.target;
|
const form = e.target;
|
||||||
@@ -358,6 +425,8 @@ function handleTableClick(e) {
|
|||||||
deleteClient(id);
|
deleteClient(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (action === 'delete-user') {
|
||||||
|
deleteUser(parseInt(id, 10));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,24 +436,38 @@ async function handleClientForm(e) {
|
|||||||
await saveClient();
|
await saveClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTabChange(e) {
|
function activateTab(tabId) {
|
||||||
const tabButton = e.target.closest('.tab-link');
|
if (!tabId) return;
|
||||||
if (!tabButton) return;
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
|
// Desactivar todas las pestañas y contenidos
|
||||||
document.querySelectorAll('.tab-link').forEach(tab => tab.classList.remove('active'));
|
document.querySelectorAll('.tab-link').forEach(tab => tab.classList.remove('active'));
|
||||||
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
const tabId = tabButton.dataset.tab;
|
// Activar la pestaña y el contenido correctos
|
||||||
tabButton.classList.add('active');
|
const tabButton = document.querySelector(`[data-tab="${tabId}"]`);
|
||||||
document.getElementById(tabId)?.classList.add('active');
|
const tabContent = document.getElementById(tabId);
|
||||||
|
|
||||||
if (tabId === 'tab-dashboard') {
|
if (tabButton) {
|
||||||
|
tabButton.classList.add('active');
|
||||||
|
}
|
||||||
|
if (tabContent) {
|
||||||
|
tabContent.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cargar datos dinámicos si es la pestaña del dashboard
|
||||||
|
if (tabId === 'tab-dashboard' && currentUser.role === 'admin') {
|
||||||
loadDashboardData();
|
loadDashboardData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTabChange(e) {
|
||||||
|
const tabButton = e.target.closest('.tab-link');
|
||||||
|
if (!tabButton) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const tabId = tabButton.dataset.tab;
|
||||||
|
activateTab(tabId);
|
||||||
|
}
|
||||||
|
|
||||||
function handleTestTicket() {
|
function handleTestTicket() {
|
||||||
const demoMovement = {
|
const demoMovement = {
|
||||||
id: 'demo',
|
id: 'demo',
|
||||||
@@ -401,23 +484,63 @@ function handleTestTicket() {
|
|||||||
renderTicketAndPrint(demoMovement, settings);
|
renderTicketAndPrint(demoMovement, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupUIForRole(role) {
|
||||||
|
const dashboardTab = document.querySelector('[data-tab="tab-dashboard"]');
|
||||||
|
const settingsTab = document.querySelector('[data-tab="tab-settings"]');
|
||||||
|
const userManagementSection = document.getElementById('user-management-section');
|
||||||
|
|
||||||
|
if (role === 'admin') {
|
||||||
|
// El admin puede ver todo
|
||||||
|
dashboardTab.style.display = 'block';
|
||||||
|
settingsTab.style.display = 'block';
|
||||||
|
userManagementSection.style.display = 'block';
|
||||||
|
|
||||||
|
// Cargar la lista de usuarios para el admin
|
||||||
|
fetch('/api/users').then(res => res.json()).then(data => {
|
||||||
|
users = data;
|
||||||
|
renderUsersTable();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// El usuario normal tiene vistas ocultas
|
||||||
|
dashboardTab.style.display = 'none';
|
||||||
|
settingsTab.style.display = 'none';
|
||||||
|
userManagementSection.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// --- INICIALIZACIÓN ---
|
// --- INICIALIZACIÓN ---
|
||||||
|
|
||||||
async function initializeApp() {
|
async function initializeApp() {
|
||||||
// Primero, verificar la autenticación
|
// 1. Verificar autenticación y obtener datos del usuario.
|
||||||
|
let userResponse;
|
||||||
try {
|
try {
|
||||||
const authResponse = await fetch('/api/check-auth');
|
userResponse = await fetch('/api/user');
|
||||||
const authData = await authResponse.json();
|
if (!userResponse.ok) {
|
||||||
if (!authData.isAuthenticated) {
|
// Si la respuesta no es 2xx, el usuario no está autenticado o hay un error.
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
return; // Detener la inicialización si no está autenticado
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Verificar que la respuesta sea JSON antes de procesarla.
|
||||||
|
const contentType = userResponse.headers.get('content-type');
|
||||||
|
if (!contentType || !contentType.includes('application/json')) {
|
||||||
|
console.error('La respuesta del servidor no es JSON. Redirigiendo al login.');
|
||||||
|
window.location.href = '/login.html';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Procesar datos del usuario.
|
||||||
|
currentUser = await userResponse.json();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Authentication check failed', error);
|
// Si hay un error de red, es probable que el servidor no esté corriendo.
|
||||||
|
console.error('Error de conexión al verificar la autenticación. Redirigiendo al login.', error);
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Añadir manejadores de eventos.
|
||||||
const tabs = document.querySelector('.tabs');
|
const tabs = document.querySelector('.tabs');
|
||||||
const btnLogout = document.getElementById('btnLogout');
|
const btnLogout = document.getElementById('btnLogout');
|
||||||
|
|
||||||
@@ -431,6 +554,11 @@ async function initializeApp() {
|
|||||||
formClient?.addEventListener('submit', handleClientForm);
|
formClient?.addEventListener('submit', handleClientForm);
|
||||||
tabs?.addEventListener('click', handleTabChange);
|
tabs?.addEventListener('click', handleTabChange);
|
||||||
|
|
||||||
|
if (currentUser.role === 'admin') {
|
||||||
|
formAddUser?.addEventListener('submit', handleAddUser);
|
||||||
|
tblUsersBody?.addEventListener('click', handleTableClick);
|
||||||
|
}
|
||||||
|
|
||||||
btnLogout?.addEventListener('click', async () => {
|
btnLogout?.addEventListener('click', async () => {
|
||||||
await fetch('/api/logout', { method: 'POST' });
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
window.location.href = '/login.html';
|
window.location.href = '/login.html';
|
||||||
@@ -441,25 +569,35 @@ async function initializeApp() {
|
|||||||
document.getElementById('c-id').value = '';
|
document.getElementById('c-id').value = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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/user').then(res => res.json())
|
|
||||||
]).then(values => {
|
]).then(values => {
|
||||||
[settings, movements, clients, user] = values;
|
[settings, movements, clients] = values;
|
||||||
|
|
||||||
renderSettings();
|
renderSettings();
|
||||||
renderTable();
|
renderTable();
|
||||||
renderClientsTable();
|
renderClientsTable();
|
||||||
updateClientDatalist();
|
updateClientDatalist();
|
||||||
if (user) {
|
|
||||||
document.getElementById('s-username').value = user.username;
|
if (currentUser) {
|
||||||
|
document.getElementById('s-username').value = currentUser.username;
|
||||||
}
|
}
|
||||||
// Cargar datos del dashboard al inicio
|
|
||||||
loadDashboardData();
|
// 5. Configurar la UI y activar la pestaña inicial correcta.
|
||||||
|
setupUIForRole(currentUser.role);
|
||||||
|
|
||||||
|
if (currentUser.role === 'admin') {
|
||||||
|
activateTab('tab-dashboard');
|
||||||
|
} else {
|
||||||
|
activateTab('tab-movements');
|
||||||
|
}
|
||||||
|
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
console.error('CRITICAL: Failed to load initial data. The app may not function correctly.', error);
|
console.error('CRITICAL: Failed to load initial data.', error);
|
||||||
alert('Error Crítico: No se pudieron cargar los datos del servidor. Asegúrate de que el servidor (npm start) esté corriendo y que no haya errores en la terminal del servidor.');
|
alert('Error Crítico: No se pudieron cargar los datos del servidor.');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>AP-POS — v0.2.1</title>
|
<title>Ale Ponce | AlMa</title>
|
||||||
|
<link rel="icon" href="data:,">
|
||||||
<link rel="stylesheet" href="styles.css?v=1.1" />
|
<link rel="stylesheet" href="styles.css?v=1.1" />
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
</head>
|
</head>
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
<!-- Logo del negocio en lugar de texto -->
|
<!-- Logo del negocio en lugar de texto -->
|
||||||
<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 active" data-tab="tab-dashboard">Dashboard</button>
|
<button type="button" class="tab-link" data-tab="tab-dashboard">Dashboard</button>
|
||||||
<button type="button" class="tab-link" data-tab="tab-movements">Recibos</button>
|
<button type="button" class="tab-link" data-tab="tab-movements">Recibos</button>
|
||||||
<button type="button" class="tab-link" data-tab="tab-clients">Clientes</button>
|
<button type="button" class="tab-link" data-tab="tab-clients">Clientes</button>
|
||||||
<button type="button" class="tab-link" data-tab="tab-settings">Configuración</button>
|
<button type="button" class="tab-link" data-tab="tab-settings">Configuración</button>
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Pestaña de Dashboard -->
|
<!-- Pestaña de Dashboard -->
|
||||||
<div id="tab-dashboard" class="tab-content active">
|
<div id="tab-dashboard" class="tab-content">
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Dashboard</h2>
|
<h2>Dashboard</h2>
|
||||||
<div class="dashboard-stats">
|
<div class="dashboard-stats">
|
||||||
@@ -178,11 +179,11 @@
|
|||||||
Toda la información de tu negocio (clientes, recibos y configuración) se guarda de forma segura en el archivo <strong>ap-pos.db</strong>, ubicado en la misma carpeta que la aplicación. Para hacer un respaldo, simplemente copia este archivo.
|
Toda la información de tu negocio (clientes, recibos y configuración) se guarda de forma segura en el archivo <strong>ap-pos.db</strong>, ubicado en la misma carpeta que la aplicación. Para hacer un respaldo, simplemente copia este archivo.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section" id="credentials-section">
|
||||||
<h2>Credenciales de Acceso</h2>
|
<h2>Mis Credenciales</h2>
|
||||||
<form id="formCredentials">
|
<form id="formCredentials">
|
||||||
<div class="form-grid">
|
<div class="form-grid">
|
||||||
<label>Usuario:</label>
|
<label>Mi Usuario:</label>
|
||||||
<input type="text" id="s-username" required />
|
<input type="text" id="s-username" required />
|
||||||
<label>Nueva Contraseña:</label>
|
<label>Nueva Contraseña:</label>
|
||||||
<input type="password" id="s-password" placeholder="Dejar en blanco para no cambiar" />
|
<input type="password" id="s-password" placeholder="Dejar en blanco para no cambiar" />
|
||||||
@@ -192,9 +193,54 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Sección de Gestión de Usuarios (Solo para Admins) -->
|
||||||
|
<div class="section" id="user-management-section" style="display: none;">
|
||||||
|
<h2>Gestión de Usuarios</h2>
|
||||||
|
<div class="sub-section">
|
||||||
|
<h3>Añadir Nuevo Usuario</h3>
|
||||||
|
<form id="formAddUser">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>Nuevo Usuario:</label>
|
||||||
|
<input type="text" id="u-username" required />
|
||||||
|
<label>Contraseña:</label>
|
||||||
|
<input type="password" id="u-password" required />
|
||||||
|
<label>Rol:</label>
|
||||||
|
<select id="u-role">
|
||||||
|
<option value="user">Usuario (Ventas)</option>
|
||||||
|
<option value="admin">Administrador</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit">Crear Usuario</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="sub-section">
|
||||||
|
<h3>Usuarios Existentes</h3>
|
||||||
|
<div class="table-wrapper">
|
||||||
|
<table id="tblUsers">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Usuario</th>
|
||||||
|
<th>Rol</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<!-- Los usuarios se insertarán aquí -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<footer class="main-footer-credits">
|
||||||
|
<p>v0.2.1</p>
|
||||||
|
<p>Autores: Gemini + Marco G.</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<!-- Área de impresión oculta -->
|
<!-- Área de impresión oculta -->
|
||||||
<div id="printArea" class="no-print"></div>
|
<div id="printArea" class="no-print"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
// Guardar el rol del usuario para usarlo en la app principal
|
||||||
|
sessionStorage.setItem('userRole', data.role);
|
||||||
window.location.href = '/'; // Redirigir a la página principal
|
window.location.href = '/'; // Redirigir a la página principal
|
||||||
} else {
|
} else {
|
||||||
const errorData = await response.json();
|
const errorData = await response.json();
|
||||||
|
|||||||
120
ap-pos/server.js
120
ap-pos/server.js
@@ -35,10 +35,18 @@ const SALT_ROUNDS = 10;
|
|||||||
|
|
||||||
// Crear tabla de usuarios y usuario admin por defecto si no existen
|
// Crear tabla de usuarios y usuario admin por defecto si no existen
|
||||||
db.serialize(() => {
|
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(`CREATE TABLE IF NOT EXISTS users (
|
db.run(`CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
username TEXT UNIQUE,
|
username TEXT UNIQUE,
|
||||||
password TEXT
|
password TEXT,
|
||||||
|
role TEXT DEFAULT 'user'
|
||||||
)`, (err) => {
|
)`, (err) => {
|
||||||
if (err) return;
|
if (err) return;
|
||||||
// Solo intentar insertar si la tabla fue creada o ya existía
|
// Solo intentar insertar si la tabla fue creada o ya existía
|
||||||
@@ -50,12 +58,16 @@ db.serialize(() => {
|
|||||||
if (!row) {
|
if (!row) {
|
||||||
bcrypt.hash(defaultPassword, SALT_ROUNDS, (err, hash) => {
|
bcrypt.hash(defaultPassword, SALT_ROUNDS, (err, hash) => {
|
||||||
if (err) return;
|
if (err) return;
|
||||||
db.run('INSERT INTO users (username, password) VALUES (?, ?)', [adminUsername, hash], (err) => {
|
// Insertar admin con su rol
|
||||||
|
db.run('INSERT INTO users (username, password, role) VALUES (?, ?, ?)', [adminUsername, hash, 'admin'], (err) => {
|
||||||
if (!err) {
|
if (!err) {
|
||||||
console.log(`Default user '${adminUsername}' created with password '${defaultPassword}'. Please change it.`);
|
console.log(`Default user '${adminUsername}' created with 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]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -80,6 +92,24 @@ const isAuthenticated = (req, res, 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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- AUTH API ROUTES ---
|
// --- AUTH API ROUTES ---
|
||||||
|
|
||||||
app.post('/api/login', (req, res) => {
|
app.post('/api/login', (req, res) => {
|
||||||
@@ -98,7 +128,8 @@ app.post('/api/login', (req, res) => {
|
|||||||
return res.status(401).json({ error: 'Invalid credentials' });
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
}
|
}
|
||||||
req.session.userId = user.id;
|
req.session.userId = user.id;
|
||||||
res.json({ message: 'Login successful' });
|
req.session.role = user.role; // Guardar rol en la sesión
|
||||||
|
res.json({ message: 'Login successful', role: user.role });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -116,7 +147,7 @@ app.post('/api/logout', (req, res) => {
|
|||||||
// Endpoint para verificar el estado de la autenticación en el frontend
|
// Endpoint para verificar el estado de la autenticación en el frontend
|
||||||
app.get('/api/check-auth', (req, res) => {
|
app.get('/api/check-auth', (req, res) => {
|
||||||
if (req.session.userId) {
|
if (req.session.userId) {
|
||||||
res.json({ isAuthenticated: true });
|
res.json({ isAuthenticated: true, role: req.session.role });
|
||||||
} else {
|
} else {
|
||||||
res.json({ isAuthenticated: false });
|
res.json({ isAuthenticated: false });
|
||||||
}
|
}
|
||||||
@@ -233,9 +264,64 @@ apiRouter.delete('/movements/:id', (req, res) => {
|
|||||||
// Registrar el router de la API protegida
|
// Registrar el router de la API protegida
|
||||||
app.use('/api', apiRouter);
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
// --- User Management ---
|
// --- 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 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 } = req.body;
|
||||||
|
if (!username || !password || !role) {
|
||||||
|
return res.status(400).json({ error: 'Username, password, 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) VALUES (?, ?, ?)', [username, hash, role], 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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) => {
|
apiRouter.get('/user', (req, res) => {
|
||||||
db.get("SELECT id, username FROM users WHERE id = ?", [req.session.userId], (err, row) => {
|
db.get("SELECT id, username, role FROM users WHERE id = ?", [req.session.userId], (err, row) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
return;
|
return;
|
||||||
@@ -274,25 +360,29 @@ apiRouter.post('/user', (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Dashboard Route ---
|
// --- Dashboard Route (Admin Only) ---
|
||||||
apiRouter.get('/dashboard', (req, res) => {
|
apiRouter.get('/dashboard', isAdmin, (req, res) => {
|
||||||
const queries = {
|
const queries = {
|
||||||
totalIncome: "SELECT SUM(monto) as total FROM movements WHERE tipo = 'Pago'",
|
totalIncome: "SELECT SUM(monto) as total FROM movements",
|
||||||
totalMovements: "SELECT COUNT(*) as total FROM movements",
|
totalMovements: "SELECT COUNT(*) as total FROM movements",
|
||||||
incomeByService: "SELECT tipo, SUM(monto) as total FROM movements WHERE tipo = 'Pago' GROUP BY tipo"
|
incomeByService: "SELECT tipo, SUM(monto) as total FROM movements GROUP BY tipo"
|
||||||
};
|
};
|
||||||
|
|
||||||
const results = {};
|
const results = {};
|
||||||
const promises = Object.keys(queries).map(key => {
|
const promises = Object.keys(queries).map(key => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
db.all(queries[key], [], (err, rows) => {
|
const query = queries[key];
|
||||||
|
// Usar db.all para incomeByService y db.get para los demás para simplificar
|
||||||
|
const method = query.includes('GROUP BY') ? 'all' : 'get';
|
||||||
|
|
||||||
|
db[method](query, [], (err, result) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
if (key === 'totalIncome' || key === 'totalMovements') {
|
if (method === 'get') {
|
||||||
resolve({ key, value: rows[0] ? rows[0].total : 0 });
|
resolve({ key, value: result ? result.total : 0 });
|
||||||
} else {
|
} else {
|
||||||
resolve({ key, value: rows });
|
resolve({ key, value: result });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -357,3 +357,17 @@ button.action-btn {
|
|||||||
.data-location-info strong {
|
.data-location-info strong {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Estilos del Pie de Página --- */
|
||||||
|
.main-footer-credits {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 40px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-footer-credits p {
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user