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 = `
@@ -150,7 +150,7 @@ export function renderBancoPage() {
- +
diff --git a/src/pages/ContactsPage.js b/src/pages/ContactsPage.js new file mode 100644 index 0000000..d388bcb --- /dev/null +++ b/src/pages/ContactsPage.js @@ -0,0 +1,402 @@ +import { icons } from '../services/icons.js'; +import { getContacts, getContactById, createContact, updateContact, deleteContact } from '../services/contacts.js'; +import { showToast } from '../services/toast.js'; +import { apiGet } from '../services/apiClient.js'; + +export function renderContactsPage() { + const container = document.createElement('div'); + container.className = 'facturas-page page-enter'; + + let allContacts = []; + let filteredContacts = []; + let searchTerm = ''; + let clients = []; + + const escHtml = (str) => { + if (str == null) return ''; + const d = document.createElement('div'); + d.textContent = String(str); + return d.innerHTML; + }; + + container.innerHTML = /*html*/` +
+

Contactos

+
+ +
+
+ ${icons.search} + +
+ +
+ +
+ + + + + + + + + + + + + +
NombreEmailTeléfonoMóvilAcciones
Cargando...
+
+ + + + + + + `; + + const tbody = container.querySelector('#contacts-tbody'); + const searchInput = container.querySelector('#contacts-search'); + const detailOverlay = container.querySelector('#contact-detail-overlay'); + const detailBody = container.querySelector('#contact-detail-body'); + const detailTitle = container.querySelector('#contact-detail-title'); + const createOverlay = container.querySelector('#contact-create-overlay'); + let currentContact = null; + + function applyFilters() { + const q = searchTerm.toLowerCase(); + filteredContacts = !q ? [...allContacts] : allContacts.filter(c => + (c.lastname || '').toLowerCase().includes(q) || + (c.firstname || '').toLowerCase().includes(q) || + (c.email || '').toLowerCase().includes(q) || + (c.phonePro || '').toLowerCase().includes(q) || + (c.phoneMobile || '').toLowerCase().includes(q) + ); + renderTable(); + } + + function renderTable() { + if (filteredContacts.length === 0) { + tbody.innerHTML = `No hay contactos`; + return; + } + tbody.innerHTML = filteredContacts.map(c => ` + + + ${icons.user} + + + ${escHtml(c.email || '—')} + ${escHtml(c.phonePro || '—')} + ${escHtml(c.phoneMobile || '—')} + + + + + + `).join(''); + + tbody.querySelectorAll('[data-id]').forEach(el => + el.addEventListener('click', () => openDetail(Number(el.dataset.id))) + ); + tbody.querySelectorAll('[data-delete]').forEach(el => + el.addEventListener('click', (e) => { e.stopPropagation(); confirmDelete(Number(el.dataset.delete)); }) + ); + } + + async function openDetail(id) { + detailOverlay.style.display = 'flex'; + detailTitle.textContent = 'Cargando...'; + detailBody.innerHTML = '
Cargando...
'; + + try { + const c = allContacts.find(x => x.id === id) || await getContactById(id); + currentContact = c; + detailTitle.textContent = [c.lastname, c.firstname].filter(Boolean).join(', ') || `Contacto #${c.id}`; + renderDetailView(c); + } catch (err) { + detailBody.innerHTML = `
No se pudo cargar: ${escHtml(err.message)}
`; + } + } + + function renderDetailView(c) { + detailBody.innerHTML = /*html*/` +
+
+
Apellidos${escHtml(c.lastname || '—')}
+
Nombre${escHtml(c.firstname || '—')}
+
Email${escHtml(c.email || '—')}
+
Tel. profesional${escHtml(c.phonePro || '—')}
+
Tel. personal${escHtml(c.phonePerso || '—')}
+
Móvil${escHtml(c.phoneMobile || '—')}
+ ${c.address ? `
Dirección${escHtml(c.address)}
` : ''} + ${(c.zip || c.town) ? `
Ciudad${escHtml([c.zip, c.town].filter(Boolean).join(' '))}
` : ''} +
+
+ +
+
+ `; + detailBody.querySelector('#contact-edit-btn').addEventListener('click', () => renderEditView(c)); + } + + function renderEditView(c) { + detailBody.innerHTML = /*html*/` +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ `; + + detailBody.querySelector('#contact-edit-cancel').addEventListener('click', () => renderDetailView(c)); + detailBody.querySelector('#contact-edit-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = detailBody.querySelector('#contact-edit-submit'); + submitBtn.disabled = true; + submitBtn.textContent = 'Guardando...'; + try { + await updateContact(c.id, { + lastname: detailBody.querySelector('#e-lastname').value.trim() || undefined, + firstname: detailBody.querySelector('#e-firstname').value.trim() || undefined, + email: detailBody.querySelector('#e-email').value.trim() || undefined, + phonePro: detailBody.querySelector('#e-phone-pro').value.trim() || undefined, + phonePerso: detailBody.querySelector('#e-phone-perso').value.trim() || undefined, + phoneMobile: detailBody.querySelector('#e-phone-mobile').value.trim() || undefined, + address: detailBody.querySelector('#e-address').value.trim() || undefined, + zip: detailBody.querySelector('#e-zip').value.trim() || undefined, + town: detailBody.querySelector('#e-town').value.trim() || undefined, + }); + showToast('Contacto actualizado', 'success'); + await reload(); + const updated = allContacts.find(x => x.id === c.id) || c; + currentContact = updated; + detailTitle.textContent = [updated.lastname, updated.firstname].filter(Boolean).join(', ') || `Contacto #${c.id}`; + renderDetailView(updated); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + submitBtn.disabled = false; + submitBtn.textContent = 'Guardar'; + } + }); + } + + async function confirmDelete(id) { + const contact = allContacts.find(c => c.id === id); + const name = contact ? [contact.lastname, contact.firstname].filter(Boolean).join(' ') : `#${id}`; + if (!confirm(`¿Eliminar el contacto "${name}"? Esta acción no se puede deshacer.`)) return; + try { + await deleteContact(id); + showToast('Contacto eliminado', 'success'); + if (detailOverlay.style.display !== 'none' && currentContact?.id === id) { + detailOverlay.style.display = 'none'; + } + await reload(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } + } + + async function reload() { + try { + allContacts = await getContacts({ limit: 500 }); + } catch (err) { + showToast(`Error al actualizar lista: ${err.message}`, 'error'); + } + applyFilters(); + } + + async function loadClients() { + if (clients.length > 0) return; + try { + clients = await apiGet('/api/Clients?limit=500'); + } catch { clients = []; } + } + + function populateClientSelect(selectEl) { + selectEl.innerHTML = ''; + clients.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.name; + selectEl.appendChild(opt); + }); + } + + // Close detail + container.querySelector('#contact-detail-close').addEventListener('click', () => { detailOverlay.style.display = 'none'; }); + detailOverlay.addEventListener('click', (e) => { if (e.target === detailOverlay) detailOverlay.style.display = 'none'; }); + + // Create modal + container.querySelector('#btn-new-contact').addEventListener('click', async () => { + await loadClients(); + populateClientSelect(container.querySelector('#c-client')); + createOverlay.style.display = 'flex'; + container.querySelector('#c-lastname').value = ''; + container.querySelector('#c-firstname').value = ''; + container.querySelector('#c-email').value = ''; + container.querySelector('#c-phone-pro').value = ''; + container.querySelector('#c-phone-perso').value = ''; + container.querySelector('#c-phone-mobile').value = ''; + container.querySelector('#c-address').value = ''; + container.querySelector('#c-zip').value = ''; + container.querySelector('#c-town').value = ''; + }); + container.querySelector('#contact-create-close').addEventListener('click', () => { createOverlay.style.display = 'none'; }); + container.querySelector('#contact-create-cancel').addEventListener('click', () => { createOverlay.style.display = 'none'; }); + createOverlay.addEventListener('click', (e) => { if (e.target === createOverlay) createOverlay.style.display = 'none'; }); + + container.querySelector('#contact-create-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = container.querySelector('#contact-create-submit'); + submitBtn.disabled = true; + submitBtn.textContent = 'Creando...'; + try { + const clientId = parseInt(container.querySelector('#c-client').value) || 0; + await createContact({ + lastname: container.querySelector('#c-lastname').value.trim(), + firstname: container.querySelector('#c-firstname').value.trim() || undefined, + clientId: clientId > 0 ? clientId : 0, + email: container.querySelector('#c-email').value.trim() || undefined, + phonePro: container.querySelector('#c-phone-pro').value.trim() || undefined, + phonePerso: container.querySelector('#c-phone-perso').value.trim() || undefined, + phoneMobile: container.querySelector('#c-phone-mobile').value.trim() || undefined, + address: container.querySelector('#c-address').value.trim() || undefined, + zip: container.querySelector('#c-zip').value.trim() || undefined, + town: container.querySelector('#c-town').value.trim() || undefined, + }); + showToast('Contacto creado', 'success'); + createOverlay.style.display = 'none'; + await reload(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Crear contacto'; + } + }); + + searchInput.addEventListener('input', () => { searchTerm = searchInput.value; applyFilters(); }); + + // Initial load + (async () => { + try { + allContacts = await getContacts({ limit: 500 }); + applyFilters(); + } catch (err) { + tbody.innerHTML = `Error al cargar: ${escHtml(err.message)}`; + showToast('Error al cargar contactos', 'error'); + } + })(); + + return container; +} diff --git a/src/pages/CreateInvoicePage.js b/src/pages/CreateInvoicePage.js index f368058..80ab5a2 100644 --- a/src/pages/CreateInvoicePage.js +++ b/src/pages/CreateInvoicePage.js @@ -4,6 +4,7 @@ import { apiGet, apiPost } from '../services/apiClient.js'; import { icons } from '../services/icons.js'; import { showToast } from '../services/toast.js'; import { getPaymentTerms } from '../services/setup.js'; +import { getInvoiceTemplates, getTemplateById } from '../services/invoices.js'; export function renderCreateInvoicePage() { const container = document.createElement('div'); @@ -16,7 +17,24 @@ export function renderCreateInvoicePage() { container.innerHTML = /*html*/`

