actualizar pantallas y realizar ajustes
This commit is contained in:
parent
f9ad12197a
commit
fa460b3ba8
4
.env
4
.env
|
|
@ -1,4 +1,4 @@
|
||||||
# API Configuration — apunta al BFF
|
# API Configuration — apunta al BFF
|
||||||
#_dev local: http://localhost:5269
|
#_dev local: http://localhost:5269
|
||||||
#docker: http://localhost:5000
|
#docker: http://localhost:5001
|
||||||
VITE_API_BASE_URL=http://localhost:5000
|
VITE_API_BASE_URL=http://localhost:5001
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { getInvoiceById, updateInvoice, validateInvoice, addInvoiceLine, deleteI
|
||||||
import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js';
|
import { showConfirmExitModal, FormChangeTracker } from './ConfirmExitModal.js';
|
||||||
import { toast } from '../services/toast.js';
|
import { toast } from '../services/toast.js';
|
||||||
import { getPaymentTypes } from '../services/setup.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) {
|
export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
const modal = document.createElement('div');
|
const modal = document.createElement('div');
|
||||||
|
|
@ -12,6 +12,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
let payments = [];
|
let payments = [];
|
||||||
let paymentTypes = [];
|
let paymentTypes = [];
|
||||||
let bankAccounts = [];
|
let bankAccounts = [];
|
||||||
|
let documents = [];
|
||||||
let isLoading = true;
|
let isLoading = true;
|
||||||
|
|
||||||
// Tracker de cambios
|
// Tracker de cambios
|
||||||
|
|
@ -33,6 +34,13 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
return date.toLocaleDateString('es-ES');
|
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
|
// Formatear moneda
|
||||||
const formatCurrency = (amount) => {
|
const formatCurrency = (amount) => {
|
||||||
return new Intl.NumberFormat('es-ES', {
|
return new Intl.NumberFormat('es-ES', {
|
||||||
|
|
@ -67,6 +75,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
<div class="loading">Cargando detalles de la factura...</div>
|
<div class="loading">Cargando detalles de la factura...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
modalContent.querySelector('.btn-close')?.addEventListener('click', handleClose);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,6 +89,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
<div class="error">No se pudo cargar la factura</div>
|
<div class="error">No se pudo cargar la factura</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
modalContent.querySelector('.btn-close')?.addEventListener('click', handleClose);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -245,17 +255,27 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
|
|
||||||
<!-- Totales -->
|
<!-- Totales -->
|
||||||
<div class="invoice-totals">
|
<div class="invoice-totals">
|
||||||
|
${invoice.totalHt != null ? `
|
||||||
|
<div class="total-row subtotal">
|
||||||
|
<span>Base imponible:</span>
|
||||||
|
<strong>${formatCurrency(invoice.totalHt)}</strong>
|
||||||
|
</div>` : ''}
|
||||||
|
${invoice.totalTax != null ? `
|
||||||
|
<div class="total-row subtotal">
|
||||||
|
<span>IVA:</span>
|
||||||
|
<strong>${formatCurrency(invoice.totalTax)}</strong>
|
||||||
|
</div>` : ''}
|
||||||
<div class="total-row">
|
<div class="total-row">
|
||||||
<span>Total:</span>
|
<span>Total:</span>
|
||||||
<strong>${formatCurrency(invoice.total)}</strong>
|
<strong class="amount-positive">${formatCurrency(invoice.total)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="total-row paid">
|
<div class="total-row paid">
|
||||||
<span>Pagado:</span>
|
<span>Pagado:</span>
|
||||||
<strong>${formatCurrency((invoice.total || 0) - (invoice.remainToPay || 0))}</strong>
|
<strong class="amount-paid">${formatCurrency((invoice.total || 0) - (invoice.remainToPay || 0))}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div class="total-row pending">
|
<div class="total-row pending">
|
||||||
<span>Pendiente:</span>
|
<span>Pendiente:</span>
|
||||||
<strong>${formatCurrency(invoice.remainToPay)}</strong>
|
<strong class="amount-pending">${formatCurrency(invoice.remainToPay)}</strong>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -289,6 +309,27 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Documentos adjuntos -->
|
||||||
|
<div class="form-section documents-section">
|
||||||
|
<h3>Documentos</h3>
|
||||||
|
${documents.length === 0
|
||||||
|
? '<p class="no-documents">No hay documentos adjuntos a esta factura.</p>'
|
||||||
|
: `<ul class="documents-list">
|
||||||
|
${documents.map(doc => `
|
||||||
|
<li class="document-item">
|
||||||
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="doc-icon"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||||
|
<span class="document-name">${doc.name}</span>
|
||||||
|
<span class="document-size">${formatFileSize(doc.size)}</span>
|
||||||
|
<button type="button" class="btn-download-doc" data-path="${encodeURIComponent(doc.relativePath || doc.name)}" data-name="${doc.name}" title="Descargar">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||||||
|
Descargar
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>`
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
${(invoice.status === 'validated' || invoice.status === 'unpaid') && invoice.remainToPay > 0 ? `
|
${(invoice.status === 'validated' || invoice.status === 'unpaid') && invoice.remainToPay > 0 ? `
|
||||||
<!-- Sección de pago -->
|
<!-- Sección de pago -->
|
||||||
<div class="form-section payment-section">
|
<div class="form-section payment-section">
|
||||||
|
|
@ -419,6 +460,11 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
btn.addEventListener('click', () => handleDeleteLine(parseInt(btn.dataset.lineId)));
|
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
|
// Botón registrar pago
|
||||||
const payBtn = modal.querySelector('#btn-pay');
|
const payBtn = modal.querySelector('#btn-pay');
|
||||||
payBtn?.addEventListener('click', handlePayment);
|
payBtn?.addEventListener('click', handlePayment);
|
||||||
|
|
@ -611,8 +657,7 @@ export function InvoiceModal(invoiceId, onClose, onUpdate) {
|
||||||
}
|
}
|
||||||
|
|
||||||
previewWindow.opener = null;
|
previewWindow.opener = null;
|
||||||
previewWindow.document.write('<!doctype html><html><head><title>Vista previa factura</title></head><body style="font-family: sans-serif; padding: 1rem;">Cargando vista previa...</body></html>');
|
previewWindow.document.title = 'Vista previa factura';
|
||||||
previewWindow.document.close();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (previewBtn) {
|
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
|
// Cargar factura + datos auxiliares en paralelo
|
||||||
const loadInvoice = async () => {
|
const loadInvoice = async () => {
|
||||||
try {
|
try {
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
renderContent();
|
renderContent();
|
||||||
|
|
||||||
const [invoiceData, paymentsData, typesData, accountsData] = await Promise.allSettled([
|
// Load invoice first so we have invoice.number for the documents endpoint
|
||||||
getInvoiceById(invoiceId),
|
invoice = await getInvoiceById(invoiceId).catch(() => null);
|
||||||
|
|
||||||
|
const [paymentsData, typesData, accountsData, documentsData] = await Promise.allSettled([
|
||||||
getPayments(invoiceId),
|
getPayments(invoiceId),
|
||||||
getPaymentTypes(),
|
getPaymentTypes(),
|
||||||
apiGet('/api/Bank/accounts'),
|
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 : [];
|
payments = paymentsData.status === 'fulfilled' ? paymentsData.value : [];
|
||||||
paymentTypes = typesData.status === 'fulfilled' ? typesData.value : [];
|
paymentTypes = typesData.status === 'fulfilled' ? typesData.value : [];
|
||||||
bankAccounts = accountsData.status === 'fulfilled' ? accountsData.value : [];
|
bankAccounts = accountsData.status === 'fulfilled' ? accountsData.value : [];
|
||||||
|
documents = documentsData.status === 'fulfilled' ? (documentsData.value ?? []) : [];
|
||||||
|
|
||||||
isLoading = false;
|
isLoading = false;
|
||||||
renderContent();
|
renderContent();
|
||||||
|
|
|
||||||
|
|
@ -141,7 +141,7 @@ export function renderBancoPage() {
|
||||||
el.innerHTML = '';
|
el.innerHTML = '';
|
||||||
list.forEach(acc => {
|
list.forEach(acc => {
|
||||||
const item = document.createElement('div');
|
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.dataset.id = acc.id;
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="banco-account-icon">
|
<div class="banco-account-icon">
|
||||||
|
|
@ -150,7 +150,7 @@ export function renderBancoPage() {
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="banco-account-info">
|
<div class="banco-account-info">
|
||||||
<span class="banco-account-name">${acc.label || acc.ref || 'Cuenta sin nombre'}</span>
|
<span class="banco-account-name">${acc.label || acc.ref || 'Cuenta sin nombre'}${acc.isClosed ? ' <span class="banco-account-closed-badge">Cerrada</span>' : ''}</span>
|
||||||
<span class="banco-account-sub">${acc.iban || acc.accountNumber || acc.ref || '—'}</span>
|
<span class="banco-account-sub">${acc.iban || acc.accountNumber || acc.ref || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="banco-account-right">
|
<div class="banco-account-right">
|
||||||
|
|
|
||||||
|
|
@ -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*/`
|
||||||
|
<div class="facturas-header">
|
||||||
|
<h1>Contactos</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="facturas-filters">
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<span class="search-icon">${icons.search}</span>
|
||||||
|
<input type="search" placeholder="Buscar por nombre, email o teléfono..." class="search-input search-input--with-icon" id="contacts-search" />
|
||||||
|
</div>
|
||||||
|
<button class="btn-new-invoice" id="btn-new-contact">${icons.plus} <span>Nuevo contacto</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invoices-table-container">
|
||||||
|
<table class="invoices-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Nombre</th>
|
||||||
|
<th>Email</th>
|
||||||
|
<th>Teléfono</th>
|
||||||
|
<th>Móvil</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="contacts-tbody">
|
||||||
|
<tr><td colspan="5" class="facturas-empty">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Detail / Edit modal -->
|
||||||
|
<div class="modal-overlay sup-detail-overlay" id="contact-detail-overlay" style="display:none">
|
||||||
|
<div class="modal-content sup-detail-modal" id="contact-detail-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="contact-detail-title">Contacto</h2>
|
||||||
|
<button class="btn-close" id="contact-detail-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="contact-detail-body">
|
||||||
|
<div class="loading">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create modal -->
|
||||||
|
<div class="modal-overlay sup-detail-overlay" id="contact-create-overlay" style="display:none">
|
||||||
|
<div class="modal-content sup-detail-modal" id="contact-create-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Nuevo contacto</h2>
|
||||||
|
<button class="btn-close" id="contact-create-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="contact-create-form">
|
||||||
|
<div class="invoice-detail-grid" style="margin-bottom:1rem">
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Apellidos *</label>
|
||||||
|
<input type="text" id="c-lastname" required style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input type="text" id="c-firstname" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact full-width" style="grid-column:1/-1">
|
||||||
|
<label>Empresa</label>
|
||||||
|
<select id="c-client" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--card-bg);color:var(--text-primary)">
|
||||||
|
<option value="">Sin empresa</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" id="c-email" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Teléfono profesional</label>
|
||||||
|
<input type="tel" id="c-phone-pro" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Teléfono personal</label>
|
||||||
|
<input type="tel" id="c-phone-perso" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Móvil</label>
|
||||||
|
<input type="tel" id="c-phone-mobile" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact full-width" style="grid-column:1/-1">
|
||||||
|
<label>Dirección</label>
|
||||||
|
<input type="text" id="c-address" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>CP</label>
|
||||||
|
<input type="text" id="c-zip" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Ciudad</label>
|
||||||
|
<input type="text" id="c-town" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions-compact" style="margin-top:1rem">
|
||||||
|
<button type="button" class="btn-cancel-compact" id="contact-create-cancel">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-submit-compact" id="contact-create-submit">Crear contacto</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<tr><td colspan="5" class="facturas-empty">No hay contactos</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = filteredContacts.map(c => `
|
||||||
|
<tr class="invoice-item" data-id="${c.id}">
|
||||||
|
<td class="invoice-client">
|
||||||
|
<span class="client-icon">${icons.user}</span>
|
||||||
|
<button class="btn-invoice-num" data-id="${c.id}">${escHtml([c.lastname, c.firstname].filter(Boolean).join(', ') || '—')}</button>
|
||||||
|
</td>
|
||||||
|
<td>${escHtml(c.email || '—')}</td>
|
||||||
|
<td>${escHtml(c.phonePro || '—')}</td>
|
||||||
|
<td>${escHtml(c.phoneMobile || '—')}</td>
|
||||||
|
<td class="invoice-actions">
|
||||||
|
<button class="btn-action btn-view" data-id="${c.id}" title="Ver detalle">${icons.eye}</button>
|
||||||
|
<button class="btn-action" data-delete="${c.id}" title="Eliminar" style="color:var(--danger)">${icons.close}</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).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 = '<div class="loading">Cargando...</div>';
|
||||||
|
|
||||||
|
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 = `<div class="error">No se pudo cargar: ${escHtml(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetailView(c) {
|
||||||
|
detailBody.innerHTML = /*html*/`
|
||||||
|
<div id="contact-view-mode">
|
||||||
|
<div class="invoice-detail-grid">
|
||||||
|
<div class="detail-row"><span class="detail-label">Apellidos</span><span class="detail-value">${escHtml(c.lastname || '—')}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Nombre</span><span class="detail-value">${escHtml(c.firstname || '—')}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Email</span><span class="detail-value">${escHtml(c.email || '—')}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Tel. profesional</span><span class="detail-value">${escHtml(c.phonePro || '—')}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Tel. personal</span><span class="detail-value">${escHtml(c.phonePerso || '—')}</span></div>
|
||||||
|
<div class="detail-row"><span class="detail-label">Móvil</span><span class="detail-value">${escHtml(c.phoneMobile || '—')}</span></div>
|
||||||
|
${c.address ? `<div class="detail-row full-width"><span class="detail-label">Dirección</span><span class="detail-value">${escHtml(c.address)}</span></div>` : ''}
|
||||||
|
${(c.zip || c.town) ? `<div class="detail-row"><span class="detail-label">Ciudad</span><span class="detail-value">${escHtml([c.zip, c.town].filter(Boolean).join(' '))}</span></div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="form-actions-compact" style="margin-top:1.5rem">
|
||||||
|
<button class="btn-cancel-compact" id="contact-edit-btn">Editar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
detailBody.querySelector('#contact-edit-btn').addEventListener('click', () => renderEditView(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEditView(c) {
|
||||||
|
detailBody.innerHTML = /*html*/`
|
||||||
|
<form id="contact-edit-form">
|
||||||
|
<div class="invoice-detail-grid" style="margin-bottom:1rem">
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Apellidos *</label>
|
||||||
|
<input type="text" id="e-lastname" value="${escHtml(c.lastname || '')}" required style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Nombre</label>
|
||||||
|
<input type="text" id="e-firstname" value="${escHtml(c.firstname || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Email</label>
|
||||||
|
<input type="email" id="e-email" value="${escHtml(c.email || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Tel. profesional</label>
|
||||||
|
<input type="tel" id="e-phone-pro" value="${escHtml(c.phonePro || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Tel. personal</label>
|
||||||
|
<input type="tel" id="e-phone-perso" value="${escHtml(c.phonePerso || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Móvil</label>
|
||||||
|
<input type="tel" id="e-phone-mobile" value="${escHtml(c.phoneMobile || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact full-width" style="grid-column:1/-1">
|
||||||
|
<label>Dirección</label>
|
||||||
|
<input type="text" id="e-address" value="${escHtml(c.address || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>CP</label>
|
||||||
|
<input type="text" id="e-zip" value="${escHtml(c.zip || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Ciudad</label>
|
||||||
|
<input type="text" id="e-town" value="${escHtml(c.town || '')}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions-compact">
|
||||||
|
<button type="button" class="btn-cancel-compact" id="contact-edit-cancel">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-submit-compact" id="contact-edit-submit">Guardar</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = '<option value="">Sin empresa</option>';
|
||||||
|
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 = `<tr><td colspan="5" class="facturas-empty">Error al cargar: ${escHtml(err.message)}</td></tr>`;
|
||||||
|
showToast('Error al cargar contactos', 'error');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import { apiGet, apiPost } from '../services/apiClient.js';
|
||||||
import { icons } from '../services/icons.js';
|
import { icons } from '../services/icons.js';
|
||||||
import { showToast } from '../services/toast.js';
|
import { showToast } from '../services/toast.js';
|
||||||
import { getPaymentTerms } from '../services/setup.js';
|
import { getPaymentTerms } from '../services/setup.js';
|
||||||
|
import { getInvoiceTemplates, getTemplateById } from '../services/invoices.js';
|
||||||
|
|
||||||
export function renderCreateInvoicePage() {
|
export function renderCreateInvoicePage() {
|
||||||
const container = document.createElement('div');
|
const container = document.createElement('div');
|
||||||
|
|
@ -16,8 +17,25 @@ export function renderCreateInvoicePage() {
|
||||||
container.innerHTML = /*html*/`
|
container.innerHTML = /*html*/`
|
||||||
<div class="invoice-header-compact">
|
<div class="invoice-header-compact">
|
||||||
<h1>Nueva Factura</h1>
|
<h1>Nueva Factura</h1>
|
||||||
|
<div style="display:flex;gap:0.5rem;align-items:center;">
|
||||||
|
<button type="button" class="btn-template" id="template-btn">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-2px;margin-right:4px;"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18M9 21V9"/></svg>
|
||||||
|
Usar plantilla
|
||||||
|
</button>
|
||||||
<button class="btn-back" id="back-btn">← Volver</button>
|
<button class="btn-back" id="back-btn">← Volver</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Panel de plantillas (oculto por defecto) -->
|
||||||
|
<div id="template-panel" class="template-panel" style="display:none;">
|
||||||
|
<div class="template-panel-header">
|
||||||
|
<span>Seleccionar plantilla</span>
|
||||||
|
<button type="button" id="template-panel-close" class="template-panel-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div id="template-list" class="template-list">
|
||||||
|
<p class="template-loading">Cargando plantillas...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="invoice-form-compact" id="invoice-form">
|
<form class="invoice-form-compact" id="invoice-form">
|
||||||
<div class="form-grid-compact">
|
<div class="form-grid-compact">
|
||||||
|
|
@ -130,6 +148,91 @@ export function renderCreateInvoicePage() {
|
||||||
}
|
}
|
||||||
loadPaymentTerms();
|
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 = '<p class="template-loading">Cargando plantillas...</p>';
|
||||||
|
try {
|
||||||
|
templatesCache = await getInvoiceTemplates();
|
||||||
|
} catch {
|
||||||
|
listEl.innerHTML = '<p class="template-loading">Error al cargar plantillas.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!templatesCache.length) {
|
||||||
|
listEl.innerHTML = '<p class="template-loading">No hay plantillas disponibles.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
listEl.innerHTML = templatesCache.map(t => `
|
||||||
|
<button type="button" class="template-item" data-id="${t.id}">
|
||||||
|
<span class="template-item-number">${t.number || `#${t.id}`}</span>
|
||||||
|
<span class="template-item-client">${t.clientName || '—'}</span>
|
||||||
|
<span class="template-item-total">${new Intl.NumberFormat('es-ES', { style: 'currency', currency: 'EUR' }).format(t.total || 0)}</span>
|
||||||
|
</button>
|
||||||
|
`).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 = '<p class="template-loading">Cargando detalle...</p>';
|
||||||
|
|
||||||
|
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 = '<p class="template-loading">Error al cargar la plantilla.</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
container.querySelector('#paymentTerms').addEventListener('change', (e) => {
|
||||||
const days = parseInt(e.target.value);
|
const days = parseInt(e.target.value);
|
||||||
if (!days || days <= 0) return;
|
if (!days || days <= 0) return;
|
||||||
|
|
|
||||||
|
|
@ -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*/`
|
||||||
|
<div class="facturas-header">
|
||||||
|
<h1>Facturas de Proveedores</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="facturas-filters">
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<span class="search-icon">${icons.search}</span>
|
||||||
|
<input type="search" placeholder="Buscar proveedor o referencia..." class="search-input search-input--with-icon" id="sup-search" />
|
||||||
|
</div>
|
||||||
|
<select class="filter-status" id="sup-filter">
|
||||||
|
<option value="">Todos los estados</option>
|
||||||
|
<option value="draft">Borrador</option>
|
||||||
|
<option value="unpaid">Pte. Pago</option>
|
||||||
|
<option value="paid">Pagada</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn-new-invoice" id="btn-new-sup">${icons.plus} <span>Nueva factura</span></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invoices-summary" id="sup-summary" style="display:none">
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">Facturas</span>
|
||||||
|
<span class="summary-value" id="sum-count">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">Base imponible</span>
|
||||||
|
<span class="summary-value" id="sum-ht">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">IVA soportado</span>
|
||||||
|
<span class="summary-value" id="sum-tax">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-item">
|
||||||
|
<span class="summary-label">Total</span>
|
||||||
|
<span class="summary-value" id="sum-total">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="summary-divider"></div>
|
||||||
|
<div class="summary-item summary-item--pending">
|
||||||
|
<span class="summary-label">Pendiente</span>
|
||||||
|
<span class="summary-value" id="sum-pending">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="invoices-table-container">
|
||||||
|
<table class="invoices-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Número</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Proveedor</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th class="text-right">Total</th>
|
||||||
|
<th class="text-right">Pendiente</th>
|
||||||
|
<th>Acciones</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sup-tbody">
|
||||||
|
<tr><td colspan="7" class="facturas-empty">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal detalle -->
|
||||||
|
<div class="modal-overlay sup-detail-overlay" id="sup-detail-overlay" style="display:none">
|
||||||
|
<div class="modal-content sup-detail-modal" id="sup-detail-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="sup-detail-title">Detalle</h2>
|
||||||
|
<button class="btn-close" id="sup-detail-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" id="sup-detail-body">
|
||||||
|
<div class="loading">Cargando...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal crear factura proveedor -->
|
||||||
|
<div class="modal-overlay sup-detail-overlay" id="sup-create-overlay" style="display:none">
|
||||||
|
<div class="modal-content sup-detail-modal" id="sup-create-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>Nueva factura de proveedor</h2>
|
||||||
|
<button class="btn-close" id="sup-create-close">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="sup-create-form">
|
||||||
|
<div class="invoice-detail-grid" style="margin-bottom:1rem">
|
||||||
|
<div class="form-field-compact full-width" style="grid-column:1/-1">
|
||||||
|
<label>Proveedor *</label>
|
||||||
|
<select id="sup-supplier-select" required style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--card-bg);color:var(--text-primary)">
|
||||||
|
<option value="">Cargando proveedores...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Ref. proveedor *</label>
|
||||||
|
<input type="text" id="sup-ref-supplier" required placeholder="Ej: FAC-2024-001"
|
||||||
|
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Fecha *</label>
|
||||||
|
<input type="date" id="sup-date" required
|
||||||
|
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Vencimiento</label>
|
||||||
|
<input type="date" id="sup-expire"
|
||||||
|
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Nota pública</label>
|
||||||
|
<input type="text" id="sup-note-public" placeholder="Opcional"
|
||||||
|
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.5rem">
|
||||||
|
<h3 class="lines-section-title" style="margin:0">Líneas *</h3>
|
||||||
|
<button type="button" class="btn-add-compact" id="sup-add-line">+ Añadir línea</button>
|
||||||
|
</div>
|
||||||
|
<div id="sup-lines-container"></div>
|
||||||
|
|
||||||
|
<div class="form-actions-compact" style="margin-top:1.5rem">
|
||||||
|
<button type="button" class="btn-cancel-compact" id="sup-create-cancel">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-submit-compact" id="sup-create-submit">Crear factura</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<tr><td colspan="7" class="facturas-empty">No hay facturas de proveedores</td></tr>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = filteredInvoices.map(inv => `
|
||||||
|
<tr class="invoice-item" data-id="${inv.id}">
|
||||||
|
<td class="invoice-number">
|
||||||
|
<button class="btn-invoice-num" data-id="${inv.id}">${escHtml(inv.number)}</button>
|
||||||
|
</td>
|
||||||
|
<td class="invoice-status">
|
||||||
|
<span class="status-badge ${STATUS_CLASS[inv.status] || 'status-draft'}">
|
||||||
|
${STATUS_TEXT[inv.status] || inv.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="invoice-client">
|
||||||
|
<span class="client-icon">${icons.user}</span>
|
||||||
|
${escHtml(inv.supplierName || 'Sin nombre')}
|
||||||
|
</td>
|
||||||
|
<td class="invoice-date">${fmtDate(inv.date)}</td>
|
||||||
|
<td class="invoice-total amount-positive">${fmt(inv.total)}</td>
|
||||||
|
<td class="invoice-remain ${parseFloat(inv.remainToPay) > 0.009 ? 'amount-pending' : 'amount-paid'}">
|
||||||
|
${fmt(inv.remainToPay)}
|
||||||
|
</td>
|
||||||
|
<td class="invoice-actions">
|
||||||
|
<button class="btn-action btn-view" data-id="${inv.id}" title="Ver detalle">
|
||||||
|
${icons.eye}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).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 = '<div class="loading">Cargando detalle...</div>';
|
||||||
|
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 = `<div class="error">No se pudo cargar el detalle: ${escHtml(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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*/`
|
||||||
|
<!-- Header info -->
|
||||||
|
<div class="invoice-detail-grid" id="sup-detail-info">
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Proveedor</span>
|
||||||
|
<span class="detail-value">${escHtml(inv.supplierName || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Ref. interna</span>
|
||||||
|
<span class="detail-value">${escHtml(inv.number || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Ref. proveedor</span>
|
||||||
|
<span class="detail-value" id="sup-d-ref">${escHtml(inv.supplierRef || '—')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Estado</span>
|
||||||
|
<span class="detail-value">
|
||||||
|
<span class="status-badge ${STATUS_CLASS[inv.status] || 'status-draft'}">
|
||||||
|
${STATUS_TEXT[inv.status] || inv.status}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Fecha</span>
|
||||||
|
<span class="detail-value">${fmtDate(inv.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row">
|
||||||
|
<span class="detail-label">Vencimiento</span>
|
||||||
|
<span class="detail-value" id="sup-d-expire">${fmtDate(inv.expireDate)}</span>
|
||||||
|
</div>
|
||||||
|
${inv.notePublic ? `<div class="detail-row full-width"><span class="detail-label">Nota pública</span><span class="detail-value" id="sup-d-note">${escHtml(inv.notePublic)}</span></div>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions bar -->
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:0.5rem;margin:1rem 0">
|
||||||
|
${isDraft ? `<button class="btn-submit-compact" id="sup-btn-validate">Validar factura</button>` : ''}
|
||||||
|
${isUnpaid ? `<button class="btn-submit-compact" id="sup-btn-paid" style="background:var(--success,#5dd39e);color:#000">Marcar como pagada</button>` : ''}
|
||||||
|
${inv.status === 'paid' || isUnpaid ? `<button class="btn-cancel-compact" id="sup-btn-draft">Volver a borrador</button>` : ''}
|
||||||
|
${isDraft ? `<button class="btn-cancel-compact" id="sup-btn-edit">Editar</button>` : ''}
|
||||||
|
${isDraft ? `<button class="btn-cancel-compact" id="sup-btn-delete" style="color:var(--danger)">Eliminar</button>` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lines -->
|
||||||
|
<h3 class="lines-section-title">Líneas</h3>
|
||||||
|
<div class="invoice-lines-table-wrap">
|
||||||
|
<table class="invoice-lines-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Descripción</th>
|
||||||
|
<th class="text-right">Cant.</th>
|
||||||
|
<th class="text-right">P. Unit.</th>
|
||||||
|
<th class="text-right">IVA %</th>
|
||||||
|
<th class="text-right">Total</th>
|
||||||
|
${isDraft ? '<th></th>' : ''}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sup-lines-tbody">
|
||||||
|
${lines.length === 0
|
||||||
|
? `<tr><td colspan="${isDraft ? 6 : 5}" class="no-lines">Sin líneas</td></tr>`
|
||||||
|
: lines.map(l => `
|
||||||
|
<tr data-line-id="${l.id}">
|
||||||
|
<td>${escHtml(l.description || '—')}</td>
|
||||||
|
<td class="text-right">${l.quantity}</td>
|
||||||
|
<td class="text-right">${fmt(l.unitPrice)}</td>
|
||||||
|
<td class="text-right">${l.taxRate}%</td>
|
||||||
|
<td class="text-right amount-positive">${fmt(l.total)}</td>
|
||||||
|
${isDraft ? `<td style="white-space:nowrap">
|
||||||
|
<button class="btn-action" data-edit-line="${l.id}" title="Editar">${icons.eye}</button>
|
||||||
|
<button class="btn-action" data-del-line="${l.id}" title="Eliminar" style="color:var(--danger)">${icons.close}</button>
|
||||||
|
</td>` : ''}
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
${isDraft ? `
|
||||||
|
<div id="sup-add-line-form" style="margin-top:0.75rem;display:none">
|
||||||
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||||||
|
<input type="text" id="nl-desc" placeholder="Descripción *" style="flex:2;min-width:140px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<input type="number" id="nl-qty" placeholder="Cant." value="1" min="0.01" step="0.01" style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<input type="number" id="nl-price" placeholder="P.Unit." min="0" step="0.01" style="width:90px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<input type="number" id="nl-tax" placeholder="IVA%" value="21" min="0" max="100" step="0.01" style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<button type="button" class="btn-submit-compact" id="nl-save">Añadir</button>
|
||||||
|
<button type="button" class="btn-cancel-compact" id="nl-cancel">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-add-compact" id="sup-show-add-line" style="margin-top:0.5rem">+ Añadir línea</button>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Totals -->
|
||||||
|
<div class="invoice-totals">
|
||||||
|
${inv.totalHt != null ? `<div class="total-row subtotal"><span>Base imponible:</span><strong>${fmt(inv.totalHt)}</strong></div>` : ''}
|
||||||
|
${inv.totalTax != null ? `<div class="total-row subtotal"><span>IVA soportado:</span><strong>${fmt(inv.totalTax)}</strong></div>` : ''}
|
||||||
|
<div class="total-row"><span>Total:</span><strong class="amount-positive">${fmt(inv.total)}</strong></div>
|
||||||
|
<div class="total-row paid"><span>Pagado:</span><strong class="amount-paid">${fmt((inv.total || 0) - (inv.remainToPay || 0))}</strong></div>
|
||||||
|
<div class="total-row pending"><span>Pendiente:</span><strong class="amount-pending">${fmt(inv.remainToPay)}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payments -->
|
||||||
|
<h3 class="lines-section-title" style="margin-top:1.5rem">Pagos</h3>
|
||||||
|
<div class="invoice-lines-table-wrap">
|
||||||
|
<table class="invoice-lines-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Referencia</th>
|
||||||
|
<th>Tipo</th>
|
||||||
|
<th class="text-right">Importe</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="sup-payments-tbody">
|
||||||
|
${payments.length === 0
|
||||||
|
? `<tr><td colspan="4" class="no-lines">Sin pagos registrados</td></tr>`
|
||||||
|
: payments.map(p => `
|
||||||
|
<tr>
|
||||||
|
<td>${p.paymentDate ? new Date(p.paymentDate).toLocaleDateString('es-ES') : '—'}</td>
|
||||||
|
<td>${escHtml(p.ref || '—')}</td>
|
||||||
|
<td>${escHtml(p.type || '—')}</td>
|
||||||
|
<td class="text-right amount-paid">${fmt(p.amount)}</td>
|
||||||
|
</tr>
|
||||||
|
`).join('')}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
${isUnpaid ? `
|
||||||
|
<div id="sup-pay-form" style="margin-top:1rem;display:none">
|
||||||
|
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center">
|
||||||
|
<input type="date" id="pay-date" style="padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<input type="number" id="pay-amount" placeholder="Importe (vacío=total)" min="0.01" step="0.01" style="width:130px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<select id="pay-type" style="padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--card-bg);color:var(--text-primary)">
|
||||||
|
${paymentTypes.length
|
||||||
|
? paymentTypes.map(t => `<option value="${t.id}">${escHtml(t.label || t.code || t.id)}</option>`).join('')
|
||||||
|
: `<option value="6">Transferencia</option><option value="4">Cheque</option><option value="7">Efectivo</option>`}
|
||||||
|
</select>
|
||||||
|
<button type="button" class="btn-submit-compact" id="pay-save">Registrar pago</button>
|
||||||
|
<button type="button" class="btn-cancel-compact" id="pay-cancel">Cancelar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-add-compact" id="sup-show-pay-form" style="margin-top:0.5rem">+ Registrar pago</button>
|
||||||
|
` : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 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*/`
|
||||||
|
<div class="invoice-detail-grid" style="margin-bottom:1rem">
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Ref. proveedor</label>
|
||||||
|
<input type="text" id="ed-ref" value="${escHtml(inv.supplierRef || '')}"
|
||||||
|
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact">
|
||||||
|
<label>Vencimiento</label>
|
||||||
|
<input type="date" id="ed-expire" value="${fmtDateInput(inv.expireDate)}"
|
||||||
|
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-field-compact full-width" style="grid-column:1/-1">
|
||||||
|
<label>Nota pública</label>
|
||||||
|
<input type="text" id="ed-note" value="${escHtml(inv.notePublic || '')}"
|
||||||
|
style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions-compact">
|
||||||
|
<button type="button" class="btn-cancel-compact" id="ed-cancel">Cancelar</button>
|
||||||
|
<button type="submit" class="btn-submit-compact" id="ed-save">Guardar cambios</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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*/`
|
||||||
|
<td><input type="text" value="${escHtml(line.description || '')}" class="el-desc" style="width:100%;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
|
||||||
|
<td><input type="number" value="${line.quantity}" class="el-qty" min="0.01" step="0.01" style="width:70px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
|
||||||
|
<td><input type="number" value="${line.unitPrice}" class="el-price" min="0" step="0.01" style="width:90px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
|
||||||
|
<td><input type="number" value="${line.taxRate}" class="el-tax" min="0" max="100" step="0.01" style="width:70px;padding:0.3rem;border:1px solid var(--border-color);border-radius:4px;background:var(--bg-primary);color:var(--text-primary)" /></td>
|
||||||
|
<td></td>
|
||||||
|
<td style="white-space:nowrap">
|
||||||
|
<button class="btn-submit-compact el-save" style="padding:0.25rem 0.5rem;font-size:0.8rem">OK</button>
|
||||||
|
<button class="btn-cancel-compact el-cancel" style="padding:0.25rem 0.5rem;font-size:0.8rem">✕</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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 = `<tr><td colspan="7" class="facturas-empty">Error al cargar: ${escHtml(err.message)}</td></tr>`;
|
||||||
|
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 = `
|
||||||
|
<input type="text" placeholder="Descripción *" class="line-desc" required style="flex:2;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<input type="number" placeholder="Cant." class="line-qty" value="1" min="0.01" step="0.01" required style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<input type="number" placeholder="P.Unit." class="line-price" min="0" step="0.01" required style="width:90px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<input type="number" placeholder="IVA%" class="line-tax" value="21" min="0" max="100" step="0.01" required style="width:70px;padding:0.4rem 0.5rem;border:1px solid var(--border-color);border-radius:5px;background:var(--bg-primary);color:var(--text-primary)" />
|
||||||
|
<button type="button" class="btn-remove-line" title="Eliminar" style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:1.1rem;padding:0 0.25rem">✕</button>
|
||||||
|
`;
|
||||||
|
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 = '<option value="">Seleccionar proveedor...</option>';
|
||||||
|
clients.forEach(c => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = c.id;
|
||||||
|
opt.textContent = c.name;
|
||||||
|
supplierSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
supplierSelect.innerHTML = '<option value="">Error al cargar</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { isDarkTheme, toggleTheme } from '../services/theme.js';
|
import { isDarkTheme, toggleTheme } from '../services/theme.js';
|
||||||
import { icons } from '../services/icons.js';
|
import { icons } from '../services/icons.js';
|
||||||
|
import { apiGet, apiPut } from '../services/apiClient.js';
|
||||||
|
import { showToast } from '../services/toast.js';
|
||||||
|
|
||||||
function decodeTokenPayload(token) {
|
function decodeTokenPayload(token) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -98,6 +100,27 @@ export function renderSettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-card" style="grid-column:1/-1">
|
||||||
|
<h2>Notificaciones</h2>
|
||||||
|
<p class="settings-help" style="margin-bottom:1rem">
|
||||||
|
URL de webhook para notificaciones de cambio de estado de facturas (compatible con Teams y Slack).
|
||||||
|
Déjalo vacío para desactivar.
|
||||||
|
</p>
|
||||||
|
<div class="settings-row" style="flex-direction:column;align-items:stretch;gap:0.75rem">
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
id="webhook-url-input"
|
||||||
|
placeholder="https://outlook.office.com/webhook/... o https://hooks.slack.com/..."
|
||||||
|
style="width:100%;padding:0.6rem 0.75rem;border:1px solid var(--border-color);border-radius:6px;background:var(--bg-primary);color:var(--text-primary);font-size:0.9rem"
|
||||||
|
/>
|
||||||
|
<div style="display:flex;gap:0.75rem;justify-content:flex-end">
|
||||||
|
<button type="button" class="btn-cancel-compact" id="webhook-clear-btn">Limpiar</button>
|
||||||
|
<button type="button" class="btn-submit-compact" id="webhook-save-btn">Guardar URL</button>
|
||||||
|
</div>
|
||||||
|
<p id="webhook-status" style="font-size:0.8rem;color:var(--text-secondary);min-height:1.2em"></p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -132,6 +155,41 @@ export function renderSettingsPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const intervalId = setInterval(renderTokenInfo, 1000);
|
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);
|
container.cleanup = () => clearInterval(intervalId);
|
||||||
|
|
||||||
return container;
|
return container;
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { renderDashboard } from './DashboardPage.js';
|
import { renderDashboard } from './DashboardPage.js';
|
||||||
import { renderFacturasPage } from './Facturas.js';
|
import { renderFacturasPage } from './Facturas.js';
|
||||||
|
import { renderFacturasProveedoresPage } from './FacturasProveedores.js';
|
||||||
import { renderCreateInvoicePage } from './CreateInvoicePage.js';
|
import { renderCreateInvoicePage } from './CreateInvoicePage.js';
|
||||||
import { renderClientesPage } from './ClientesPage.js';
|
import { renderClientesPage } from './ClientesPage.js';
|
||||||
|
import { renderContactsPage } from './ContactsPage.js';
|
||||||
import { renderSettingsPage } from './SettingsPage.js';
|
import { renderSettingsPage } from './SettingsPage.js';
|
||||||
import { renderBancoPage } from './BancoPage.js';
|
import { renderBancoPage } from './BancoPage.js';
|
||||||
import { icons } from '../services/icons.js';
|
import { icons } from '../services/icons.js';
|
||||||
|
|
@ -25,6 +27,15 @@ export const pagesRegistry = [
|
||||||
showInSidebar: true,
|
showInSidebar: true,
|
||||||
render: renderFacturasPage
|
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',
|
route: 'create-invoice',
|
||||||
name: 'Nueva Factura',
|
name: 'Nueva Factura',
|
||||||
|
|
@ -43,6 +54,15 @@ export const pagesRegistry = [
|
||||||
showInSidebar: true,
|
showInSidebar: true,
|
||||||
render: renderClientesPage
|
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',
|
route: 'banco',
|
||||||
name: 'Banco',
|
name: 'Banco',
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,11 @@ export function initRouter() {
|
||||||
currentPageCleanup = null;
|
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') {
|
if (hash === '#login') {
|
||||||
applyLoginTheme();
|
applyLoginTheme();
|
||||||
hideVoiceAssistant();
|
hideVoiceAssistant();
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,12 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
|
||||||
let reloginInProgress = false;
|
let reloginInProgress = false;
|
||||||
|
|
||||||
function redirectToLogin() {
|
function redirectToLogin() {
|
||||||
if (window.location.hash !== '#login') {
|
// Always clean up body-level modals regardless of current hash —
|
||||||
// Close any open modals/overlays appended to body before leaving
|
// 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 => {
|
document.querySelectorAll('body > [class*="overlay"], body > [class*="modal"]').forEach(el => {
|
||||||
if (!el.classList.contains('connection-lost-overlay')) el.remove();
|
if (!el.classList.contains('connection-lost-overlay')) el.remove();
|
||||||
});
|
});
|
||||||
|
if (window.location.hash !== '#login') {
|
||||||
window.location.hash = '#login';
|
window.location.hash = '#login';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`);
|
||||||
|
}
|
||||||
|
|
@ -32,6 +32,8 @@ export const icons = {
|
||||||
|
|
||||||
invoices: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13h6"/><path d="M9 17h4"/></svg>`,
|
invoices: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><path d="M9 13h6"/><path d="M9 17h4"/></svg>`,
|
||||||
|
|
||||||
|
supplierInvoices: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="1" y="3" width="15" height="13" rx="2"/><path d="M16 8h4l3 5v4h-7V8z"/><circle cx="5.5" cy="18.5" r="2.5"/><circle cx="18.5" cy="18.5" r="2.5"/></svg>`,
|
||||||
|
|
||||||
clients: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M21 21v-2a4 4 0 0 0-3-3.87"/></svg>`,
|
clients: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="7" r="4"/><path d="M3 21v-2a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v2"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/><path d="M21 21v-2a4 4 0 0 0-3-3.87"/></svg>`,
|
||||||
|
|
||||||
settings: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
settings: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>`,
|
||||||
|
|
|
||||||
|
|
@ -57,15 +57,14 @@ export async function downloadInvoicePdf(invoiceNumber) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteInvoice(id) {
|
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}`);
|
await apiDelete(`/api/Invoices/${id}`);
|
||||||
} catch (error) {
|
|
||||||
// Si no existe DELETE, intentar cancelar
|
|
||||||
return await updateInvoiceStatus(id, 'canceled');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getInvoiceTemplates() {
|
||||||
|
return apiGet('/api/Invoices/templates');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTemplateById(id) {
|
||||||
|
return apiGet(`/api/Invoices/templates/${id}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,11 @@ export async function getCompany() {
|
||||||
export async function getCountries() {
|
export async function getCountries() {
|
||||||
return apiGet('/api/Setup/countries');
|
return apiGet('/api/Setup/countries');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getCivilities() {
|
||||||
|
return apiGet('/api/Setup/civilities');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getContactTypes() {
|
||||||
|
return apiGet('/api/Setup/contact-types');
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -213,6 +213,31 @@
|
||||||
|
|
||||||
.banco-account-balance--negative { color: var(--danger); }
|
.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 {
|
.banco-account-arrow {
|
||||||
color: var(--gray-300);
|
color: var(--gray-300);
|
||||||
transition: color 0.15s, transform 0.15s;
|
transition: color 0.15s, transform 0.15s;
|
||||||
|
|
|
||||||
|
|
@ -348,3 +348,19 @@ button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
|
||||||
color: #86efac;
|
color: #86efac;
|
||||||
}
|
}
|
||||||
:root[data-theme='dark'] .badge-paid::before { background: #86efac; }
|
:root[data-theme='dark'] .badge-paid::before { background: #86efac; }
|
||||||
|
|
||||||
|
/* Fix select dropdown visibility in dark mode:
|
||||||
|
Native <select> dropdowns render with OS background — force color-scheme
|
||||||
|
so the browser renders options with the correct light/dark contrast */
|
||||||
|
:root[data-theme='dark'] {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
color-scheme: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
select option {
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
color: var(--text-primary, #1e293b);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,95 @@
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-template {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--primary);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 13px;
|
||||||
|
height: 32px;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn-template:hover { background: var(--primary); color: #fff; }
|
||||||
|
|
||||||
|
/* Template panel */
|
||||||
|
.template-panel {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--card-bg);
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 1rem;
|
||||||
|
background: var(--primary-light);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-panel-close {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0 0.2rem;
|
||||||
|
}
|
||||||
|
.template-panel-close:hover { color: var(--danger); background: transparent; }
|
||||||
|
|
||||||
|
.template-list {
|
||||||
|
max-height: 240px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.4rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-loading {
|
||||||
|
margin: 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: background 0.12s ease, border-color 0.12s ease;
|
||||||
|
}
|
||||||
|
.template-item:hover { background: var(--primary-light); border-color: var(--primary); color: var(--primary); }
|
||||||
|
|
||||||
|
.template-item-number { font-weight: 600; font-size: 0.825rem; min-width: 100px; }
|
||||||
|
.template-item-client { flex: 1; font-size: 0.8rem; color: var(--text-secondary); }
|
||||||
|
.template-item:hover .template-item-client { color: inherit; }
|
||||||
|
.template-item-total { font-size: 0.8rem; font-weight: 500; white-space: nowrap; }
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .template-panel { border-color: rgba(255,255,255,0.08); }
|
||||||
|
:root[data-theme='dark'] .template-panel-header { background: rgba(37,99,235,0.1); border-color: rgba(255,255,255,0.08); }
|
||||||
|
:root[data-theme='dark'] .template-item { border-color: rgba(255,255,255,0.08); }
|
||||||
|
:root[data-theme='dark'] .template-item:hover { background: rgba(37,99,235,0.12); border-color: rgba(37,99,235,0.4); }
|
||||||
|
|
||||||
.invoice-form-compact {
|
.invoice-form-compact {
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
|
|
|
||||||
|
|
@ -297,6 +297,11 @@
|
||||||
font-size: 0.9375rem;
|
font-size: 0.9375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.total-row.subtotal {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
}
|
||||||
|
|
||||||
.total-row.paid {
|
.total-row.paid {
|
||||||
color: var(--success, #22c55e);
|
color: var(--success, #22c55e);
|
||||||
}
|
}
|
||||||
|
|
@ -920,3 +925,180 @@
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Supplier invoice detail modal ===== */
|
||||||
|
.sup-detail-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0,0,0,0.45);
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sup-detail-modal {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 780px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem 1.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-detail-grid .detail-row.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--gray-500);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lines-section-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin: 1.25rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-lines-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-lines-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-lines-table th,
|
||||||
|
.invoice-lines-table td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-lines-table th {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--gray-500);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice-lines-table .text-right,
|
||||||
|
.facturas-table .text-right { text-align: right; }
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .detail-row { border-color: rgba(255,255,255,0.08); }
|
||||||
|
:root[data-theme='dark'] .invoice-lines-table th { background: rgba(255,255,255,0.04); }
|
||||||
|
:root[data-theme='dark'] .invoice-lines-table th,
|
||||||
|
:root[data-theme='dark'] .invoice-lines-table td { border-color: rgba(255,255,255,0.08); }
|
||||||
|
|
||||||
|
.line-row-compact {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Documentos adjuntos ── */
|
||||||
|
.documents-section .no-documents {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.documents-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
background: var(--bg-secondary, #f8fafc);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.825rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-icon { color: var(--text-tertiary); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.document-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-size {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-download-doc {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--primary-light);
|
||||||
|
color: var(--primary);
|
||||||
|
border: 1px solid var(--primary);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
transition: background 0.15s ease, color 0.15s ease;
|
||||||
|
}
|
||||||
|
.btn-download-doc:hover { background: var(--primary); color: #fff; }
|
||||||
|
|
||||||
|
:root[data-theme='dark'] .document-item {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
border-color: rgba(255,255,255,0.08);
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] .btn-download-doc {
|
||||||
|
background: rgba(37,99,235,0.12);
|
||||||
|
border-color: rgba(37,99,235,0.4);
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
:root[data-theme='dark'] .btn-download-doc:hover {
|
||||||
|
background: var(--primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue