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
|
||||
|
||||
# Establecer el directorio de trabajo dentro del contenedor
|
||||
# Establecer el directorio de trabajo en el contenedor
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Copiar los archivos de definición de paquetes y dependencias
|
||||
# Copiar package.json y package-lock.json
|
||||
COPY package*.json ./
|
||||
|
||||
# Instalar las dependencias de producción
|
||||
RUN npm install --production
|
||||
# Instalar las dependencias de la aplicación
|
||||
RUN npm install
|
||||
|
||||
# Copiar el resto de los archivos de la aplicación
|
||||
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
|
||||
EXPOSE 3000
|
||||
|
||||
# Definir el comando para iniciar la aplicación
|
||||
# Comando para iniciar la aplicación
|
||||
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:**
|
||||
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
|
||||
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
|
||||
- **Gemini**
|
||||
|
||||
178
ap-pos/app.js
178
ap-pos/app.js
@@ -20,6 +20,7 @@ let movements = [];
|
||||
let clients = [];
|
||||
let users = [];
|
||||
let incomeChart = null;
|
||||
let paymentMethodChart = null;
|
||||
let currentUser = {};
|
||||
|
||||
// --- DOM ELEMENTS ---
|
||||
@@ -34,6 +35,7 @@ const clientDatalist = document.getElementById('client-list');
|
||||
const formCredentials = document.getElementById('formCredentials');
|
||||
const formAddUser = document.getElementById('formAddUser');
|
||||
const tblUsersBody = document.getElementById('tblUsers')?.querySelector('tbody');
|
||||
const appointmentsList = document.getElementById('upcoming-appointments-list');
|
||||
|
||||
let isDashboardLoading = false;
|
||||
|
||||
@@ -41,7 +43,7 @@ let isDashboardLoading = false;
|
||||
|
||||
async function loadDashboardData() {
|
||||
// Guardia para prevenir ejecuciones múltiples y re-entradas.
|
||||
if (currentUser.role !== 'admin' || !incomeChart || isDashboardLoading) {
|
||||
if (currentUser.role !== 'admin' || isDashboardLoading) {
|
||||
return;
|
||||
}
|
||||
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-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.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');
|
||||
}
|
||||
|
||||
// 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) {
|
||||
console.error('Error al cargar el dashboard:', error);
|
||||
@@ -218,16 +246,17 @@ function renderTable() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderClientsTable() {
|
||||
function renderClientsTable(clientList = clients) {
|
||||
if (!tblClientsBody) return;
|
||||
tblClientsBody.innerHTML = '';
|
||||
clients.forEach(c => {
|
||||
clientList.forEach(c => {
|
||||
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 = `
|
||||
<td>${c.nombre}</td>
|
||||
<td>${c.telefono || ''}</td>
|
||||
<td>${c.cumpleaños ? new Date(c.cumpleaños).toLocaleDateString('es-MX') : ''}</td>
|
||||
<td>${c.consentimiento ? 'Sí' : 'No'}</td>
|
||||
<td>${c.esOncologico ? 'Sí' : 'No'}</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="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 = {
|
||||
id: crypto.randomUUID(),
|
||||
folio: generateFolio(),
|
||||
fechaISO: new Date().toISOString(),
|
||||
clienteId: client.id,
|
||||
tipo: document.getElementById('m-tipo').value,
|
||||
tipo: tipoServicio,
|
||||
subtipo: subtipo,
|
||||
monto: Number(monto.toFixed(2)),
|
||||
metodo: document.getElementById('m-metodo').value,
|
||||
concepto: document.getElementById('m-concepto').value,
|
||||
@@ -425,25 +462,68 @@ async function handleNewMovement(e) {
|
||||
};
|
||||
|
||||
await addMovement(newMovement);
|
||||
const movementForTicket = { ...newMovement, cliente: client.nombre };
|
||||
renderTicketAndPrint(movementForTicket, settings);
|
||||
renderTicketAndPrint({ ...newMovement, client }, settings);
|
||||
form.reset();
|
||||
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) {
|
||||
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();
|
||||
const id = e.target.dataset.id;
|
||||
const action = e.target.dataset.action;
|
||||
const id = actionBtn.dataset.id;
|
||||
const action = actionBtn.dataset.action;
|
||||
|
||||
if (action === 'reprint' || action === 'delete') {
|
||||
const movement = movements.find(m => m.id === id);
|
||||
if (movement) {
|
||||
if (action === 'reprint') {
|
||||
const client = clients.find(c => c.id === movement.clienteId);
|
||||
const movementForTicket = { ...movement, cliente: client ? client.nombre : 'N/A' };
|
||||
renderTicketAndPrint(movementForTicket, settings);
|
||||
renderTicketAndPrint({ ...movement, client }, settings);
|
||||
} else if (action === 'delete') {
|
||||
deleteMovement(id);
|
||||
}
|
||||
@@ -459,15 +539,11 @@ function handleTableClick(e) {
|
||||
document.getElementById('c-cumple').value = client.cumpleaños;
|
||||
document.getElementById('c-consent').checked = client.consentimiento;
|
||||
|
||||
// Campos oncológicos
|
||||
const esOncologicoCheckbox = document.getElementById('c-esOncologico');
|
||||
const oncologicoFields = document.getElementById('oncologico-fields');
|
||||
esOncologicoCheckbox.checked = client.esOncologico;
|
||||
if (client.esOncologico) {
|
||||
oncologicoFields.classList.remove('hidden');
|
||||
} else {
|
||||
oncologicoFields.classList.add('hidden');
|
||||
}
|
||||
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 || '';
|
||||
@@ -485,12 +561,19 @@ function handleTableClick(e) {
|
||||
document.getElementById('u-name').value = user.name;
|
||||
document.getElementById('u-username').value = user.username;
|
||||
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';
|
||||
}
|
||||
} else if (action === 'delete-user') {
|
||||
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
|
||||
loadDashboardData();
|
||||
}
|
||||
@@ -557,7 +659,13 @@ function handleTestTicket() {
|
||||
id: 'demo',
|
||||
folio: 'DEMO-000001',
|
||||
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',
|
||||
monto: 123.45,
|
||||
metodo: 'Efectivo',
|
||||
@@ -634,12 +742,15 @@ async function initializeApp() {
|
||||
const tabs = document.querySelector('.tabs');
|
||||
const btnLogout = document.getElementById('btnLogout');
|
||||
const btnCancelEditUser = document.getElementById('btnCancelEditUser');
|
||||
const searchClientInput = document.getElementById('search-client');
|
||||
const tipoServicioSelect = document.getElementById('m-tipo');
|
||||
|
||||
formSettings?.addEventListener('submit', handleSaveSettings);
|
||||
formCredentials?.addEventListener('submit', handleSaveCredentials);
|
||||
formMove?.addEventListener('submit', handleNewMovement);
|
||||
tblMovesBody?.addEventListener('click', handleTableClick);
|
||||
tblClientsBody?.addEventListener('click', handleTableClick);
|
||||
appointmentsList?.addEventListener('click', handleTableClick);
|
||||
btnExport?.addEventListener('click', exportCSV);
|
||||
btnTestTicket?.addEventListener('click', handleTestTicket);
|
||||
formClient?.addEventListener('submit', handleClientForm);
|
||||
@@ -663,11 +774,7 @@ async function initializeApp() {
|
||||
|
||||
document.getElementById('c-esOncologico')?.addEventListener('change', (e) => {
|
||||
const oncologicoFields = document.getElementById('oncologico-fields');
|
||||
if (e.target.checked) {
|
||||
oncologicoFields.classList.remove('hidden');
|
||||
} else {
|
||||
oncologicoFields.classList.add('hidden');
|
||||
}
|
||||
oncologicoFields.classList.toggle('hidden', !e.target.checked);
|
||||
});
|
||||
|
||||
btnCancelEditUser?.addEventListener('click', (e) => {
|
||||
@@ -677,6 +784,18 @@ async function initializeApp() {
|
||||
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.
|
||||
Promise.all([
|
||||
load(KEY_SETTINGS, DEFAULT_SETTINGS),
|
||||
@@ -713,3 +832,4 @@ async function initializeApp() {
|
||||
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializeApp);
|
||||
|
||||
|
||||
@@ -53,10 +53,22 @@
|
||||
<p id="stat-total-movements">0</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-grid">
|
||||
<div class="dashboard-chart">
|
||||
<h3>Ingresos por Servicio</h3>
|
||||
<canvas id="incomeChart"></canvas>
|
||||
</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>
|
||||
|
||||
@@ -78,6 +90,13 @@
|
||||
<option value="Nail Art">Nail Art</option>
|
||||
<option value="Pago">Pago</option>
|
||||
</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>
|
||||
<input type="date" id="m-fecha-cita" />
|
||||
<label>Hora de Cita:</label>
|
||||
@@ -196,14 +215,16 @@
|
||||
</div>
|
||||
<div class="section">
|
||||
<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">
|
||||
<table id="tblClients">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nombre</th>
|
||||
<th>Teléfono</th>
|
||||
<th>Cumpleaños</th>
|
||||
<th>Consentimiento</th>
|
||||
<th>Oncológico</th>
|
||||
<th>Acciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -40,14 +40,28 @@ function templateTicket(mov, settings) {
|
||||
|
||||
lines.push('<div class="t-divider"></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.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.notas) lines.push(`<div class="t-small">Notas: ${esc(mov.notas)}</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>`);
|
||||
|
||||
|
||||
@@ -23,11 +23,14 @@ app.use(session({
|
||||
}));
|
||||
|
||||
// --- 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) {
|
||||
console.error(err.message);
|
||||
}
|
||||
console.log('Connected to the ap-pos.db database.');
|
||||
console.log('Connected to the database.');
|
||||
});
|
||||
|
||||
// --- AUTHENTICATION LOGIC ---
|
||||
@@ -96,7 +99,7 @@ db.serialize(() => {
|
||||
cedulaMedico TEXT,
|
||||
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
|
||||
@@ -269,10 +272,10 @@ apiRouter.get('/movements', (req, res) => {
|
||||
|
||||
apiRouter.post('/movements', (req, res) => {
|
||||
const { movement } = req.body;
|
||||
const { id, folio, fechaISO, clienteId, tipo, 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)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, folio, fechaISO, clienteId, tipo, monto, metodo, concepto, staff, notas, fechaCita, horaCita], function(err) {
|
||||
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;
|
||||
@@ -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
|
||||
app.use('/api', apiRouter);
|
||||
|
||||
@@ -419,15 +434,23 @@ 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"
|
||||
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 incomeByService y db.get para los demás para simplificar
|
||||
const method = query.includes('GROUP BY') ? 'all' : 'get';
|
||||
// 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) {
|
||||
|
||||
@@ -348,6 +348,43 @@ button.action-btn {
|
||||
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 --- */
|
||||
.form-grid-single {
|
||||
display: grid;
|
||||
@@ -386,6 +423,40 @@ button.action-btn {
|
||||
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 --- */
|
||||
.data-location-info {
|
||||
background-color: #e9ecef;
|
||||
|
||||
Reference in New Issue
Block a user