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,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) {
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'modal-overlay';
|
||||
|
||||
let invoice = null;
|
||||
let payments = [];
|
||||
let isLoading = true;
|
||||
|
||||
// Tracker de cambios
|
||||
const changeTracker = new FormChangeTracker();
|
||||
let savedSuccessfully = false;
|
||||
|
||||
// Formatear fecha para input type="date"
|
||||
const formatDateForInput = (dateString) => {
|
||||
if (!dateString) return '';
|
||||
|
|
@ -197,20 +203,31 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
<th>Precio Unitario</th>
|
||||
<th>IVA %</th>
|
||||
<th>Total</th>
|
||||
${isDraft ? '<th class="line-actions-col"></th>' : ''}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${invoice.lines && invoice.lines.length > 0
|
||||
? invoice.lines.map(line => `
|
||||
<tr>
|
||||
<tr data-line-id="${line.id}">
|
||||
<td>${line.description || ''}</td>
|
||||
<td>${line.quantity || 0}</td>
|
||||
<td>${formatCurrency(line.unitPrice)}</td>
|
||||
<td>${line.taxRate || 0}%</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>
|
||||
`).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>
|
||||
</table>
|
||||
|
|
@ -222,12 +239,81 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
<span>Total:</span>
|
||||
<strong>${formatCurrency(invoice.total)}</strong>
|
||||
</div>
|
||||
<div class="total-row paid">
|
||||
<span>Pagado:</span>
|
||||
<strong>${formatCurrency((invoice.total || 0) - (invoice.remainToPay || 0))}</strong>
|
||||
</div>
|
||||
<div class="total-row pending">
|
||||
<span>Pendiente:</span>
|
||||
<strong>${formatCurrency(invoice.remainToPay)}</strong>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
|
@ -243,15 +329,24 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
`;
|
||||
|
||||
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
|
||||
const attachEventListeners = () => {
|
||||
// Botón cerrar
|
||||
// Botón cerrar (X) - muestra modal si hay cambios
|
||||
const closeBtn = modal.querySelector('.btn-close');
|
||||
closeBtn?.addEventListener('click', handleClose);
|
||||
|
||||
// Botón cancelar modal
|
||||
// Botón cancelar modal - muestra modal si hay cambios
|
||||
const cancelBtn = modal.querySelector('.btn-cancel-modal');
|
||||
cancelBtn?.addEventListener('click', handleClose);
|
||||
|
||||
|
|
@ -278,7 +373,20 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
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) => {
|
||||
if (e.target === modal) {
|
||||
handleClose();
|
||||
|
|
@ -287,7 +395,36 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
};
|
||||
|
||||
// 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();
|
||||
if (onClose) onClose();
|
||||
};
|
||||
|
|
@ -319,6 +456,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
|
||||
await updateInvoice(invoiceId, data);
|
||||
|
||||
// Marcar como guardado exitosamente
|
||||
savedSuccessfully = true;
|
||||
changeTracker.markAsSaved();
|
||||
|
||||
alert('Factura actualizada correctamente');
|
||||
if (onUpdate) onUpdate();
|
||||
handleClose();
|
||||
|
|
@ -347,11 +488,18 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
|
||||
await validateInvoice(invoiceId);
|
||||
|
||||
// Marcar como guardado para no mostrar el modal de confirmación
|
||||
savedSuccessfully = true;
|
||||
changeTracker.markAsSaved();
|
||||
|
||||
alert('Factura validada correctamente');
|
||||
if (onUpdate) onUpdate();
|
||||
|
||||
// Recargar la factura para mostrar el nuevo estado
|
||||
await loadInvoice();
|
||||
|
||||
// Recapturar estado inicial después de recargar
|
||||
savedSuccessfully = false;
|
||||
} catch (error) {
|
||||
console.error('Error al validar:', error);
|
||||
alert('Error al validar la factura: ' + error.message);
|
||||
|
|
@ -422,7 +570,10 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
|
||||
alert('Línea añadida correctamente');
|
||||
toggleAddLineForm();
|
||||
|
||||
// Recargar y recapturar estado inicial
|
||||
await loadInvoice();
|
||||
changeTracker.markAsSaved();
|
||||
} catch (error) {
|
||||
console.error('Error al añadir línea:', error);
|
||||
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
|
||||
const loadInvoice = async () => {
|
||||
try {
|
||||
|
|
@ -442,6 +688,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
|||
renderContent();
|
||||
|
||||
invoice = await getInvoiceById(invoiceId);
|
||||
try {
|
||||
payments = await getPayments(invoiceId);
|
||||
} catch (e) {
|
||||
payments = [];
|
||||
}
|
||||
isLoading = false;
|
||||
renderContent();
|
||||
} 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() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'create-invoice-page';
|
||||
|
||||
// Tracker de cambios
|
||||
const changeTracker = new FormChangeTracker();
|
||||
let savedSuccessfully = false;
|
||||
|
||||
container.innerHTML = /*html*/`
|
||||
<div class="invoice-header-compact">
|
||||
<h1>Nueva Factura</h1>
|
||||
|
|
@ -143,14 +150,42 @@ export function renderCreateInvoicePage() {
|
|||
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', () => {
|
||||
window.location.hash = '#invoices';
|
||||
});
|
||||
|
||||
// Botón cancelar - el router se encarga del modal
|
||||
container.querySelector('#cancel-btn').addEventListener('click', () => {
|
||||
if (confirm('¿Cancelar? Se perderán los cambios.')) {
|
||||
window.location.hash = '#invoices';
|
||||
}
|
||||
});
|
||||
|
||||
container.querySelector('#invoice-form').addEventListener('submit', async (e) => {
|
||||
|
|
@ -224,6 +259,13 @@ export function renderCreateInvoicePage() {
|
|||
}
|
||||
|
||||
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})`);
|
||||
window.location.hash = '#invoices';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { renderDashboard } from './DashboardPage.js';
|
||||
import { renderFacturasPage } from './Facturas.js';
|
||||
import { renderCreateInvoicePage } from './CreateInvoicePage.js';
|
||||
import { renderClientesPage } from './ClientesPage.js';
|
||||
|
||||
// Registry of all pages available in the application
|
||||
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>',
|
||||
requiresAuth: true,
|
||||
showInSidebar: true,
|
||||
render: () => {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = '<h1>Clientes</h1><p>Página en construcción...</p>';
|
||||
return div;
|
||||
}
|
||||
render: renderClientesPage
|
||||
},
|
||||
{
|
||||
route: 'settings',
|
||||
|
|
|
|||
|
|
@ -2,16 +2,69 @@ import { auth } from './services/auth.js';
|
|||
import { renderLoginPage } from './pages/LoginPage.js';
|
||||
import { createSidebar } from './components/Sidebar.js';
|
||||
import { getAvailablePages, getPageByRoute } from './pages/pagesRegistry.js';
|
||||
import { showConfirmExitModal } from './components/ConfirmExitModal.js';
|
||||
|
||||
// 🔧 Modo DEV: cambiar a false para activar login
|
||||
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() {
|
||||
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 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') {
|
||||
window.location.hash = '#login';
|
||||
|
|
@ -33,6 +86,7 @@ export function initRouter() {
|
|||
// Create main content area
|
||||
const mainContent = document.createElement('main');
|
||||
mainContent.className = 'main-content';
|
||||
mainContent.dataset.currentRoute = route; // Guardar ruta actual
|
||||
|
||||
// Get page from registry
|
||||
const page = getPageByRoute(route);
|
||||
|
|
@ -69,6 +123,6 @@ export function initRouter() {
|
|||
}
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', navigate);
|
||||
navigate();
|
||||
window.addEventListener('hashchange', () => navigate(false));
|
||||
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();
|
||||
}
|
||||
|
||||
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) {
|
||||
// Nota: No hay endpoint DELETE en el OpenAPI spec,
|
||||
// pero podríamos usar el endpoint de cambio de estado a "canceled"
|
||||
|
|
|
|||
550
src/style.css
550
src/style.css
|
|
@ -1100,3 +1100,553 @@ button:focus-visible {
|
|||
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;
|
||||
}
|
||||
|
||||
/* 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 {
|
||||
margin-top: 1.5rem;
|
||||
|
|
@ -265,6 +297,10 @@
|
|||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.total-row.paid {
|
||||
color: var(--success, #22c55e);
|
||||
}
|
||||
|
||||
.total-row.pending {
|
||||
font-size: 1.125rem;
|
||||
color: var(--primary);
|
||||
|
|
@ -272,6 +308,89 @@
|
|||
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 */
|
||||
.btn-small {
|
||||
padding: 0.5rem 1rem;
|
||||
|
|
@ -391,3 +510,241 @@
|
|||
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