MVP final

This commit is contained in:
Nachou-u
2026-08-20 21:01:12 -04:00
parent 08a26cbf2f
commit 66655fb05c
10 changed files with 593 additions and 32 deletions
+8 -1
View File
@@ -2,11 +2,18 @@
{% load humanize %}
{% block content %}
<div class="view-header">
<div class="view-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div class="view-title">
<h2>Cuentas por Pagar (CXP)</h2>
<p>Consolidado de deudas por proveedor en estado Solicitado a Finanzas.</p>
</div>
{% if debts %}
<div>
<a href="{% url 'export_bank_transfer_csv' %}" class="btn-primary" style="background: var(--efintel-blue); text-decoration: none; display: inline-flex; align-items: center; gap: 0.4rem; padding: 0.6rem 1rem;">
<i data-lucide="download" style="width:16px;"></i> Exportar Nómina de Transferencias (CSV)
</a>
</div>
{% endif %}
</div>
<section class="main-content">
+9
View File
@@ -97,6 +97,15 @@
<td style="font-weight:600; color:var(--efintel-dark);">
{{ expense.description|truncatechars:50 }}<br>
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
{% if expense.documents.all %}
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
{% for doc in expense.documents.all %}
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none; display:inline-flex; align-items:center; gap:0.2rem;" title="{{ doc.title|default:doc.file.name }}">
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:25 }}
</a>
{% endfor %}
</div>
{% endif %}
</td>
<td class="amount-cell">
<div>${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP</div>
+256 -18
View File
@@ -90,31 +90,66 @@
<div class="form-group">
<label for="{{ form.vendor.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Proveedor (Opcional)</label>
{{ form.vendor }}
<div style="margin-top: 0.5rem;">
<label style="font-size: 0.75rem; display: flex; align-items: center; gap: 0.35rem; cursor: pointer; color: var(--efintel-blue); font-weight: 600;">
<input type="checkbox" id="toggle-new-vendor" style="width: auto; margin: 0; cursor: pointer;"> Registrar un nuevo proveedor
</label>
</div>
{% if form.vendor.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.vendor.errors }}</div>{% endif %}
</div>
</div>
<div id="new-vendor-fields" style="display: none; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem; background: #f0fdf4; padding: 1.25rem; border-radius: 6px; border: 1px solid #bbf7d0; box-shadow: inset 0 2px 4px 0 rgba(0, 0, 0, 0.05);">
<div class="form-group">
<label for="id_new_vendor_rut" style="font-weight: 600; font-size: 0.85rem; color: #166534;">RUT del Nuevo Proveedor</label>
{{ form.new_vendor_rut }}
{% if form.new_vendor_rut.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.new_vendor_rut.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="id_new_vendor_name" style="font-weight: 600; font-size: 0.85rem; color: #166534;">Nombre / Razón Social del Nuevo Proveedor</label>
{{ form.new_vendor_name }}
{% if form.new_vendor_name.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.new_vendor_name.errors }}</div>{% endif %}
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label for="{{ form.description.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Detalle de Imputación (Justificación)</label>
{{ form.description }}
{% if form.description.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.description.errors }}</div>{% endif %}
</div>
<div class="form-group" style="margin-bottom: 1.5rem; background: #f8fafc; padding: 1.5rem; border-radius: 6px; border: 1px dashed var(--efintel-blue);">
<label for="{{ form.receipt.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem;"><i data-lucide="paperclip" style="width:14px;"></i> Adjuntar Evidencia(s) (Boletas / Facturas)</label>
{{ form.receipt }}
<div id="new-files-list" style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.4rem;"></div>
<small style="color: var(--efintel-gray); font-size: 0.7rem; display:block; margin-top:0.3rem; margin-bottom: 0.75rem;">Puedes seleccionar múltiples archivos (imágenes o PDF).</small>
<div class="form-group" style="margin-bottom: 1.5rem; background: #f8fafc; padding: 1.5rem; border-radius: 6px; border: 1px solid var(--efintel-border);">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem; margin-bottom: 0.75rem;"><i data-lucide="paperclip" style="width:14px;"></i> Evidencias Adjuntas (Boletas / Facturas)</label>
<!-- Drag and Drop Zone Container -->
<div id="drop-zone" style="border: 2px dashed var(--efintel-blue); border-radius: 6px; padding: 2rem; text-align: center; background: white; cursor: pointer; transition: all 0.3s; margin-bottom: 0.75rem; display: flex; flex-direction: column; align-items: center; justify-content: center;">
<i data-lucide="upload-cloud" style="width: 36px; height: 36px; color: var(--efintel-blue); margin-bottom: 0.5rem; transition: transform 0.3s;"></i>
<p style="margin: 0; font-size: 0.85rem; font-weight: 600; color: var(--efintel-petroleum);">Arrastre sus boletas/facturas aquí o haga clic para buscar</p>
<span style="font-size: 0.7rem; color: var(--efintel-gray); display: block; margin-top: 0.25rem;">Formatos aceptados: PDF, PNG, JPG, JPEG (Múltiple)</span>
</div>
<!-- Hidden native input -->
<div style="display: none;">
{{ form.receipt }}
</div>
<!-- Live Queue files list -->
<div id="new-files-list" style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.5rem;"></div>
{% if form.receipt.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.receipt.errors }}</div>{% endif %}
{% if form.instance.pk and form.instance.documents.all %}
<div style="margin-top: 1rem; border-top: 1px solid #e2e8f0; padding-top: 0.75rem;">
<div style="margin-top: 1.25rem; border-top: 1px solid #e2e8f0; padding-top: 0.75rem;">
<strong style="font-size: 0.8rem; color: var(--efintel-petroleum); display: block; margin-bottom: 0.5rem;">Documentos Adjuntos Actuales:</strong>
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
{% for doc in form.instance.documents.all %}
<div class="document-row" id="doc-row-{{ doc.id }}" style="display: flex; justify-content: space-between; align-items: center; background: white; padding: 0.5rem 0.75rem; border: 1px solid #e2e8f0; border-radius: 4px; font-size: 0.8rem;">
<span style="font-weight: 500; color: var(--efintel-dark); display: flex; align-items: center; gap: 0.3rem;">
<i data-lucide="file-text" style="width: 14px; color: var(--efintel-blue);"></i>
<span style="font-weight: 500; color: var(--efintel-dark); display: flex; align-items: center; gap: 0.5rem;">
{% if ".pdf" in doc.file.name|lower %}
<i data-lucide="file-text" style="width: 18px; color: #ef4444;"></i>
{% else %}
<img src="{{ doc.file.url }}" style="width: 24px; height: 24px; object-fit: cover; border-radius: 3px; border: 1px solid #cbd5e1;" alt="thumbnail">
{% endif %}
<a href="{{ doc.file.url }}" target="_blank" style="color: var(--efintel-blue); text-decoration: none;">{{ doc.title|default:doc.file.name }}</a>
</span>
<button type="button" onclick="deleteDocument(event, {{ doc.id }})" style="background: none; border: none; color: var(--efintel-danger); cursor: pointer; display: flex; align-items: center; padding: 0.2rem;" title="Eliminar evidencia">
@@ -175,8 +210,17 @@
const taxAmountInput = document.getElementById('id_tax_amount_clp');
const totalAmountInput = document.getElementById('id_total_amount_clp');
function formatNumber(num) {
return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
}
function cleanNumberString(str) {
return str.replace(/\./g, '');
}
function calculateTaxes() {
const amount = parseFloat(amountInput.value) || 0;
const cleanVal = cleanNumberString(amountInput.value);
const amount = parseFloat(cleanVal) || 0;
const taxType = taxTypeSelect.value;
let taxAmount = 0;
@@ -186,14 +230,35 @@
taxAmount = amount * 0.1375;
}
taxAmountInput.value = taxAmount.toFixed(0);
totalAmountInput.value = (amount + taxAmount).toFixed(0);
taxAmountInput.value = formatNumber(Math.round(taxAmount));
totalAmountInput.value = formatNumber(Math.round(amount + taxAmount));
}
amountInput.addEventListener('input', calculateTaxes);
amountInput.addEventListener('input', (e) => {
let cursorPosition = e.target.selectionStart;
const oldLength = e.target.value.length;
const cleanVal = cleanNumberString(e.target.value).replace(/\D/g, '');
if (cleanVal) {
e.target.value = formatNumber(cleanVal);
} else {
e.target.value = '';
}
const newLength = e.target.value.length;
cursorPosition = cursorPosition + (newLength - oldLength);
e.target.setSelectionRange(cursorPosition, cursorPosition);
calculateTaxes();
});
taxTypeSelect.addEventListener('change', calculateTaxes);
// Initial calculation
// Initial formatting on load
if (amountInput.value) {
const cleanVal = cleanNumberString(amountInput.value).replace(/\D/g, '');
amountInput.value = formatNumber(cleanVal);
}
calculateTaxes();
// Custom Searchable Dropdown for Category
@@ -437,19 +502,71 @@
});
}
// Client-side Upload File Deletion Queue using DataTransfer
// Client-side Upload File Deletion Queue using DataTransfer + Drag and Drop
const fileInput = document.getElementById('id_receipt');
const newFilesList = document.getElementById('new-files-list');
const dropZone = document.getElementById('drop-zone');
if (fileInput && newFilesList) {
if (fileInput && newFilesList && dropZone) {
let dt = new DataTransfer();
// Clicking the drop zone triggers the hidden file input
dropZone.addEventListener('click', () => {
fileInput.click();
});
// Highlight drop zone on drag hover
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.border = '2px dashed var(--efintel-blue)';
dropZone.style.background = 'var(--efintel-light)';
const icon = dropZone.querySelector('i');
if (icon) icon.style.transform = 'scale(1.2)';
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropZone.style.border = '2px dashed var(--efintel-blue)';
dropZone.style.background = 'white';
const icon = dropZone.querySelector('i');
if (icon) icon.style.transform = 'scale(1)';
}, false);
});
// Handle dropped files
dropZone.addEventListener('drop', (e) => {
const files = e.dataTransfer.files;
if (files.length > 0) {
for (let i = 0; i < files.length; i++) {
const f = files[i];
// Validate file type before putting in queue (extensions: pdf, png, jpg, jpeg)
const ext = f.name.split('.').pop().toLowerCase();
if (['pdf', 'png', 'jpg', 'jpeg'].includes(ext)) {
dt.items.add(f);
} else {
Swal.fire({
icon: 'error',
title: 'Archivo no permitido',
text: `El archivo "${f.name}" no es una evidencia válida (PDF, PNG, JPG, JPEG).`,
confirmButtonColor: 'var(--efintel-blue)'
});
}
}
fileInput.files = dt.files;
renderNewFiles();
}
});
// Handle standard file selection
fileInput.addEventListener('change', () => {
// Append newly selected files to our ongoing list
for (let i = 0; i < fileInput.files.length; i++) {
dt.items.add(fileInput.files[i]);
}
// Update input files list
fileInput.files = dt.files;
renderNewFiles();
});
@@ -479,11 +596,42 @@
row.style.border = '1px solid var(--efintel-border)';
row.style.marginBottom = '0.3rem';
// File Detail Info with preview
const infoWrapper = document.createElement('div');
infoWrapper.style.display = 'flex';
infoWrapper.style.alignItems = 'center';
infoWrapper.style.gap = '0.5rem';
// Check file extension
const ext = file.name.split('.').pop().toLowerCase();
if (['png', 'jpg', 'jpeg'].includes(ext)) {
// Render image thumbnail
const img = document.createElement('img');
img.src = URL.createObjectURL(file);
img.style.width = '24px';
img.style.height = '24px';
img.style.objectFit = 'cover';
img.style.borderRadius = '3px';
img.style.border = '1px solid #cbd5e1';
img.onload = () => URL.revokeObjectURL(img.src); // Free memory
infoWrapper.appendChild(img);
} else if (ext === 'pdf') {
// Render PDF file icon
const pdfIcon = document.createElement('span');
pdfIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line><polyline points="10 9 9 9 8 9"></polyline></svg>`;
infoWrapper.appendChild(pdfIcon);
} else {
const defaultIcon = document.createElement('span');
defaultIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="var(--efintel-blue)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path></svg>`;
infoWrapper.appendChild(defaultIcon);
}
const nameSpan = document.createElement('span');
nameSpan.style.color = 'var(--efintel-petroleum)';
nameSpan.style.fontWeight = '600';
nameSpan.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
row.appendChild(nameSpan);
infoWrapper.appendChild(nameSpan);
row.appendChild(infoWrapper);
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
@@ -514,5 +662,95 @@
});
}
}
// Toggle logic for unregistered vendor inputs
const toggleNewVendor = document.getElementById('toggle-new-vendor');
const newVendorFields = document.getElementById('new-vendor-fields');
const originalVendorSelect = document.getElementById('id_vendor');
if (toggleNewVendor && newVendorFields) {
toggleNewVendor.addEventListener('change', (e) => {
if (e.target.checked) {
newVendorFields.style.display = 'grid';
if (originalVendorSelect) {
originalVendorSelect.value = '';
}
} else {
newVendorFields.style.display = 'none';
document.getElementById('id_new_vendor_rut').value = '';
document.getElementById('id_new_vendor_name').value = '';
}
});
}
</script>
{% endblock %}
{% block extra_js %}
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Backend Validation SweetAlert Popups
{% if form.errors or form.non_field_errors %}
Swal.fire({
icon: 'error',
title: 'Error de Validación',
html: `<p style="text-align: left; margin-bottom: 0.5rem; font-weight:600;">Ocurrieron errores al procesar el formulario:</p>
<ul style="text-align: left; margin-top: 0; color: #ef4444; font-size: 0.85rem;">
{% for field, errors in form.errors.items %}
<li><strong>{{ field|capfirst }}:</strong> {{ errors|join:", " }}</li>
{% endfor %}
{% for err in form.non_field_errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>`,
confirmButtonColor: 'var(--efintel-blue)'
});
{% endif %}
// Form Submit Frontend Validation
const formEl = document.querySelector('form');
if (formEl) {
formEl.addEventListener('submit', (e) => {
let missing = [];
const project = document.getElementById('id_project').value;
const category = document.getElementById('id_category').value;
const amount = document.getElementById('id_amount_clp').value.trim();
const date = document.getElementById('id_date').value;
const desc = document.getElementById('id_description').value.trim();
const responsible = document.getElementById('id_responsible_person').value;
if (!project) missing.push('Centro de Costo / Proyecto');
if (!category) missing.push('Categoría Contable');
if (!amount) missing.push('Monto Neto (CLP)');
if (!date) missing.push('Fecha de Gasto');
if (!desc) missing.push('Detalle de Imputación (Justificación)');
if (!responsible) missing.push('Responsable Solicitante');
// If toggle new vendor is checked
const toggleNewVendor = document.getElementById('toggle-new-vendor');
if (toggleNewVendor && toggleNewVendor.checked) {
const newRut = document.getElementById('id_new_vendor_rut').value.trim();
const newName = document.getElementById('id_new_vendor_name').value.trim();
if (!newRut) missing.push('RUT del Nuevo Proveedor');
if (!newName) missing.push('Nombre / Razón Social del Nuevo Proveedor');
}
if (missing.length > 0) {
e.preventDefault();
Swal.fire({
icon: 'warning',
title: 'Campos Obligatorios Incompletos',
html: `<p style="text-align: left; margin-bottom: 0.5rem;">Por favor, rellene los siguientes campos requeridos antes de enviar:</p>
<ul style="text-align: left; margin-top: 0; font-size: 0.85rem;">
${missing.map(f => `<li><strong>${f}</strong></li>`).join('')}
</ul>`,
confirmButtonColor: 'var(--efintel-blue)'
});
return false;
}
});
}
});
</script>
{% endblock %}
+8 -2
View File
@@ -67,8 +67,14 @@
<td style="font-weight:600; color:var(--efintel-dark); font-size: 0.85rem;">
{{ expense.description|truncatechars:40 }}<br>
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
{% if expense.documents.first %}
<br><a href="{{ expense.documents.first.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none; display:inline-flex; align-items:center; gap:0.2rem; margin-top:0.2rem;"><i data-lucide="paperclip" style="width:10px;"></i> Evidencia Adjunta</a>
{% if expense.documents.all %}
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
{% for doc in expense.documents.all %}
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none; display:inline-flex; align-items:center; gap:0.2rem;" title="{{ doc.title|default:doc.file.name }}">
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:25 }}
</a>
{% endfor %}
</div>
{% endif %}
</td>
<td class="amount-cell">