Pasted 2
Pasted 2
addEventListener('DOMContentLoaded', () => {
// =================================================================
// 1. CAPA DE API - Conexión con el Backend Real (PHP)
// =================================================================
const api = {
async request(endpoint, options = {}) {
try {
const defaultOptions = {
method: 'GET',
headers: { 'Content-Type': 'application/json', 'Accept':
'application/json' }
};
const config = { ...defaultOptions, ...options };
if (config.body) {
config.body = JSON.stringify(config.body);
}
const response = await fetch(`api/${endpoint}`, config);
const text = await response.text();
if (!response.ok) {
let error = 'Error desconocido en el servidor.';
try {
error = JSON.parse(text).error || `Error del servidor: $
{response.statusText}`;
} catch {
error = text.substring(0, 200);
}
throw new Error(error);
}
return text ? JSON.parse(text) : {};
} catch (error) {
if (!error.message.includes('Acceso no autorizado')) {
App.notify(error.message, 'error');
}
throw error;
}
},
login: (username, password) => api.request('login.php', { method: 'POST',
body: { username, password } }),
fetchInitialData: () => api.request('get_data.php'),
saveOrder: (orderData) => api.request('save_order.php', { method: 'POST',
body: orderData }),
// ===== INICIO DE LA MODIFICACIÓN: Se añade la nueva ruta a la API =====
updateOrderStatus: (orderId, status) =>
api.request('update_order_status.php', { method: 'POST', body: { id: orderId,
status: status } }),
// ===== FIN DE LA MODIFICACIÓN =====
deleteOrder: (orderId) => api.request('delete_order.php', { method: 'POST',
body: { id: orderId } }),
findOrderById: (orderId) => api.request(`get_order.php?id=${orderId}`),
saveClient: (clientData) => api.request('save_client.php', { method:
'POST', body: clientData }),
deleteClient: (clientId) => api.request('delete_client.php', { method:
'POST', body: { id: clientId } }),
saveService: (serviceData) => api.request('save_service.php', { method:
'POST', body: serviceData }),
deleteService: (serviceId) => api.request('delete_service.php', { method:
'POST', body: { id: serviceId } }),
saveProduct: (productData) => api.request('save_product.php', { method:
'POST', body: productData }),
updateStock: (productId, quantityChange) => api.request('update_stock.php',
{ method: 'POST', body: { id: productId, change: quantityChange } }),
saveExpense: (expenseData) => api.request('save_expense.php', { method:
'POST', body: expenseData }),
deleteExpense: (expenseId) => api.request('delete_expense.php', { method:
'POST', body: { id: expenseId } }),
saveSettings: (settingsData) => api.request('save_settings.php', { method:
'POST', body: settingsData }),
fetchUsers: () => api.request('get_users.php'),
saveUser: (userData) => api.request('save_user.php', { method: 'POST',
body: userData }),
verifyPin: (pin) => api.request('verify_pin.php', { method: 'POST', body: {
pin: pin } }),
};
// =================================================================
// 2. CAPA DE RENDERIZADO DE UI (Funciones que generan HTML)
// =================================================================
const ui = {
appContainer: document.getElementById('app-container'),
contentArea: document.getElementById('content-area'),
navList: document.getElementById('nav-list'),
renderNavigation: function() {
const navItems = App.getNavItems().filter(item =>
App.state.settings.menuVisibility[item.id]);
const navHTML = navItems.map(item => `
<li class="w-full sm:w-auto sm:flex-shrink-0">
<a href="#" class="nav-link flex flex-col items-center justify-
center w-full sm:w-24 h-20 bg-slate-800 rounded-lg p-2 text-slate-400 font-medium
text-center" data-page="${item.id}" title="${item.name}">
<i class="${item.icon} text-2xl mb-1"></i>
<span class="text-xs">${item.name}</span>
${item.isPremium && App.state.userInfo.plan === 'Básico' ?
'<i class="fas fa-lock text-amber-400 text-xs absolute top-2 right-2"></i>' : ''}
</a>
</li>
`).join('');
this.navList.innerHTML = navHTML;
},
renderDashboard: function() {
const headerActions = `
<button id="search-btn" class="btn btn-secondary"><i class="fas fa-
search"></i> Buscar Folio</button>
<button id="quick-cash-cut-btn" class="btn btn-ghost" title="Corte
de Caja Rápido"><i class="fas fa-cash-register"></i></button>
<button id="config-dashboard-btn" class="btn btn-ghost"
title="Configurar Dashboard"><i class="fas fa-cog"></i></button>
<button id="new-order-btn" class="btn btn-primary w-full sm:w-
auto">
<i class="fas fa-plus-circle"></i> Nueva Orden
</button>`;
const cardsHTML = `
<div class="grid grid-cols-1 md:grid-cols-5 gap-6 mb-8">
${App.state.settings.dashboardCards.cashBalance ? `
<div id="cash-register-card" class="bg-slate-900 border border-
slate-800 rounded-lg p-4 flex flex-col items-center justify-center text-center
cursor-pointer hover:border-sky-500 transition-colors">
<i class="fas fa-cash-register text-sky-400 text-3xl mb-
2"></i>
<p class="text-slate-400 text-xs whitespace-
nowrap">Efectivo en Caja</p>
<p class="text-2xl font-bold text-white">$$
{stats.cashInRegister.toFixed(2)}</p>
</div>` : ''}
${App.state.settings.dashboardCards.ordersCount ? `
<div class="md:col-span-2 bg-slate-900 border border-slate-800
rounded-lg p-6">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-white">Total de
Órdenes</h4>
<i class="fas fa-receipt text-green-400"></i>
</div>
<div class="flex justify-around text-center mt-4">
<div><p class="text-2xl font-bold text-white">$
{stats.ordersToday}</p><p class="text-xs text-slate-400">Hoy</p></div>
<div><p class="text-2xl font-bold text-white">$
{stats.ordersWeek}</p><p class="text-xs text-slate-400">Semana</p></div>
<div><p class="text-2xl font-bold text-white">$
{stats.ordersMonth}</p><p class="text-xs text-slate-400">Mes</p></div>
</div>
</div>` : ''}
${App.state.settings.dashboardCards.salesTotal ? `
<div class="md:col-span-2 bg-slate-900 border border-slate-800
rounded-lg p-6">
<div class="flex justify-between items-center mb-2">
<h4 class="font-semibold text-white">Total de
Ventas</h4>
<i class="fas fa-chart-line text-amber-400"></i>
</div>
<div class="flex justify-around text-center mt-4">
<div><p class="text-xl font-bold text-white">$$
{stats.salesToday.toFixed(0)}</p><p class="text-xs text-slate-400">Hoy</p></div>
<div><p class="text-xl font-bold text-white">$$
{stats.salesWeek.toFixed(0)}</p><p class="text-xs text-slate-400">Semana</p></div>
<div><p class="text-xl font-bold text-white">$$
{stats.salesMonth.toFixed(0)}</p><p class="text-xs text-slate-400">Mes</p></div>
</div>
</div>` : ''}
</div>
`;
this.renderDashboardTable();
App.attachDashboardListeners();
},
const statusInfo = {
pending: { text: 'Pendiente', icon: 'fa-hourglass-half', color:
'text-amber-400', bg: 'bg-amber-500/10' },
completed: { text: 'Completado', icon: 'fa-check-circle', color:
'text-green-400', bg: 'bg-green-500/10' },
ready: { text: 'Listo', icon: 'fa-box-check', color: 'text-indigo-
400', bg: 'bg-indigo-500/10' },
delivered: { text: 'Entregado', icon: 'fa-shipping-fast', color:
'text-sky-400', bg: 'bg-sky-500/10' },
};
return `
<tr class="hover:bg-slate-800/50 transition-colors">
<td class="p-4 border-b border-slate-800">
<div class="font-semibold text-white">#${order.id}</div>
<div class="text-xs text-slate-400">${order.cashier}</div>
</td>
<td class="p-4 border-b border-slate-800 capitalize">$
{order.client_name}</td>
<td class="p-4 border-b border-slate-800 text-sm">
<div><span class="font-semibold text-slate-
400">Recibido:</span> ${receptionDate.toLocaleDateString('es-ES')}</div>
<div><span class="font-semibold
text-slate-400">Entrega:</span> ${new
Date(order.delivery_date).toLocaleDateString('es-ES')}</div>
<div class="text-xs text-sky-400 mt-1">${daysText}</div>
</td>
<td class="p-4 border-b border-slate-800 text-right font-mono
text-lg">$${parseFloat(order.total).toFixed(2)}</td>
<td class="p-4 border-b border-slate-800">
<span class="inline-flex items-center gap-2 px-3 py-1
rounded-full text-xs font-medium ${status.bg} ${status.color}">
<i class="fas ${status.icon}"></i> ${status.text}
</span>
</td>
<td class="p-4 border-b border-slate-800 text-center">
<div class="flex items-center justify-center gap-1">
${ (order.status === 'pending' &&
App.state.settings.enableQuickFinish) ? `
<button class="btn btn-ghost quick-complete-btn" data-
id="${order.id}" title="Marcar como Terminado y Listo">
<i class="fas fa-check-double text-green-400"></i>
</button>` : '' }
container.innerHTML = `
<div class="p-4 bg-slate-800/30 flex items-center gap-4">
<div class="flex-1 input-icon-wrapper">
<i class="icon fas fa-search"></i>
<input type="text" id="dashboard-search-input" class="form-
input form-input-icon" placeholder="Buscar por Folio o Cliente..." value="$
{filters.query || ''}">
</div>
<div class="flex-1">
<select id="dashboard-status-filter" class="form-select">
<option value="all">Todos los Estados</option>
${statusOptions}
</select>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left text-slate-300">
<thead class="bg-slate-800/50">
<tr class="text-slate-400 text-sm">
<th class="p-4 font-semibold">Folio/Cajero</th>
<th class="p-4 font-semibold">Cliente</th>
<th class="p-4 font-semibold">Fechas</th>
<th class="p-4 font-semibold text-right">Total</th>
<th class="p-4 font-semibold">Estado</th>
<th class="p-4 font-semibold text-
center">Acciones</th>
</tr>
</thead>
<tbody>${tableRows}</tbody>
</table>
</div>`;
document.getElementById('dashboard-status-filter').value =
filters.status || 'all';
searchInput.addEventListener('input', applyFilters);
statusFilter.addEventListener('change', applyFilters);
},
renderClientsPage: function() {
const clients = App.state.clients;
const headerActions = `<button id="new-client-btn" class="btn btn-
primary"><i class="fas fa-user-plus"></i> Nuevo Cliente</button>`;
const tableRows = clients.length > 0 ? clients.map(client => `
<tr class="hover:bg-slate-800/50">
<td class="p-4 border-b border-slate-800 font-semibold text-
white">${client.id}</td>
<td class="p-4 border-b border-slate-800 capitalize">$
{client.name}</td>
<td class="p-4 border-b border-slate-800">${client.address ||
'N/A'}</td>
<td class="p-4 border-b border-slate-800">${client.phone ||
'N/A'}</td>
<td class="p-4 border-b border-slate-800 text-center">
<button class="btn btn-ghost edit-client-btn" data-id="$
{client.id}" title="Editar"><i class="fas fa-edit"></i></button>
</td>
</tr>
`).join('') : `<tr><td colspan="5" class="p-8 text-center text-slate-
500">No hay clientes registrados.</td></tr>`;
renderServicesPage: function() {
const serviceCategories = App.state.services;
const headerActions = `<button id="new-service-btn" class="btn btn-
primary"><i class="fas fa-plus"></i> Nuevo Servicio</button>`;
renderProductsPage: function() {
const products = App.state.products;
const headerActions = `<button id="new-product-btn" class="btn btn-
primary"><i class="fas fa-plus"></i> Nuevo Suministro</button>`;
const tableRows = products.length > 0 ? products.map(p => `
<tr class="hover:bg-slate-800/50">
<td class="p-4 border-b border-slate-800 font-semibold text-
white">${p.id}</td>
<td class="p-4 border-b border-slate-800">${p.name}</td>
<td class="p-4 border-b border-slate-800 text-right font-
mono">$${(parseFloat(p.cost) || 0).toFixed(2)}</td>
<td class="p-4 border-b border-slate-800 text-right font-
mono">${p.stock}</td>
<td class="p-4 border-b border-slate-800">${p.unit}</td>
<td class="p-4 border-b border-slate-800 text-center">
<button class="btn btn-ghost manage-stock-btn" data-id="$
{p.id}" title="Gestionar Stock"><i class="fas fa-exchange-alt"></i></button>
<button class="btn btn-ghost edit-product-btn" data-id="$
{p.id}" title="Editar Suministro"><i class="fas fa-edit"></i></button>
</td>
</tr>
`).join('') : `<tr><td colspan="6" class="p-8 text-center text-slate-
500">No hay suministros registrados.</td></tr>`;
renderExpensesPage: function() {
const expenses = App.state.expenses;
const headerActions = `<button id="new-expense-btn" class="btn btn-
primary"><i class="fas fa-plus"></i> Nuevo Gasto</button>`;
const totalExpenses = expenses.reduce((sum, exp) => sum +
parseFloat(exp.total), 0);
renderReportsPage: function() {
const headerActions = `
<button id="cash-cut-btn" class="btn btn-secondary"><i class="fas
fa-cash-register"></i> Corte de Caja</button>
<button id="sales-report-btn" class="btn btn-primary"><i class="fas
fa-file-pdf"></i> Reporte de Ventas</button>
`;
this.contentArea.innerHTML = `<div class="page-enter">
${this._createPageHeader('Reportes', 'fas fa-chart-pie',
headerActions)}
<div class="bg-slate-900 p-6 rounded-lg border border-slate-800
text-center">
<i class="fas fa-chart-line text-5xl text-slate-500 mb-4"></i>
<h3 class="text-2xl font-bold text-white">Generación de
Reportes</h3>
<p class="text-slate-400 mt-2">Utiliza los botones de arriba
para generar reportes y realizar el corte de caja.</p>
</div>
</div>`;
},
renderMarketingPage: function() {
const clients = App.state.clients;
const clientsListHTML = clients.map(client => `
<label for="client-${client.id}" class="flex items-center p-3 bg-
slate-800 rounded-lg hover:bg-slate-700/50 cursor-pointer">
<input type="checkbox" id="client-${client.id}" class="h-4 w-4
rounded border-slate-600 bg-slate-700 text-sky-500 focus:ring-sky-600">
<div class="ml-3">
<p class="font-semibold text-white capitalize">$
{client.name}</p>
<p class="text-xs text-slate-400">${client.email || 'Sin
email'}</p>
</div>
</label>
`).join('');
const pageHTML = `
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-1">
<h3 class="text-xl font-bold text-white mb-4">Lista de
Clientes</h3>
<div class="bg-slate-900 p-4 rounded-lg border border-
slate-800 max-h-[60vh] overflow-y-auto space-y-2">
${clientsListHTML}
</div>
</div>
<div class="lg:col-span-2">
<h3 class="text-xl font-bold text-white mb-4">Crear
Campaña</h3>
<div class="bg-slate-900 p-6 rounded-lg border border-
slate-800 space-y-4">
<div>
<label class="form-label">Asunto del
Mensaje</label>
<input type="text" class="form-input"
placeholder="¡Tenemos una promoción para ti!">
</div>
<div>
<label class="form-label">Contenido del
Mensaje</label>
<textarea class="form-textarea" rows="8"
placeholder="Hola {nombre_cliente}, aprovecha nuestro 20% de descuento
en..."></textarea>
</div>
<div class="flex justify-end">
<button id="send-campaign-btn" class="btn btn-
primary"><i class="fas fa-paper-plane"></i> Enviar Campaña (Simulado)</button>
</div>
</div>
</div>
</div>
`;
renderSettingsPage: function() {
const settings = App.state.settings;
settings.security = settings.security || { loginRequired: false };
const pinActions = [
{ section: 'dashboard', actions: ['create_order', 'deliver_order',
'search'], label: 'Dashboard' },
{ section: 'orders', actions: ['view_details', 'edit', 'delete',
'filter'], label: 'Órdenes' },
{ section: 'services', actions: ['create', 'edit', 'delete'],
label: 'Servicios' },
{ section: 'products', actions: ['create', 'edit', 'delete'],
label: 'Suministros' },
{ section: 'clients', actions: ['create', 'edit', 'delete'], label:
'Clientes' },
{ section: 'expenses', actions: ['create', 'delete'], label:
'Gastos' },
{ section: 'reports', actions: ['cut_cash'], label: 'Reportes' },
{ section: 'settings', actions: ['view'], label: 'Ajustes' },
];
const posOptionHTML = `
<div>
<label class="form-label">Método de "Nueva Orden"</label>
<select name="newServiceMethod" class="form-select">
<option value="modal" ${settings.newServiceMethod ===
'modal' ? 'selected' : ''}>Modal Clásico</option>
<option value="pos" ${settings.newServiceMethod === 'pos' ?
'selected' : ''}>Punto de Venta (POS)</option>
<option value="pos_v2" ${settings.newServiceMethod ===
'pos_v2' ? 'selected' : ''}>Plantilla de Cobro Completa</option>
</select>
</div>`;
const pageContent = `
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div class="lg:col-span-2 space-y-8">
<div class="bg-slate-900 p-6 rounded-lg border border-
slate-800">
<h3 class="text-xl font-bold text-white mb-
4">Información del Negocio</h3>
<form id="business-settings-form" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><label
class="form-label">Nombre</label><input type="text" name="businessName"
class="form-input" value="${settings.businessName}"></div>
<div><label class="form-label">Folio
Inicial</label><input type="number" name="folioCounter" class="form-input" value="$
{settings.folioCounter}"></div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div><label
class="form-label">Teléfono</label><input type="text" name="phone" class="form-
input" value="${settings.phone}"></div>
<div><label
class="form-label">Email</label><input type="email" name="email" class="form-input"
value="${settings.email}"></div>
</div>
<div><label
class="form-label">Dirección</label><input type="text" name="address" class="form-
input" value="${settings.address}"></div>
${posOptionHTML}
<div>
<label class="form-label">Logo del
Negocio</label>
${logoOptionHTML}
</div>
<div class="flex items-center justify-between bg-
slate-800 p-3 rounded-md">
<label for="toggle-quick-finish" class="font-
medium text-white">Activar estado "Listo" y Acción Rápida</label>
<label class="toggle-switch">
<input type="checkbox" id="toggle-quick-
finish" name="enableQuickFinish" ${settings.enableQuickFinish ? 'checked' : ''}>
<span class="slider"></span>
</label>
</div>
</form>
</div>
<div class="bg-slate-900 p-6 rounded-lg border border-
slate-800">
<h3 class="text-xl font-bold text-white mb-
4">Seguridad</h3>
renderMembershipCard: function(plan) {
const isPremium = plan === 'Premium';
return `
<div class="bg-slate-800 rounded-lg p-6 border ${isPremium ?
'border-amber-400' : 'border-sky-500'}">
<h4 class="text-2xl font-bold ${isPremium ? 'text-amber-400' :
'text-sky-400'}">Plan ${plan}</h4>
<p class="text-slate-400 mt-2">${isPremium ? 'Todas las
funciones avanzadas activadas.' : 'Funciones esenciales para tu negocio.'}</p>
${!isPremium ? `
<button id="upgrade-btn" class="w-full btn btn-primary mt-6 bg-
amber-500 border-amber-500 hover:bg-amber-400">
<i class="fas fa-gem"></i> Actualizar a Premium
</button>
` : ''}
</div>
`;
},
};
// =================================================================
// 4. CAPA CONTROLADORA (Lógica de la Aplicación)
// =================================================================
const App = {
state: {
clients: [], orders: [], products: [], expenses: [], services: [],
users: [],
settings: {
menuVisibility: {},
dashboardCards: {},
pinProtection: {},
security: { loginRequired: false }
},
userInfo: {},
cashierData: {},
},
modalElement: document.getElementById('main-modal'),
modalContent: document.getElementById('modal-content'),
loadingSpinner: document.getElementById('loading-spinner'),
notificationContainer: document.getElementById('notification-container'),
currentOrderItems: [],
navItems: [
{ id: 'dashboard', name: 'INICIO', icon: 'fas fa-home', isPremium:
false },
{ id: 'clients', name: 'CLIENTES', icon: 'fas fa-users', isPremium:
false },
{ id: 'services', name: 'SERVICIOS', icon: 'fas fa-tshirt', isPremium:
false },
{ id: 'products', name: 'SUMINISTROS', icon: 'fas fa-box-open',
isPremium: false },
{ id: 'expenses', name: 'GASTOS', icon: 'fas fa-money-bill-wave',
isPremium: false },
{ id: 'reports', name: 'REPORTES', icon: 'fas fa-chart-pie', isPremium:
true },
{ id: 'marketing', name: 'MARKETING', icon: 'fas fa-bullhorn',
isPremium: true },
{ id: 'settings', name: 'AJUSTES', icon: 'fas fa-cog', isPremium: false
},
],
this.showLoading(true);
try {
const initialData = await api.fetchInitialData();
this.state = initialData;
this.state.users = await api.fetchUsers();
this.showInitialCashModal(this.state.userInfo, true);
} catch (error) {
if (error.message && error.message.includes('Acceso no
autorizado')) {
window.location.href = 'login.html';
} else {
this.notify('Error fatal al cargar la aplicación. Revisa la
conexión con el servidor.', 'error');
console.error(error);
}
} finally {
this.showLoading(false);
}
},
getDashboardStats: function() {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(),
now.getDate());
const oneDay = 24 * 60 * 60 * 1000;
const stats = {
cashInRegister: this.state.cashierData.cashBalance || 0,
ordersToday: 0, ordersWeek: 0, ordersMonth: 0,
salesToday: 0, salesWeek: 0, salesMonth: 0,
};
if (diffDays === 0) {
stats.ordersToday++;
stats.salesToday += parseFloat(order.total);
}
if (diffDays < 7) {
stats.ordersWeek++;
stats.salesWeek += parseFloat(order.total);
}
if (diffDays < 30) {
stats.ordersMonth++;
stats.salesMonth += parseFloat(order.total);
}
});
return stats;
},
updateUIOnLogin: function() {
const user = this.state.userInfo;
if (!user) {
window.location.href = 'login.html';
return;
}
document.getElementById('current-user').textContent = user.username;
this.updatePlanDisplay();
this.updateLogo();
if (ui.navList) {
ui.navList.className = 'grid grid-cols-4 gap-2 p-2 sm:flex
sm:items-center sm:gap-2 sm:p-0 sm:overflow-x-auto';
}
ui.renderNavigation();
this.setupNavEventListeners();
this.navigateTo('dashboard');
},
updatePlanDisplay: function() {
const planText = `Plan ${this.state.userInfo.plan}`;
document.getElementById('current-plan').textContent = planText;
},
updateLogo: function() {
const logoImg = document.getElementById('business-logo');
if (this.state.settings.logoUrl) {
logoImg.src = this.state.settings.logoUrl;
logoImg.onerror = () => { logoImg.src =
'https://placehold.co/100x40/020617/38bdf8?text=Error'; };
} else {
logoImg.src = 'https://placehold.co/100x40/020617/38bdf8?
text=DevGMXA';
}
},
navigateTo: function(pageId) {
const pageInfo = this.getNavItems().find(item => item.id === pageId);
if (!pageInfo) return;
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.toggle('active', link.dataset.page === pageId);
});
switch (pageId) {
case 'dashboard': ui.renderDashboard(); break;
case 'clients': ui.renderClientsPage(); break;
case 'services': ui.renderServicesPage(); break;
case 'products': ui.renderProductsPage(); break;
case 'expenses': ui.renderExpensesPage(); break;
case 'reports': ui.renderReportsPage(); break;
case 'marketing': ui.renderMarketingPage(); break;
case 'settings': ui.renderSettingsPage(); break;
default: this.contentArea.innerHTML = `<p>Página no
encontrada.</p>`;
}
},
showModal: function(contentHTML) {
this.modalElement.classList.remove('hidden');
this.modalElement.classList.add('flex');
this.modalContent.innerHTML = contentHTML;
void this.modalContent.offsetWidth;
this.modalElement.classList.add('modal-open');
},
hideModal: function(callback) {
this.modalElement.classList.remove('modal-open');
setTimeout(() => {
this.modalElement.classList.add('hidden');
this.modalElement.classList.remove('flex');
if (callback && typeof callback === 'function') {
callback();
}
}, 300);
},
showPinModal: function(onSuccess) {
const pinHTML = `
<div class="p-8 text-center">
<h3 class="text-xl font-bold text-white mb-2">Se requiere NIP
de Administrador</h3>
<p class="text-slate-400 mb-4">Ingresa el NIP para
continuar.</p>
<input type="password" id="pin-input" class="form-input text-
center text-2xl tracking-[.5em] w-48 mx-auto" maxlength="4" autofocus>
<div class="mt-6 flex justify-center gap-4">
<button class="btn btn-secondary modal-close-
btn">Cancelar</button>
<button id="verify-pin-btn" class="btn btn-
primary">Verificar</button>
</div>
</div>`;
this.showModal(pinHTML);
const pinInput = document.getElementById('pin-input');
const verifyBtn = document.getElementById('verify-pin-btn');
verifyBtn.disabled = true;
verifyBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i>`;
try {
const response = await api.verifyPin(pin);
if (response.success) {
this.hideModal(onSuccess);
} else {
this.notify(response.message || 'NIP incorrecto.',
'error');
pinInput.value = '';
pinInput.focus();
}
} catch (error) {
// El error ya es notificado por la capa API
} finally {
verifyBtn.disabled = false;
verifyBtn.innerHTML = `Verificar`;
}
};
verifyBtn.addEventListener('click', verify);
pinInput.addEventListener('keydown', (e) => { if(e.key === 'Enter')
verify() });
},
if (isPinProtected) {
this.showPinModal(checkPermissionsAndRun);
} else {
checkPermissionsAndRun();
}
},
setupPersistentEventListeners: function() {
document.getElementById('logo-container').addEventListener('click', ()
=> this.navigateTo('dashboard'));
document.getElementById('logout-btn').addEventListener('click', () => {
window.location.href = 'api/logout.php';
});
document.getElementById('current-plan').addEventListener('click', () =>
this.handlePlanClick());
handlePlanClick: function() {
if (this.state.userInfo.plan === 'Básico') {
this.showPremiumBenefitsModal();
} else {
this.runProtectedAction('settings.view', () =>
this.navigateTo('settings'));
}
},
showPremiumBenefitsModal: function() {
const modalHTML = `
<div class="p-6 text-center">
<i class="fas fa-gem text-5xl mb-4 text-amber-400"></i>
<h3 class="text-2xl font-bold text-white">Desbloquea todo el
Potencial con Premium</h3>
<ul class="text-slate-300 mt-4 space-y-2 text-left list-disc
list-inside">
<li>Genera reportes de ventas avanzados.</li>
<li>Envía notificaciones por WhatsApp a tus clientes.</li>
<li>Utiliza herramientas de marketing para fidelizar
clientes.</li>
<li>Personaliza el logo de tu negocio.</li>
<li>Soporte prioritario.</li>
</ul>
<div class="mt-6 flex justify-center gap-4">
<button class="btn btn-secondary
modal-close-btn">Cerrar</button>
<button id="go-to-upgrade-btn" class="btn btn-primary bg-
amber-500 border-amber-500 hover:bg-amber-400">
<i class="fas fa-rocket"></i> Ver Planes
</button>
</div>
</div>`;
this.showModal(modalHTML);
document.getElementById('go-to-upgrade-btn').addEventListener('click',
() => {
this.hideModal();
this.navigateTo('settings');
});
},
setupNavEventListeners: function() {
document.querySelectorAll('.nav-link').forEach(link => {
link.addEventListener('click', e => {
e.preventDefault();
const pageId = link.dataset.page;
const navItem = this.getNavItems().find(item => item.id ===
pageId);
attachDashboardListeners: function() {
document.getElementById('new-order-btn').addEventListener('click', ()
=> this.runProtectedAction('dashboard.create_order', () =>
this.startNewOrderFlow()));
const cashCard = document.getElementById('cash-register-card');
if(cashCard) cashCard.addEventListener('click', () =>
this.handleShowCashDetails());
setupDelegatedEventListeners: function() {
document.body.addEventListener('click', (e) => {
const targetButton = e.target.closest('button');
if (!targetButton) return;
const id = targetButton.dataset.id;
if (id) {
if (targetButton.classList.contains('update-status-btn'))
this.handleUpdateStatus(id);
else if (targetButton.classList.contains('quick-complete-btn'))
this.handleQuickCompleteOrder(id);
else if (targetButton.classList.contains('whatsapp-notify-
btn')) this.handleWhatsAppNotify(id);
else if (targetButton.classList.contains('view-ticket-btn'))
this.runProtectedAction('orders.view_details', () => this.handleViewTicket(id));
else if (targetButton.classList.contains('edit-order-btn'))
this.runProtectedAction('orders.edit', () => this.handleEditOrder(id));
else if (targetButton.classList.contains('delete-order-btn'))
this.runProtectedAction('orders.delete', () => this.handleDeleteOrder(id));
else if (targetButton.classList.contains('edit-client-btn'))
this.runProtectedAction('clients.edit', () => this.handleEditClient(id));
else if (targetButton.classList.contains('edit-service-btn'))
this.runProtectedAction('services.edit', () => this.handleNewService(id));
else if (targetButton.classList.contains('edit-product-btn'))
this.runProtectedAction('products.edit', () => this.handleEditProduct(id));
else if (targetButton.classList.contains('edit-expense-btn'))
this.runProtectedAction('expenses.edit', () => this.handleEditExpense(id));
else if (targetButton.classList.contains('delete-expense-btn'))
this.runProtectedAction('expenses.delete', () => this.handleDeleteExpense(id));
else if (targetButton.classList.contains('manage-stock-btn'))
this.runProtectedAction('products.edit', () => this.handleManageStock(id));
return;
}
switch (targetButton.id) {
case 'new-client-btn':
this.runProtectedAction('clients.create', () => this.handleEditClient(null));
break;
case 'new-product-btn':
this.runProtectedAction('products.create', () => this.handleEditProduct(null));
break;
case 'new-service-btn':
this.runProtectedAction('services.create', () => this.handleNewService()); break;
case 'new-expense-btn':
this.runProtectedAction('expenses.create', () => this.handleEditExpense(null));
break;
case 'sales-report-btn': this.handleGenerateSalesReport();
break;
case 'cash-cut-btn':
this.runProtectedAction('reports.cut_cash', () => this.handleCashCut()); break;
case 'save-settings-btn':
this.handleSaveSettings(targetButton); break;
case 'manage-users-btn': this.handleManageUsers(); break;
case 'change-username-btn': this.handleChangeUsername(); break;
case 'change-pin-btn': this.handleChangePin(); break;
case 'upgrade-btn': this.showPremiumBenefitsModal(); break;
case 'buy-logo-feature-btn': this.handleBuyLogoFeature();
break;
}
});
},
const modalHTML = `
<div class="p-8 text-center">
<h3 class="text-xl font-bold text-white mb-2">Iniciar
Turno</h3>
<p class="text-slate-400 mb-4">Ingresa el efectivo inicial en
caja.</p>
<form id="initial-cash-form">
<input type="number" id="initial-cash-input" class="form-
input text-center text-2xl w-48 mx-auto" step="0.01" min="0" value="0.00"
autofocus>
<div class="mt-6 flex justify-center gap-4">
<button type="button" class="btn btn-secondary modal-
close-btn">Cancelar</button>
<button type="submit" class="btn btn-primary">Comenzar
Turno</button>
</div>
</form>
</div>
`;
this.showModal(modalHTML);
document.getElementById('initial-cash-form').addEventListener('submit',
(e) => {
e.preventDefault();
const initialCash = parseFloat(document.getElementById('initial-
cash-input').value);
if (isNaN(initialCash) || initialCash < 0) {
this.notify('Por favor, ingresa un monto válido.', 'error');
return;
}
this.state.cashierData = {
initialCash: initialCash,
cashBalance: initialCash,
todaySales: { total: 0, cash: 0, card: 0, transfer: 0, vales: 0
},
todayExpenses: 0
};
this.hideModal();
this.updateUIOnLogin();
this.notify(`Turno iniciado para ${user.username} con $$
{initialCash.toFixed(2)}`, 'success');
});
},
startNewOrderFlow: function() {
this.showClientSelectorForNewOrder();
},
showClientSelectorForNewOrder: function() {
const clients = this.state.clients;
const modalHTML = `
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold text-white">Seleccionar
Cliente</h3>
<button class="btn btn-ghost
modal-close-btn">×</button>
</div>
<div class="space-y-4">
<div class="input-icon-wrapper">
<i class="icon fas fa-search"></i>
<input type="text" id="client-search-input"
class="form-input form-input-icon" placeholder="Buscar cliente por nombre..."
autofocus>
</div>
<div id="client-search-results" class="max-h-60 overflow-y-
auto space-y-2 border-t border-slate-700 pt-2"></div>
</div>
<div class="flex justify-end items-center gap-4 pt-4 mt-4
border-t border-slate-700">
<button type="button" id="quick-add-client-btn" class="btn
btn-secondary"><i class="fas fa-plus"></i> Añadir Cliente</button>
</div>
</div>`;
this.showModal(modalHTML);
searchInput.addEventListener('input', () => {
const query = searchInput.value.toLowerCase();
const filtered = clients.filter(c =>
c.name.toLowerCase().includes(query));
renderResults(filtered);
});
document.getElementById('quick-add-client-
btn').addEventListener('click', () => {
this.handleEditClient(null, (newClient) => {
this.proceedToCreateOrder(newClient.id);
});
});
renderResults(clients);
},
proceedToCreateOrder: function(clientId) {
const method = this.state.settings.newServiceMethod;
const client = this.state.clients.find(c => c.id == clientId);
if (!client) {
this.notify('Cliente no encontrado.', 'error');
return;
}
this.hideModal(() => {
const dataToPOS = {
client,
services: this.state.services,
settings: this.state.settings
};
try {
localStorage.setItem('posInitialData',
JSON.stringify(dataToPOS));
} catch (e) {
console.error("Error al guardar datos para el POS:", e);
this.notify("No se pudo iniciar el POS.", 'error');
return;
}
const posWindowFeatures =
'width=1200,height=800,scrollbars=yes,resizable=yes';
const newOrderData = {
clientId: client.id,
clientName: client.name,
deliveryDate,
total,
details: items,
payments,
totalPaid: payments.reduce((sum, p) => sum + p.amount, 0),
observations,
cashier: this.state.userInfo.username
};
await api.saveOrder(newOrderData);
await this.reloadData('dashboard');
this.notify('Orden guardada desde el POS.', 'success');
},
if (pageToNavigate) this.navigateTo(pageToNavigate);
} catch (error) {
this.notify('No se pudo recargar la información.', 'error');
} finally {
this.showLoading(false);
}
},
const formHTML = `
<div class="p-6 flex flex-col h-[90vh] sm:h-auto">
<div class="flex-shrink-0">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold text-white">${orderId ?
`Editando Orden #${orderId}` : 'Nueva Orden de Servicio'}</h3>
<button class="btn btn-ghost modal-close-
btn">×</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label class="form-label">Cliente</label>
<input type="text" id="client-name-display"
class="form-input bg-slate-800" readonly value="${client?.name || 'N/A'}">
<input type="hidden" id="client-id" value="$
{client?.id || ''}">
</div>
<div><label class="form-label">Fecha de
Entrega</label><input type="date" id="delivery-date" class="form-input mt-1"
value="${deliveryDateValue}"></div>
</div>
</div>
this.currentOrderItems.splice(e.currentTarget.dataset.index, 1);
renderOrderItems();
});
});
};
paymentInput.addEventListener('input', updateTotals);
document.getElementById('add-service-btn').addEventListener('click', ()
=> {
const serviceSelect = document.getElementById('service-select');
const selectedOption =
serviceSelect.options[serviceSelect.selectedIndex];
const quantityInput = document.getElementById('service-quantity');
const quantity = parseFloat(quantityInput.value);
const price = parseFloat(selectedOption.dataset.price);
this.currentOrderItems.push({
id: selectedOption.value,
name: selectedOption.text.split('(')[0].trim(),
price: price,
quantity: quantity,
subtotal: quantity * price
});
renderOrderItems();
quantityInput.value = '1';
serviceSelect.focus();
});
document.getElementById('save-order-btn').addEventListener('click',
async (e) => {
const btn = e.currentTarget;
const currentClientId = document.getElementById('client-id').value;
const clientName = document.getElementById('client-name-
display').value;
const deliveryDate = document.getElementById('delivery-
date').value;
if (!currentClientId || !deliveryDate ||
this.currentOrderItems.length === 0) {
this.notify("Cliente, fecha y al menos un servicio son
requeridos.", 'error'); return;
}
const orderData = {
id: orderId,
clientId: parseInt(currentClientId),
clientName: clientName,
deliveryDate,
total: totalAmount,
details: this.currentOrderItems,
payments: payments,
totalPaid: payments.reduce((sum, p) => sum +
(parseFloat(p.amount) || 0), 0),
observations: document.getElementById('observations').value,
status: order ? order.status : 'pending',
cashier: this.state.userInfo.username
};
if (isNaN(orderData.total)) {
this.notify('Error al calcular el total. Revisa los servicios y
precios añadidos.', 'error');
return;
}
btn.disabled = true;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i>
Guardando...`;
await api.saveOrder(orderData);
btn.disabled = false;
btn.innerHTML = orderId ? 'Guardar Cambios' : 'Crear Orden';
this.hideModal();
await this.reloadData('dashboard');
this.notify('Orden guardada con éxito', 'success');
});
renderOrderItems();
},
handleDeleteOrder: function(orderId) {
this.showConfirmationModal({
title: 'Eliminar Orden',
message: `¿Estás seguro de que quieres eliminar la orden #$
{orderId}? Esta acción no se puede deshacer.`,
icon: 'fas fa-exclamation-triangle text-red-400',
confirmText: 'Sí, Eliminar',
onConfirm: async () => {
this.showLoading(true);
await api.deleteOrder(orderId);
this.showLoading(false);
this.hideModal();
await this.reloadData('dashboard');
this.notify('Orden eliminada con éxito.', 'success');
}
});
},
if(callback) {
await this.reloadData();
callback(this.state.clients.find(c => c.name ===
result.client.name));
} else {
this.hideModal();
await this.reloadData('clients');
}
this.notify('Cliente guardado con éxito.', 'success');
});
},
const modalHTML = `
<div class="p-0">
<div class="flex justify-between items-center p-4 bg-slate-800
border-b border-slate-700 rounded-t-xl">
<h3 class="text-xl font-bold text-white flex items-center
gap-3"><i class="fas fa-plus-circle text-sky-400"></i>${service ? 'Editar Servicio'
: 'Nuevo Servicio'}</h3>
<button class="btn btn-ghost
modal-close-btn">×</button>
</div>
<form id="service-form" class="p-6 space-y-4">
<input type="hidden" name="id" value="${service?.id ||
''}">
<div class="input-icon-wrapper">
<i class="icon fas fa-tags"></i>
<input list="category-list" name="category"
class="form-input form-input-icon" placeholder="Selecciona o escribe una categoría"
value="${service?.category || ''}" required>
<datalist
id="category-list">${categoryOptions}</datalist>
</div>
<div class="input-icon-wrapper">
<i class="icon fas fa-tshirt"></i>
<input type="text" name="name" class="form-input form-
input-icon" placeholder="Nombre del Servicio" value="${service?.name || ''}"
required>
</div>
<div class="input-icon-wrapper">
<i class="icon fas fa-ruler-combined"></i>
<input type="text" name="unit" class="form-input form-
input-icon" placeholder="Unidad (ej. Pieza, Lavado, Kg)" value="${service?.unit ||
''}" required>
</div>
<div class="input-icon-wrapper">
<i class="icon fas fa-dollar-sign"></i>
<input type="number" name="price" class="form-input
form-input-icon" step="0.01" min="0" placeholder="Precio" value="${service?.price
|| ''}" required>
</div>
<div class="flex justify-end gap-4 pt-4">
<button type="button" class="btn btn-secondary modal-
close-btn">Cancelar</button>
<button type="submit" class="btn btn-primary">Guardar
Servicio</button>
</div>
</form>
</div>`;
this.showModal(modalHTML);
document.getElementById('service-form').addEventListener('submit',
async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const serviceData = Object.fromEntries(formData.entries());
await api.saveService(serviceData);
this.hideModal();
await this.reloadData('services');
this.notify('Servicio guardado con éxito.', 'success');
});
},
<div class="input-icon-wrapper">
<i class="icon fas fa-align-left"></i>
<input type="text" name="concept" class="form-input
form-input-icon" value="${expense?.concept || ''}" placeholder="Concepto del gasto"
required>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="input-icon-wrapper">
<i class="icon fas fa-calendar-alt"></i>
<input type="date" name="date" class="form-input
form-input-icon" value="${expense?.date || new Date().toISOString().split('T')[0]}"
required>
</div>
<div class="input-icon-wrapper">
<i class="icon fas fa-dollar-sign"></i>
<input type="number" name="total" class="form-input
form-input-icon" value="${expense?.total || ''}" step="0.01" min="0"
placeholder="Total" required>
</div>
</div>
<div class="input-icon-wrapper">
<i class="icon fas fa-file-invoice"></i>
<input type="text" name="document" class="form-input
form-input-icon" value="${expense?.document || ''}" placeholder="Documento
(Factura/Recibo)">
</div>
<div class="flex justify-end gap-4 pt-4">
<button type="button" class="btn btn-secondary modal-
close-btn">Cancelar</button>
<button type="submit" class="btn btn-primary">Guardar
Gasto</button>
</div>
</form>
</div>`;
this.showModal(modalHTML);
document.getElementById('expense-form').addEventListener('submit',
async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const expenseData = Object.fromEntries(formData.entries());
expenseData.total = parseFloat(expenseData.total);
await api.saveExpense(expenseData);
this.hideModal();
await this.reloadData('expenses');
this.notify('Gasto guardado con éxito.', 'success');
});
},
handleDeleteExpense: function(expenseId) {
this.showConfirmationModal({
title: 'Eliminar Gasto',
message: `¿Estás seguro de que quieres eliminar este gasto?`,
icon: 'fas fa-exclamation-triangle text-red-400',
confirmText: 'Sí, Eliminar',
onConfirm: async () => {
await api.deleteExpense(expenseId);
this.hideModal();
await this.reloadData('expenses');
this.notify('Gasto eliminado.', 'success');
}
});
},
handleManageStock: function(productId) {
const product = this.state.products.find(p => p.id === productId);
if (!product) return;
const modalHTML = `
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold text-white">Gestionar
Suministro</h3>
<button class="btn btn-ghost
modal-close-btn">×</button>
</div>
<p class="text-slate-400 mb-2">Producto: <span class="font-
semibold text-white">${product.name}</span></p>
<p class="text-slate-400 mb-4">Stock Actual: <span class="font-
bold text-2xl text-sky-400">${product.stock} ${product.unit}(s)</span></p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 items-end">
<div>
<label class="form-label">Cantidad a ajustar</label>
<input type="number" id="stock-quantity" class="form-
input" value="1" min="0">
</div>
<div class="flex gap-2">
<button id="add-stock-btn" class="btn btn-success flex-
1"><i class="fas fa-plus"></i> Añadir</button>
<button id="remove-stock-btn" class="btn btn-danger
flex-1"><i class="fas fa-minus"></i> Retirar</button>
</div>
</div>
</div>`;
this.showModal(modalHTML);
const ticketHTML = `
<div class="p-0 max-w-md mx-auto">
<div class="flex justify-between items-center p-4 bg-slate-800
border-b border-slate-700 rounded-t-xl no-print">
<h3 class="text-xl font-bold text-white">Ticket de
Orden</h3>
<button class="btn btn-ghost
modal-close-btn">×</button>
</div>
<div class="print-area">
<div class="ticket-preview">
<div class="text-center">
<h2 class="font-bold">$
{this.state.settings.businessName}</h2>
<p>${this.state.settings.address}</p>
<p>Tel: ${this.state.settings.phone}</p>
</div>
<hr class="border-dashed border-black my-2">
<p>Folio: ${order.id}</p>
<p>Cliente: ${order.client_name}</p>
<p>Fecha: ${new
Date(order.reception_date).toLocaleString('es-ES')}</p>
<hr class="border-dashed border-black my-2">
<table class="w-full">
<thead><tr><th>Cant.</th><th class="text-
left">Descripción</th><th class="text-right">Importe</th></tr></thead>
<tbody>${itemsHTML}</tbody>
</table>
<hr class="border-dashed border-black my-2">
<p class="text-right font-bold">TOTAL: $$
{parseFloat(order.total).toFixed(2)}</p>
<p class="text-right">Pagado: $$
{(parseFloat(order.total_paid) || 0).toFixed(2)}</p>
<p class="text-right font-bold">Pendiente: $$
{(parseFloat(order.total) - (parseFloat(order.total_paid) || 0)).toFixed(2)}</p>
<hr class="border-dashed border-black my-2">
<p class="text-center text-xs">¡Gracias por su
preferencia!</p>
</div>
</div>
<div class="p-4 flex justify-end gap-3 bg-slate-800 border-t
border-slate-700 rounded-b-xl no-print">
<button class="btn btn-secondary
modal-close-btn">Cerrar</button>
<button id="print-ticket-btn" class="btn btn-primary"><i
class="fas fa-print"></i> Imprimir</button>
</div>
</div>
`;
this.showModal(ticketHTML);
document.getElementById('print-ticket-btn').addEventListener('click',
() => {
window.print();
this.hideModal();
});
},
handleCashCut: function() {
const modalHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-white mb-4">Corte de
Caja</h3>
<p class="text-slate-400 mb-4">Genera un reporte del turno
actual o por un rango de fechas.</p>
<div class="flex justify-center gap-4">
<button id="cut-current-shift" class="btn btn-
secondary">Turno Actual</button>
<button id="cut-date-range" class="btn btn-primary">Por
Fechas</button>
</div>
</div>`;
this.showModal(modalHTML);
document.getElementById('cut-current-
shift').addEventListener('click', () => this.generateCashCutReport(true));
document.getElementById('cut-date-range').addEventListener('click',
() => this.showDateRangePickerForCashCut());
},
showDateRangePickerForCashCut: function() {
const formHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-white mb-4">Corte por Rango
de Fechas</h3>
<form id="cash-cut-form" class="space-y-4">
<div><label class="form-label">Fecha de
Inicio</label><input type="date" id="start-date" class="form-input" required></div>
<div><label class="form-label">Fecha de Fin</label><input
type="date" id="end-date" class="form-input" required></div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-primary">Generar
Reporte</button>
</div>
</form>
</div>`;
this.showModal(formHTML);
if (isCurrentShift) {
reportTitle = `TURNO ACTUAL (${this.state.userInfo.username})`;
const { initialCash, todaySales, todayExpenses, cashBalance } =
this.state.cashierData;
reportData = {
initialCash,
salesByMethod: todaySales,
totalExpenses: todayExpenses,
netBalance: todaySales.cash - todayExpenses,
finalCash: cashBalance
};
} else {
reportTitle = `REPORTE DE ${startDate.toLocaleDateString()} - $
{endDate.toLocaleDateString()}`;
const ordersInRange = this.state.orders.filter(o => {
const orderDate = new Date(o.reception_date);
return orderDate >= startDate && orderDate <= endDate;
});
const expensesInRange = this.state.expenses.filter(exp => {
const expenseDate = new Date(exp.date + 'T00:00:00');
return expenseDate >= startDate && expenseDate <= endDate;
});
const reportHTML = `
<div class="p-0 max-w-md mx-auto">
<div class="flex justify-between items-center p-4 bg-slate-800
border-b border-slate-700 rounded-t-xl no-print">
<h3 class="text-xl font-bold text-white">Corte de Caja</h3>
<button class="btn btn-ghost
modal-close-btn">×</button>
</div>
<div class="print-area">
<div class="ticket-preview">
<div class="text-center">
<h2 class="font-bold">$
{this.state.settings.businessName}</h2>
<p>${reportTitle}</p>
</div>
<hr class="border-dashed border-black my-2">
${reportData.initialCash !== null ? `<p>Fondo Inicial:
$${reportData.initialCash.toFixed(2)}</p>` : ''}
<p class="font-bold">INGRESOS:</p>
<p>Efectivo: $$
{reportData.salesByMethod.cash.toFixed(2)}</p>
<p>Tarjeta: $$
{reportData.salesByMethod.card.toFixed(2)}</p>
<p>Transferencia: $$
{reportData.salesByMethod.transfer.toFixed(2)}</p>
<p>Vales: $$
{reportData.salesByMethod.vales.toFixed(2)}</p>
<p class="font-bold">TOTAL INGRESOS: $$
{reportData.salesByMethod.total.toFixed(2)}</p>
<hr class="border-dashed border-black my-2">
<p class="font-bold">GASTOS:</p>
<p>Total Gastos: -$$
{reportData.totalExpenses.toFixed(2)}</p>
<hr class="border-dashed border-black my-2">
<p class="font-bold">BALANCE (Efectivo - Gastos):</p>
<p class="text-lg font-bold">$$
{reportData.netBalance.toFixed(2)}</p>
${reportData.finalCash !== null ? `<p class="font-bold
mt-2">TOTAL EN CAJA ESPERADO:</p><p class="text-lg font-bold">$$
{reportData.finalCash.toFixed(2)}</p>` : ''}
</div>
</div>
<div class="p-4 flex justify-end gap-3 bg-slate-800 border-t
border-slate-700 rounded-b-xl no-print">
<button class="btn btn-secondary
modal-close-btn">Cerrar</button>
<button id="print-cash-cut-btn" class="btn btn-primary"><i
class="fas fa-print"></i> Imprimir</button>
</div>
</div>
`;
this.showModal(reportHTML);
document.getElementById('print-cash-cut-btn').addEventListener('click',
() => {
window.print();
this.hideModal();
});
},
showCreateAdminPinModal: function(onSuccess) {
const modalHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-white mb-2">Crear NIP de
Administrador</h3>
<p class="text-slate-400 mb-4">Estás activando el inicio de
sesión. Por favor, crea un NIP de 4 dígitos para la cuenta 'admin'.</p>
<form id="create-pin-form" class="space-y-4">
<div><label class="form-label">Nuevo NIP (4
dígitos)</label><input type="password" id="new-admin-pin" class="form-input"
maxlength="4" required></div>
<div><label class="form-label">Confirmar NIP</label><input
type="password" id="confirm-admin-pin" class="form-input" maxlength="4"
required></div>
<div class="flex justify-end pt-4">
<button type="submit" class="btn btn-
primary">Establecer NIP y Guardar</button>
</div>
</form>
</div>`;
this.showModal(modalHTML);
document.getElementById('create-pin-form').addEventListener('submit',
async (e) => {
e.preventDefault();
const newPin = document.getElementById('new-admin-pin').value;
const confirmPin = document.getElementById('confirm-admin-
pin').value;
if (newPin.length !== 4) {
this.notify('El NIP debe tener exactamente 4 dígitos.',
'error');
return;
}
if (newPin !== confirmPin) {
this.notify('Los NIPs no coinciden.', 'error');
return;
}
adminUser.password = newPin;
await api.saveUser(adminUser);
this.notify('NIP de administrador actualizado con éxito.',
'success');
onSuccess();
});
},
handleUpgradeToPremium: function() {
App.showPremiumBenefitsModal();
},
handleBuyLogoFeature: function() {
App.showConfirmationModal({
title: 'Comprar Función Premium',
message: '¿Deseas comprar la función para personalizar el logo de
tu negocio?',
icon: 'fas fa-image text-sky-400',
confirmText: 'Sí, Comprar',
onConfirm: async () => {
this.state.settings.premiumFeatures.changeLogo = true;
await api.saveSettings(this.state.settings);
App.hideModal();
App.navigateTo('settings');
App.notify('¡Función de Logo comprada con éxito!', 'success');
}
});
},
handleManageUsers: async function() {
const users = await api.fetchUsers();
this.state.users = users;
const usersHTML = users.map(user => `
<div class="flex items-center justify-between p-3 bg-slate-800
rounded-md">
<div>
<p class="font-semibold text-white">${user.username}</p>
<p class="text-xs text-slate-400 capitalize">$
{user.role}</p>
</div>
<div class="flex items-center gap-2">
${user.role !== 'admin' ? `<button class="btn btn-secondary
btn-sm edit-permissions-btn" data-username="${user.username}"><i class="fas fa-
shield-alt"></i> Permisos</button>` : ''}
</div>
</div>
`).join('');
const modalHTML = `
<div class="p-6 max-w-2xl w-full">
<h3 class="text-xl font-bold text-white mb-4">Gestionar
Usuarios</h3>
<div class="space-y-3 mb-6">${usersHTML}</div>
<button id="add-user-btn" class="btn btn-primary w-full"><i
class="fas fa-user-plus"></i> Añadir Cajero</button>
</div>
`;
this.showModal(modalHTML);
document.getElementById('add-user-btn').addEventListener('click', () =>
this.handleNewUser());
document.querySelectorAll('.edit-permissions-btn').forEach(btn => {
btn.addEventListener('click', (e) =>
this.handleEditPermissions(e.currentTarget.dataset.username));
});
},
handleNewUser: function() {
const cashierCount = this.state.users.filter(u => u.role ===
'cashier').length;
if (cashierCount >= this.state.settings.userSlotLimit) {
this.showConfirmationModal({
title: 'Límite de Cajeros Alcanzado',
message: `Has alcanzado tu límite de $
{this.state.settings.userSlotLimit} cajero(s). ¿Deseas comprar un espacio
adicional?`,
icon: 'fas fa-user-plus text-sky-400',
confirmText: 'Comprar Espacio',
onConfirm: async () => {
this.state.settings.userSlotLimit++;
await api.saveSettings(this.state.settings);
this.notify(`¡Espacio comprado! Ahora tu límite es de $
{this.state.settings.userSlotLimit} cajeros.`, 'success');
this.hideModal();
}
});
return;
}
const formHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-white mb-4">Añadir Nuevo
Cajero</h3>
<form id="new-user-form" class="space-y-4">
<div><label class="form-label">Nombre de
Usuario</label><input type="text" id="new-username" class="form-input"
required></div>
<div><label class="form-label">NIP (4
dígitos)</label><input type="password" id="new-userpin" class="form-input"
maxlength="4" required></div>
<div class="flex justify-end gap-4 pt-4"><button
type="button" class="btn btn-secondary modal-close-btn">Cancelar</button><button
type="submit" class="btn btn-primary">Crear Usuario</button></div>
</form>
</div>`;
this.showModal(formHTML);
document.getElementById('new-user-form').addEventListener('submit',
async (e) => {
e.preventDefault();
const name = document.getElementById('new-username').value;
const pin = document.getElementById('new-userpin').value;
if (this.state.users.find(u => u.username === name)) {
this.notify('El nombre de usuario ya existe.', 'error');
return;
}
const newUser = { username: name, password: pin, role: 'cashier',
permissions: {} };
await api.saveUser(newUser);
this.hideModal();
this.handleManageUsers();
this.notify('Nuevo cajero creado con éxito.', 'success');
});
},
handleEditPermissions: async function(username) {
const user = this.state.users.find(u => u.username === username);
if (!user) return;
const permissionSections = [
{ id: 'dashboard', label: 'Dashboard', actions: ['create_order',
'deliver_order', 'search'] },
{ id: 'orders', label: 'Órdenes', actions: ['view_details', 'edit',
'delete', 'filter'] },
{ id: 'clients', label: 'Clientes', actions: ['create', 'edit',
'delete'] },
{ id: 'services', label: 'Servicios', actions: ['create', 'edit',
'delete'] },
{ id: 'products', label: 'Suministros', actions: ['create', 'edit',
'delete'] },
{ id: 'expenses', label: 'Gastos', actions: ['create', 'delete'] },
{ id: 'reports', label: 'Reportes', actions: ['cut_cash'] },
{ id: 'settings', label: 'Ajustes', actions: ['view'] }
];
const modalHTML = `
<div class="p-6 w-full max-w-4xl">
<h3 class="text-xl font-bold text-white mb-4">Permisos para
<span class="text-sky-400">${username}</span></h3>
<div id="permissions-form" class="space-y-4 max-h-[60vh]
overflow-y-auto pr-2">${permissionsHTML}</div>
<div class="flex justify-end gap-4 pt-6">
<button type="button" class="btn btn-secondary modal-close-
btn">Cancelar</button>
<button id="save-permissions-btn" class="btn btn-
primary">Guardar Permisos</button>
</div>
</div>`;
this.showModal(modalHTML);
document.querySelectorAll('.select-all-perms').forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
const section = e.target.dataset.section;
const isChecked = e.target.checked;
document.querySelectorAll(`#permissions-form input[data-
section="${section}"]`).forEach(el => {
if (el !== e.target) el.checked = isChecked;
});
});
});
document.getElementById('save-permissions-
btn').addEventListener('click', async () => {
const newPermissions = {};
document.querySelectorAll('#permissions-form .perm-
toggle').forEach(el => {
const { section, action } = el.dataset;
if (!newPermissions[section]) newPermissions[section] = {};
newPermissions[section][action] = el.checked;
});
user.permissions = newPermissions;
await api.saveUser(user);
this.hideModal();
this.notify(`Permisos para ${username} actualizados.`, 'success');
});
},
processPayment: function(payment) {
const { amount, method } = payment;
this.state.cashierData.todaySales.total += amount;
const orderDataToSave = {
id: order.id,
clientId: order.client_id || 0,
clientName: order.client_name || 'Cliente no especificado',
deliveryDate: order.delivery_date || new
Date().toISOString().split('T')[0],
total: order.total || 0.00,
details: order.details || [],
payments: order.payments || [],
totalPaid: order.total_paid || 0,
observations: order.observations || '',
status: 'ready',
cashier: order.cashier || this.state.userInfo.username
};
await api.saveOrder(orderDataToSave);
await this.reloadData('dashboard');
this.notify(`Orden #${orderId} marcada como lista.`, 'success');
} catch (error) {
console.error("Error en handleQuickCompleteOrder:", error);
} finally {
this.showLoading(false);
}
},
this.showLoading(true);
const order = await api.findOrderById(orderId);
const client = this.state.clients.find(c => c.id == order.client_id);
this.showLoading(false);
if (!client || !client.phone) {
this.notify('El cliente no tiene un número de teléfono
registrado.', 'error');
return;
}
window.open(whatsappUrl, '_blank');
this.notify('Abriendo WhatsApp para notificar al cliente.', 'info');
},
const modalHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-white mb-4">Liquidar /
Entregar Orden #${orderId}</h3>
<div class="mb-6 bg-slate-800 p-4 rounded-lg space-y-2">
<div class="flex justify-between"><span class="text-slate-
400">Cliente:</span> <span class="font-semibold
text-white">${order.client_name}</span></div>
<div class="flex justify-between"><span class="text-slate-
400">Total Orden:</span> <span class="font-semibold text-white">$$
{parseFloat(order.total).toFixed(2)}</span></div>
<div class="flex justify-between"><span class="text-slate-
400">Total Pagado:</span> <span class="font-semibold text-green-400">$$
{(parseFloat(order.total_paid) || 0).toFixed(2)}</span></div>
<div class="flex justify-between text-lg"><span
class="text-slate-400">Saldo Pendiente:</span> <span class="font-bold text-red-
400">$${dueAmount.toFixed(2)}</span></div>
</div>
${paymentHTML}
</div>`;
this.showModal(modalHTML);
if (isFinalPayment) {
if (amount !== dueAmount) {
this.notify(`Para liquidar, el monto debe ser exactamente
el saldo pendiente de $${dueAmount.toFixed(2)}.`, 'error'); return;
}
} else {
if (amount === 0) {
this.notify('El abono debe ser mayor a cero.', 'error');
return;
}
if (amount >= dueAmount) {
this.notify('Para liquidar la orden, usa el botón "Liquidar
y Entregar".', 'error'); return;
}
}
if (isFinalPayment) {
order.status = 'delivered';
}
await api.saveOrder(order);
this.hideModal();
await this.reloadData('dashboard');
this.notify(isFinalPayment ? `Orden #${orderId} liquidada y
entregada.` : `Abono de $${amount.toFixed(2)} registrado.`, 'success');
};
document.getElementById('save-dashboard-
config').addEventListener('click', async () => {
this.state.settings.dashboardCards.cashBalance =
document.getElementById('toggle-cash-card').checked;
this.state.settings.dashboardCards.ordersCount =
document.getElementById('toggle-orders-card').checked;
this.state.settings.dashboardCards.salesTotal =
document.getElementById('toggle-sales-card').checked;
await api.saveSettings(this.state.settings);
this.hideModal();
this.navigateTo('dashboard');
this.notify('Panel de inicio actualizado.', 'success');
});
},
handleShowCashDetails: function() {
const { initialCash, todaySales, todayExpenses, cashBalance } =
this.state.cashierData;
const modalHTML = `
<div class="p-0">
<div class="flex justify-between items-center p-4 bg-slate-800
border-b border-slate-700 rounded-t-xl">
<h3 class="text-xl font-bold text-white flex items-center
gap-3"><i class="fas fa-cash-register text-sky-400"></i>Detalle de Caja del
Turno</h3>
<button class="btn btn-ghost
modal-close-btn">×</button>
</div>
<div class="p-6 space-y-3">
<div class="flex justify-between items-center text-lg">
<span class="text-slate-400">Fondo Inicial:</span>
<span class="font-semibold text-white">$$
{initialCash.toFixed(2)}</span>
</div>
<hr class="border-slate-700">
<div class="flex justify-between items-center text-lg">
<span class="text-slate-400">Ventas en Efectivo:</span>
<span class="font-semibold text-green-400">+ $$
{todaySales.cash.toFixed(2)}</span>
</div>
<div class="flex justify-between items-center text-sm ml-
4">
<span class="text-slate-500">Ventas con Tarjeta:</span>
<span class="font-semibold text-slate-400">$$
{todaySales.card.toFixed(2)}</span>
</div>
<div class="flex justify-between items-center text-sm ml-
4">
<span class="text-slate-500">Transferencias:</span>
<span class="font-semibold text-slate-400">$$
{todaySales.transfer.toFixed(2)}</span>
</div>
<hr class="border-slate-700">
<div class="flex justify-between items-center text-lg">
<span class="text-slate-400">Gastos del Turno:</span>
<span class="font-semibold text-red-400">- $$
{todayExpenses.toFixed(2)}</span>
</div>
<hr class="border-slate-700">
<div class="flex justify-between items-center text-2xl">
<span class="font-bold text-white">Total en
Caja:</span>
<span class="font-bold text-sky-400">$$
{cashBalance.toFixed(2)}</span>
</div>
</div>
</div>`;
this.showModal(modalHTML);
},
handleGenerateSalesReport: function() {
const modalHTML = `
<div class="p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold text-white">Reporte de
Ventas</h3>
<button class="btn btn-ghost
modal-close-btn">×</button>
</div>
<p class="text-slate-400 mb-4">Selecciona un rango de fechas
para generar el reporte.</p>
<form id="sales-report-form" class="space-y-4">
<div><label class="form-label">Fecha de
Inicio</label><input type="date" id="report-start-date" class="form-input"
required></div>
<div><label class="form-label">Fecha de Fin</label><input
type="date" id="report-end-date" class="form-input" required></div>
<div class="flex justify-end gap-2 pt-4">
<button type="button" id="export-pdf-btn" class="btn
btn-secondary"><i class="fas fa-file-pdf"></i> Exportar a PDF</button>
<button type="button" id="email-report-btn" class="btn
btn-primary"><i class="fas fa-envelope"></i> Enviar por Correo</button>
</div>
</form>
</div>`;
this.showModal(modalHTML);
document.getElementById('export-pdf-btn').addEventListener('click', ()
=> this.notify('Función de exportar a PDF en desarrollo.', 'info'));
document.getElementById('email-report-btn').addEventListener('click',
() => this.handleEmailReport());
},
handleEmailReport: function() {
const emailProviders = ['gmail.com', 'outlook.com', 'hotmail.com',
'yahoo.com', 'icloud.com'];
const providerButtons = emailProviders.map(p => `<button type="button"
class="btn btn-sm btn-secondary email-provider-btn"
data-provider="@${p}">@${p}</button>`).join('');
const modalHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-white mb-4">Enviar Reporte
por Correo</h3>
<div class="input-icon-wrapper">
<i class="icon fas fa-envelope"></i>
<input type="email" id="report-email-input" class="form-
input form-input-icon" placeholder="correo@ejemplo.com">
</div>
<div class="flex flex-wrap gap-2 mt-2">
${providerButtons}
</div>
<div class="flex justify-end gap-2 pt-4">
<button type="button" class="btn btn-secondary modal-close-
btn">Cancelar</button>
<button id="send-email-confirm-btn" class="btn btn-
primary">Enviar</button>
</div>
</div>`;
this.showModal(modalHTML);
document.getElementById('send-email-confirm-
btn').addEventListener('click', () => {
const email = emailInput.value;
if (email) {
this.notify(`Simulando envío de reporte a ${email}`,
'success');
this.hideModal();
} else {
this.notify('Por favor, ingresa un correo válido.', 'error');
}
});
},
handleChangeUsername: function() {
const currentUser = this.state.userInfo;
const modalHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-white mb-4">Cambiar Nombre de
Usuario</h3>
<form id="change-username-form" class="space-y-4">
<div>
<label class="form-label">Nuevo Nombre de
Usuario</label>
<input type="text" id="new-username" class="form-input"
value="${currentUser.username}" required>
</div>
<div class="flex justify-end gap-2 pt-4">
<button type="button" class="btn btn-secondary modal-
close-btn">Cancelar</button>
<button type="submit" class="btn btn-primary">Guardar
Cambios</button>
</div>
</form>
</div>`;
this.showModal(modalHTML);
document.getElementById('change-username-
form').addEventListener('submit', async (e) => {
e.preventDefault();
const newUsername = document.getElementById('new-
username').value.trim();
if (!newUsername) {
this.notify('El nombre de usuario no puede estar vacío.',
'error');
return;
}
if (this.state.users.some(user => user.username === newUsername &&
user.id !== currentUser.id)) {
this.notify('Ese nombre de usuario ya está en uso.', 'error');
return;
}
handleChangePin: function() {
const currentUser = this.state.userInfo;
const modalHTML = `
<div class="p-6">
<h3 class="text-xl font-bold text-white mb-4">Cambiar NIP</h3>
<form id="change-pin-form" class="space-y-4">
<div>
<label class="form-label">NIP Anterior</label>
<input type="password" id="old-pin" class="form-input"
maxlength="4" required>
</div>
<div>
<label class="form-label">Nuevo NIP (4 dígitos)</label>
<input type="password" id="new-pin" class="form-input"
maxlength="4" required>
</div>
<div>
<label class="form-label">Confirmar Nuevo NIP</label>
<input type="password" id="confirm-new-pin" class="form-
input" maxlength="4" required>
</div>
<div class="flex justify-end gap-2 pt-4">
<button type="button" class="btn btn-secondary modal-close-
btn">Cancelar</button>
<button type="submit" class="btn btn-primary">Actualizar
NIP</button>
</div>
</form>
</div>`;
this.showModal(modalHTML);
document.getElementById('change-pin-form').addEventListener('submit',
async (e) => {
e.preventDefault();
const oldPin = document.getElementById('old-pin').value;
const newPin = document.getElementById('new-pin').value;
const confirmNewPin = document.getElementById('confirm-new-
pin').value;
try {
const verification = await api.verifyPin(oldPin);
if (!verification.success) {
this.notify('El NIP anterior es incorrecto.', 'error');
return;
}
if (newPin.length !== 4) {
this.notify('El nuevo NIP debe tener 4 dígitos.', 'error');
return;
}
if (newPin !== confirmNewPin) {
this.notify('Los nuevos NIPs no coinciden.', 'error');
return;
}
this.state.userInfo.password = newPin;
this.hideModal();
this.notify('NIP actualizado con éxito.', 'success');
} catch(error) {
console.error("Error al cambiar el NIP", error);
}
});
},
};
// =================================================================
// LÓGICA PARA COMUNICACIÓN ENTRE VENTANAS (POS)
// =================================================================
const POS_ORDER_RESULT_KEY = 'posOrderResult';
function listenForOrderFromPOS(callback) {
window.addEventListener('storage', (event) => {
if (event.key === POS_ORDER_RESULT_KEY) {
try {
const orderData = JSON.parse(event.newValue);
if (orderData) {
callback(orderData);
localStorage.removeItem(POS_ORDER_RESULT_KEY);
}
} catch (e) {
console.error("Error al procesar la orden recibida del POS:",
e);
}
}
});
}
// Iniciar la aplicación
App.init();
});