Files
erp-efintel-2026-portainer/templates/expenses/expense_form.html
T
2026-09-07 18:44:32 -03:00

907 lines
49 KiB
HTML

{% extends 'base.html' %}
{% block content %}
<div class="form-container" style="max-width: 800px; margin: 0 auto;">
<div class="data-card" style="padding: 2.5rem; background: white; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05), 0 2px 4px -1px rgba(0,0,0,0.03);">
<h2 style="color: var(--efintel-petroleum); margin-bottom: 2rem; border-bottom: 2px solid var(--efintel-light); padding-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<i data-lucide="{% if form.instance.pk %}edit-3{% else %}plus-circle{% endif %}"></i>
{% if form.instance.pk %}Modificar Gasto Registrado{% else %}Registro de Nueva Erogación{% endif %}
</h2>
<form method="post" enctype="multipart/form-data" x-data="expenseForm()">
{% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group" style="position: relative;">
<label for="{{ form.project.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Centro de Costo / Proyecto</label>
<div class="custom-select-wrapper" style="position: relative;">
{{ form.project }}
<input type="text" id="project-search-input" placeholder="Escriba código o nombre del proyecto..." autocomplete="off">
<div id="project-dropdown-list" class="custom-dropdown-list" style="display: none; position: absolute; background: white; border: 1px solid var(--efintel-border); border-radius: 4px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 100%; z-index: 1000; max-height: 250px; overflow-y: auto; margin-top: 4px; box-sizing: border-box;">
<!-- Items dynamically populated -->
</div>
</div>
{% if form.project.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.project.errors }}</div>{% endif %}
</div>
<div class="form-group" style="position: relative;">
<label for="{{ form.category.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Categoría Contable</label>
<div class="custom-select-wrapper" style="position: relative;">
{{ form.category }}
<input type="text" id="category-search-input" placeholder="Escriba código o nombre para buscar..." autocomplete="off">
<div id="category-dropdown-list" class="custom-dropdown-list" style="display: none; position: absolute; background: white; border: 1px solid var(--efintel-border); border-radius: 4px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 100%; z-index: 1000; max-height: 250px; overflow-y: auto; margin-top: 4px; box-sizing: border-box;">
<!-- Items dynamically populated -->
</div>
</div>
{% if form.category.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.category.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Neto (CLP)</label>
{{ form.amount_clp }}
{% if form.amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.amount_clp.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.tax_type.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Tipo de Impuesto</label>
{{ form.tax_type }}
{% if form.tax_type.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.tax_type.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.tax_amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Impuesto (CLP)</label>
{{ form.tax_amount_clp }}
{% if form.tax_amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.tax_amount_clp.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.total_amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Total (CLP)</label>
{{ form.total_amount_clp }}
<small style="color: var(--efintel-gray); font-size: 0.7rem;">El equivalente en USD será calculado automáticamente por el sistema.</small>
{% if form.total_amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.total_amount_clp.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.currency.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Moneda Original</label>
{{ form.currency }}
{% if form.currency.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.currency.errors }}</div>{% endif %}
</div>
<div class="form-group" style="background:#f8fafc; padding:0.75rem; border-radius:6px; border:1px solid #e2e8f0;">
<label style="font-weight: 700; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.5rem; cursor:pointer;">
{{ form.is_reimbursement }} ¿Es Rendición de Gastos?
</label>
<div id="responsible-person-container" style="margin-top:0.5rem;" x-show="isReimbursement" x-transition x-cloak>
<label for="{{ form.responsible_person.id_for_label }}" style="font-weight: 600; font-size: 0.75rem; color: var(--efintel-dark);">Responsable de Rendición</label>
{{ form.responsible_person }}
<small style="color:var(--efintel-gray); font-size:0.68rem; display:block;">Si NO es rendición, se imputa directamente al Centro de Costos I+D+i.</small>
</div>
{% if form.responsible_person.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.responsible_person.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.date.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Fecha de Emisión / Gasto</label>
{{ form.date }}
{% if form.date.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.date.errors }}</div>{% endif %}
</div>
<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;" x-model="isNewVendor"> 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" x-show="isNewVendor" x-transition x-cloak style="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); align-items: end; display: grid;">
<div class="form-group" style="display:flex; flex-direction:column;">
<label for="id_new_vendor_rut" style="font-weight: 600; font-size: 0.85rem; color: #166534; margin-bottom: 0.35rem;">RUT del Nuevo Proveedor</label>
{{ form.new_vendor_rut }}
<small style="color: #166534; opacity: 0.7; font-size: 0.7rem; margin-top: 0.25rem;">Ej: 12.345.678-9</small>
{% 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" style="display:flex; flex-direction:column;">
<label for="id_new_vendor_name" style="font-weight: 600; font-size: 0.85rem; color: #166534; margin-bottom: 0.35rem;">Nombre / Razón Social</label>
{{ form.new_vendor_name }}
<small style="color: #166534; opacity: 0.7; font-size: 0.7rem; margin-top: 0.25rem;">Nombre legal del proveedor</small>
{% 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 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: 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.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">
<i data-lucide="trash-2" style="width: 14px;"></i>
</button>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Requerimiento 14: El apartado de vehículo SOLO aplica para erogaciones de Combustible (3101) -->
<div id="vehicle-log-container" style="display: none; margin-bottom: 2rem; background: #fffbebfb; padding: 1.5rem; border-radius: 8px; border: 1px solid #fef3c7;">
<h4 style="margin: 0 0 1rem 0; font-size: 0.9rem; color: #92400e; display:flex; align-items:center; gap:0.4rem;">
<i data-lucide="car" style="width:16px;"></i> Informes Bitácora de Vehículo y Combustible (Requerido para Categoría Combustibles)
</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<div class="form-group">
<label for="{{ form.vehicle_ownership.id_for_label }}" style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Modalidad / Vehículo</label>
{{ form.vehicle_ownership }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Patente Vehículo</label>
{{ form.v_license_plate }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Conductor / Responsable</label>
{{ form.v_driver_name }}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Odómetro Inicial (KM)</label>
{{ form.v_odometer_start }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Odómetro Final (KM)</label>
{{ form.v_odometer_end }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Origen</label>
{{ form.v_route_origin }}
</div>
<div class="form-group">
<label style="font-size: 0.75rem; font-weight: 600; color:#92400e;">Destino / Faena</label>
{{ form.v_route_destination }}
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 2rem; background: var(--efintel-light); padding: 1.25rem; border-radius: 6px;">
<h4 style="grid-column: 1 / -1; margin: 0 0 0.75rem 0; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem;"><i data-lucide="map-pin" style="width:14px;"></i> Ubiación Geográfica Operativa (Opcional)</h4>
<div class="form-group">
<label for="{{ form.region.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Región Operativa</label>
<select name="region" id="id_region" x-model="selectedRegion" @change="updateComunas()">
<option value="">--- Seleccione Región ---</option>
<template x-for="(communes, regionName) in regionsData" :key="regionName">
<option :value="regionName" x-text="regionName" :selected="regionName === selectedRegion"></option>
</template>
</select>
</div>
<div class="form-group">
<label for="{{ form.comuna.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Comuna</label>
<select name="comuna" id="id_comuna" x-model="selectedComuna">
<option value="">--- Seleccione Comuna ---</option>
<template x-for="comunaName in availableComunas" :key="comunaName">
<option :value="comunaName" x-text="comunaName" :selected="comunaName === selectedComuna"></option>
</template>
</select>
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; border-top: 1px solid var(--efintel-border); padding-top: 1.5rem;">
<a href="{% url 'expense_list' %}" class="btn-outline" style="padding: 0.75rem 1.5rem; text-decoration: none; color: var(--efintel-gray);">Cancelar</a>
<button type="submit" class="btn-primary" style="padding: 0.75rem 2rem; background: var(--efintel-blue); font-weight: 600;">
<i data-lucide="save" style="width: 16px; margin-right: 6px;"></i> Guardar Registro
</button>
</div>
</form>
</div>
</div>
<!-- Chilean regions JSON data -->
<script id="regions-communes-data" type="application/json">
{{ regions_communes_json|safe }}
</script>
<style>
[x-cloak] { display: none !important; }
</style>
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('expenseForm', () => ({
isReimbursement: document.getElementById('id_is_reimbursement')?.checked || false,
isNewVendor: false,
amountClp: document.getElementById('id_amount_clp')?.value || '',
taxType: document.getElementById('id_tax_type')?.value || 'EXENTO',
taxAmountClp: document.getElementById('id_tax_amount_clp')?.value || '0',
totalAmountClp: document.getElementById('id_total_amount_clp')?.value || '0',
newVendorRut: '',
rutError: false,
regionsData: JSON.parse(document.getElementById('regions-communes-data').textContent || '{}'),
selectedRegion: '{{ form.instance.region|default:"" }}',
selectedComuna: '{{ form.instance.comuna|default:"" }}',
availableComunas: [],
init() {
if (this.selectedRegion) {
this.updateComunas();
}
},
updateComunas() {
this.availableComunas = this.regionsData[this.selectedRegion] || [];
if (!this.availableComunas.includes(this.selectedComuna)) {
this.selectedComuna = '';
}
},
formatAmount() {
let cleanVal = this.amountClp.replace(/\./g, '').replace(/\D/g, '');
if (cleanVal) {
this.amountClp = cleanVal.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
} else {
this.amountClp = '';
}
},
async calculateTax() {
let cleanVal = this.amountClp.replace(/\./g, '');
let amount = parseFloat(cleanVal) || 0;
if (amount > 0 && this.taxType) {
try {
const response = await fetch(`/api/v1/sii/calculate-tax?amount=${amount}&tax_type=${this.taxType}`);
const data = await response.json();
this.taxAmountClp = data.tax_amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
this.totalAmountClp = data.total_amount.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ".");
} catch (e) {
console.error('Error calculating tax', e);
}
} else {
this.taxAmountClp = '0';
this.totalAmountClp = '0';
}
},
async validateRut() {
if (!this.newVendorRut) {
this.rutError = false;
return;
}
try {
const response = await fetch(`/api/v1/sii/validate-rut?rut=${this.newVendorRut}`);
const data = await response.json();
if (data.is_valid) {
this.newVendorRut = data.formatted_rut;
this.rutError = false;
} else {
this.rutError = true;
if (typeof Swal !== 'undefined') {
Swal.fire({
icon: 'error',
title: 'RUT Inválido',
text: data.error_message || "El formato del RUT no es válido.",
confirmButtonColor: 'var(--efintel-blue)'
});
}
}
} catch (e) {
console.error('Error validating RUT', e);
}
}
}));
});
</script>
<script>
document.querySelectorAll('input:not([type="checkbox"]), select, textarea').forEach(el => {
el.style.width = '100%';
el.style.padding = '0.65rem';
el.style.border = '1px solid var(--efintel-border)';
el.style.borderRadius = '4px';
el.style.fontFamily = "'Inter', sans-serif";
el.style.fontSize = '0.9rem';
el.style.boxSizing = 'border-box';
});
// AlpineJS manages tax calculation and toggles now.
// Requerimiento 14: Apartado de Vehículo SOLO para Combustibles (3101)
const vehicleLogContainer = document.getElementById('vehicle-log-container');
function toggleVehicleLog() {
if (!categorySelect || !vehicleLogContainer) return;
const selText = categorySelect.options[categorySelect.selectedIndex] ? categorySelect.options[categorySelect.selectedIndex].textContent.toLowerCase() : '';
const selVal = categorySelect.value;
if (selText.includes('3101') || selText.includes('combustible')) {
vehicleLogContainer.style.display = 'block';
} else {
vehicleLogContainer.style.display = 'none';
}
}
if (categorySelect) {
categorySelect.addEventListener('change', toggleVehicleLog);
toggleVehicleLog();
}
const categorySelect = document.getElementById('id_category');
const searchInput = document.getElementById('category-search-input');
const dropdownList = document.getElementById('category-dropdown-list');
if (categorySelect && searchInput && dropdownList) {
categorySelect.style.display = 'none';
const options = Array.from(categorySelect.options)
.filter(opt => opt.value !== '')
.map(opt => ({
value: opt.value,
text: opt.textContent
}));
function renderList(filteredOptions) {
dropdownList.innerHTML = '';
if (filteredOptions.length === 0) {
dropdownList.innerHTML = '<div style="padding: 0.65rem; color: var(--efintel-gray); font-size: 0.85rem; text-align: center;">No se encontraron categorías</div>';
return;
}
filteredOptions.forEach(opt => {
const item = document.createElement('div');
item.className = 'dropdown-item';
item.style.padding = '0.65rem 1rem';
item.style.fontSize = '0.85rem';
item.style.borderBottom = '1px solid #f1f5f9';
item.textContent = opt.text;
// Check if this is a parent category (1000, 2000, 3000, 4000)
const isParent = opt.text.includes('1000: ') || opt.text.includes('2000: ') || opt.text.includes('3000: ') || opt.text.includes('4000: ');
if (isParent) {
item.style.fontWeight = '700';
item.style.background = '#f1f5f9';
item.style.color = 'var(--efintel-gray)';
item.style.cursor = 'not-allowed';
item.style.opacity = '0.8';
// Disable in the original select to prevent browser submit/autofill
const origOpt = categorySelect.querySelector(`option[value="${opt.value}"]`);
if (origOpt) origOpt.disabled = true;
} else {
item.style.cursor = 'pointer';
item.addEventListener('click', () => {
categorySelect.value = opt.value;
searchInput.value = opt.text;
dropdownList.style.display = 'none';
categorySelect.dispatchEvent(new Event('change'));
});
item.addEventListener('mouseenter', () => {
item.style.background = 'var(--efintel-light)';
item.style.color = 'var(--efintel-blue)';
});
item.addEventListener('mouseleave', () => {
item.style.background = 'white';
item.style.color = 'inherit';
});
}
dropdownList.appendChild(item);
});
}
const selectedOption = categorySelect.options[categorySelect.selectedIndex];
if (selectedOption && selectedOption.value !== '') {
searchInput.value = selectedOption.textContent;
}
searchInput.addEventListener('focus', () => {
renderList(options);
dropdownList.style.display = 'block';
});
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = options.filter(opt => opt.text.toLowerCase().includes(query));
renderList(filtered);
});
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !dropdownList.contains(e.target)) {
dropdownList.style.display = 'none';
}
});
}
// Custom Searchable Dropdown for Project
const projectSelect = document.getElementById('id_project');
const projectSearchInput = document.getElementById('project-search-input');
const projectDropdownList = document.getElementById('project-dropdown-list');
if (projectSelect && projectSearchInput && projectDropdownList) {
projectSelect.style.display = 'none';
const projectOptions = Array.from(projectSelect.options)
.filter(opt => opt.value !== '')
.map(opt => ({
value: opt.value,
text: opt.textContent
}));
function renderProjectList(filteredOptions) {
projectDropdownList.innerHTML = '';
if (filteredOptions.length === 0) {
projectDropdownList.innerHTML = '<div style="padding: 0.65rem; color: var(--efintel-gray); font-size: 0.85rem; text-align: center;">No se encontraron proyectos</div>';
return;
}
filteredOptions.forEach(opt => {
const item = document.createElement('div');
item.className = 'dropdown-item';
item.style.padding = '0.65rem 1rem';
item.style.fontSize = '0.85rem';
item.style.borderBottom = '1px solid #f1f5f9';
item.textContent = opt.text;
// Check if this is a Portfolio (starts with PORT-). Cost Centers (CC-) are selectable.
const isPortfolio = opt.text.startsWith('PORT-');
if (isPortfolio) {
item.style.fontWeight = '700';
item.style.background = '#f1f5f9';
item.style.color = 'var(--efintel-gray)';
item.style.cursor = 'not-allowed';
item.style.opacity = '0.8';
// Disable in the original select
const origOpt = projectSelect.querySelector(`option[value="${opt.value}"]`);
if (origOpt) origOpt.disabled = true;
} else {
item.style.cursor = 'pointer';
item.addEventListener('click', () => {
projectSelect.value = opt.value;
projectSearchInput.value = opt.text;
projectDropdownList.style.display = 'none';
projectSelect.dispatchEvent(new Event('change'));
});
item.addEventListener('mouseenter', () => {
item.style.background = 'var(--efintel-light)';
item.style.color = 'var(--efintel-blue)';
});
item.addEventListener('mouseleave', () => {
item.style.background = 'white';
item.style.color = 'inherit';
});
}
projectDropdownList.appendChild(item);
});
}
const selectedProjectOption = projectSelect.options[projectSelect.selectedIndex];
if (selectedProjectOption && selectedProjectOption.value !== '') {
projectSearchInput.value = selectedProjectOption.textContent;
}
projectSearchInput.addEventListener('focus', () => {
renderProjectList(projectOptions);
projectDropdownList.style.display = 'block';
});
projectSearchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = projectOptions.filter(opt => opt.text.toLowerCase().includes(query));
renderProjectList(filtered);
});
document.addEventListener('click', (e) => {
if (!projectSearchInput.contains(e.target) && !projectDropdownList.contains(e.target)) {
projectDropdownList.style.display = 'none';
}
});
}
// Dynamic Region & Commune Dropdowns
const regionSelect = document.getElementById('id_region');
const communeSelect = document.getElementById('id_comuna');
const dataEl = document.getElementById('regions-communes-data');
if (regionSelect && communeSelect && dataEl) {
const regionsCommunes = JSON.parse(dataEl.textContent);
function updateCommunes(selectedRegion, selectedCommune = '') {
communeSelect.innerHTML = '<option value="">--- Seleccione Comuna ---</option>';
if (selectedRegion && regionsCommunes[selectedRegion]) {
regionsCommunes[selectedRegion].forEach(comm => {
const opt = document.createElement('option');
opt.value = comm;
opt.textContent = comm;
if (comm === selectedCommune) {
opt.selected = true;
}
communeSelect.appendChild(opt);
});
}
}
regionSelect.addEventListener('change', (e) => {
updateCommunes(e.target.value);
});
// Initialize region/commune values on load
const initialRegion = regionSelect.value;
const initialCommune = "{{ form.instance.comuna|default:'' }}";
if (initialRegion) {
updateCommunes(initialRegion, initialCommune);
}
}
// AJAX Document Evidences Deletion (Saved in DB)
function deleteDocument(e, docId) {
e.preventDefault();
if (!confirm('¿Estás seguro de que deseas eliminar este documento de evidencia?')) return;
fetch(`/gastos/documento/${docId}/eliminar/`, {
method: 'POST',
headers: {
'X-CSRFToken': '{{ csrf_token }}',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
const row = document.getElementById(`doc-row-${docId}`);
if (row) {
row.style.transition = 'all 0.3s';
row.style.opacity = '0';
setTimeout(() => row.remove(), 300);
}
} else {
alert('Error: ' + (data.error || 'No se pudo eliminar el archivo.'));
}
})
.catch(err => {
console.error(err);
alert('Error al conectar con el servidor.');
});
}
// 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 && 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', () => {
for (let i = 0; i < fileInput.files.length; i++) {
dt.items.add(fileInput.files[i]);
}
fileInput.files = dt.files;
renderNewFiles();
});
function renderNewFiles() {
newFilesList.innerHTML = '';
if (dt.files.length > 0) {
const header = document.createElement('strong');
header.style.fontSize = '0.75rem';
header.style.color = 'var(--efintel-blue)';
header.style.display = 'block';
header.style.marginBottom = '0.35rem';
header.textContent = 'Archivos en cola para subir:';
newFilesList.appendChild(header);
}
Array.from(dt.files).forEach((file, index) => {
const row = document.createElement('div');
row.style.display = 'flex';
row.style.justifyContent = 'space-between';
row.style.alignItems = 'center';
row.style.background = 'var(--efintel-light)';
row.style.padding = '0.45rem 0.75rem';
row.style.borderRadius = '4px';
row.style.fontSize = '0.75rem';
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)`;
infoWrapper.appendChild(nameSpan);
row.appendChild(infoWrapper);
const deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.style.background = 'none';
deleteBtn.style.border = 'none';
deleteBtn.style.color = 'var(--efintel-danger)';
deleteBtn.style.cursor = 'pointer';
deleteBtn.style.padding = '0.2rem';
deleteBtn.style.display = 'flex';
deleteBtn.style.alignItems = 'center';
deleteBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>`;
deleteBtn.addEventListener('click', (e) => {
e.preventDefault();
const newDt = new DataTransfer();
for (let i = 0; i < dt.files.length; i++) {
if (i !== index) {
newDt.items.add(dt.files[i]);
}
}
dt = newDt;
fileInput.files = dt.files;
renderNewFiles();
});
row.appendChild(deleteBtn);
newFilesList.appendChild(row);
});
}
}
// 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 = '';
}
});
}
// RUT Auto-formatter (XX.XXX.XXX-X)
const rutInput = document.getElementById('id_new_vendor_rut');
if (rutInput) {
rutInput.addEventListener('input', () => {
let val = rutInput.value.replace(/[^0-9kK]/g, '').toUpperCase();
if (val.length === 0) { rutInput.value = ''; return; }
// Separate body and verifier digit
const dv = val.slice(-1);
let body = val.slice(0, -1);
// Add thousands separator to body
body = body.replace(/\B(?=(\d{3})+(?!\d))/g, '.');
rutInput.value = body ? `${body}-${dv}` : dv;
});
// Validate on blur: simple dv check using modulo 11
rutInput.addEventListener('blur', () => {
const raw = rutInput.value.replace(/\./g, '').replace(/-/g, '');
if (raw.length < 2) return;
const body = raw.slice(0, -1);
const dv = raw.slice(-1).toUpperCase();
let sum = 0, factor = 2;
for (let i = body.length - 1; i >= 0; i--) {
sum += parseInt(body[i]) * factor;
factor = factor === 7 ? 2 : factor + 1;
}
const remainder = 11 - (sum % 11);
const expectedDv = remainder === 11 ? '0' : remainder === 10 ? 'K' : String(remainder);
if (dv !== expectedDv) {
rutInput.style.border = '2px solid var(--efintel-danger)';
rutInput.title = `RUT inválido (dígito verificador esperado: ${expectedDv})`;
} else {
rutInput.style.border = '';
rutInput.title = '';
}
});
}
</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 %}