From 84c99b8155501b03dad9bf89ef61bb748b15efb5 Mon Sep 17 00:00:00 2001 From: javiermengual Date: Tue, 3 Feb 2026 16:26:05 +0100 Subject: [PATCH] Add Invoice Modal component and integrate with invoice management --- index.html | 26 +- src/components/InvoiceItem.js | 10 +- src/components/InvoiceModal.js | 470 +++++++++++++++++++++++++++++++++ src/pages/Facturas.js | 15 +- src/services/invoices.js | 98 +++++++ src/style.css | 19 +- src/styles/modal.css | 392 +++++++++++++++++++++++++++ 7 files changed, 1005 insertions(+), 25 deletions(-) create mode 100644 src/components/InvoiceModal.js create mode 100644 src/services/invoices.js create mode 100644 src/styles/modal.css diff --git a/index.html b/index.html index 70d54c7..491f46d 100755 --- a/index.html +++ b/index.html @@ -1,13 +1,17 @@ - - - - - doli-front - - -
- - - + + + + + + doli-front + + + + +
+ + + + \ No newline at end of file diff --git a/src/components/InvoiceItem.js b/src/components/InvoiceItem.js index 5bc89d2..f79c2d7 100644 --- a/src/components/InvoiceItem.js +++ b/src/components/InvoiceItem.js @@ -1,4 +1,4 @@ -export function InvoiceItem(invoice) { +export function InvoiceItem(invoice, onView) { const item = document.createElement('div'); item.className = 'invoice-item'; @@ -58,7 +58,7 @@ export function InvoiceItem(invoice) {
${formatCurrency(invoice.total)}
${formatCurrency(invoice.remainToPay)}
-
@@ -67,9 +67,9 @@ export function InvoiceItem(invoice) { // Event listener para el botón de ver const viewBtn = item.querySelector('.btn-view'); viewBtn.addEventListener('click', () => { - console.log('Ver factura:', invoice.id); - // Aquí puedes agregar la lógica para ver los detalles - alert(`Ver detalles de factura: ${invoice.number}`); + if (onView) { + onView(invoice.id); + } }); return item; diff --git a/src/components/InvoiceModal.js b/src/components/InvoiceModal.js new file mode 100644 index 0000000..c02febd --- /dev/null +++ b/src/components/InvoiceModal.js @@ -0,0 +1,470 @@ +import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine } from '../services/invoices.js'; + +export function InvoiceModal(invoiceId, onClose, onUpdate) { + const modal = document.createElement('div'); + modal.className = 'modal-overlay'; + + let invoice = null; + let isLoading = true; + + // Formatear fecha para input type="date" + const formatDateForInput = (dateString) => { + if (!dateString) return ''; + const date = new Date(dateString); + return date.toISOString().split('T')[0]; + }; + + // Formatear fecha para mostrar + const formatDate = (dateString) => { + if (!dateString) return '-'; + const date = new Date(dateString); + return date.toLocaleDateString('es-ES'); + }; + + // Formatear moneda + const formatCurrency = (amount) => { + return new Intl.NumberFormat('es-ES', { + style: 'currency', + currency: 'EUR' + }).format(amount || 0); + }; + + // Obtener texto de estado + const getStatusText = (status) => { + const statusTextMap = { + 'draft': 'Borrador', + 'validated': 'Validada', + 'paid': 'Pagada', + 'unpaid': 'Impagada', + 'canceled': 'Cancelada' + }; + return statusTextMap[status] || status; + }; + + // Renderizar contenido del modal + const renderContent = () => { + const modalContent = modal.querySelector('.modal-content'); + + if (isLoading) { + modalContent.innerHTML = ` + + + `; + return; + } + + if (!invoice) { + modalContent.innerHTML = ` + + + `; + return; + } + + const isDraft = invoice.status === 'draft'; + const canEdit = isDraft || invoice.status === 'validated' || invoice.status === 'unpaid'; + + modalContent.innerHTML = ` + + + + + + `; + + attachEventListeners(); + }; + + // Adjuntar event listeners + const attachEventListeners = () => { + // Botón cerrar + const closeBtn = modal.querySelector('.btn-close'); + closeBtn?.addEventListener('click', handleClose); + + // Botón cancelar modal + const cancelBtn = modal.querySelector('.btn-cancel-modal'); + cancelBtn?.addEventListener('click', handleClose); + + // Botón guardar + const saveBtn = modal.querySelector('.btn-save'); + saveBtn?.addEventListener('click', handleSave); + + // Botón validar + const validateBtn = modal.querySelector('.btn-validate'); + validateBtn?.addEventListener('click', handleValidate); + + // Botón añadir línea + const addLineBtn = modal.querySelector('.btn-add-line'); + addLineBtn?.addEventListener('click', toggleAddLineForm); + + // Botón guardar línea + const saveLineBtn = modal.querySelector('.btn-save-line'); + saveLineBtn?.addEventListener('click', handleSaveLine); + + // Botón cancelar línea + const cancelLineBtn = modal.querySelector('.btn-cancel-line'); + cancelLineBtn?.addEventListener('click', () => { + const form = modal.querySelector('.add-line-form'); + if (form) form.style.display = 'none'; + }); + + // Click fuera del modal + modal.addEventListener('click', (e) => { + if (e.target === modal) { + handleClose(); + } + }); + }; + + // Manejar cierre + const handleClose = () => { + modal.remove(); + if (onClose) onClose(); + }; + + // Manejar guardado + const handleSave = async () => { + const form = modal.querySelector('#invoice-form'); + const formData = new FormData(form); + + const data = { + number: formData.get('number') || undefined, + expireDate: formData.get('expireDate') || undefined, + notePublic: formData.get('notePublic') || undefined, + notePrivate: formData.get('notePrivate') || undefined + }; + + // Filtrar valores undefined + Object.keys(data).forEach(key => { + if (data[key] === undefined || data[key] === '') { + delete data[key]; + } + }); + + try { + const saveBtn = modal.querySelector('.btn-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Guardando...'; + + await updateInvoice(invoiceId, data); + + alert('Factura actualizada correctamente'); + if (onUpdate) onUpdate(); + handleClose(); + } catch (error) { + console.error('Error al guardar:', error); + alert('Error al guardar la factura: ' + error.message); + } finally { + const saveBtn = modal.querySelector('.btn-save'); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = 'Guardar Cambios'; + } + } + }; + + // Manejar validación + const handleValidate = async () => { + if (!confirm('¿Estás seguro de que quieres validar esta factura? No podrás editarla completamente después.')) { + return; + } + + try { + const validateBtn = modal.querySelector('.btn-validate'); + validateBtn.disabled = true; + validateBtn.textContent = 'Validando...'; + + await validateInvoice(invoiceId); + + alert('Factura validada correctamente'); + if (onUpdate) onUpdate(); + + // Recargar la factura para mostrar el nuevo estado + await loadInvoice(); + } catch (error) { + console.error('Error al validar:', error); + alert('Error al validar la factura: ' + error.message); + + const validateBtn = modal.querySelector('.btn-validate'); + if (validateBtn) { + validateBtn.disabled = false; + validateBtn.textContent = '✓ Validar Factura'; + } + } + }; + + // Mostrar/ocultar formulario de añadir línea + const toggleAddLineForm = () => { + const form = modal.querySelector('.add-line-form'); + if (form) { + const isVisible = form.style.display !== 'none'; + form.style.display = isVisible ? 'none' : 'block'; + + if (!isVisible) { + // Limpiar campos + modal.querySelector('#line-description').value = ''; + modal.querySelector('#line-quantity').value = '1'; + modal.querySelector('#line-price').value = '0'; + modal.querySelector('#line-tax').value = '21'; + } + } + }; + + // Guardar nueva línea + const handleSaveLine = async () => { + const description = modal.querySelector('#line-description').value.trim(); + const quantity = parseFloat(modal.querySelector('#line-quantity').value); + const unitPrice = parseFloat(modal.querySelector('#line-price').value); + const taxRate = parseFloat(modal.querySelector('#line-tax').value); + + if (!description) { + alert('La descripción es obligatoria'); + return; + } + + if (!quantity || quantity <= 0) { + alert('La cantidad debe ser mayor que 0'); + return; + } + + if (unitPrice < 0) { + alert('El precio no puede ser negativo'); + return; + } + + if (taxRate < 0 || taxRate > 100) { + alert('El IVA debe estar entre 0 y 100'); + return; + } + + try { + const saveBtn = modal.querySelector('.btn-save-line'); + saveBtn.disabled = true; + saveBtn.textContent = 'Guardando...'; + + await addInvoiceLine(invoiceId, { + description, + quantity, + unitPrice, + taxRate + }); + + alert('Línea añadida correctamente'); + toggleAddLineForm(); + await loadInvoice(); + } catch (error) { + console.error('Error al añadir línea:', error); + alert('Error al añadir línea: ' + error.message); + } finally { + const saveBtn = modal.querySelector('.btn-save-line'); + if (saveBtn) { + saveBtn.disabled = false; + saveBtn.textContent = 'Guardar Línea'; + } + } + }; + + // Cargar factura + const loadInvoice = async () => { + try { + isLoading = true; + renderContent(); + + invoice = await getInvoiceById(invoiceId); + isLoading = false; + renderContent(); + } catch (error) { + console.error('Error al cargar factura:', error); + isLoading = false; + invoice = null; + renderContent(); + } + }; + + // Inicializar modal + modal.innerHTML = ` + + `; + + // Cargar factura + loadInvoice(); + + return modal; +} diff --git a/src/pages/Facturas.js b/src/pages/Facturas.js index 2f935dc..1d1c5b3 100755 --- a/src/pages/Facturas.js +++ b/src/pages/Facturas.js @@ -1,4 +1,5 @@ import { InvoiceItem } from '../components/InvoiceItem.js'; +import { InvoiceModal } from '../components/InvoiceModal.js'; export function renderFacturasPage() { const container = document.createElement('div'); @@ -16,7 +17,6 @@ export function renderFacturasPage() { container.innerHTML = /*html*/`

Facturas

-
@@ -24,11 +24,11 @@ export function renderFacturasPage() { +
@@ -92,11 +92,20 @@ export function renderFacturasPage() { // Renderizar cada factura invoices.forEach(invoice => { - const invoiceItem = InvoiceItem(invoice); + const invoiceItem = InvoiceItem(invoice, handleViewInvoice); invoicesList.appendChild(invoiceItem); }); } + // Manejar ver/editar factura + function handleViewInvoice(invoiceId) { + const modal = InvoiceModal(invoiceId, null, () => { + // Callback cuando se actualiza la factura + loadAllInvoices(); + }); + document.body.appendChild(modal); + } + // Función para cargar todas las facturas async function loadAllInvoices() { const invoicesList = container.querySelector('.invoices-list'); diff --git a/src/services/invoices.js b/src/services/invoices.js new file mode 100644 index 0000000..bcfc386 --- /dev/null +++ b/src/services/invoices.js @@ -0,0 +1,98 @@ +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 getInvoiceById(id) { + const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, { + method: 'GET', + headers: getAuthHeaders() + }); + + if (!response.ok) { + throw new Error('Error al obtener la factura'); + } + + return await response.json(); +} + +export async function updateInvoice(id, data) { + const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(data) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Error al actualizar la factura'); + } + + return true; +} + +export async function validateInvoice(id) { + const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/validate`, { + method: 'POST', + headers: getAuthHeaders() + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Error al validar la factura'); + } + + return true; +} + +export async function updateInvoiceStatus(id, status) { + const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/status`, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify({ status }) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Error al actualizar el estado'); + } + + return true; +} + +export async function addInvoiceLine(id, lineData) { + const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}/lines`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify(lineData) + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.detail || 'Error al agregar línea'); + } + + 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" + // o implementarlo si está disponible en el backend + const response = await fetch(`${API_BASE_URL}/api/Invoices/${id}`, { + method: 'DELETE', + headers: getAuthHeaders() + }); + + if (!response.ok) { + // Si no existe DELETE, intentar cancelar + return await updateInvoiceStatus(id, 'canceled'); + } + + return true; +} diff --git a/src/style.css b/src/style.css index 5d8142e..82db186 100755 --- a/src/style.css +++ b/src/style.css @@ -31,6 +31,7 @@ a { color: var(--primary); text-decoration: inherit; } + a:hover { color: var(--primary-hover); } @@ -57,9 +58,11 @@ h1 { will-change: filter; transition: filter 300ms; } + .logo:hover { filter: drop-shadow(0 0 2em #646cffaa); } + .logo.vanilla:hover { filter: drop-shadow(0 0 2em #f7df1eaa); } @@ -84,9 +87,11 @@ button { cursor: pointer; transition: background-color 0.25s, border-color 0.25s; } + button:hover { background-color: var(--primary-hover); } + button:focus, button:focus-visible { outline: 4px auto -webkit-focus-ring-color; @@ -97,6 +102,7 @@ button:focus-visible { color: var(--text-primary); background-color: #f8fafc; } + a:hover { color: var(--primary-hover); } @@ -405,7 +411,7 @@ button:focus-visible { background-color: #f8fafc; } -.main-content > div { +.main-content>div { max-width: 1280px; margin: 0 auto; padding: 2rem; @@ -440,9 +446,6 @@ button:focus-visible { } .facturas-header { - display: flex; - justify-content: space-between; - align-items: center; margin-bottom: 2rem; } @@ -461,6 +464,7 @@ button:focus-visible { cursor: pointer; font-weight: 600; transition: background-color 0.2s; + margin-left: auto; } .btn-new-invoice:hover { @@ -471,6 +475,7 @@ button:focus-visible { display: flex; gap: 1rem; margin-bottom: 1.5rem; + align-items: center; } .search-input, @@ -714,6 +719,7 @@ button:focus-visible { } @media (max-width: 1400px) { + .invoices-header, .invoice-item { grid-template-columns: 40px 120px 100px 50px 130px 100px 100px 100px 70px; @@ -758,13 +764,13 @@ button:focus-visible { padding: 1.5rem; } - .invoice-item > div { + .invoice-item>div { display: flex; justify-content: space-between; align-items: center; } - .invoice-item > div::before { + .invoice-item>div::before { content: attr(class); font-weight: 600; color: var(--text-secondary); @@ -815,6 +821,7 @@ button:focus-visible { min-height: 100vh; width: 100%; } + /* Create Invoice Page - Ultra Compact */ .create-invoice-page { padding: 1rem; diff --git a/src/styles/modal.css b/src/styles/modal.css new file mode 100644 index 0000000..4a32a54 --- /dev/null +++ b/src/styles/modal.css @@ -0,0 +1,392 @@ +/* Modal Styles */ +.modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 1rem; + animation: fadeIn 0.2s ease-in-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +.modal-content { + background: var(--card-bg); + border-radius: 12px; + max-width: 900px; + width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + animation: slideUp 0.3s ease-out; +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header { + padding: 1.5rem 2rem; + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; +} + +.modal-title { + display: flex; + align-items: center; + gap: 1rem; +} + +.modal-title h2 { + margin: 0; + font-size: 1.5rem; + color: var(--text-primary); +} + +.btn-close { + background: none; + border: none; + font-size: 2rem; + line-height: 1; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.btn-close:hover { + background-color: #f1f5f9; + color: var(--text-primary); +} + +.modal-body { + padding: 2rem; + overflow-y: auto; + flex: 1; +} + +.modal-footer { + padding: 1.5rem 2rem; + border-top: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + gap: 1rem; +} + +.footer-actions-left, +.footer-actions-right { + display: flex; + gap: 0.75rem; +} + +/* Form Styles in Modal */ +.form-section { + margin-bottom: 2rem; +} + +.form-section:last-child { + margin-bottom: 0; +} + +.form-section h3 { + margin: 0 0 1rem 0; + font-size: 1.125rem; + color: var(--text-primary); + font-weight: 600; +} + +.section-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + margin-bottom: 1rem; +} + +.form-group { + display: flex; + flex-direction: column; +} + +.form-group.full-width { + grid-column: 1 / -1; +} + +.form-group label { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.5rem; +} + +.form-group input, +.form-group textarea, +.form-group select { + padding: 0.625rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 6px; + font-size: 0.9375rem; + transition: all 0.2s; + font-family: inherit; + background-color: #ffffff; +} + +.form-group input:focus, +.form-group textarea:focus, +.form-group select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1); +} + +.form-group input:disabled, +.form-group textarea:disabled, +.form-group select:disabled { + background-color: #f8fafc; + color: var(--text-secondary); + cursor: not-allowed; +} + +.form-group input[readonly] { + background-color: #f8fafc; + color: var(--text-secondary); +} + +/* Add Line Form */ +.add-line-form { + background-color: #f8fafc; + padding: 1.5rem; + border-radius: 8px; + margin-bottom: 1rem; + border: 2px dashed var(--border-color); +} + +.form-group textarea { + resize: vertical; + min-height: 80px; +} + +/* Invoice Lines Table */ +.invoice-lines { + margin: 1rem 0; +} + +.lines-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.lines-table thead { + background-color: #f8fafc; +} + +.lines-table th { + padding: 0.75rem; + text-align: left; + font-weight: 600; + color: var(--text-secondary); + border-bottom: 2px solid var(--border-color); +} + +.lines-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +.lines-table tbody tr:hover { + background-color: #f8fafc; +} + +.no-lines { + text-align: center; + color: var(--text-secondary); + font-style: italic; +} + +/* Invoice Totals */ +.invoice-totals { + margin-top: 1.5rem; + padding: 1rem; + background-color: #f8fafc; + border-radius: 8px; + display: flex; + flex-direction: column; + gap: 0.75rem; + max-width: 300px; + margin-left: auto; +} + +.total-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 0.9375rem; +} + +.total-row.pending { + font-size: 1.125rem; + color: var(--primary); + padding-top: 0.75rem; + border-top: 2px solid var(--border-color); +} + +/* Button Styles */ +.btn-small { + padding: 0.5rem 1rem; + font-size: 0.875rem; +} + +.btn-primary { + background-color: var(--primary); + color: white; +} + +.btn-primary:hover { + background-color: var(--primary-hover); +} + +.btn-success { + background-color: var(--success); + color: white; +} + +.btn-success:hover { + background-color: var(--success-hover); +} + +.btn-cancel { + background-color: transparent; + color: var(--text-secondary); + border: 1px solid var(--border-color); +} + +.btn-cancel:hover { + background-color: #f8fafc; + color: var(--text-primary); +} + +/* Invoice Actions Buttons */ +.invoice-actions { + display: flex; + gap: 0.5rem; + align-items: center; + justify-content: center; +} + +.btn-action { + background: none; + border: none; + font-size: 1.25rem; + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 4px; + transition: all 0.2s; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-action:hover { + background-color: #f1f5f9; + transform: scale(1.1); +} + +.btn-delete:hover { + background-color: #fee2e2; +} + +/* Loading and Error States */ +.loading { + text-align: center; + padding: 2rem; + color: var(--text-secondary); +} + +.error { + text-align: center; + padding: 2rem; + color: var(--danger); +} + +/* Responsive Modal */ +@media (max-width: 768px) { + .modal-overlay { + padding: 0; + } + + .modal-content { + max-width: 100%; + max-height: 100vh; + border-radius: 0; + } + + .modal-header, + .modal-body, + .modal-footer { + padding: 1rem; + } + + .form-row { + grid-template-columns: 1fr; + gap: 0.75rem; + } + + .invoice-totals { + max-width: 100%; + } + + .modal-footer { + flex-direction: column; + } + + .footer-actions-left, + .footer-actions-right { + width: 100%; + } + + .footer-actions-left button, + .footer-actions-right button { + flex: 1; + } +} \ No newline at end of file