mirror of
https://github.com/marcogll/ap_pos.git
synced 2026-01-13 13:15:16 +00:00
feat: add client history and subtypes
- Added subtypes for services (Service/Retouch). - Implemented expandable client rows to show service history. - Added a search bar to filter clients by name. - Added 'Oncological' status column to the client list. - Created a new API endpoint for client history. fix(db): ensure database persistence in Docker - The database path is now configurable via the DB_PATH environment variable. - The Dockerfile has been updated to create a persistent volume for data. - The README now contains the correct 'docker run' command for data persistence.
This commit is contained in:
@@ -1,20 +1,27 @@
|
|||||||
# Usar una imagen base de Node.js ligera
|
# Usar una imagen base de Node.js
|
||||||
FROM node:18-alpine
|
FROM node:18-alpine
|
||||||
|
|
||||||
# Establecer el directorio de trabajo dentro del contenedor
|
# Establecer el directorio de trabajo en el contenedor
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# Copiar los archivos de definición de paquetes y dependencias
|
# Copiar package.json y package-lock.json
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
# Instalar las dependencias de producción
|
# Instalar las dependencias de la aplicación
|
||||||
RUN npm install --production
|
RUN npm install
|
||||||
|
|
||||||
# Copiar el resto de los archivos de la aplicación
|
# Copiar el resto de los archivos de la aplicación
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Crear un directorio para la base de datos persistente y definirlo como volumen
|
||||||
|
RUN mkdir -p /usr/src/app/data
|
||||||
|
VOLUME /usr/src/app/data
|
||||||
|
|
||||||
|
# Establecer la ruta de la base de datos a través de una variable de entorno
|
||||||
|
ENV DB_PATH /usr/src/app/data/ap-pos.db
|
||||||
|
|
||||||
# Exponer el puerto en el que corre la aplicación
|
# Exponer el puerto en el que corre la aplicación
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
# Definir el comando para iniciar la aplicación
|
# Comando para iniciar la aplicación
|
||||||
CMD [ "node", "server.js" ]
|
CMD [ "node", "server.js" ]
|
||||||
|
|||||||
@@ -60,11 +60,11 @@ Este es un sistema de punto de venta (POS) simple y moderno basado en la web, di
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Ejecutar el Contenedor:**
|
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.
|
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
|
```bash
|
||||||
docker run -p 3000:3000 -v $(pwd)/data:/usr/src/app ap-pos-app
|
docker run -p 3000:3000 -v $(pwd)/data:/usr/src/app/data ap-pos-app
|
||||||
```
|
```
|
||||||
*Nota: El comando anterior crea un directorio `data` en tu carpeta actual para almacenar `ap-pos.db`.*
|
*Nota: La primera vez que ejecutes esto, se creará un directorio `data` en tu carpeta actual para almacenar `ap-pos.db`.*
|
||||||
|
|
||||||
## Autores
|
## Autores
|
||||||
- **Gemini**
|
- **Gemini**
|
||||||
|
|||||||
178
ap-pos/app.js
178
ap-pos/app.js
@@ -20,6 +20,7 @@ let movements = [];
|
|||||||
let clients = [];
|
let clients = [];
|
||||||
let users = [];
|
let users = [];
|
||||||
let incomeChart = null;
|
let incomeChart = null;
|
||||||
|
let paymentMethodChart = null;
|
||||||
let currentUser = {};
|
let currentUser = {};
|
||||||
|
|
||||||
// --- DOM ELEMENTS ---
|
// --- DOM ELEMENTS ---
|
||||||
@@ -34,6 +35,7 @@ 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 appointmentsList = document.getElementById('upcoming-appointments-list');
|
||||||
|
|
||||||
let isDashboardLoading = false;
|
let isDashboardLoading = false;
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ let isDashboardLoading = false;
|
|||||||
|
|
||||||
async function loadDashboardData() {
|
async function loadDashboardData() {
|
||||||
// Guardia para prevenir ejecuciones múltiples y re-entradas.
|
// Guardia para prevenir ejecuciones múltiples y re-entradas.
|
||||||
if (currentUser.role !== 'admin' || !incomeChart || isDashboardLoading) {
|
if (currentUser.role !== 'admin' || isDashboardLoading) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isDashboardLoading = true;
|
isDashboardLoading = true;
|
||||||
@@ -68,12 +70,38 @@ async function loadDashboardData() {
|
|||||||
document.getElementById('stat-total-income').textContent = `${Number(data.totalIncome || 0).toFixed(2)}`;
|
document.getElementById('stat-total-income').textContent = `${Number(data.totalIncome || 0).toFixed(2)}`;
|
||||||
document.getElementById('stat-total-movements').textContent = data.totalMovements || 0;
|
document.getElementById('stat-total-movements').textContent = data.totalMovements || 0;
|
||||||
|
|
||||||
// Actualizar datos del gráfico
|
// Actualizar datos del gráfico de ingresos
|
||||||
|
if (incomeChart) {
|
||||||
incomeChart.data.labels = data.incomeByService.map(item => item.tipo);
|
incomeChart.data.labels = data.incomeByService.map(item => item.tipo);
|
||||||
incomeChart.data.datasets[0].data = data.incomeByService.map(item => item.total);
|
incomeChart.data.datasets[0].data = data.incomeByService.map(item => item.total);
|
||||||
|
|
||||||
// Usar 'none' para el modo de actualización previene bucles de renderizado por animación/responsividad.
|
|
||||||
incomeChart.update('none');
|
incomeChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actualizar datos del gráfico de método de pago
|
||||||
|
if (paymentMethodChart) {
|
||||||
|
paymentMethodChart.data.labels = data.incomeByPaymentMethod.map(item => item.metodo);
|
||||||
|
paymentMethodChart.data.datasets[0].data = data.incomeByPaymentMethod.map(item => item.total);
|
||||||
|
paymentMethodChart.update('none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderizar próximas citas
|
||||||
|
if (appointmentsList) {
|
||||||
|
appointmentsList.innerHTML = '';
|
||||||
|
if (data.upcomingAppointments.length > 0) {
|
||||||
|
data.upcomingAppointments.forEach(appt => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'appointment-item';
|
||||||
|
const fechaCita = new Date(appt.fechaCita + 'T00:00:00').toLocaleDateString('es-MX', { day: '2-digit', month: 'long' });
|
||||||
|
item.innerHTML = `
|
||||||
|
<a href="#" data-id="${appt.id}" data-action="reprint">${appt.clienteNombre}</a>
|
||||||
|
<span class="date">${fechaCita} - ${appt.horaCita}</span>
|
||||||
|
`;
|
||||||
|
appointmentsList.appendChild(item);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
appointmentsList.innerHTML = '<p>No hay citas próximas.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error al cargar el dashboard:', error);
|
console.error('Error al cargar el dashboard:', error);
|
||||||
@@ -218,16 +246,17 @@ function renderTable() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderClientsTable() {
|
function renderClientsTable(clientList = clients) {
|
||||||
if (!tblClientsBody) return;
|
if (!tblClientsBody) return;
|
||||||
tblClientsBody.innerHTML = '';
|
tblClientsBody.innerHTML = '';
|
||||||
clients.forEach(c => {
|
clientList.forEach(c => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
|
tr.dataset.id = c.id; // Importante para la función de expandir
|
||||||
|
tr.style.cursor = 'pointer'; // Indicar que la fila es clickeable
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${c.nombre}</td>
|
<td>${c.nombre}</td>
|
||||||
<td>${c.telefono || ''}</td>
|
<td>${c.telefono || ''}</td>
|
||||||
<td>${c.cumpleaños ? new Date(c.cumpleaños).toLocaleDateString('es-MX') : ''}</td>
|
<td>${c.esOncologico ? 'Sí' : 'No'}</td>
|
||||||
<td>${c.consentimiento ? 'Sí' : 'No'}</td>
|
|
||||||
<td>
|
<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="edit-client">Editar</button>
|
||||||
<button class="action-btn" data-id="${c.id}" data-action="delete-client">Eliminar</button>
|
<button class="action-btn" data-id="${c.id}" data-action="delete-client">Eliminar</button>
|
||||||
@@ -409,12 +438,20 @@ 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: document.getElementById('m-tipo').value,
|
tipo: tipoServicio,
|
||||||
|
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-concepto').value,
|
||||||
@@ -425,25 +462,68 @@ async function handleNewMovement(e) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await addMovement(newMovement);
|
await addMovement(newMovement);
|
||||||
const movementForTicket = { ...newMovement, cliente: client.nombre };
|
renderTicketAndPrint({ ...newMovement, client }, settings);
|
||||||
renderTicketAndPrint(movementForTicket, settings);
|
|
||||||
form.reset();
|
form.reset();
|
||||||
document.getElementById('m-cliente').focus();
|
document.getElementById('m-cliente').focus();
|
||||||
|
subtipoContainer.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<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>`;
|
||||||
|
row.after(historyRow);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error al cargar el historial del cliente:', error);
|
||||||
|
alert('No se pudo cargar el historial.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleTableClick(e) {
|
function handleTableClick(e) {
|
||||||
if (e.target.classList.contains('action-btn')) {
|
const target = e.target;
|
||||||
|
const row = target.closest('tr');
|
||||||
|
if (!row) return;
|
||||||
|
|
||||||
|
const actionBtn = target.closest('.action-btn');
|
||||||
|
if (actionBtn) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const id = e.target.dataset.id;
|
const id = actionBtn.dataset.id;
|
||||||
const action = e.target.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) {
|
||||||
if (action === 'reprint') {
|
if (action === 'reprint') {
|
||||||
const client = clients.find(c => c.id === movement.clienteId);
|
const client = clients.find(c => c.id === movement.clienteId);
|
||||||
const movementForTicket = { ...movement, cliente: client ? client.nombre : 'N/A' };
|
renderTicketAndPrint({ ...movement, client }, settings);
|
||||||
renderTicketAndPrint(movementForTicket, settings);
|
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
deleteMovement(id);
|
deleteMovement(id);
|
||||||
}
|
}
|
||||||
@@ -459,15 +539,11 @@ function handleTableClick(e) {
|
|||||||
document.getElementById('c-cumple').value = client.cumpleaños;
|
document.getElementById('c-cumple').value = client.cumpleaños;
|
||||||
document.getElementById('c-consent').checked = client.consentimiento;
|
document.getElementById('c-consent').checked = client.consentimiento;
|
||||||
|
|
||||||
// Campos oncológicos
|
|
||||||
const esOncologicoCheckbox = document.getElementById('c-esOncologico');
|
const esOncologicoCheckbox = document.getElementById('c-esOncologico');
|
||||||
const oncologicoFields = document.getElementById('oncologico-fields');
|
const oncologicoFields = document.getElementById('oncologico-fields');
|
||||||
esOncologicoCheckbox.checked = client.esOncologico;
|
esOncologicoCheckbox.checked = client.esOncologico;
|
||||||
if (client.esOncologico) {
|
oncologicoFields.classList.toggle('hidden', !client.esOncologico);
|
||||||
oncologicoFields.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
oncologicoFields.classList.add('hidden');
|
|
||||||
}
|
|
||||||
document.getElementById('c-oncologoAprueba').checked = client.oncologoAprueba;
|
document.getElementById('c-oncologoAprueba').checked = client.oncologoAprueba;
|
||||||
document.getElementById('c-nombreMedico').value = client.nombreMedico || '';
|
document.getElementById('c-nombreMedico').value = client.nombreMedico || '';
|
||||||
document.getElementById('c-telefonoMedico').value = client.telefonoMedico || '';
|
document.getElementById('c-telefonoMedico').value = client.telefonoMedico || '';
|
||||||
@@ -485,12 +561,19 @@ function handleTableClick(e) {
|
|||||||
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 = ''; // Limpiar campo de contraseña
|
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 (row.parentElement.id === 'tblClientsBody') {
|
||||||
|
// Si se hace clic en cualquier parte de la fila del cliente (que no sea un botón)
|
||||||
|
const clientId = row.dataset.id;
|
||||||
|
const client = clients.find(c => c.id === clientId);
|
||||||
|
if (client) {
|
||||||
|
toggleClientHistory(row, client);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +622,25 @@ function activateTab(tabId) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!paymentMethodChart) {
|
||||||
|
const ctx = document.getElementById('paymentMethodChart').getContext('2d');
|
||||||
|
paymentMethodChart = new Chart(ctx, {
|
||||||
|
type: 'doughnut',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [{
|
||||||
|
label: 'Ingresos por Método de Pago',
|
||||||
|
data: [],
|
||||||
|
backgroundColor: ['#4BC0C0', '#9966FF', '#FF9F40', '#FF6384', '#36A2EB', '#FFCE56'],
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
// Cargar (o recargar) los datos del dashboard
|
// Cargar (o recargar) los datos del dashboard
|
||||||
loadDashboardData();
|
loadDashboardData();
|
||||||
}
|
}
|
||||||
@@ -557,7 +659,13 @@ function handleTestTicket() {
|
|||||||
id: 'demo',
|
id: 'demo',
|
||||||
folio: 'DEMO-000001',
|
folio: 'DEMO-000001',
|
||||||
fechaISO: new Date().toISOString(),
|
fechaISO: new Date().toISOString(),
|
||||||
cliente: 'Cliente de Prueba',
|
client: {
|
||||||
|
nombre: 'Cliente de Prueba',
|
||||||
|
esOncologico: true,
|
||||||
|
nombreMedico: 'Dr. Juan Pérez',
|
||||||
|
telefonoMedico: '5512345678',
|
||||||
|
cedulaMedico: '1234567'
|
||||||
|
},
|
||||||
tipo: 'Pago',
|
tipo: 'Pago',
|
||||||
monto: 123.45,
|
monto: 123.45,
|
||||||
metodo: 'Efectivo',
|
metodo: 'Efectivo',
|
||||||
@@ -634,12 +742,15 @@ 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');
|
||||||
|
|
||||||
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);
|
||||||
|
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);
|
||||||
@@ -663,11 +774,7 @@ async function initializeApp() {
|
|||||||
|
|
||||||
document.getElementById('c-esOncologico')?.addEventListener('change', (e) => {
|
document.getElementById('c-esOncologico')?.addEventListener('change', (e) => {
|
||||||
const oncologicoFields = document.getElementById('oncologico-fields');
|
const oncologicoFields = document.getElementById('oncologico-fields');
|
||||||
if (e.target.checked) {
|
oncologicoFields.classList.toggle('hidden', !e.target.checked);
|
||||||
oncologicoFields.classList.remove('hidden');
|
|
||||||
} else {
|
|
||||||
oncologicoFields.classList.add('hidden');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
btnCancelEditUser?.addEventListener('click', (e) => {
|
btnCancelEditUser?.addEventListener('click', (e) => {
|
||||||
@@ -677,6 +784,18 @@ async function initializeApp() {
|
|||||||
document.getElementById('u-password').placeholder = 'Contraseña';
|
document.getElementById('u-password').placeholder = 'Contraseña';
|
||||||
});
|
});
|
||||||
|
|
||||||
|
searchClientInput?.addEventListener('input', (e) => {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
const filteredClients = clients.filter(c => c.nombre.toLowerCase().includes(searchTerm));
|
||||||
|
renderClientsTable(filteredClients);
|
||||||
|
});
|
||||||
|
|
||||||
|
tipoServicioSelect?.addEventListener('change', (e) => {
|
||||||
|
const subtipoContainer = document.getElementById('m-subtipo-container');
|
||||||
|
const servicesWithSubtype = ['Microblading', 'Lashes', 'Nail Art'];
|
||||||
|
subtipoContainer.classList.toggle('hidden', !servicesWithSubtype.includes(e.target.value));
|
||||||
|
});
|
||||||
|
|
||||||
// 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),
|
||||||
@@ -713,3 +832,4 @@ async function initializeApp() {
|
|||||||
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initializeApp);
|
document.addEventListener('DOMContentLoaded', initializeApp);
|
||||||
|
|
||||||
|
|||||||
@@ -53,10 +53,22 @@
|
|||||||
<p id="stat-total-movements">0</p>
|
<p id="stat-total-movements">0</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dashboard-grid">
|
||||||
<div class="dashboard-chart">
|
<div class="dashboard-chart">
|
||||||
<h3>Ingresos por Servicio</h3>
|
<h3>Ingresos por Servicio</h3>
|
||||||
<canvas id="incomeChart"></canvas>
|
<canvas id="incomeChart"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dashboard-chart">
|
||||||
|
<h3>Ingresos por Método de Pago</h3>
|
||||||
|
<canvas id="paymentMethodChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="section">
|
||||||
|
<h3>Próximas Citas</h3>
|
||||||
|
<div id="upcoming-appointments-list" class="appointments-list">
|
||||||
|
<!-- Las citas se renderizarán aquí -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -78,6 +90,13 @@
|
|||||||
<option value="Nail Art">Nail Art</option>
|
<option value="Nail Art">Nail Art</option>
|
||||||
<option value="Pago">Pago</option>
|
<option value="Pago">Pago</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div id="m-subtipo-container" class="hidden">
|
||||||
|
<label>Subtipo:</label>
|
||||||
|
<select id="m-subtipo">
|
||||||
|
<option value="Servicio">Servicio</option>
|
||||||
|
<option value="Retoque">Retoque</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<label>Fecha de Cita:</label>
|
<label>Fecha de Cita:</label>
|
||||||
<input type="date" id="m-fecha-cita" />
|
<input type="date" id="m-fecha-cita" />
|
||||||
<label>Hora de Cita:</label>
|
<label>Hora de Cita:</label>
|
||||||
@@ -196,14 +215,16 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h2>Lista de Clientes</h2>
|
<h2>Lista de Clientes</h2>
|
||||||
|
<div class="form-grid-single">
|
||||||
|
<input type="text" id="search-client" placeholder="Buscar cliente por nombre..." />
|
||||||
|
</div>
|
||||||
<div class="table-wrapper">
|
<div class="table-wrapper">
|
||||||
<table id="tblClients">
|
<table id="tblClients">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Nombre</th>
|
<th>Nombre</th>
|
||||||
<th>Teléfono</th>
|
<th>Teléfono</th>
|
||||||
<th>Cumpleaños</th>
|
<th>Oncológico</th>
|
||||||
<th>Consentimiento</th>
|
|
||||||
<th>Acciones</th>
|
<th>Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -40,14 +40,28 @@ function templateTicket(mov, settings) {
|
|||||||
|
|
||||||
lines.push('<div class="t-divider"></div>');
|
lines.push('<div class="t-divider"></div>');
|
||||||
lines.push(`<div><span class="t-bold">${esc(mov.tipo)}</span></div>`);
|
lines.push(`<div><span class="t-bold">${esc(mov.tipo)}</span></div>`);
|
||||||
if (mov.cliente) lines.push(`<div class="t-small">Cliente: ${esc(mov.cliente)}</div>`);
|
if (mov.client) lines.push(`<div class="t-small">Cliente: ${esc(mov.client.nombre)}</div>`);
|
||||||
if (mov.concepto) lines.push(`<div class="t-small">Concepto: ${esc(mov.concepto)}</div>`);
|
if (mov.concepto) lines.push(`<div class="t-small">Concepto: ${esc(mov.concepto)}</div>`);
|
||||||
if (mov.staff) lines.push(`<div class="t-small"><b>Te atendió:</b> ${esc(mov.staff)}</div>`);
|
if (mov.staff) lines.push(`<div class="t-small"><b>Te atendió:</b> ${esc(mov.staff)}</div>`);
|
||||||
if (mov.metodo) lines.push(`<div class="t-small">Método: ${esc(mov.metodo)}</div>`);
|
if (mov.metodo) lines.push(`<div class="t-small">Método: ${esc(mov.metodo)}</div>`);
|
||||||
if (mov.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</div>`);
|
if (mov.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</div>`);
|
||||||
|
|
||||||
lines.push('<div class="t-divider"></div>');
|
lines.push('<div class="t-divider"></div>');
|
||||||
lines.push(`<div class="t-row t-bold"><span>Total</span><span>$${montoFormateado}</span></div>`);
|
lines.push(`<div class="t-row t-bold"><span>Total</span><span>${montoFormateado}</span></div>`);
|
||||||
|
|
||||||
|
// Sección de consentimientos
|
||||||
|
if (mov.client && (mov.client.esOncologico || mov.client.consentimiento)) {
|
||||||
|
lines.push('<div class="t-divider"></div>');
|
||||||
|
if (mov.client.esOncologico) {
|
||||||
|
lines.push('<div class="t-section t-bold t-center">Consentimiento Oncológico</div>');
|
||||||
|
lines.push(`<div class="t-small">El cliente declara ser paciente oncológico y que la información de su médico es veraz.</div>`);
|
||||||
|
if (mov.client.nombreMedico) lines.push(`<div class="t-small">Médico: ${esc(mov.client.nombreMedico)}</div>`);
|
||||||
|
if (mov.client.telefonoMedico) lines.push(`<div class="t-small">Tel. Médico: ${esc(mov.client.telefonoMedico)}</div>`);
|
||||||
|
if (mov.client.cedulaMedico) lines.push(`<div class="t-small">Cédula: ${esc(mov.client.cedulaMedico)}</div>`);
|
||||||
|
}
|
||||||
|
lines.push('<div class="t-divider"></div>');
|
||||||
|
lines.push(`<div class="t-small t-center">Al consentir el servicio, declara que la información médica proporcionada es veraz.</div>`);
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.leyenda) lines.push(`<div class="t-footer t-center t-small">${esc(settings.leyenda)}</div>`);
|
if (settings.leyenda) lines.push(`<div class="t-footer t-center t-small">${esc(settings.leyenda)}</div>`);
|
||||||
|
|
||||||
|
|||||||
@@ -23,11 +23,14 @@ app.use(session({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// --- DATABASE INITIALIZATION ---
|
// --- DATABASE INITIALIZATION ---
|
||||||
const db = new sqlite3.Database('./ap-pos.db', (err) => {
|
const dbPath = process.env.DB_PATH || './ap-pos.db';
|
||||||
|
console.log(`Connecting to database at: ${dbPath}`);
|
||||||
|
|
||||||
|
const db = new sqlite3.Database(dbPath, (err) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
console.error(err.message);
|
console.error(err.message);
|
||||||
}
|
}
|
||||||
console.log('Connected to the ap-pos.db database.');
|
console.log('Connected to the database.');
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- AUTHENTICATION LOGIC ---
|
// --- AUTHENTICATION LOGIC ---
|
||||||
@@ -96,7 +99,7 @@ db.serialize(() => {
|
|||||||
cedulaMedico TEXT,
|
cedulaMedico TEXT,
|
||||||
pruebaAprobacion INTEGER
|
pruebaAprobacion INTEGER
|
||||||
)`);
|
)`);
|
||||||
db.run(`CREATE TABLE IF NOT EXISTS movements (id TEXT PRIMARY KEY, folio TEXT, fechaISO TEXT, clienteId TEXT, tipo TEXT, monto REAL, metodo TEXT, concepto TEXT, staff TEXT, notas TEXT, fechaCita TEXT, horaCita TEXT, FOREIGN KEY (clienteId) REFERENCES clients (id))`);
|
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
|
// Middleware para verificar si el usuario está autenticado
|
||||||
@@ -269,10 +272,10 @@ apiRouter.get('/movements', (req, res) => {
|
|||||||
|
|
||||||
apiRouter.post('/movements', (req, res) => {
|
apiRouter.post('/movements', (req, res) => {
|
||||||
const { movement } = req.body;
|
const { movement } = req.body;
|
||||||
const { id, folio, fechaISO, clienteId, tipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita } = movement;
|
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, monto, metodo, concepto, staff, notas, fechaCita, horaCita)
|
db.run(`INSERT INTO movements (id, folio, fechaISO, clienteId, tipo, subtipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[id, folio, fechaISO, clienteId, tipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita], function(err) {
|
[id, folio, fechaISO, clienteId, tipo, subtipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita], function(err) {
|
||||||
if (err) {
|
if (err) {
|
||||||
res.status(500).json({ error: err.message });
|
res.status(500).json({ error: err.message });
|
||||||
return;
|
return;
|
||||||
@@ -292,6 +295,18 @@ apiRouter.delete('/movements/:id', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- 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
|
// Registrar el router de la API protegida
|
||||||
app.use('/api', apiRouter);
|
app.use('/api', apiRouter);
|
||||||
|
|
||||||
@@ -419,15 +434,23 @@ apiRouter.get('/dashboard', isAdmin, (req, res) => {
|
|||||||
const queries = {
|
const queries = {
|
||||||
totalIncome: "SELECT SUM(monto) as total FROM movements",
|
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 GROUP BY tipo"
|
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 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) => {
|
||||||
const query = queries[key];
|
const query = queries[key];
|
||||||
// Usar db.all para incomeByService y db.get para los demás para simplificar
|
// Usar db.all para consultas que devuelven múltiples filas
|
||||||
const method = query.includes('GROUP BY') ? 'all' : 'get';
|
const method = ['incomeByService', 'incomeByPaymentMethod', 'upcomingAppointments'].includes(key) ? 'all' : 'get';
|
||||||
|
|
||||||
db[method](query, [], (err, result) => {
|
db[method](query, [], (err, result) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
|
|||||||
@@ -348,6 +348,43 @@ button.action-btn {
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-item {
|
||||||
|
background-color: #fff;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-item a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: #343a40;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.appointment-item a:hover {
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appointment-item .date {
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Estilos específicos de Clientes --- */
|
/* --- Estilos específicos de Clientes --- */
|
||||||
.form-grid-single {
|
.form-grid-single {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -386,6 +423,40 @@ button.action-btn {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#m-subtipo-container {
|
||||||
|
/* Esto asegura que el contenedor del subtipo se alinee correctamente en el grid */
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 160px 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-history-row td {
|
||||||
|
padding: 0 !important;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.client-history-content {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
.client-history-content h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
}
|
||||||
|
.client-history-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.client-history-content th, .client-history-content td {
|
||||||
|
padding: 8px;
|
||||||
|
border-bottom: 1px solid #dee2e6;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.client-history-content th {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
/* --- Estilos de Configuración --- */
|
/* --- Estilos de Configuración --- */
|
||||||
.data-location-info {
|
.data-location-info {
|
||||||
background-color: #e9ecef;
|
background-color: #e9ecef;
|
||||||
|
|||||||
Reference in New Issue
Block a user