Nueva Factura

- +
+ + +
+
+ + +
@@ -130,6 +148,91 @@ export function renderCreateInvoicePage() { } loadPaymentTerms(); + // --- Plantillas --- + let templatesCache = null; + + async function openTemplatePanel() { + const panel = container.querySelector('#template-panel'); + const listEl = container.querySelector('#template-list'); + panel.style.display = 'block'; + + if (templatesCache === null) { + listEl.innerHTML = '

Cargando plantillas...

'; + try { + templatesCache = await getInvoiceTemplates(); + } catch { + listEl.innerHTML = '

Error al cargar plantillas.

'; + return; + } + } + + if (!templatesCache.length) { + listEl.innerHTML = '

No hay plantillas disponibles.

'; + return; + } + + listEl.innerHTML = templatesCache.map(t => ` + + `).join(''); + + listEl.querySelectorAll('.template-item').forEach(btn => { + btn.addEventListener('click', () => applyTemplate(parseInt(btn.dataset.id))); + }); + } + + async function applyTemplate(id) { + const panel = container.querySelector('#template-panel'); + const listEl = container.querySelector('#template-list'); + listEl.innerHTML = '

Cargando detalle...

'; + + try { + const detail = await getTemplateById(id); + panel.style.display = 'none'; + + // Pre-fill client + if (detail.clientId) { + const clientSelect = container.querySelector('#clientId'); + clientSelect.value = String(detail.clientId); + } + + // Pre-fill notes + if (detail.notePublic) container.querySelector('#notePublic').value = detail.notePublic; + if (detail.notePrivate) container.querySelector('#notePrivate').value = detail.notePrivate; + + // Replace lines + const linesContainer = container.querySelector('#invoice-lines-container'); + linesContainer.innerHTML = ''; + lineCounter = 0; + + const linesToAdd = detail.lines?.length ? detail.lines : [null]; + linesToAdd.forEach(line => { + const lineDiv = createInvoiceLine(); + if (line) { + lineDiv.querySelector('.line-desc-compact').value = line.description || ''; + lineDiv.querySelector('.line-qty-compact').value = line.quantity ?? 1; + lineDiv.querySelector('.line-price-compact').value = line.unitPrice ?? 0; + lineDiv.querySelector('.line-tax-compact').value = line.taxRate ?? 21; + lineDiv.querySelector('.line-total-compact').textContent = + `${((line.quantity ?? 1) * (line.unitPrice ?? 0) * (1 + (line.taxRate ?? 21) / 100)).toFixed(2)} €`; + } + linesContainer.appendChild(lineDiv); + }); + + showToast('Plantilla aplicada', 'success'); + } catch { + listEl.innerHTML = '

Error al cargar la plantilla.

'; + } + } + + container.querySelector('#template-btn').addEventListener('click', openTemplatePanel); + container.querySelector('#template-panel-close').addEventListener('click', () => { + container.querySelector('#template-panel').style.display = 'none'; + }); + container.querySelector('#paymentTerms').addEventListener('change', (e) => { const days = parseInt(e.target.value); if (!days || days <= 0) return; diff --git a/src/pages/FacturasProveedores.js b/src/pages/FacturasProveedores.js new file mode 100644 index 0000000..f628cff --- /dev/null +++ b/src/pages/FacturasProveedores.js @@ -0,0 +1,794 @@ +import { icons } from '../services/icons.js'; +import { + getSupplierInvoices, getSupplierInvoiceById, createSupplierInvoice, + updateSupplierInvoice, deleteSupplierInvoice, changeSupplierInvoiceStatus, + addSupplierInvoiceLine, updateSupplierInvoiceLine, deleteSupplierInvoiceLine, + getSupplierInvoicePayments, addSupplierInvoicePayment +} from '../services/supplierInvoices.js'; +import { showToast } from '../services/toast.js'; +import { apiGet } from '../services/apiClient.js'; +import { getPaymentTypes } from '../services/setup.js'; + +export function renderFacturasProveedoresPage() { + const container = document.createElement('div'); + container.className = 'facturas-page page-enter'; + + let allInvoices = []; + let filteredInvoices = []; + let currentFilter = ''; + let searchTerm = ''; + let currentInvoice = null; + let payments = []; + let paymentTypes = []; + + const fmt = (n) => new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(n || 0); + const fmtDate = (d) => d ? new Date(d).toLocaleDateString('es-ES') : '—'; + const fmtDateInput = (d) => d ? new Date(d).toISOString().split('T')[0] : ''; + + const STATUS_TEXT = { + draft: 'Borrador', + unpaid: 'Pte. Pago', + paid: 'Pagada', + cancelled: 'Cancelada', + unknown: 'Desconocido' + }; + const STATUS_CLASS = { + draft: 'status-draft', + unpaid: 'status-unpaid', + paid: 'status-paid', + cancelled: 'status-canceled', + unknown: 'status-draft' + }; + + function escHtml(str) { + if (str == null) return ''; + const d = document.createElement('div'); + d.textContent = String(str); + return d.innerHTML; + } + + container.innerHTML = /*html*/` +
+

