dejar mas bonito clientes, posiblidad de quitar lineas en lineas de factura y posiblidad de pagar en las impagadas

This commit is contained in:
Алекс 2026-02-06 19:27:48 +01:00
parent 4b4ec43f9d
commit 75459a90c3
12 changed files with 2007 additions and 20 deletions

View File

@ -0,0 +1,45 @@
/**
* Muestra un toast de aviso cuando se bloquea la navegación
* @param {string} message - Mensaje a mostrar
* @param {number} duration - Duración en ms (default: 3000)
*/
export function showBlockedNavigationToast(message = 'Guarda los cambios antes de salir', duration = 3000) {
// Eliminar toast existente si hay uno
const existingToast = document.querySelector('.blocked-nav-toast');
if (existingToast) {
existingToast.remove();
}
const toast = document.createElement('div');
toast.className = 'blocked-nav-toast';
toast.innerHTML = /*html*/`
<div class="blocked-nav-toast-content">
<svg class="blocked-nav-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="8" x2="12" y2="12"/>
<line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
<span class="blocked-nav-message">${message}</span>
</div>
`;
document.body.appendChild(toast);
// Animar entrada
requestAnimationFrame(() => {
toast.classList.add('visible');
});
// Auto-cerrar después del tiempo especificado
setTimeout(() => {
toast.classList.remove('visible');
toast.classList.add('hiding');
setTimeout(() => {
toast.remove();
}, 300);
}, duration);
return toast;
}

View File

@ -0,0 +1,91 @@
// Helper para mostrar N/A si el valor es nulo/vacío/inválido
function displayValue(value) {
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
return value;
}
export function ClientItem(client, onView) {
const fragment = document.createDocumentFragment();
const item = document.createElement('tr');
item.className = 'client-item';
const contactsCount = client.contacts ? client.contacts.length : 0;
// Obtener texto de estado
const getStatusText = (status) => {
const statusMap = { '0': 'Inactivo', '1': 'Activo' };
return statusMap[status] || 'N/A';
};
const getStatusClass = (status) => {
const classMap = { '0': 'status-inactive', '1': 'status-active' };
return classMap[status] || 'status-unknown';
};
// Obtener iniciales para el avatar
const getInitials = () => {
if (!client.name) return '?';
const words = client.name.split(' ');
if (words.length >= 2) {
return (words[0][0] + words[1][0]).toUpperCase();
}
return client.name.substring(0, 2).toUpperCase();
};
// Generar color consistente basado en el nombre
const getAvatarColor = () => {
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444',
'#8b5cf6', '#ec4899', '#06b6d4', '#84cc16',
];
let hash = 0;
const name = client.name || '';
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
};
item.innerHTML = /*html*/`
<td class="client-avatar-cell">
<div class="client-avatar" style="background-color: ${getAvatarColor()}">
${getInitials()}
</div>
</td>
<td class="client-name">
<div class="client-name-wrapper">
<span class="client-company-name">${displayValue(client.name)}</span>
<span class="client-code">${displayValue(client.codeClient)}</span>
</div>
</td>
<td class="client-type">${displayValue(client.typentCode)}</td>
<td class="client-status">
<span class="status-badge-client ${getStatusClass(client.status)}">${getStatusText(client.status)}</span>
</td>
<td class="client-email">
<span class="email-text" title="${displayValue(client.email)}">${displayValue(client.email)}</span>
</td>
<td class="client-phone">${displayValue(client.phone)}</td>
<td class="client-actions">
<button class="btn-action btn-view" data-client-id="${client.id}" title="Ver detalles">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/>
</svg>
</button>
</td>
`;
fragment.appendChild(item);
// Event listener para el botón de ver
const viewBtn = item.querySelector('.btn-view');
viewBtn.addEventListener('click', () => {
if (onView) {
onView(client.id);
}
});
return fragment;
}

View File

@ -0,0 +1,228 @@
/**
* Componente de modal de confirmación para salir sin guardar cambios
* @param {Object} options - Opciones del modal
* @param {string} options.title - Título del modal (opcional)
* @param {string} options.message - Mensaje del modal (opcional)
* @param {string} options.confirmText - Texto del botón de confirmar (opcional)
* @param {string} options.cancelText - Texto del botón de cancelar (opcional)
* @param {Function} options.onConfirm - Callback al confirmar salida
* @param {Function} options.onCancel - Callback al cancelar (quedarse)
* @returns {HTMLElement} El elemento del modal
*/
export function ConfirmExitModal(options = {}) {
const {
title = 'Cambios sin guardar',
message = '¿Estás seguro de que quieres salir? Los cambios no guardados se perderán.',
confirmText = 'Salir sin guardar',
cancelText = 'Quedarse',
onConfirm = () => {},
onCancel = () => {}
} = options;
const modal = document.createElement('div');
modal.className = 'confirm-exit-overlay';
modal.innerHTML = /*html*/`
<div class="confirm-exit-modal">
<div class="confirm-exit-icon">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
</div>
<h3 class="confirm-exit-title">${title}</h3>
<p class="confirm-exit-message">${message}</p>
<div class="confirm-exit-actions">
<button class="btn-stay" type="button">${cancelText}</button>
<button class="btn-exit" type="button">${confirmText}</button>
</div>
</div>
`;
const handleClose = (confirmed) => {
modal.classList.add('closing');
setTimeout(() => {
modal.remove();
if (confirmed) {
onConfirm();
} else {
onCancel();
}
}, 200);
};
// Botón quedarse
modal.querySelector('.btn-stay').addEventListener('click', () => {
handleClose(false);
});
// Botón salir
modal.querySelector('.btn-exit').addEventListener('click', () => {
handleClose(true);
});
// Cerrar con Escape
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
handleClose(false);
document.removeEventListener('keydown', handleKeyDown);
}
};
document.addEventListener('keydown', handleKeyDown);
// Click fuera del modal (quedarse)
modal.addEventListener('click', (e) => {
if (e.target === modal) {
handleClose(false);
}
});
return modal;
}
/**
* Muestra el modal de confirmación de salida
* @param {Object} options - Opciones del modal
* @returns {Promise<boolean>} true si confirma salir, false si cancela
*/
export function showConfirmExitModal(options = {}) {
return new Promise((resolve) => {
const modal = ConfirmExitModal({
...options,
onConfirm: () => resolve(true),
onCancel: () => resolve(false)
});
document.body.appendChild(modal);
});
}
/**
* Clase para gestionar el seguimiento de cambios en formularios
*/
export class FormChangeTracker {
constructor() {
this.initialState = null;
this.hasChanges = false;
this.listeners = [];
}
/**
* Captura el estado inicial del formulario
* @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario
*/
captureInitialState(container) {
this.initialState = this.getFormState(container);
this.hasChanges = false;
}
/**
* Obtiene el estado actual del formulario
* @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario
* @returns {Object} Estado del formulario
*/
getFormState(container) {
const state = {};
// Inputs de texto, date, number, etc.
container.querySelectorAll('input:not([type="button"]):not([type="submit"])').forEach(input => {
const key = input.name || input.id || input.className;
if (key) {
state[`input_${key}`] = input.value;
}
});
// Textareas
container.querySelectorAll('textarea').forEach(textarea => {
const key = textarea.name || textarea.id || textarea.className;
if (key) {
state[`textarea_${key}`] = textarea.value;
}
});
// Selects
container.querySelectorAll('select').forEach(select => {
const key = select.name || select.id || select.className;
if (key) {
state[`select_${key}`] = select.value;
}
});
// Contar líneas de factura si existen
const lines = container.querySelectorAll('.line-row-compact, .invoice-lines tr');
state['_lineCount'] = lines.length;
return state;
}
/**
* Verifica si hay cambios comparando con el estado inicial
* @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario
* @returns {boolean} true si hay cambios
*/
checkForChanges(container) {
if (!this.initialState) return false;
const currentState = this.getFormState(container);
// Comparar estados
const initialKeys = Object.keys(this.initialState);
const currentKeys = Object.keys(currentState);
// Si cambiaron las claves, hay cambios
if (initialKeys.length !== currentKeys.length) {
this.hasChanges = true;
return true;
}
// Comparar valores
for (const key of initialKeys) {
if (this.initialState[key] !== currentState[key]) {
this.hasChanges = true;
return true;
}
}
this.hasChanges = false;
return false;
}
/**
* Configura listeners automáticos para detectar cambios
* @param {HTMLFormElement|HTMLElement} container - Contenedor del formulario
*/
setupAutoTracking(container) {
const checkChanges = () => {
this.checkForChanges(container);
};
// Escuchar cambios en inputs
container.addEventListener('input', checkChanges);
container.addEventListener('change', checkChanges);
// Observar cambios en el DOM (líneas añadidas/eliminadas)
const observer = new MutationObserver(checkChanges);
observer.observe(container, { childList: true, subtree: true });
this.listeners.push({ container, checkChanges, observer });
}
/**
* Marca que se han guardado los cambios
*/
markAsSaved() {
this.hasChanges = false;
}
/**
* Limpia los listeners
*/
cleanup() {
this.listeners.forEach(({ container, checkChanges, observer }) => {
container.removeEventListener('input', checkChanges);
container.removeEventListener('change', checkChanges);
observer.disconnect();
});
this.listeners = [];
}
}

