From fa460b3ba8cb9d54459339a3228f967a40ab1324 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81?= Date: Sun, 17 May 2026 00:00:43 +0200 Subject: [PATCH] actualizar pantallas y realizar ajustes --- .env | 4 +- src/components/InvoiceModal.js | 95 +++- src/pages/BancoPage.js | 4 +- src/pages/ContactsPage.js | 402 ++++++++++++++++ src/pages/CreateInvoicePage.js | 105 +++- src/pages/FacturasProveedores.js | 794 +++++++++++++++++++++++++++++++ src/pages/SettingsPage.js | 58 +++ src/pages/pagesRegistry.js | 20 + src/router.js | 5 + src/services/apiClient.js | 9 +- src/services/contacts.js | 21 + src/services/icons.js | 2 + src/services/invoices.js | 19 +- src/services/setup.js | 8 + src/services/supplierInvoices.js | 47 ++ src/styles/banco.css | 25 + src/styles/base.css | 16 + src/styles/create-invoice.css | 89 ++++ src/styles/modal.css | 182 +++++++ 19 files changed, 1874 insertions(+), 31 deletions(-) create mode 100644 src/pages/ContactsPage.js create mode 100644 src/pages/FacturasProveedores.js create mode 100644 src/services/contacts.js create mode 100644 src/services/supplierInvoices.js diff --git a/.env b/.env index de7a7fa..2547912 100755 --- a/.env +++ b/.env @@ -1,4 +1,4 @@ # API Configuration — apunta al BFF #_dev local: http://localhost:5269 -#docker: http://localhost:5000 -VITE_API_BASE_URL=http://localhost:5000 +#docker: http://localhost:5001 +VITE_API_BASE_URL=http://localhost:5001 diff --git a/src/components/InvoiceModal.js b/src/components/InvoiceModal.js index 22fe9f0..d998b79 100644 --- a/src/components/InvoiceModal.js +++ b/src/components/InvoiceModal.js @@ -2,7 +2,7 @@ import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteI import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js'; import { toast } from '../services/toast.js'; import { getPaymentTypes } from '../services/setup.js'; -import { apiGet } from '../services/apiClient.js'; +import { apiGet, apiRequest } from '../services/apiClient.js'; export function InvoiceModal(invoiceId, onClose, onUpdate) { const modal = document.createElement('div'); @@ -12,6 +12,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { let payments = []; let paymentTypes = []; let bankAccounts = []; + let documents = []; let isLoading = true; // Tracker de cambios @@ -33,6 +34,13 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { return date.toLocaleDateString('es-ES'); }; + // Formatear tamaño de archivo + const formatFileSize = (bytes) => { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + }; + // Formatear moneda const formatCurrency = (amount) => { return new Intl.NumberFormat('es-ES', { @@ -67,6 +75,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
Cargando detalles de la factura...
`; + modalContent.querySelector('.btn-close')?.addEventListener('click', handleClose); return; } @@ -80,6 +89,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
No se pudo cargar la factura
`; + modalContent.querySelector('.btn-close')?.addEventListener('click', handleClose); return; } @@ -245,17 +255,27 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
+ ${invoice.totalHt != null ? ` +
+ Base imponible: + ${formatCurrency(invoice.totalHt)} +
` : ''} + ${invoice.totalTax != null ? ` +
+ IVA: + ${formatCurrency(invoice.totalTax)} +
` : ''}
Total: - ${formatCurrency(invoice.total)} + ${formatCurrency(invoice.total)}
Pendiente: - ${formatCurrency(invoice.remainToPay)} + ${formatCurrency(invoice.remainToPay)}
@@ -289,6 +309,27 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { ` : ''} + +
+

Documentos

+ ${documents.length === 0 + ? '

No hay documentos adjuntos a esta factura.

' + : `` + } +
+ ${(invoice.status === 'validated' || invoice.status === 'unpaid') && invoice.remainToPay > 0 ? `
@@ -419,6 +460,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { btn.addEventListener('click', () => handleDeleteLine(parseInt(btn.dataset.lineId))); }); + // Botones descargar documento + modal.querySelectorAll('.btn-download-doc').forEach(btn => { + btn.addEventListener('click', () => handleDocumentDownload(btn.dataset.path, btn.dataset.name)); + }); + // Botón registrar pago const payBtn = modal.querySelector('#btn-pay'); payBtn?.addEventListener('click', handlePayment); @@ -611,8 +657,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { } previewWindow.opener = null; - previewWindow.document.write('Vista previa facturaCargando vista previa...'); - previewWindow.document.close(); + previewWindow.document.title = 'Vista previa factura'; try { if (previewBtn) { @@ -865,23 +910,49 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) { } }; + // Descargar documento adjunto + const handleDocumentDownload = async (encodedPath, name) => { + try { + const response = await apiRequest( + `/api/Document/download?modulePart=invoice&file=${encodedPath}`, + { method: 'GET', responseType: 'raw' } + ); + const blob = await response.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } catch (err) { + toast.error('Error al descargar documento: ' + err.message); + } + }; + // Cargar factura + datos auxiliares en paralelo const loadInvoice = async () => { try { isLoading = true; renderContent(); - const [invoiceData, paymentsData, typesData, accountsData] = await Promise.allSettled([ - getInvoiceById(invoiceId), + // Load invoice first so we have invoice.number for the documents endpoint + invoice = await getInvoiceById(invoiceId).catch(() => null); + + const [paymentsData, typesData, accountsData, documentsData] = await Promise.allSettled([ getPayments(invoiceId), getPaymentTypes(), apiGet('/api/Bank/accounts'), + invoice?.id + ? apiGet(`/api/Document/list?modulePart=invoice&id=${invoice.id}`) + : Promise.resolve([]), ]); - invoice = invoiceData.status === 'fulfilled' ? invoiceData.value : null; - payments = paymentsData.status === 'fulfilled' ? paymentsData.value : []; - paymentTypes = typesData.status === 'fulfilled' ? typesData.value : []; - bankAccounts = accountsData.status === 'fulfilled' ? accountsData.value : []; + payments = paymentsData.status === 'fulfilled' ? paymentsData.value : []; + paymentTypes = typesData.status === 'fulfilled' ? typesData.value : []; + bankAccounts = accountsData.status === 'fulfilled' ? accountsData.value : []; + documents = documentsData.status === 'fulfilled' ? (documentsData.value ?? []) : []; isLoading = false; renderContent(); diff --git a/src/pages/BancoPage.js b/src/pages/BancoPage.js index 1300e2a..67f5fcf 100644 --- a/src/pages/BancoPage.js +++ b/src/pages/BancoPage.js @@ -141,7 +141,7 @@ export function renderBancoPage() { el.innerHTML = ''; list.forEach(acc => { const item = document.createElement('div'); - item.className = 'banco-account-item'; + item.className = `banco-account-item${acc.isClosed ? ' banco-account-item--closed' : ''}`; item.dataset.id = acc.id; item.innerHTML = `
+ +
+

Notificaciones

+

+ URL de webhook para notificaciones de cambio de estado de facturas (compatible con Teams y Slack). + Déjalo vacío para desactivar. +

+
+ +
+ + +
+

+
+
`; @@ -132,6 +155,41 @@ export function renderSettingsPage() { }); const intervalId = setInterval(renderTokenInfo, 1000); + + // Webhook config + const webhookInput = container.querySelector('#webhook-url-input'); + const webhookStatus = container.querySelector('#webhook-status'); + + async function loadWebhookUrl() { + try { + const data = await apiGet('/api/Settings/webhook'); + webhookInput.value = data.url || ''; + webhookStatus.textContent = data.url ? 'Webhook configurado.' : 'Sin webhook configurado.'; + } catch { + webhookStatus.textContent = 'No se pudo cargar la configuración.'; + } + } + + container.querySelector('#webhook-save-btn').addEventListener('click', async () => { + const saveBtn = container.querySelector('#webhook-save-btn'); + saveBtn.disabled = true; + try { + await apiPut('/api/Settings/webhook', { url: webhookInput.value.trim() }); + showToast('URL de webhook guardada', 'success'); + webhookStatus.textContent = webhookInput.value.trim() ? 'Webhook configurado.' : 'Sin webhook configurado.'; + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } finally { + saveBtn.disabled = false; + } + }); + + container.querySelector('#webhook-clear-btn').addEventListener('click', () => { + webhookInput.value = ''; + }); + + loadWebhookUrl(); + container.cleanup = () => clearInterval(intervalId); return container; diff --git a/src/pages/pagesRegistry.js b/src/pages/pagesRegistry.js index 88649d4..873306d 100755 --- a/src/pages/pagesRegistry.js +++ b/src/pages/pagesRegistry.js @@ -1,7 +1,9 @@ import { renderDashboard } from './DashboardPage.js'; import { renderFacturasPage } from './Facturas.js'; +import { renderFacturasProveedoresPage } from './FacturasProveedores.js'; import { renderCreateInvoicePage } from './CreateInvoicePage.js'; import { renderClientesPage } from './ClientesPage.js'; +import { renderContactsPage } from './ContactsPage.js'; import { renderSettingsPage } from './SettingsPage.js'; import { renderBancoPage } from './BancoPage.js'; import { icons } from '../services/icons.js'; @@ -25,6 +27,15 @@ export const pagesRegistry = [ showInSidebar: true, render: renderFacturasPage }, + { + route: 'facturas-proveedores', + name: 'Fact. Proveedores', + icon: icons.supplierInvoices, + voicePatterns: ['facturas proveedores', 'proveedores', 'facturas de proveedor', 'compras'], + requiresAuth: true, + showInSidebar: true, + render: renderFacturasProveedoresPage + }, { route: 'create-invoice', name: 'Nueva Factura', @@ -43,6 +54,15 @@ export const pagesRegistry = [ showInSidebar: true, render: renderClientesPage }, + { + route: 'contacts', + name: 'Contactos', + icon: icons.user, + voicePatterns: ['contactos', 'ver contactos', 'ir a contactos', 'lista contactos'], + requiresAuth: true, + showInSidebar: true, + render: renderContactsPage + }, { route: 'banco', name: 'Banco', diff --git a/src/router.js b/src/router.js index 8f90a55..fde9a5f 100755 --- a/src/router.js +++ b/src/router.js @@ -81,6 +81,11 @@ export function initRouter() { currentPageCleanup = null; } + // Remove any body-level modals left open from the previous page + document.querySelectorAll('body > [class*="overlay"], body > [class*="modal"]').forEach(el => { + if (!el.classList.contains('connection-lost-overlay')) el.remove(); + }); + if (hash === '#login') { applyLoginTheme(); hideVoiceAssistant(); diff --git a/src/services/apiClient.js b/src/services/apiClient.js index 0f8e95f..45fac35 100644 --- a/src/services/apiClient.js +++ b/src/services/apiClient.js @@ -2,11 +2,12 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; let reloginInProgress = false; function redirectToLogin() { + // Always clean up body-level modals regardless of current hash — + // auth:logout can navigate to #login before this runs, making the hash check a no-op. + document.querySelectorAll('body > [class*="overlay"], body > [class*="modal"]').forEach(el => { + if (!el.classList.contains('connection-lost-overlay')) el.remove(); + }); if (window.location.hash !== '#login') { - // Close any open modals/overlays appended to body before leaving - document.querySelectorAll('body > [class*="overlay"], body > [class*="modal"]').forEach(el => { - if (!el.classList.contains('connection-lost-overlay')) el.remove(); - }); window.location.hash = '#login'; } } diff --git a/src/services/contacts.js b/src/services/contacts.js new file mode 100644 index 0000000..7919d6b --- /dev/null +++ b/src/services/contacts.js @@ -0,0 +1,21 @@ +import { apiGet, apiPost, apiPut, apiDelete } from './apiClient.js'; + +export async function getContacts({ limit = 100, page = 1 } = {}) { + return apiGet(`/api/Contacts?limit=${limit}&page=${page}`); +} + +export async function getContactById(id) { + return apiGet(`/api/Contacts/${id}`); +} + +export async function createContact(data) { + return apiPost('/api/Contacts', data); +} + +export async function updateContact(id, data) { + return apiPut(`/api/Contacts/${id}`, data); +} + +export async function deleteContact(id) { + return apiDelete(`/api/Contacts/${id}`); +} diff --git a/src/services/icons.js b/src/services/icons.js index 6a8d89e..7967a8a 100644 --- a/src/services/icons.js +++ b/src/services/icons.js @@ -32,6 +32,8 @@ export const icons = { invoices: ``, + supplierInvoices: ``, + clients: ``, settings: ``, diff --git a/src/services/invoices.js b/src/services/invoices.js index 988f55d..acd0a77 100644 --- a/src/services/invoices.js +++ b/src/services/invoices.js @@ -57,15 +57,14 @@ export async function downloadInvoicePdf(invoiceNumber) { } 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 - try { - await apiDelete(`/api/Invoices/${id}`); - } catch (error) { - // Si no existe DELETE, intentar cancelar - return await updateInvoiceStatus(id, 'canceled'); - } - + await apiDelete(`/api/Invoices/${id}`); return true; } + +export async function getInvoiceTemplates() { + return apiGet('/api/Invoices/templates'); +} + +export async function getTemplateById(id) { + return apiGet(`/api/Invoices/templates/${id}`); +} diff --git a/src/services/setup.js b/src/services/setup.js index e160584..5bef859 100644 --- a/src/services/setup.js +++ b/src/services/setup.js @@ -15,3 +15,11 @@ export async function getCompany() { export async function getCountries() { return apiGet('/api/Setup/countries'); } + +export async function getCivilities() { + return apiGet('/api/Setup/civilities'); +} + +export async function getContactTypes() { + return apiGet('/api/Setup/contact-types'); +} diff --git a/src/services/supplierInvoices.js b/src/services/supplierInvoices.js new file mode 100644 index 0000000..eecced4 --- /dev/null +++ b/src/services/supplierInvoices.js @@ -0,0 +1,47 @@ +import { apiGet, apiPost, apiPut, apiPatch, apiDelete } from './apiClient.js'; + +export async function getSupplierInvoices({ limit = 50, page = 1, status } = {}) { + let url = `/api/SupplierInvoices?limit=${limit}&page=${page}`; + if (status) url += `&status=${encodeURIComponent(status)}`; + return apiGet(url); +} + +export async function getSupplierInvoiceById(id) { + return apiGet(`/api/SupplierInvoices/${id}`); +} + +export async function createSupplierInvoice(data) { + return apiPost('/api/SupplierInvoices', data); +} + +export async function updateSupplierInvoice(id, data) { + return apiPut(`/api/SupplierInvoices/${id}`, data); +} + +export async function deleteSupplierInvoice(id) { + return apiDelete(`/api/SupplierInvoices/${id}`); +} + +export async function changeSupplierInvoiceStatus(id, status) { + return apiPost(`/api/SupplierInvoices/${id}/status`, { status }); +} + +export async function addSupplierInvoiceLine(id, line) { + return apiPost(`/api/SupplierInvoices/${id}/lines`, line); +} + +export async function updateSupplierInvoiceLine(id, lineId, line) { + return apiPut(`/api/SupplierInvoices/${id}/lines/${lineId}`, line); +} + +export async function deleteSupplierInvoiceLine(id, lineId) { + return apiDelete(`/api/SupplierInvoices/${id}/lines/${lineId}`); +} + +export async function getSupplierInvoicePayments(id) { + return apiGet(`/api/SupplierInvoices/${id}/payments`); +} + +export async function addSupplierInvoicePayment(id, payment) { + return apiPost(`/api/SupplierInvoices/${id}/payments`, payment); +} diff --git a/src/styles/banco.css b/src/styles/banco.css index 65eff50..8a88411 100644 --- a/src/styles/banco.css +++ b/src/styles/banco.css @@ -213,6 +213,31 @@ .banco-account-balance--negative { color: var(--danger); } +.banco-account-item--closed { + opacity: 0.55; +} + +.banco-account-closed-badge { + display: inline-block; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--gray-500); + background: var(--gray-100); + border: 1px solid var(--gray-200); + border-radius: 3px; + padding: 1px 5px; + margin-left: 6px; + vertical-align: middle; +} + +:root[data-theme='dark'] .banco-account-closed-badge { + color: var(--gray-400); + background: rgba(255,255,255,0.07); + border-color: rgba(255,255,255,0.12); +} + .banco-account-arrow { color: var(--gray-300); transition: color 0.15s, transform 0.15s; diff --git a/src/styles/base.css b/src/styles/base.css index e503053..712e4ba 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -348,3 +348,19 @@ button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } color: #86efac; } :root[data-theme='dark'] .badge-paid::before { background: #86efac; } + +/* Fix select dropdown visibility in dark mode: + Native