dejar mas bonito clientes, posiblidad de quitar lineas en lineas de factura y posiblidad de pagar en las impagadas
This commit is contained in:
parent
4b4ec43f9d
commit
75459a90c3
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
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) => {
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
552
src/style.css
552
src/style.css
|
|
@ -1099,4 +1099,554 @@ button:focus-visible {
|
||||||
.btn-delete-compact {
|
.btn-delete-compact {
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -390,4 +509,242 @@
|
||||||
.footer-actions-right button {
|
.footer-actions-right button {
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue