@@ -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('
Cargando 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 = `
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*/`
+
+
+
+
+ ${icons.search}
+
+
+
+
+
+
+
+
+
+ | Nombre |
+ Email |
+ Teléfono |
+ Móvil |
+ Acciones |
+
+
+
+ | 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*/`
+
+ `;
+ 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*/`
+
+
+
+
+
+
Cargando plantillas...
+