Facturas de Proveedores

+
+ +
+
+ ${icons.search} + +
+ + +
+ + + +
+ + + + + + + + + + + + + + + +
NúmeroEstadoProveedorFechaTotalPendienteAcciones
Cargando...
+
+ + + + + +
+ `; + + const tbody = container.querySelector('#sup-tbody'); + const searchInput = container.querySelector('#sup-search'); + const filterSelect = container.querySelector('#sup-filter'); + const summary = container.querySelector('#sup-summary'); + const overlay = container.querySelector('#sup-detail-overlay'); + const detailBody = container.querySelector('#sup-detail-body'); + const detailTitle = container.querySelector('#sup-detail-title'); + container.querySelector('#sup-detail-close').addEventListener('click', closeDetail); + overlay.addEventListener('click', (e) => { if (e.target === overlay) closeDetail(); }); + + function applyFilters() { + filteredInvoices = allInvoices.filter(inv => { + const matchStatus = !currentFilter || inv.status === currentFilter; + const q = searchTerm.toLowerCase(); + const matchSearch = !q + || (inv.number || '').toLowerCase().includes(q) + || (inv.supplierRef || '').toLowerCase().includes(q) + || (inv.supplierName || '').toLowerCase().includes(q); + return matchStatus && matchSearch; + }); + renderTable(); + renderSummary(); + } + + function renderTable() { + if (filteredInvoices.length === 0) { + tbody.innerHTML = `No hay facturas de proveedores`; + return; + } + tbody.innerHTML = filteredInvoices.map(inv => ` + + + + + + + ${STATUS_TEXT[inv.status] || inv.status} + + + + ${icons.user} + ${escHtml(inv.supplierName || 'Sin nombre')} + + ${fmtDate(inv.date)} + ${fmt(inv.total)} + + ${fmt(inv.remainToPay)} + + + + + + `).join(''); + + tbody.querySelectorAll('[data-id]').forEach(el => { + el.addEventListener('click', () => openDetail(Number(el.dataset.id))); + }); + } + + function renderSummary() { + if (filteredInvoices.length === 0) { summary.style.display = 'none'; return; } + summary.style.display = 'flex'; + const totalHt = filteredInvoices.reduce((s, i) => s + (i.totalHt || 0), 0); + const totalTax = filteredInvoices.reduce((s, i) => s + (i.totalTax || 0), 0); + const total = filteredInvoices.reduce((s, i) => s + (i.total || 0), 0); + const pending = filteredInvoices.reduce((s, i) => s + (i.remainToPay || 0), 0); + container.querySelector('#sum-count').textContent = filteredInvoices.length; + container.querySelector('#sum-ht').textContent = fmt(totalHt); + container.querySelector('#sum-tax').textContent = fmt(totalTax); + container.querySelector('#sum-total').textContent = fmt(total); + container.querySelector('#sum-pending').textContent = fmt(pending); + } + + async function openDetail(id) { + overlay.style.display = 'flex'; + detailTitle.textContent = 'Cargando...'; + detailBody.innerHTML = '
Cargando detalle...
'; + payments = []; + + // Load payment types once per page instance + if (paymentTypes.length === 0) { + try { paymentTypes = await getPaymentTypes(); } catch { paymentTypes = []; } + } + + try { + const inv = await getSupplierInvoiceById(id); + currentInvoice = inv; + detailTitle.textContent = `Factura ${inv.number}`; + await loadPayments(id); + renderDetail(inv); + } catch (err) { + detailBody.innerHTML = `
No se pudo cargar el detalle: ${escHtml(err.message)}
`; + } + } + + async function refreshDetail() { + if (!currentInvoice) return; + try { + const inv = await getSupplierInvoiceById(currentInvoice.id); + currentInvoice = inv; + detailTitle.textContent = `Factura ${inv.number}`; + await loadPayments(inv.id); + renderDetail(inv); + // Refresh list row too + const idx = allInvoices.findIndex(x => x.id === inv.id); + if (idx >= 0) allInvoices[idx] = { ...allInvoices[idx], ...inv }; + applyFilters(); + } catch (err) { + showToast(`Error al recargar: ${err.message}`, 'error'); + } + } + + async function loadPayments(id) { + try { + payments = await getSupplierInvoicePayments(id); + } catch { + payments = []; + } + } + + function renderDetail(inv) { + const isDraft = inv.status === 'draft'; + const isUnpaid = inv.status === 'unpaid'; + const lines = inv.lines || []; + + detailBody.innerHTML = /*html*/` + +
+
+ Proveedor + ${escHtml(inv.supplierName || '—')} +
+
+ Ref. interna + ${escHtml(inv.number || '—')} +
+
+ Ref. proveedor + ${escHtml(inv.supplierRef || '—')} +
+
+ Estado + + + ${STATUS_TEXT[inv.status] || inv.status} + + +
+
+ Fecha + ${fmtDate(inv.date)} +
+
+ Vencimiento + ${fmtDate(inv.expireDate)} +
+ ${inv.notePublic ? `
Nota pública${escHtml(inv.notePublic)}
` : ''} +
+ + +
+ ${isDraft ? `` : ''} + ${isUnpaid ? `` : ''} + ${inv.status === 'paid' || isUnpaid ? `` : ''} + ${isDraft ? `` : ''} + ${isDraft ? `` : ''} +
+ + +

