feat: Implement unified products table with anticipos management

- Added unified table for services, courses, and anticipos in Products tab
- Implemented table sorting by folio, date, appointment, and description
- Added filtering by product type and real-time search functionality
- Created action buttons with icons for edit, cancel/reactivate, and delete
- Added special handling for anticipos with appointment date/time fields
- Fixed JavaScript conflicts and function naming issues
- Integrated with existing product management system
- Added responsive design with category badges and status indicators

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Marco Gallegos
2025-09-02 15:07:32 -06:00
parent 541d2f8883
commit 43eca8269e
4 changed files with 526 additions and 28 deletions

314
app.js
View File

@@ -1050,6 +1050,7 @@ async function handleAddOrUpdateProduct(e) {
products.push(result);
}
renderProductTables();
updateUnifiedProductsAfterChange(); // Actualizar tabla unificada
formProduct.reset();
document.getElementById('p-id').value = '';
} else {
@@ -1067,6 +1068,7 @@ async function deleteProduct(id) {
if (response.ok) {
products = products.filter(p => p.id !== id);
renderProductTables();
updateUnifiedProductsAfterChange(); // Actualizar tabla unificada
}
} catch (error) {
alert('Error de conexión al eliminar el producto.');
@@ -1763,6 +1765,8 @@ async function initializeApp() {
populateFooter();
console.log('Initializing dynamic system...');
initializeDynamicSystem();
console.log('Initializing unified products table...');
initializeUnifiedTable();
console.log('Initialization complete.');
}).catch(error => {
@@ -1771,4 +1775,314 @@ async function initializeApp() {
});
}
// --- NUEVA IMPLEMENTACIÓN: TABLA UNIFICADA DE PRODUCTOS ---
// Estado global para la tabla unificada
let allProductsData = [];
let currentSortField = 'descripcion';
let currentSortDirection = 'asc';
// Generar folio único para productos
function generateProductFolio(type) {
const folioPrefix = settings.folioPrefix || 'PRD';
const timestamp = Date.now().toString().slice(-6);
const typeCode = {
'service': 'SRV',
'course': 'CRS',
'anticipo': 'ANT'
};
return `${folioPrefix}-${typeCode[type]}-${timestamp}`;
}
// Construir fecha de cita para anticipos
function construirFechaCitaProducto() {
const dia = document.getElementById('p-cita-dia').value;
const mes = document.getElementById('p-cita-mes').value;
const año = document.getElementById('p-cita-año').value;
const hora = document.getElementById('p-hora-cita').value;
if (!dia || !mes || !año) {
return '';
}
const diaStr = dia.padStart(2, '0');
const mesStr = mes.padStart(2, '0');
const horaStr = hora || '00:00';
return `${diaStr}/${mesStr}/${año} ${horaStr}`;
}
// Renderizar tabla unificada
function renderUnifiedProductsTable() {
const tableBody = document.querySelector('#tblAllProducts tbody');
if (!tableBody) return;
// Limpiar tabla
tableBody.innerHTML = '';
// Aplicar filtros
let filteredData = [...allProductsData];
const filterType = document.getElementById('filter-type')?.value;
if (filterType) {
filteredData = filteredData.filter(item => item.categoria === filterType);
}
const searchTerm = document.getElementById('search-products')?.value?.toLowerCase();
if (searchTerm) {
filteredData = filteredData.filter(item =>
item.descripcion.toLowerCase().includes(searchTerm) ||
item.categoria.toLowerCase().includes(searchTerm)
);
}
// Ordenar datos
filteredData.sort((a, b) => {
let aValue = a[currentSortField] || '';
let bValue = b[currentSortField] || '';
if (typeof aValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (currentSortDirection === 'asc') {
return aValue < bValue ? -1 : aValue > bValue ? 1 : 0;
} else {
return aValue > bValue ? -1 : aValue < bValue ? 1 : 0;
}
});
// Renderizar filas
filteredData.forEach(item => {
const row = document.createElement('tr');
const statusClass = item.status === 'cancelled' ? 'status-cancelled' : 'status-active';
const categoryClass = `category-${item.categoria}`;
row.innerHTML = `
<td>${item.folio}</td>
<td>${item.fecha}</td>
<td>${item.cita || 'N/A'}</td>
<td>${escapeHTML(item.descripcion)}</td>
<td><span class="category-badge ${categoryClass}">${getCategoryName(item.categoria)}</span></td>
<td>$${parseFloat(item.precio || 0).toFixed(2)}</td>
<td>
<div class="action-buttons">
<button class="btn-icon btn-edit" onclick="editUnifiedProduct('${item.id}')" title="Editar">
<span class="material-icons-outlined">edit</span>
</button>
<button class="btn-icon btn-cancel" onclick="toggleProductStatus('${item.id}')" title="${item.status === 'cancelled' ? 'Reactivar' : 'Cancelar'}">
<span class="material-icons-outlined">${item.status === 'cancelled' ? 'check_circle' : 'cancel'}</span>
</button>
<button class="btn-icon btn-delete" onclick="deleteUnifiedProduct('${item.id}')" title="Eliminar">
<span class="material-icons-outlined">delete</span>
</button>
</div>
</td>
`;
tableBody.appendChild(row);
});
}
// Obtener nombre amigable de categoría
function getCategoryName(categoria) {
const names = {
'service': 'Servicio',
'course': 'Curso',
'anticipo': 'Anticipo'
};
return names[categoria] || categoria;
}
// Cargar datos unificados
function loadUnifiedProductsData() {
allProductsData = [];
// Agregar productos existentes (servicios y cursos)
products.forEach(product => {
allProductsData.push({
id: product.id,
folio: product.folio || generateProductFolio(product.type),
fecha: product.created_at ? new Date(product.created_at).toLocaleDateString('es-ES') : new Date().toLocaleDateString('es-ES'),
cita: '', // Los servicios y cursos no tienen cita predefinida
descripcion: product.name,
categoria: product.type,
precio: product.price || 0,
status: product.status || 'active'
});
});
// Agregar anticipos desde movimientos
const anticiposMovements = movements.filter(m =>
m.concepto && (m.concepto.includes('Anticipo') || m.concepto.includes('anticipo'))
);
anticiposMovements.forEach(anticipo => {
allProductsData.push({
id: `anticipo-${anticipo.id}`,
folio: anticipo.folio,
fecha: new Date(anticipo.fecha).toLocaleDateString('es-ES'),
cita: anticipo.fechaCita ? new Date(anticipo.fechaCita).toLocaleDateString('es-ES') + ' ' + (anticipo.horaCita || '') : 'N/A',
descripcion: anticipo.concepto,
categoria: 'anticipo',
precio: anticipo.monto || 0,
status: anticipo.aplicado ? 'cancelled' : 'active'
});
});
}
// Ordenar tabla
function sortTable(field) {
if (currentSortField === field) {
currentSortDirection = currentSortDirection === 'asc' ? 'desc' : 'asc';
} else {
currentSortField = field;
currentSortDirection = 'asc';
}
// Actualizar iconos de ordenamiento
document.querySelectorAll('.sort-icon').forEach(icon => {
icon.textContent = '↕';
});
const currentIcon = document.querySelector(`th[onclick="sortTable('${field}')"] .sort-icon`);
if (currentIcon) {
currentIcon.textContent = currentSortDirection === 'asc' ? '↑' : '↓';
}
renderUnifiedProductsTable();
}
// Editar producto unificado
function editUnifiedProduct(id) {
if (id.startsWith('anticipo-')) {
// Manejar edición de anticipo
const anticipoId = id.replace('anticipo-', '');
const anticipo = movements.find(m => m.id == anticipoId);
if (anticipo) {
alert('La edición de anticipos se realiza desde el historial de ventas.');
}
return;
}
// Manejar edición de producto regular
const product = products.find(p => p.id == id);
if (product) {
document.getElementById('p-id').value = product.id;
document.getElementById('p-name').value = product.name;
document.getElementById('p-type').value = product.type;
document.getElementById('p-price').value = product.price || '';
// Mostrar/ocultar campos de anticipo
toggleAnticipoFields(product.type);
}
}
// Cambiar estado del producto
async function toggleProductStatus(id) {
if (id.startsWith('anticipo-')) {
alert('El estado de los anticipos se maneja desde el sistema de ventas.');
return;
}
const product = products.find(p => p.id == id);
if (!product) return;
const newStatus = product.status === 'cancelled' ? 'active' : 'cancelled';
const actionText = newStatus === 'cancelled' ? 'cancelar' : 'reactivar';
if (confirm(`¿Estás seguro de que quieres ${actionText} este producto?`)) {
try {
const response = await fetch(`/api/products/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
...product,
status: newStatus
})
});
if (response.ok) {
product.status = newStatus;
loadUnifiedProductsData();
renderUnifiedProductsTable();
}
} catch (error) {
alert('Error de conexión al actualizar el producto.');
}
}
}
// Eliminar producto unificado
async function deleteUnifiedProduct(id) {
if (id.startsWith('anticipo-')) {
alert('Los anticipos no se pueden eliminar desde aquí. Usa el historial de ventas.');
return;
}
if (confirm('¿Estás seguro de que quieres eliminar este producto permanentemente?')) {
try {
const response = await fetch(`/api/products/${id}`, { method: 'DELETE' });
if (response.ok) {
products = products.filter(p => p.id != id);
loadUnifiedProductsData();
renderUnifiedProductsTable();
renderProductTables(); // Actualizar también las tablas originales
}
} catch (error) {
alert('Error de conexión al eliminar el producto.');
}
}
}
// Mostrar/ocultar campos de anticipo
function toggleAnticipoFields(type) {
const anticipoFields = document.getElementById('anticipo-fields');
if (anticipoFields) {
anticipoFields.style.display = type === 'anticipo' ? 'block' : 'none';
}
}
// Función para actualizar tabla unificada después de cambios
function updateUnifiedProductsAfterChange() {
loadUnifiedProductsData();
renderUnifiedProductsTable();
}
// Inicializar controles de la tabla unificada
function initializeUnifiedTable() {
// Verificar que los elementos existan antes de agregar listeners
const filterType = document.getElementById('filter-type');
const searchProducts = document.getElementById('search-products');
const productTypeSelect = document.getElementById('p-type');
if (filterType) {
filterType.addEventListener('change', renderUnifiedProductsTable);
}
if (searchProducts) {
searchProducts.addEventListener('input', renderUnifiedProductsTable);
}
if (productTypeSelect) {
productTypeSelect.addEventListener('change', (e) => {
toggleAnticipoFields(e.target.value);
});
}
// Solo cargar si hay datos disponibles
if (typeof products !== 'undefined' && typeof movements !== 'undefined') {
loadUnifiedProductsData();
renderUnifiedProductsTable();
}
}
// Exponer funciones globalmente para uso en onclick
window.sortTable = sortTable;
window.editUnifiedProduct = editUnifiedProduct;
window.toggleProductStatus = toggleProductStatus;
window.deleteUnifiedProduct = deleteUnifiedProduct;
document.addEventListener('DOMContentLoaded', initializeApp);

View File

@@ -1,6 +1,6 @@
services:
ap-pos:
image: coderk/ap_pos:1.3.5
image: marcogll/ap_pos:1.3.5
container_name: ap-pos
restart: unless-stopped
ports:

View File

@@ -417,22 +417,86 @@
<!-- Pestaña de Productos -->
<div id="tab-products" class="tab-content">
<div class="section">
<h2>Gestión de Productos y Cursos</h2>
<h2>Gestión de Productos, Servicios y Anticipos</h2>
<!-- Controles de filtrado y ordenamiento -->
<div class="filter-controls">
<div class="filter-row">
<div class="form-group">
<label>Filtrar por tipo:</label>
<select id="filter-type">
<option value="">Todos</option>
<option value="service">Servicios</option>
<option value="course">Cursos</option>
<option value="anticipo">Anticipos</option>
</select>
</div>
<div class="form-group">
<label>Ordenar por:</label>
<select id="sort-by">
<option value="descripcion">Descripción</option>
<option value="categoria">Categoría</option>
<option value="fecha">Fecha</option>
</select>
</div>
<div class="form-group">
<label>Búsqueda:</label>
<input type="text" id="search-products" placeholder="Buscar por descripción..." />
</div>
</div>
</div>
<!-- Formulario para añadir/editar -->
<div class="sub-section">
<h3>Añadir/Editar</h3>
<h3>Añadir/Editar Producto</h3>
<form id="formProduct">
<input type="hidden" id="p-id" />
<div class="form-grid">
<label>Nombre:</label>
<input type="text" id="p-name" required />
<label>Tipo:</label>
<label>Descripción:</label>
<input type="text" id="p-name" required placeholder="Nombre del producto/servicio" />
<label>Tipo/Categoría:</label>
<select id="p-type" required>
<option value="service">Servicio</option>
<option value="course">Curso</option>
<option value="anticipo">Anticipo</option>
</select>
<label>Precio (MXN):</label>
<input type="number" id="p-price" step="0.01" min="0" />
<input type="number" id="p-price" step="0.01" min="0" placeholder="0.00" />
</div>
<!-- Campos adicionales para anticipos -->
<div id="anticipo-fields" class="form-grid" style="display: none;">
<label>Fecha de cita:</label>
<div class="date-time-container">
<input type="number" id="p-cita-dia" min="1" max="31" placeholder="DD" class="date-field" />
<span class="date-separator">/</span>
<input type="number" id="p-cita-mes" min="1" max="12" placeholder="MM" class="date-field" />
<span class="date-separator">/</span>
<input type="number" id="p-cita-año" min="2024" max="2030" placeholder="AAAA" class="date-field-year" />
</div>
<label>Hora de cita:</label>
<select id="p-hora-cita" class="time-select">
<option value="">-- Seleccionar hora --</option>
<option value="10:00">10:00 AM</option>
<option value="10:30">10:30 AM</option>
<option value="11:00">11:00 AM</option>
<option value="11:30">11:30 AM</option>
<option value="12:00">12:00 PM</option>
<option value="12:30">12:30 PM</option>
<option value="13:00">1:00 PM</option>
<option value="13:30">1:30 PM</option>
<option value="14:00">2:00 PM</option>
<option value="14:30">2:30 PM</option>
<option value="15:00">3:00 PM</option>
<option value="15:30">3:30 PM</option>
<option value="16:00">4:00 PM</option>
<option value="16:30">4:30 PM</option>
<option value="17:00">5:00 PM</option>
<option value="17:30">5:30 PM</option>
<option value="18:00">6:00 PM</option>
</select>
</div>
<div class="form-actions">
<button type="submit">Guardar</button>
<button type="reset" id="btnCancelEditProduct" class="btn-danger">Cancelar</button>
@@ -442,31 +506,18 @@
<hr class="section-divider">
<!-- Tabla unificada -->
<div class="sub-section">
<h3>Servicios</h3>
<h3>Todos los Productos</h3>
<div class="table-wrapper">
<table id="tblServices">
<table id="tblAllProducts">
<thead>
<tr>
<th>Nombre</th>
<th>Precio</th>
<th>Acciones</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<hr class="section-divider">
<div class="sub-section">
<h3>Cursos</h3>
<div class="table-wrapper">
<table id="tblCourses">
<thead>
<tr>
<th>Nombre</th>
<th onclick="sortTable('folio')">Folio <span class="sort-icon"></span></th>
<th onclick="sortTable('fecha')">Fecha <span class="sort-icon"></span></th>
<th onclick="sortTable('cita')">Cita <span class="sort-icon"></span></th>
<th onclick="sortTable('descripcion')">Descripción <span class="sort-icon"></span></th>
<th onclick="sortTable('categoria')">Categoría <span class="sort-icon"></span></th>
<th>Precio</th>
<th>Acciones</th>
</tr>

View File

@@ -47,6 +47,139 @@ h3 {
font-size: 18px;
}
/* --- Controles de filtrado y búsqueda --- */
.filter-controls {
margin-bottom: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #dee2e6;
}
.filter-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 15px;
align-items: end;
}
@media (max-width: 768px) {
.filter-row {
grid-template-columns: 1fr;
}
}
/* --- Estilos para tabla unificada --- */
.sort-icon {
font-size: 12px;
margin-left: 5px;
color: #6c757d;
}
th[onclick] {
cursor: pointer;
user-select: none;
}
th[onclick]:hover {
background-color: #f8f9fa;
}
.action-buttons {
display: flex;
gap: 5px;
justify-content: center;
}
.btn-icon {
background: none;
border: none;
padding: 5px;
cursor: pointer;
border-radius: 4px;
transition: background-color 0.2s;
font-size: 16px;
}
.btn-icon:hover {
background-color: #f8f9fa;
}
.btn-icon.btn-edit {
color: #007bff;
}
.btn-icon.btn-cancel {
color: #ffc107;
}
.btn-icon.btn-delete {
color: #dc3545;
}
.btn-icon.btn-edit:hover {
background-color: #e7f3ff;
}
.btn-icon.btn-cancel:hover {
background-color: #fff3cd;
}
.btn-icon.btn-delete:hover {
background-color: #f8d7da;
}
/* --- Campos adicionales para anticipos --- */
#anticipo-fields {
margin-top: 15px;
padding: 15px;
background: #f8f9fa;
border-radius: 6px;
border: 1px solid #dee2e6;
}
/* --- Estados de productos --- */
.product-status {
padding: 3px 8px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
text-align: center;
}
.status-active {
background-color: #d4edda;
color: #155724;
}
.status-cancelled {
background-color: #f8d7da;
color: #721c24;
}
.category-badge {
padding: 3px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.category-service {
background-color: #cce5ff;
color: #0066cc;
}
.category-course {
background-color: #d1ecf1;
color: #0c5460;
}
.category-anticipo {
background-color: #fff3cd;
color: #856404;
}
.section {
margin-bottom: 40px;
}