View File

@ -1,12 +1,18 @@
import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine } from '../services/invoices.js'; import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteInvoiceLine, addPayment, getPayments } from '../services/invoices.js';
import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js';
export function InvoiceModal(invoiceId, onClose, onUpdate) { export function InvoiceModal(invoiceId, onClose, onUpdate) {
const modal = document.createElement('div'); const modal = document.createElement('div');
modal.className = 'modal-overlay'; modal.className = 'modal-overlay';
let invoice = null; let invoice = null;
let payments = [];
let isLoading = true; let isLoading = true;
// Tracker de cambios
const changeTracker = new FormChangeTracker();
let savedSuccessfully = false;
// Formatear fecha para input type="date" // Formatear fecha para input type="date"
const formatDateForInput = (dateString) => { const formatDateForInput = (dateString) => {
if (!dateString) return ''; if (!dateString) return '';
@ -197,20 +203,31 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<th>Precio Unitario</th> <th>Precio Unitario</th>
<th>IVA %</th> <th>IVA %</th>
<th>Total</th> <th>Total</th>
${isDraft ? '<th class="line-actions-col"></th>' : ''}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${invoice.lines && invoice.lines.length > 0 ${invoice.lines && invoice.lines.length > 0
? invoice.lines.map(line => ` ? invoice.lines.map(line => `
<tr> <tr data-line-id="${line.id}">
<td>${line.description || ''}</td> <td>${line.description || ''}</td>
<td>${line.quantity || 0}</td> <td>${line.quantity || 0}</td>
<td>${formatCurrency(line.unitPrice)}</td> <td>${formatCurrency(line.unitPrice)}</td>
<td>${line.taxRate || 0}%</td> <td>${line.taxRate || 0}%</td>
<td>${formatCurrency(line.total)}</td> <td>${formatCurrency(line.total)}</td>
${isDraft ? `
<td class="line-actions">
<button type="button" class="btn-delete-line" data-line-id="${line.id}" title="Eliminar línea">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
</button>
</td>
` : ''}
</tr> </tr>
`).join('') `).join('')
: '<tr><td colspan="5" class="no-lines">No hay líneas en esta factura</td></tr>' : `<tr><td colspan="${isDraft ? 6 : 5}" class="no-lines">No hay líneas en esta factura</td></tr>`
} }
</tbody> </tbody>
</table> </table>
@ -222,12 +239,81 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
<span>Total:</span> <span>Total:</span>
<strong>${formatCurrency(invoice.total)}</strong> <strong>${formatCurrency(invoice.total)}</strong>
</div> </div>
<div class="total-row paid">
<span>Pagado:</span>
<strong>${formatCurrency((invoice.total || 0) - (invoice.remainToPay || 0))}</strong>
</div>
<div class="total-row pending"> <div class="total-row pending">
<span>Pendiente:</span> <span>Pendiente:</span>
<strong>${formatCurrency(invoice.remainToPay)}</strong> <strong>${formatCurrency(invoice.remainToPay)}</strong>
</div> </div>
</div> </div>
</div> </div>
${payments.length > 0 ? `
<!-- Historial de pagos -->
<div class="form-section payments-history-section">
<h3>Historial de Pagos</h3>
<div class="table-responsive">
<table class="invoice-lines-table">
<thead>
<tr>
<th>Fecha</th>
<th>Importe</th>
<th>Referencia</th>
<th>Tipo</th>
<th>Nº Transacción</th>
</tr>
</thead>
<tbody>
${payments.map(p => `
<tr>
<td>${formatDate(p.paymentDate)}</td>
<td><strong class="payment-amount-cell">${formatCurrency(p.amount)}</strong></td>
<td>${p.ref || '-'}</td>
<td>${p.type || '-'}</td>
<td>${p.transactionNum || '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
` : ''}
${(invoice.status === 'validated' || invoice.status === 'unpaid') && invoice.remainToPay > 0 ? `
<!-- Sección de pago -->
<div class="form-section payment-section">
<h3>Registrar Pago</h3>
<div class="payment-form" id="payment-form">
<div class="form-row">
<div class="form-group">
<label>Cantidad a pagar (€)</label>
<input type="number" id="payment-amount"
min="0" max="${invoice.remainToPay}" step="0.01"
placeholder="Dejar vacío o 0 = pago total (${invoice.remainToPay?.toFixed(2)} €)" />
<small class="payment-hint">Máximo: ${formatCurrency(invoice.remainToPay)}. Vacío o 0 = pago completo.</small>
</div>
<div class="form-group">
<label>Fecha de pago</label>
<input type="date" id="payment-date" value="${new Date().toISOString().split('T')[0]}" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Nº Referencia (opcional)</label>
<input type="text" id="payment-ref" placeholder="Nº de referencia del pago" />
</div>
<div class="form-group" style="display: flex; align-items: flex-end;">
<button type="button" class="btn-pay btn-success" id="btn-pay">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
Registrar Pago
</button>
</div>
</div>
</div>
</div>
` : ''}
</form> </form>
</div> </div>
@ -243,15 +329,24 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
`; `;
attachEventListeners(); attachEventListeners();
// Capturar estado inicial después de renderizar
setTimeout(() => {
const modalContent = modal.querySelector('.modal-content');
if (modalContent && !isLoading) {
changeTracker.captureInitialState(modalContent);
changeTracker.setupAutoTracking(modalContent);
}
}, 100);
}; };
// Adjuntar event listeners // Adjuntar event listeners
const attachEventListeners = () => { const attachEventListeners = () => {
// Botón cerrar // Botón cerrar (X) - muestra modal si hay cambios
const closeBtn = modal.querySelector('.btn-close'); const closeBtn = modal.querySelector('.btn-close');
closeBtn?.addEventListener('click', handleClose); closeBtn?.addEventListener('click', handleClose);
// Botón cancelar modal // Botón cancelar modal - muestra modal si hay cambios
const cancelBtn = modal.querySelector('.btn-cancel-modal'); const cancelBtn = modal.querySelector('.btn-cancel-modal');
cancelBtn?.addEventListener('click', handleClose); cancelBtn?.addEventListener('click', handleClose);
@ -278,7 +373,20 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
if (form) form.style.display = 'none'; if (form) form.style.display = 'none';
}); });
// Click fuera del modal // Botones eliminar línea
const deleteLineBtns = modal.querySelectorAll('.btn-delete-line');
deleteLineBtns.forEach(btn => {
btn.addEventListener('click', () => {
const lineId = parseInt(btn.dataset.lineId);
handleDeleteLine(lineId);
});
});
// Botón registrar pago
const payBtn = modal.querySelector('#btn-pay');
payBtn?.addEventListener('click', handlePayment);
// Click fuera del modal - muestra modal si hay cambios
modal.addEventListener('click', (e) => { modal.addEventListener('click', (e) => {
if (e.target === modal) { if (e.target === modal) {
handleClose(); handleClose();
@ -287,7 +395,36 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
}; };
// Manejar cierre // Manejar cierre
const handleClose = () => { const handleClose = async () => {
// Si ya se guardó exitosamente, cerrar directamente
if (savedSuccessfully) {
changeTracker.cleanup();
modal.remove();
if (onClose) onClose();
return;
}
// Verificar si hay cambios sin guardar
const modalContent = modal.querySelector('.modal-content');
if (modalContent) {
changeTracker.checkForChanges(modalContent);
}
if (changeTracker.hasChanges) {
// Mostrar modal de confirmación
const confirmed = await showConfirmExitModal({
title: 'Cambios sin guardar',
message: 'Tienes cambios sin guardar en la factura. ¿Seguro que quieres cerrar?',
confirmText: 'Cerrar sin guardar',
cancelText: 'Seguir editando'
});
if (!confirmed) {
return; // No cerrar el modal
}
}
changeTracker.cleanup();
modal.remove(); modal.remove();
if (onClose) onClose(); if (onClose) onClose();
}; };
@ -319,6 +456,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
await updateInvoice(invoiceId, data); await updateInvoice(invoiceId, data);
// Marcar como guardado exitosamente
savedSuccessfully = true;
changeTracker.markAsSaved();
alert('Factura actualizada correctamente'); alert('Factura actualizada correctamente');
if (onUpdate) onUpdate(); if (onUpdate) onUpdate();
handleClose(); handleClose();
@ -347,11 +488,18 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
await validateInvoice(invoiceId); await validateInvoice(invoiceId);
// Marcar como guardado para no mostrar el modal de confirmación
savedSuccessfully = true;
changeTracker.markAsSaved();
alert('Factura validada correctamente'); alert('Factura validada correctamente');
if (onUpdate) onUpdate(); if (onUpdate) onUpdate();
// Recargar la factura para mostrar el nuevo estado // Recargar la factura para mostrar el nuevo estado
await loadInvoice(); await loadInvoice();
// Recapturar estado inicial después de recargar
savedSuccessfully = false;
} catch (error) { } catch (error) {
console.error('Error al validar:', error); console.error('Error al validar:', error);
alert('Error al validar la factura: ' + error.message); alert('Error al validar la factura: ' + error.message);
@ -422,7 +570,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
alert('Línea añadida correctamente'); alert('Línea añadida correctamente');
toggleAddLineForm(); toggleAddLineForm();
// Recargar y recapturar estado inicial
await loadInvoice(); await loadInvoice();
changeTracker.markAsSaved();
} catch (error) { } catch (error) {
console.error('Error al añadir línea:', error); console.error('Error al añadir línea:', error);
alert('Error al añadir línea: ' + error.message); alert('Error al añadir línea: ' + error.message);
@ -435,6 +586,101 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
} }
}; };
// Eliminar línea de factura
const handleDeleteLine = async (lineId) => {
if (!confirm('¿Estás seguro de que quieres eliminar esta línea?')) {
return;
}
try {
// Deshabilitar el botón mientras se elimina
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
if (btn) {
btn.disabled = true;
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="spin"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>';
}
await deleteInvoiceLine(invoiceId, lineId);
if (onUpdate) onUpdate();
await loadInvoice();
changeTracker.markAsSaved();
} catch (error) {
console.error('Error al eliminar línea:', error);
alert('Error al eliminar línea: ' + error.message);
const btn = modal.querySelector(`.btn-delete-line[data-line-id="${lineId}"]`);
if (btn) {
btn.disabled = false;
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>';
}
}
};
// Manejar pago de factura
const handlePayment = async () => {
const amountInput = modal.querySelector('#payment-amount');
const dateInput = modal.querySelector('#payment-date');
const refInput = modal.querySelector('#payment-ref');
if (!amountInput || !dateInput) return;
const rawAmount = parseFloat(amountInput.value);
const remainToPay = invoice.remainToPay;
// Si vacío o 0, pago total (enviar null)
let amount = null;
if (!isNaN(rawAmount) && rawAmount > 0) {
if (rawAmount > remainToPay) {
alert(`La cantidad no puede superar el pendiente de pago (${remainToPay.toFixed(2)} €)`);
return;
}
amount = rawAmount;
}
const paymentDate = dateInput.value;
if (!paymentDate) {
alert('La fecha de pago es obligatoria');
return;
}
const paymentRef = refInput?.value?.trim() || undefined;
try {
const payBtn = modal.querySelector('#btn-pay');
payBtn.disabled = true;
payBtn.textContent = 'Procesando...';
await addPayment(invoiceId, {
amount: amount,
paymentDate: paymentDate,
paymentModeId: 4,
closePaidInvoices: "yes",
accountId: 1,
numPayment: paymentRef
});
savedSuccessfully = true;
changeTracker.markAsSaved();
const displayAmount = amount ? `${amount.toFixed(2)} €` : `${remainToPay.toFixed(2)} € (total)`;
alert(`Pago de ${displayAmount} registrado correctamente`);
if (onUpdate) onUpdate();
await loadInvoice();
savedSuccessfully = false;
} catch (error) {
console.error('Error al registrar pago:', error);
alert('Error al registrar el pago: ' + error.message);
} finally {
const payBtn = modal.querySelector('#btn-pay');
if (payBtn) {
payBtn.disabled = false;
payBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 4px;"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>Registrar Pago';
}
}
};
// Cargar factura // Cargar factura
const loadInvoice = async () => { const loadInvoice = async () => {
try { try {
@ -442,6 +688,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
renderContent(); renderContent();
invoice = await getInvoiceById(invoiceId); invoice = await getInvoiceById(invoiceId);
try {
payments = await getPayments(invoiceId);
} catch (e) {
payments = [];
}
isLoading = false; isLoading = false;
renderContent(); renderContent();
} catch (error) { } catch (error) {

294
src/pages/ClientesPage.js Normal file
View File

@ -0,0 +1,294 @@
import { ClientItem } from '../components/ClientItem.js';
import { getClients } from '../services/clients.js';
export function renderClientesPage() {
const container = document.createElement('div');
container.className = 'clientes-page';
// Estado de paginación
let currentPage = 1;
let totalPages = 1;
const pageSize = 20;
let allClients = [];
let filteredClients = [];
let searchTerm = '';
container.innerHTML = /*html*/`
<div class="clientes-header">
<h1>Clientes</h1>
</div>
<div class="clientes-filters">
<input type="search" placeholder="Buscar clientes..." class="search-input" />
</div>
<div class="clients-table-container">
<table class="clients-table">
<thead>
<tr>
<th class="client-avatar-col"></th>
<th class="client-name-col">Nombre / Código</th>
<th class="client-type-col">Tipo</th>
<th class="client-status-col">Estado</th>
<th class="client-email-col">Email</th>
<th class="client-phone-col">Teléfono</th>
<th class="client-actions-col">Acciones</th>
</tr>
</thead>
<tbody class="clients-list">
<tr>
<td colspan="7" class="loading">Cargando clientes...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button class="btn-pagination btn-prev">← Anterior</button>
<div class="pagination-info">
<span class="current-page">1</span> / <span class="total-pages">1</span>
</div>
<button class="btn-pagination btn-next">Siguiente →</button>
</div>
`;
// Función para aplicar filtros
function applyFilters() {
if (!searchTerm) {
filteredClients = [...allClients];
} else {
const term = searchTerm.toLowerCase();
filteredClients = allClients.filter(client => {
const nameMatch = client.name?.toLowerCase().includes(term);
const codeMatch = client.codeClient?.toLowerCase().includes(term);
const emailMatch = client.email?.toLowerCase().includes(term);
const phoneMatch = client.phone?.toLowerCase().includes(term);
const contactMatch = client.contacts?.some(c =>
c.firstname?.toLowerCase().includes(term) ||
c.lastname?.toLowerCase().includes(term) ||
c.email?.toLowerCase().includes(term)
);
return nameMatch || codeMatch || emailMatch || phoneMatch || contactMatch;
});
}
renderPage(1);
}
// Función para renderizar la página actual
function renderPage(page = 1) {
const clientsList = container.querySelector('.clients-list');
currentPage = page;
totalPages = Math.ceil(filteredClients.length / pageSize);
// Calcular índices para la página
const startIndex = (page - 1) * pageSize;
const endIndex = Math.min(startIndex + pageSize, filteredClients.length);
const clients = filteredClients.slice(startIndex, endIndex);
console.log(`Página ${currentPage}/${totalPages}, Clientes en esta página: ${clients.length}, Total filtrado: ${filteredClients.length}`);
// Actualizar UI de paginación
updatePaginationUI();
// Limpiar lista
clientsList.innerHTML = '';
// Si no hay clientes
if (clients.length === 0) {
clientsList.innerHTML = '<tr><td colspan="7" class="no-clients">No hay clientes disponibles</td></tr>';
return;
}
// Renderizar cada cliente
clients.forEach(client => {
const clientItem = ClientItem(client, handleViewClient);
clientsList.appendChild(clientItem);
});
}
// Manejar ver cliente
function handleViewClient(clientId) {
const client = allClients.find(c => c.id === clientId);
if (client) {
showClientModal(client);
}
}
// Función helper para mostrar N/A si el valor es nulo/vacío/inválido
function displayValue(value) {
if (value === null || value === undefined || value === '' || value === 'null') return 'N/A';
return value;
}
// Obtener texto de estado del cliente
function getClientStatusText(status) {
const statusMap = {
'0': 'Inactivo',
'1': 'Activo'
};
return statusMap[status] || 'N/A';
}
// Modal simple para ver detalles del cliente
function showClientModal(client) {
const modal = document.createElement('div');
modal.className = 'client-modal-overlay';
const contactsHtml = client.contacts && client.contacts.length > 0
? client.contacts.map(contact => `
<div class="contact-card">
<div class="contact-header">
<span class="contact-full-name">${displayValue(contact.firstname)} ${displayValue(contact.lastname)}</span>
</div>
<div class="contact-details">
<div class="contact-row"><span class="label">📧 Email:</span> <span>${displayValue(contact.email)}</span></div>
<div class="contact-row"><span class="label">📱 Móvil:</span> <span>${displayValue(contact.phoneMobile)}</span></div>
<div class="contact-row"><span class="label">☎️ Profesional:</span> <span>${displayValue(contact.phonePro)}</span></div>
<div class="contact-row"><span class="label">📞 Personal:</span> <span>${displayValue(contact.phonePerso)}</span></div>
</div>
</div>
`).join('')
: '<p class="no-contacts">No hay contactos registrados</p>';
modal.innerHTML = /*html*/`
<div class="client-modal">
<div class="client-modal-header">
<h2>${displayValue(client.name)}</h2>
<button class="btn-close-modal">✕</button>
</div>
<div class="client-modal-body">
<div class="client-info-section">
<h3>Información del Cliente</h3>
<div class="info-grid">
<div class="info-item">
<span class="info-label">ID</span>
<span class="info-value">${client.id}</span>
</div>
<div class="info-item">
<span class="info-label">Código</span>
<span class="info-value">${displayValue(client.codeClient)}</span>
</div>
<div class="info-item">
<span class="info-label">Tipo</span>
<span class="info-value">${displayValue(client.typentCode)}</span>
</div>
<div class="info-item">
<span class="info-label">Estado</span>
<span class="info-value">${getClientStatusText(client.status)}</span>
</div>
<div class="info-item">
<span class="info-label">Email</span>
<span class="info-value">${displayValue(client.email)}</span>
</div>
<div class="info-item">
<span class="info-label">Teléfono</span>
<span class="info-value">${displayValue(client.phone)}</span>
</div>
</div>
</div>
<div class="contacts-section">
<h3>Contactos (${client.contacts?.length || 0})</h3>
<div class="contacts-list">
${contactsHtml}
</div>
</div>
</div>
</div>
`;
// Cerrar modal
const closeBtn = modal.querySelector('.btn-close-modal');
closeBtn.addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
document.body.appendChild(modal);
}
// Función para cargar todos los clientes
async function loadAllClients() {
const clientsList = container.querySelector('.clients-list');
try {
clientsList.innerHTML = '<tr><td colspan="7" class="loading">Cargando clientes...</td></tr>';
const data = await getClients(1000, 1);
// Manejo de respuesta
allClients = Array.isArray(data) ? data : data.data || data.clients || [];
filteredClients = [...allClients];
console.log(`Total de clientes cargados: ${allClients.length}`);
// Renderizar la primera página
renderPage(1);
} catch (error) {
console.error('Error al cargar clientes:', error);
clientsList.innerHTML = '<tr><td colspan="7" class="error">Error al cargar los clientes. Por favor, intenta de nuevo.</td></tr>';
}
}
// Función para actualizar la UI de paginación
function updatePaginationUI() {
const paginationDiv = container.querySelector('.pagination');
const currentPageSpan = container.querySelector('.current-page');
const totalPagesSpan = container.querySelector('.total-pages');
const btnPrev = container.querySelector('.btn-prev');
const btnNext = container.querySelector('.btn-next');
currentPageSpan.textContent = currentPage;
totalPagesSpan.textContent = totalPages;
// Mostrar u ocultar la paginación según el número de páginas
if (totalPages <= 1) {
paginationDiv.style.display = 'none';
} else {
paginationDiv.style.display = 'flex';
}
// Deshabilitar botones según corresponda
btnPrev.disabled = currentPage === 1;
btnNext.disabled = currentPage === totalPages;
}
// Event listeners
const searchInput = container.querySelector('.search-input');
searchInput.addEventListener('input', (e) => {
clearTimeout(searchInput.debounceTimer);
searchInput.debounceTimer = setTimeout(() => {
searchTerm = e.target.value.trim();
applyFilters();
}, 300);
});
// Event listeners de paginación
const btnPrev = container.querySelector('.btn-prev');
const btnNext = container.querySelector('.btn-next');
btnPrev.addEventListener('click', () => {
if (currentPage > 1) {
renderPage(currentPage - 1);
scrollToTop();
}
});
btnNext.addEventListener('click', () => {
if (currentPage < totalPages) {
renderPage(currentPage + 1);
scrollToTop();
}
});
// Función para scroll al inicio
function scrollToTop() {
container.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Cargar todos los clientes al montar el componente
loadAllClients();
return container;
}

View File

@ -1,7 +1,14 @@
import { FormChangeTracker } from '../components/ConfirmExitModal.js';
import { navigationGuards } from '../router.js';
export function renderCreateInvoicePage() { export function renderCreateInvoicePage() {
const container = document.createElement('div'); const container = document.createElement('div');
container.className = 'create-invoice-page'; container.className = 'create-invoice-page';
// Tracker de cambios
const changeTracker = new FormChangeTracker();
let savedSuccessfully = false;
container.innerHTML = /*html*/` container.innerHTML = /*html*/`
<div class="invoice-header-compact"> <div class="invoice-header-compact">
<h1>Nueva Factura</h1> <h1>Nueva Factura</h1>
@ -143,14 +150,42 @@ export function renderCreateInvoicePage() {
linesContainer.appendChild(createInvoiceLine()); linesContainer.appendChild(createInvoiceLine());
}); });
// Capturar estado inicial después de cargar clientes y crear líneas iniciales
setTimeout(() => {
changeTracker.captureInitialState(container);
changeTracker.setupAutoTracking(container);
// Registrar guard en el router - bloquea navegación si hay cambios
navigationGuards.register(() => {
if (savedSuccessfully) return false;
changeTracker.checkForChanges(container);
return changeTracker.hasChanges;
});
}, 500);
// Interceptar beforeunload (cerrar pestaña/navegador)
const handleBeforeUnload = (e) => {
if (savedSuccessfully) return;
changeTracker.checkForChanges(container);
if (changeTracker.hasChanges) {
e.preventDefault();
e.returnValue = '';
return '';
}
};
window.addEventListener('beforeunload', handleBeforeUnload);
// Botón volver - el router se encarga del modal
container.querySelector('#back-btn').addEventListener('click', () => { container.querySelector('#back-btn').addEventListener('click', () => {
window.location.hash = '#invoices'; window.location.hash = '#invoices';
}); });
// Botón cancelar - el router se encarga del modal
container.querySelector('#cancel-btn').addEventListener('click', () => { container.querySelector('#cancel-btn').addEventListener('click', () => {
if (confirm('¿Cancelar? Se perderán los cambios.')) { window.location.hash = '#invoices';
window.location.hash = '#invoices';
}
}); });
container.querySelector('#invoice-form').addEventListener('submit', async (e) => { container.querySelector('#invoice-form').addEventListener('submit', async (e) => {
@ -224,6 +259,13 @@ export function renderCreateInvoicePage() {
} }
const invoiceId = await response.json(); const invoiceId = await response.json();
// Marcar como guardado exitosamente para evitar el modal de confirmación
savedSuccessfully = true;
changeTracker.cleanup();
navigationGuards.unregister();
window.removeEventListener('beforeunload', handleBeforeUnload);
alert(`Factura creada (ID: ${invoiceId})`); alert(`Factura creada (ID: ${invoiceId})`);
window.location.hash = '#invoices'; window.location.hash = '#invoices';

View File

@ -1,6 +1,7 @@
import { renderDashboard } from './DashboardPage.js'; import { renderDashboard } from './DashboardPage.js';
import { renderFacturasPage } from './Facturas.js'; import { renderFacturasPage } from './Facturas.js';
import { renderCreateInvoicePage } from './CreateInvoicePage.js'; import { renderCreateInvoicePage } from './CreateInvoicePage.js';
import { renderClientesPage } from './ClientesPage.js';
// Registry of all pages available in the application // Registry of all pages available in the application
export const pagesRegistry = [ export const pagesRegistry = [
@ -34,11 +35,7 @@ export const pagesRegistry = [
icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>', icon: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>',
requiresAuth: true, requiresAuth: true,
showInSidebar: true, showInSidebar: true,
render: () => { render: renderClientesPage
const div = document.createElement('div');
div.innerHTML = '<h1>Clientes</h1><p>Página en construcción...</p>';
return div;
}
}, },
{ {
route: 'settings', route: 'settings',

View File

@ -2,16 +2,69 @@ import { auth } from './services/auth.js';
import { renderLoginPage } from './pages/LoginPage.js'; import { renderLoginPage } from './pages/LoginPage.js';
import { createSidebar } from './components/Sidebar.js'; import { createSidebar } from './components/Sidebar.js';
import { getAvailablePages, getPageByRoute } from './pages/pagesRegistry.js'; import { getAvailablePages, getPageByRoute } from './pages/pagesRegistry.js';
import { showConfirmExitModal } from './components/ConfirmExitModal.js';
// 🔧 Modo DEV: cambiar a false para activar login // 🔧 Modo DEV: cambiar a false para activar login
const DEV_MODE = false; const DEV_MODE = false;
// Sistema de guards para bloquear navegación
const navigationGuards = {
currentGuard: null,
// Registrar un guard (función que devuelve true si hay cambios sin guardar)
register(checkFn) {
this.currentGuard = checkFn;
},
// Eliminar el guard actual
unregister() {
this.currentGuard = null;
},
// Verificar si hay cambios pendientes
hasUnsavedChanges() {
return this.currentGuard ? this.currentGuard() : false;
}
};
// Exportar para uso en páginas
export { navigationGuards };
export function initRouter() { export function initRouter() {
const app = document.querySelector('#app'); const app = document.querySelector('#app');
let isNavigating = false;
let pendingHash = null;
function navigate() { async function navigate(forceNavigate = false) {
const hash = window.location.hash || (DEV_MODE ? '#dashboard' : '#login'); const hash = window.location.hash || (DEV_MODE ? '#dashboard' : '#login');
const route = hash.substring(1); // Remove the # symbol const route = hash.substring(1);
// Si hay un guard activo y no estamos forzando navegación
if (!forceNavigate && navigationGuards.hasUnsavedChanges()) {
// Guardar el hash al que se quiere ir
pendingHash = hash;
// Volver al hash anterior temporalmente
const previousHash = '#' + (document.querySelector('.main-content')?.dataset?.currentRoute || 'dashboard');
history.pushState(null, '', previousHash);
// Mostrar modal de confirmación
const confirmed = await showConfirmExitModal({
title: 'Cambios sin guardar',
message: 'Tienes cambios sin guardar. ¿Seguro que quieres salir?',
confirmText: 'Salir sin guardar',
cancelText: 'Seguir editando'
});
if (confirmed) {
// Usuario confirmó salir - limpiar guard y navegar
navigationGuards.unregister();
window.location.hash = pendingHash;
}
// Si no confirmó, ya estamos en la página correcta
pendingHash = null;
return;
}
if (!DEV_MODE && !auth.checkAuth() && hash !== '#login') { if (!DEV_MODE && !auth.checkAuth() && hash !== '#login') {
window.location.hash = '#login'; window.location.hash = '#login';
@ -33,6 +86,7 @@ export function initRouter() {
// Create main content area // Create main content area
const mainContent = document.createElement('main'); const mainContent = document.createElement('main');
mainContent.className = 'main-content'; mainContent.className = 'main-content';
mainContent.dataset.currentRoute = route; // Guardar ruta actual
// Get page from registry // Get page from registry
const page = getPageByRoute(route); const page = getPageByRoute(route);
@ -69,6 +123,6 @@ export function initRouter() {
} }
} }
window.addEventListener('hashchange', navigate); window.addEventListener('hashchange', () => navigate(false));
navigate(); navigate(true); // Primera navegación sin guard
} }

35
src/services/clients.js Normal file
View File

@ -0,0 +1,35 @@
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
function getAuthHeaders() {
const token = localStorage.getItem('token');
return {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
};
}
export async function getClients(limit = 50, page = 1) {
const response = await fetch(`${API_BASE_URL}/api/Clients?limit=${limit}&page=${page}`, {
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Error al obtener los clientes');
}
return await response.json();
}
export async function getClientById(id) {
const response = await fetch(`${API_BASE_URL}/api/Clients/${id}`, {
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
throw new Error('Error al obtener el cliente');
}
return await response.json();
}

View File

@ -80,6 +80,49 @@ export async function addInvoiceLine(id, lineData) {
return await response.json(); return await response.json();
} }
export async function deleteInvoiceLine(invoiceId, lineId) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/lines/${lineId}`, {
method: 'DELETE',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al eliminar línea');
}
return true;
}
export async function addPayment(invoiceId, paymentData) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify(paymentData)
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al registrar el pago');
}
return await response.json();
}
export async function getPayments(invoiceId) {
const response = await fetch(`${API_BASE_URL}/api/Invoices/${invoiceId}/payments`, {
method: 'GET',
headers: getAuthHeaders()
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.detail || 'Error al obtener los pagos');
}
return await response.json();
}
export async function deleteInvoice(id) { export async function deleteInvoice(id) {
// Nota: No hay endpoint DELETE en el OpenAPI spec, // Nota: No hay endpoint DELETE en el OpenAPI spec,
// pero podríamos usar el endpoint de cambio de estado a "canceled" // pero podríamos usar el endpoint de cambio de estado a "canceled"

View File

@ -1100,3 +1100,553 @@ button:focus-visible {
grid-column: 2; grid-column: 2;
} }
} }
/* ==========================================
CLIENTES PAGE STYLES
========================================== */
.clientes-page {
padding: 2rem;
max-width: 1600px;
margin: 0 auto;
}
.clientes-header {
margin-bottom: 2rem;
}
.clientes-header h1 {
color: var(--text-primary);
font-size: 2rem;
margin: 0;
}
.clientes-filters {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
align-items: center;
}
.clientes-filters .search-input {
flex: 1;
max-width: 500px;
}
/* Clients Table */
.clients-table-container {
background: var(--card-bg);
border-radius: 12px;
border: 1px solid var(--border-color);
overflow: hidden;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.clients-table {
width: 100%;
border-collapse: collapse;
}
.clients-table thead {
background: #f8fafc;
border-bottom: 2px solid var(--border-color);
}
.clients-table th {
font-weight: 600;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
padding: 1rem 1.5rem;
text-align: left;
}
.clients-table tbody tr {
border-bottom: 1px solid var(--border-color);
transition: background-color 0.2s;
}
.clients-table tbody tr:hover {
background-color: #f8fafc;
}
.clients-table tbody tr:last-child {
border-bottom: none;
}
.clients-table td {
padding: 1rem 1.5rem;
vertical-align: middle;
}
.client-avatar-col {
width: 60px;
}
.client-avatar-cell {
width: 60px;
}
.client-avatar {
width: 42px;
height: 42px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 600;
font-size: 0.9rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.client-name-wrapper {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.client-company-name {
font-weight: 600;
color: var(--text-primary);
}
.client-code {
font-size: 0.8rem;
color: var(--text-secondary);
font-family: monospace;
}
.contact-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.contact-name {
color: var(--text-primary);
}
.contacts-count {
font-size: 0.75rem;
color: var(--primary);
background: rgba(37, 99, 235, 0.1);
padding: 0.15rem 0.5rem;
border-radius: 10px;
display: inline-block;
width: fit-content;
}
.client-email .email-text {
color: var(--text-secondary);
max-width: 200px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: inline-block;
}
.client-phone {
color: var(--text-primary);
font-family: monospace;
}
.client-actions {
text-align: center;
}
.client-actions .btn-action {
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
.clients-table .client-actions-col {
width: 80px;
text-align: center;
}
.clients-table td.client-actions {
text-align: center;
vertical-align: middle;
}
.clients-table td.client-actions .btn-view {
margin: 0 auto;
}
/* Status badge for clients */
.status-badge-client {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
}
.status-badge-client.status-active {
background: rgba(16, 185, 129, 0.1);
color: #059669;
}
.status-badge-client.status-inactive {
background: rgba(239, 68, 68, 0.1);
color: #dc2626;
}
.status-badge-client.status-unknown {
background: rgba(100, 116, 139, 0.1);
color: #64748b;
}
/* Contacts button */
.btn-contacts {
position: relative;
background: rgba(37, 99, 235, 0.08);
border: 1px solid rgba(37, 99, 235, 0.2);
color: var(--primary);
border-radius: 6px;
padding: 0.35rem 0.5rem;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 0.15rem;
}
.btn-contacts:hover {
background: rgba(37, 99, 235, 0.15);
border-color: var(--primary);
}
.btn-contacts.active {
background: var(--primary);
color: white;
border-color: var(--primary);
}
.contacts-badge {
font-size: 0.7rem;
font-weight: 700;
min-width: 16px;
text-align: center;
}
/* Expandable contacts row */
.contacts-expand-row td {
padding: 0 !important;
background: #f8fafc;
}
.contacts-expand-content {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-color);
border-bottom: 2px solid var(--primary);
animation: expandDown 0.2s ease-out;
}
@keyframes expandDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 500px;
}
}
.contacts-expand-header h4 {
margin: 0 0 0.75rem 0;
font-size: 0.85rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.contacts-expand-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
}
.contact-expand-card {
background: white;
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
}
.contact-expand-name {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 0.5rem;
font-size: 0.95rem;
}
.contact-expand-name svg {
color: var(--primary);
flex-shrink: 0;
}
.contact-expand-details {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.3rem 1rem;
font-size: 0.8rem;
color: var(--text-secondary);
}
.contact-expand-details span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.no-clients {
padding: 3rem;
text-align: center;
color: var(--text-secondary);
font-size: 1.1rem;
}
/* Client Modal */
.client-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 1rem;
}
.client-modal {
background: var(--card-bg);
border-radius: 12px;
width: 100%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15);
}
.client-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid var(--border-color);
background: #f8fafc;
}
.client-modal-header h2 {
margin: 0;
font-size: 1.5rem;
color: var(--text-primary);
}
.btn-close-modal {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
border-radius: 4px;
transition: all 0.2s;
}
.btn-close-modal:hover {
background: var(--border-color);
color: var(--text-primary);
}
.client-modal-body {
padding: 1.5rem;
overflow-y: auto;
}
.client-info-section,
.contacts-section {
margin-bottom: 1.5rem;
}
.client-info-section h3,
.contacts-section h3 {
margin: 0 0 1rem 0;
font-size: 1rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.info-item {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-label {
font-size: 0.8rem;
color: var(--text-secondary);
text-transform: uppercase;
}
.info-value {
font-weight: 600;
color: var(--text-primary);
}
.contacts-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.contact-card {
background: #f8fafc;
border-radius: 8px;
padding: 1rem;
border: 1px solid var(--border-color);
}
.contact-header {
margin-bottom: 0.75rem;
}
.contact-full-name {
font-weight: 600;
color: var(--text-primary);
font-size: 1.05rem;
}
.contact-details {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.contact-row {
display: flex;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--text-secondary);
}
.contact-row .label {
min-width: 120px;
}
.no-contacts {
color: var(--text-secondary);
text-align: center;
padding: 2rem;
background: #f8fafc;
border-radius: 8px;
}
/* Responsive Clients */
@media (max-width: 1024px) {
.clientes-page {
padding: 1rem;
}
.clients-table th,
.clients-table td {
padding: 0.75rem 1rem;
}
.client-email-col,
.client-email,
.client-type-col,
.client-type {
display: none;
}
}
@media (max-width: 768px) {
.clientes-filters {
flex-direction: column;
}
.clientes-filters .search-input {
max-width: 100%;
}
.clients-table thead {
display: none;
}
.clients-table tbody tr.client-item {
display: flex;
flex-wrap: wrap;
padding: 1rem;
gap: 0.5rem;
position: relative;
}
.clients-table tbody tr.contacts-expand-row {
display: block;
}
.clients-table td {
padding: 0;
}
.client-avatar-cell {
width: auto;
margin-right: 1rem;
}
.client-name {
flex: 1;
}
.client-status,
.client-phone {
width: 100%;
padding-left: 58px;
}
.client-type-col,
.client-type {
display: none;
}
.client-actions {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
}
.contacts-expand-grid {
grid-template-columns: 1fr;
}
.contact-expand-details {
grid-template-columns: 1fr;
}
.info-grid {
grid-template-columns: 1fr;
}
}

View File

@ -245,6 +245,38 @@
font-style: italic; font-style: italic;
} }
/* Delete line button */
.line-actions-col {
width: 50px;
}
.line-actions {
text-align: center;
}
.btn-delete-line {
background: none;
border: none;
cursor: pointer;
color: var(--text-secondary);
padding: 0.35rem;
border-radius: 6px;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
}
.btn-delete-line:hover {
color: var(--danger, #ef4444);
background: rgba(239, 68, 68, 0.1);
}
.btn-delete-line:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Invoice Totals */ /* Invoice Totals */
.invoice-totals { .invoice-totals {
margin-top: 1.5rem; margin-top: 1.5rem;
@ -265,6 +297,10 @@
font-size: 0.9375rem; font-size: 0.9375rem;
} }
.total-row.paid {
color: var(--success, #22c55e);
}
.total-row.pending { .total-row.pending {
font-size: 1.125rem; font-size: 1.125rem;
color: var(--primary); color: var(--primary);
@ -272,6 +308,89 @@
border-top: 2px solid var(--border-color); border-top: 2px solid var(--border-color);
} }
/* Payment Section */
.payment-section {
border-top: 2px solid var(--border-color);
padding-top: 1.25rem;
margin-top: 1rem;
}
.payment-section h3 {
margin: 0 0 1rem;
font-size: 1.05rem;
color: var(--text-primary);
}
.payment-form .form-row {
display: flex;
gap: 1rem;
margin-bottom: 0.75rem;
}
.payment-form .form-group {
flex: 1;
}
.payment-form label {
display: block;
font-size: 0.8125rem;
font-weight: 500;
margin-bottom: 0.35rem;
color: var(--text-secondary);
}
.payment-form input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 0.875rem;
background: var(--card-bg);
color: var(--text-primary);
box-sizing: border-box;
}
.payment-form input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}
.payment-hint {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
margin-top: 0.25rem;
}
.btn-pay {
padding: 0.5rem 1.25rem;
font-size: 0.875rem;
white-space: nowrap;
}
.btn-pay:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* Payment History */
.payments-history-section {
border-top: 2px solid var(--border-color);
padding-top: 1.25rem;
margin-top: 1rem;
}
.payments-history-section h3 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
color: var(--text-primary);
}
.payment-amount-cell {
color: var(--success, #22c55e);
}
/* Button Styles */ /* Button Styles */
.btn-small { .btn-small {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
@ -391,3 +510,241 @@
flex: 1; flex: 1;
} }
} }
/* ============================================
Confirm Exit Modal Styles
============================================ */
.confirm-exit-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 1rem;
animation: confirmFadeIn 0.2s ease-out;
}
.confirm-exit-overlay.closing {
animation: confirmFadeOut 0.2s ease-out forwards;
}
@keyframes confirmFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes confirmFadeOut {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.confirm-exit-modal {
background: var(--card-bg, #ffffff);
border-radius: 16px;
padding: 2rem;
max-width: 400px;
width: 100%;
text-align: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
animation: confirmSlideUp 0.3s ease-out;
}
.confirm-exit-overlay.closing .confirm-exit-modal {
animation: confirmSlideDown 0.2s ease-out forwards;
}
@keyframes confirmSlideUp {
from {
transform: translateY(20px) scale(0.95);
opacity: 0;
}
to {
transform: translateY(0) scale(1);
opacity: 1;
}
}
@keyframes confirmSlideDown {
from {
transform: translateY(0) scale(1);
opacity: 1;
}
to {
transform: translateY(20px) scale(0.95);
opacity: 0;
}
}
.confirm-exit-icon {
width: 72px;
height: 72px;
margin: 0 auto 1.25rem;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.confirm-exit-icon svg {
color: #d97706;
}
.confirm-exit-title {
margin: 0 0 0.75rem;
font-size: 1.375rem;
font-weight: 600;
color: var(--text-primary, #1e293b);
}
.confirm-exit-message {
margin: 0 0 1.75rem;
font-size: 0.9375rem;
color: var(--text-secondary, #64748b);
line-height: 1.5;
}
.confirm-exit-actions {
display: flex;
gap: 0.75rem;
}
.confirm-exit-actions button {
flex: 1;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
border: none;
}
.confirm-exit-actions .btn-stay {
background-color: var(--primary, #2563eb);
color: white;
}
.confirm-exit-actions .btn-stay:hover {
background-color: #1d4ed8;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(37, 99, 235, 0.3);
}
.confirm-exit-actions .btn-exit {
background-color: #f1f5f9;
color: var(--text-primary, #1e293b);
border: 1px solid var(--border-color, #e2e8f0);
}
.confirm-exit-actions .btn-exit:hover {
background-color: #e2e8f0;
border-color: #cbd5e1;
}
/* Responsive Confirm Modal */
@media (max-width: 480px) {
.confirm-exit-modal {
padding: 1.5rem;
margin: 1rem;
}
.confirm-exit-icon {
width: 60px;
height: 60px;
}
.confirm-exit-icon svg {
width: 36px;
height: 36px;
}
.confirm-exit-title {
font-size: 1.25rem;
}
.confirm-exit-actions {
flex-direction: column-reverse;
}
}
/* ============================================
Blocked Navigation Toast Styles
============================================ */
.blocked-nav-toast {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%) translateY(-100px);
z-index: 3000;
opacity: 0;
transition: all 0.3s ease-out;
pointer-events: none;
}
.blocked-nav-toast.visible {
transform: translateX(-50%) translateY(0);
opacity: 1;
}
.blocked-nav-toast.hiding {
transform: translateX(-50%) translateY(-20px);
opacity: 0;
}
.blocked-nav-toast-content {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1.25rem;
background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%);
border: 1px solid #fbbf24;
border-radius: 10px;
box-shadow: 0 10px 25px -5px rgba(251, 191, 36, 0.3),
0 4px 6px -2px rgba(0, 0, 0, 0.1);
}
.blocked-nav-icon {
color: #d97706;
flex-shrink: 0;
}
.blocked-nav-message {
font-size: 0.9375rem;
font-weight: 500;
color: #92400e;
white-space: nowrap;
}
@media (max-width: 480px) {
.blocked-nav-toast {
left: 1rem;
right: 1rem;
transform: translateX(0) translateY(-100px);
}
.blocked-nav-toast.visible {
transform: translateX(0) translateY(0);
}
.blocked-nav-toast.hiding {
transform: translateX(0) translateY(-20px);
}
.blocked-nav-message {
white-space: normal;
}
}