Líneas

+
+ + + + + + + + + ${isDraft ? '' : ''} + + + + ${lines.length === 0 + ? `` + : lines.map(l => ` + + + + + + + ${isDraft ? `` : ''} + + `).join('')} + +
DescripciónCant.P. Unit.IVA %Total
Sin líneas
${escHtml(l.description || '—')}${l.quantity}${fmt(l.unitPrice)}${l.taxRate}%${fmt(l.total)} + + +
+
+ ${isDraft ? ` + + + ` : ''} + + +
+ ${inv.totalHt != null ? `
Base imponible:${fmt(inv.totalHt)}
` : ''} + ${inv.totalTax != null ? `
IVA soportado:${fmt(inv.totalTax)}
` : ''} +
Total:${fmt(inv.total)}
+ +
Pendiente:${fmt(inv.remainToPay)}
+
+ + +

Pagos

+
+ + + + + + + + + + + ${payments.length === 0 + ? `` + : payments.map(p => ` + + + + + + + `).join('')} + +
FechaReferenciaTipoImporte
Sin pagos registrados
${p.paymentDate ? new Date(p.paymentDate).toLocaleDateString('es-ES') : '—'}${escHtml(p.ref || '—')}${escHtml(p.type || '—')}${fmt(p.amount)}
+
+ ${isUnpaid ? ` + + + ` : ''} + `; + + // Wire up status buttons + detailBody.querySelector('#sup-btn-validate')?.addEventListener('click', () => changeStatus('unpaid')); + detailBody.querySelector('#sup-btn-paid')?.addEventListener('click', () => changeStatus('paid')); + detailBody.querySelector('#sup-btn-draft')?.addEventListener('click', () => changeStatus('draft')); + detailBody.querySelector('#sup-btn-delete')?.addEventListener('click', handleDelete); + detailBody.querySelector('#sup-btn-edit')?.addEventListener('click', () => renderEditForm(inv)); + + // Wire up line actions + detailBody.querySelectorAll('[data-del-line]').forEach(btn => { + btn.addEventListener('click', () => handleDeleteLine(parseInt(btn.dataset.delLine))); + }); + detailBody.querySelectorAll('[data-edit-line]').forEach(btn => { + btn.addEventListener('click', () => openEditLineForm(parseInt(btn.dataset.editLine), inv)); + }); + + // Add line form + const showAddLine = detailBody.querySelector('#sup-show-add-line'); + const addLineForm = detailBody.querySelector('#sup-add-line-form'); + showAddLine?.addEventListener('click', () => { + addLineForm.style.display = 'flex'; + showAddLine.style.display = 'none'; + detailBody.querySelector('#nl-desc').focus(); + }); + detailBody.querySelector('#nl-cancel')?.addEventListener('click', () => { + addLineForm.style.display = 'none'; + showAddLine.style.display = ''; + }); + detailBody.querySelector('#nl-save')?.addEventListener('click', handleAddLine); + + // Payment form + const showPayForm = detailBody.querySelector('#sup-show-pay-form'); + const payForm = detailBody.querySelector('#sup-pay-form'); + showPayForm?.addEventListener('click', () => { + payForm.style.display = 'flex'; + showPayForm.style.display = 'none'; + const dateInput = detailBody.querySelector('#pay-date'); + if (dateInput) dateInput.value = new Date().toISOString().split('T')[0]; + }); + detailBody.querySelector('#pay-cancel')?.addEventListener('click', () => { + payForm.style.display = 'none'; + showPayForm.style.display = ''; + }); + detailBody.querySelector('#pay-save')?.addEventListener('click', handleAddPayment); + } + + function renderEditForm(inv) { + const editSection = detailBody.querySelector('#sup-detail-info'); + if (!editSection) return; + + const actionsBar = detailBody.querySelector('div[style*="flex-wrap"]'); + + // Replace info grid with edit form + const form = document.createElement('form'); + form.id = 'sup-edit-form'; + form.innerHTML = /*html*/` +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+ `; + + detailBody.querySelector('#sup-detail-info').replaceWith(form); + if (actionsBar) actionsBar.style.display = 'none'; + + form.querySelector('#ed-cancel').addEventListener('click', () => renderDetail(inv)); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const saveBtn = form.querySelector('#ed-save'); + saveBtn.disabled = true; + saveBtn.textContent = 'Guardando...'; + try { + await updateSupplierInvoice(inv.id, { + supplierRef: form.querySelector('#ed-ref').value.trim() || undefined, + expireDate: form.querySelector('#ed-expire').value || undefined, + notePublic: form.querySelector('#ed-note').value.trim() || undefined, + }); + showToast('Factura actualizada', 'success'); + await refreshDetail(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + saveBtn.disabled = false; + saveBtn.textContent = 'Guardar cambios'; + } + }); + } + + async function changeStatus(status) { + try { + await changeSupplierInvoiceStatus(currentInvoice.id, status); + showToast(`Estado actualizado a ${STATUS_TEXT[status] || status}`, 'success'); + await refreshDetail(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } + } + + async function handleDelete() { + if (!confirm(`¿Eliminar la factura ${currentInvoice?.number}? Esta acción no se puede deshacer.`)) return; + try { + await deleteSupplierInvoice(currentInvoice.id); + showToast('Factura eliminada', 'success'); + closeDetail(); + allInvoices = allInvoices.filter(i => i.id !== currentInvoice.id); + applyFilters(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } + } + + async function handleAddLine() { + const desc = detailBody.querySelector('#nl-desc').value.trim(); + const qty = parseFloat(detailBody.querySelector('#nl-qty').value) || 1; + const price = parseFloat(detailBody.querySelector('#nl-price').value) || 0; + const tax = parseFloat(detailBody.querySelector('#nl-tax').value) || 0; + if (!desc) { showToast('La descripción es obligatoria', 'error'); return; } + const saveBtn = detailBody.querySelector('#nl-save'); + saveBtn.disabled = true; + try { + await addSupplierInvoiceLine(currentInvoice.id, { + description: desc, quantity: qty, unitPrice: price, taxRate: tax + }); + showToast('Línea añadida', 'success'); + await refreshDetail(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + saveBtn.disabled = false; + } + } + + function openEditLineForm(lineId, inv) { + const line = inv.lines?.find(l => l.id === lineId); + if (!line) return; + const row = detailBody.querySelector(`[data-line-id="${lineId}"]`); + if (!row) return; + + row.innerHTML = /*html*/` + + + + + + + + + + `; + + row.querySelector('.el-cancel').addEventListener('click', () => renderDetail(inv)); + row.querySelector('.el-save').addEventListener('click', async () => { + const btn = row.querySelector('.el-save'); + btn.disabled = true; + try { + await updateSupplierInvoiceLine(currentInvoice.id, lineId, { + description: row.querySelector('.el-desc').value.trim() || undefined, + quantity: parseFloat(row.querySelector('.el-qty').value) || undefined, + unitPrice: parseFloat(row.querySelector('.el-price').value) || undefined, + taxRate: parseFloat(row.querySelector('.el-tax').value) ?? undefined, + }); + showToast('Línea actualizada', 'success'); + await refreshDetail(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + btn.disabled = false; + } + }); + } + + async function handleDeleteLine(lineId) { + if (!confirm('¿Eliminar esta línea?')) return; + try { + await deleteSupplierInvoiceLine(currentInvoice.id, lineId); + showToast('Línea eliminada', 'success'); + await refreshDetail(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } + } + + async function handleAddPayment() { + const date = detailBody.querySelector('#pay-date').value; + const amountRaw = detailBody.querySelector('#pay-amount').value; + const paymentModeId = parseInt(detailBody.querySelector('#pay-type').value) || 6; + if (!date) { showToast('Selecciona una fecha de pago', 'error'); return; } + const saveBtn = detailBody.querySelector('#pay-save'); + saveBtn.disabled = true; + try { + await addSupplierInvoicePayment(currentInvoice.id, { + paymentDate: date, + amount: amountRaw ? parseFloat(amountRaw) : undefined, + paymentModeId, + closePaidInvoices: 'yes', + accountId: 1, + }); + showToast('Pago registrado', 'success'); + await refreshDetail(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + saveBtn.disabled = false; + } + } + + function closeDetail() { + overlay.style.display = 'none'; + currentInvoice = null; + } + + async function load() { + try { + allInvoices = await getSupplierInvoices({ limit: 200 }); + applyFilters(); + } catch (err) { + tbody.innerHTML = `Error al cargar: ${escHtml(err.message)}`; + showToast('Error al cargar facturas de proveedores', 'error'); + } + } + + searchInput.addEventListener('input', () => { searchTerm = searchInput.value; applyFilters(); }); + filterSelect.addEventListener('change', () => { currentFilter = filterSelect.value; applyFilters(); }); + + // ===== CREAR FACTURA ===== + const createOverlay = container.querySelector('#sup-create-overlay'); + const supplierSelect = container.querySelector('#sup-supplier-select'); + const linesContainer = container.querySelector('#sup-lines-container'); + let lineCount = 0; + + function addLine() { + lineCount++; + const row = document.createElement('div'); + row.className = 'line-row-compact'; + row.dataset.line = lineCount; + row.innerHTML = ` + + + + + + `; + row.querySelector('.btn-remove-line').addEventListener('click', () => { + row.remove(); + if (linesContainer.children.length === 0) addLine(); + }); + linesContainer.appendChild(row); + } + + function openCreateModal() { + createOverlay.style.display = 'flex'; + linesContainer.innerHTML = ''; + lineCount = 0; + container.querySelector('#sup-ref-supplier').value = ''; + container.querySelector('#sup-date').value = new Date().toISOString().split('T')[0]; + container.querySelector('#sup-expire').value = ''; + container.querySelector('#sup-note-public').value = ''; + addLine(); + } + + function closeCreate() { + createOverlay.style.display = 'none'; + } + + async function loadSuppliers() { + try { + const clients = await apiGet('/api/Clients?limit=500&supplier=true'); + supplierSelect.innerHTML = ''; + clients.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.id; + opt.textContent = c.name; + supplierSelect.appendChild(opt); + }); + } catch { + supplierSelect.innerHTML = ''; + } + } + + container.querySelector('#btn-new-sup').addEventListener('click', () => { + openCreateModal(); + loadSuppliers(); + }); + container.querySelector('#sup-create-close').addEventListener('click', closeCreate); + container.querySelector('#sup-create-cancel').addEventListener('click', closeCreate); + createOverlay.addEventListener('click', (e) => { if (e.target === createOverlay) closeCreate(); }); + container.querySelector('#sup-add-line').addEventListener('click', addLine); + + container.querySelector('#sup-create-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const submitBtn = container.querySelector('#sup-create-submit'); + const supplierId = parseInt(supplierSelect.value); + if (!supplierId) { showToast('Selecciona un proveedor', 'error'); return; } + + const lines = [...linesContainer.querySelectorAll('.line-row-compact')].map(row => ({ + description: row.querySelector('.line-desc').value.trim(), + quantity: parseFloat(row.querySelector('.line-qty').value) || 1, + unitPrice: parseFloat(row.querySelector('.line-price').value) || 0, + taxRate: parseFloat(row.querySelector('.line-tax').value) || 0, + })); + + if (lines.some(l => !l.description)) { showToast('Rellena la descripción de todas las líneas', 'error'); return; } + + submitBtn.disabled = true; + submitBtn.textContent = 'Creando...'; + + try { + await createSupplierInvoice({ + supplierId, + supplierRef: container.querySelector('#sup-ref-supplier').value.trim(), + date: container.querySelector('#sup-date').value, + expireDate: container.querySelector('#sup-expire').value || null, + notePublic: container.querySelector('#sup-note-public').value.trim() || null, + lines + }); + showToast('Factura de proveedor creada', 'success'); + closeCreate(); + allInvoices = await getSupplierInvoices({ limit: 200 }); + applyFilters(); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + } finally { + submitBtn.disabled = false; + submitBtn.textContent = 'Crear factura'; + } + }); + + load(); + + return container; +} diff --git a/src/pages/SettingsPage.js b/src/pages/SettingsPage.js index ca0c7a6..d92d3d3 100644 --- a/src/pages/SettingsPage.js +++ b/src/pages/SettingsPage.js @@ -1,5 +1,7 @@ import { isDarkTheme, toggleTheme } from '../services/theme.js'; import { icons } from '../services/icons.js'; +import { apiGet, apiPut } from '../services/apiClient.js'; +import { showToast } from '../services/toast.js'; function decodeTokenPayload(token) { try { @@ -98,6 +100,27 @@ export function renderSettingsPage() {
+ +
+

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