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
+1 -1
View File
@@ -115,7 +115,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/6.1/topics/i18n/
LANGUAGE_CODE = 'es-es'
LANGUAGE_CODE = 'es-cl'
TIME_ZONE = 'UTC'
+98 -9
View File
@@ -4,25 +4,40 @@ from .models import Expense, Project, Category, SystemSetting, UserProfile, Docu
from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES
from decimal import Decimal
import os
class MultipleFileInput(forms.FileInput):
allow_multiple_selected = True
class MultipleFileField(forms.FileField):
def __init__(self, *args, **kwargs):
kwargs.setdefault("widget", MultipleFileInput(attrs={'multiple': True, 'accept': 'image/*,.pdf'}))
super().__init__(*args, **kwargs)
def clean(self, data, initial=None):
single_file_clean = super().clean
if isinstance(data, (list, tuple)):
result = [single_file_clean(d, initial) for d in data]
else:
result = single_file_clean(data, initial)
return result
class ExpenseForm(forms.ModelForm):
date = forms.DateField(
widget=forms.DateInput(attrs={'type': 'date'}),
label='Fecha del Gasto'
)
amount_clp = forms.DecimalField(
amount_clp = forms.CharField(
label='Monto Neto (CLP)',
required=True,
widget=forms.NumberInput(attrs={'placeholder': 'Ej: 1450000'})
widget=forms.TextInput(attrs={'placeholder': 'Ej: 1.450.000'})
)
receipt = forms.FileField(
receipt = MultipleFileField(
label='Adjuntar Evidencia(s) (Boleta / Factura)',
required=False,
widget=MultipleFileInput(attrs={'multiple': True, 'accept': 'image/*,.pdf'})
required=False
)
region = forms.ChoiceField(
@@ -43,16 +58,28 @@ class ExpenseForm(forms.ModelForm):
required=False
)
tax_amount_clp = forms.DecimalField(
tax_amount_clp = forms.CharField(
label='Monto Impuesto (CLP)',
required=False,
widget=forms.NumberInput(attrs={'readonly': 'readonly'})
widget=forms.TextInput(attrs={'readonly': 'readonly'})
)
total_amount_clp = forms.DecimalField(
total_amount_clp = forms.CharField(
label='Monto Total (CLP)',
required=False,
widget=forms.NumberInput(attrs={'readonly': 'readonly'})
widget=forms.TextInput(attrs={'readonly': 'readonly'})
)
new_vendor_rut = forms.CharField(
label="RUT Nuevo Proveedor",
required=False,
widget=forms.TextInput(attrs={'placeholder': 'Ej: 76.543.210-K', 'id': 'id_new_vendor_rut'})
)
new_vendor_name = forms.CharField(
label="Nombre / Razón Social Nuevo Proveedor",
required=False,
widget=forms.TextInput(attrs={'placeholder': 'Ej: IMPORTADORA SPA', 'id': 'id_new_vendor_name'})
)
class Meta:
@@ -65,6 +92,68 @@ class ExpenseForm(forms.ModelForm):
'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno'}),
}
def clean_amount_clp(self):
data = self.cleaned_data.get('amount_clp', '')
if not data:
raise forms.ValidationError("Este campo es obligatorio.")
data_clean = str(data).replace('.', '').replace(',', '.')
try:
return Decimal(data_clean)
except Exception:
raise forms.ValidationError("Ingrese un monto válido (ej: 1.450.000).")
def clean_tax_amount_clp(self):
data = self.cleaned_data.get('tax_amount_clp', '')
if not data:
return Decimal('0.00')
data_clean = str(data).replace('.', '').replace(',', '.')
try:
return Decimal(data_clean)
except Exception:
return Decimal('0.00')
def clean_total_amount_clp(self):
data = self.cleaned_data.get('total_amount_clp', '')
if not data:
return Decimal('0.00')
data_clean = str(data).replace('.', '').replace(',', '.')
try:
return Decimal(data_clean)
except Exception:
return Decimal('0.00')
def clean_receipt(self):
files = self.files.getlist('receipt')
for f in files:
ext = os.path.splitext(f.name)[1].lower()
if ext not in ['.pdf', '.png', '.jpg', '.jpeg']:
raise forms.ValidationError(f"El archivo '{f.name}' no tiene una extensión permitida (PDF, PNG, JPG, JPEG).")
return self.cleaned_data.get('receipt')
def clean(self):
cleaned_data = super().clean()
new_name = cleaned_data.get('new_vendor_name')
new_rut = cleaned_data.get('new_vendor_rut')
if (new_name and not new_rut) or (new_rut and not new_name):
raise forms.ValidationError("Si desea registrar un nuevo proveedor, debe ingresar tanto el RUT como la Razón Social.")
return cleaned_data
def save(self, commit=True):
instance = super().save(commit=False)
new_name = self.cleaned_data.get('new_vendor_name')
new_rut = self.cleaned_data.get('new_vendor_rut')
if new_name and new_rut:
from .models import Vendor
vendor, _ = Vendor.objects.get_or_create(
rut=new_rut.strip(),
defaults={'name': new_name.strip()}
)
instance.vendor = vendor
if commit:
instance.save()
return instance
class ProjectForm(forms.ModelForm):
class Meta:
+1 -1
View File
@@ -11,7 +11,7 @@ def expense_saved(sender, instance, created, **kwargs):
expense_data = {
'id': instance.id,
'amount': str(instance.amount),
'amount': str(instance.amount_clp),
'category': instance.category.name if instance.category else None,
'project': instance.project.name if instance.project else None,
'user': instance.user.username if instance.user else None,
+1
View File
@@ -18,6 +18,7 @@ urlpatterns = [
# Export / Import
path('gastos/exportar/csv/', views.export_expenses_csv, name='export_csv'),
path('gastos/exportar/transferencias/', views.export_bank_transfer_csv, name='export_bank_transfer_csv'),
path('proyectos/', views.project_list, name='project_list'),
+53
View File
@@ -343,6 +343,59 @@ def export_expenses_csv(request):
return response
@login_required
@role_required('Administrador', 'Administrador Finanzas')
def export_bank_transfer_csv(request):
# Get all expenses requested for payment that are linked to a vendor
pending_expenses = Expense.objects.filter(
approval_status=ApprovalStatusEnum.SOLICITADO_FINANZAS,
vendor__isnull=False
)
# Group debts by vendor to perform single bank transfer per supplier
debts = pending_expenses.values(
'vendor__id', 'vendor__rut', 'vendor__name', 'vendor__bank', 'vendor__account_type', 'vendor__account_number'
).annotate(
total_clp=Sum('amount_clp'),
total_usd=Sum('amount_usd'),
expense_count=Count('id')
).order_by('vendor__name')
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="Nomina_Transferencias_{date.today()}.csv"'
# Write BOM for Excel Spanish compatibility
response.write('\ufeff'.encode('utf-8'))
writer = csv.writer(response, delimiter=';')
writer.writerow([
'RUT Proveedor', 'Nombre / Razón Social', 'Banco', 'Tipo Cuenta',
'Número Cuenta', 'Monto Transferir (CLP)', 'Monto Transferir (USD)',
'Comprobantes Pendientes', 'Glosa / Comentario'
])
for debt in debts:
writer.writerow([
debt['vendor__rut'],
debt['vendor__name'],
debt['vendor__bank'] or 'No Registrado',
debt['vendor__account_type'] or 'No Registrado',
debt['vendor__account_number'] or 'No Registrado',
int(debt['total_clp']),
round(debt['total_usd'], 2),
debt['expense_count'],
'Pago Facturas Pendientes EFINTEL ERP'
])
AuditLog.objects.create(
user=request.user,
action="EXPORT_BANK_TRANSFER",
description="Se exportó la nómina bancaria de transferencias a proveedores."
)
return response
@login_required
def project_list(request):
projects = Project.objects.annotate(
+158
View File
@@ -0,0 +1,158 @@
import os
import sys
import django
from django.test import Client
from django.core.files.uploadedfile import SimpleUploadedFile
from decimal import Decimal
# Set up Django environment
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.conf import settings
if 'testserver' not in settings.ALLOWED_HOSTS:
settings.ALLOWED_HOSTS.append('testserver')
from django.contrib.auth.models import User
from expenses.models import Project, Category, Vendor, Expense, Document, SystemSetting, CurrencyEnum, TaxTypeEnum, ApprovalStatusEnum
def run_test():
print("=" * 60)
print("EFINTEL ERP — Expense Creation Flow Integration Test")
print("=" * 60)
# 1. Setup client and login
print("Step 1: Setting up Django test Client & Authenticating...")
client = Client()
admin_user = User.objects.filter(username='admin').first()
if not admin_user:
print("Admin user 'admin' not found in database. Creating user...")
admin_user = User.objects.create_superuser('admin', 'admin@example.com', 'admin')
print("Admin user created successfully.")
client.force_login(admin_user)
print(f"Authenticated successfully as: {admin_user.username} (ID: {admin_user.id})")
# Fetch required objects
print("\nFetching Project (PRY-101) and Category (1201)...")
project = Project.objects.filter(code='PRY-101').first()
if not project:
raise ValueError("Project PRY-101 not found! Please run project seed scripts first.")
print(f"Found Project: {project.code} - '{project.name}' (ID: {project.id})")
category = Category.objects.filter(code='1201').first()
if not category:
raise ValueError("Category 1201 not found! Please run category seed scripts first.")
print(f"Found Category: {category.code} - '{category.name}' (ID: {category.id})")
# Fetch/create SystemSetting to get exchange rate
setting, created = SystemSetting.objects.get_or_create(id=1, defaults={'usd_exchange_rate': 950.0})
usd_rate = setting.usd_exchange_rate
print(f"System setting exchange rate is: {usd_rate}")
# Prepare Mock Files
print("\nPreparing mock evidence files for upload...")
file1 = SimpleUploadedFile("receipt1.pdf", b"pdf content mock", content_type="application/pdf")
file2 = SimpleUploadedFile("receipt2.png", b"png content mock", content_type="image/png")
# 2. Prepare POST data
print("\nStep 2: Preparing POST request data for '/gastos/nuevo/'...")
post_data = {
'project': project.id,
'category': category.id,
'amount_clp': '2.500.000',
'tax_type': TaxTypeEnum.IVA_19,
'tax_amount_clp': '475.000', # 19% of 2.500.000
'total_amount_clp': '2.975.000', # 2.500.000 + 475.000
'currency': CurrencyEnum.CLP,
'responsible_person': admin_user.id,
'date': '2026-08-21',
'description': 'Servicios de consultoría de prototipado rápido y PCBs',
'new_vendor_rut': '77.888.999-5',
'new_vendor_name': 'CONEXIONES SPA',
'toggle-new-vendor': 'on',
'region': 'Región Metropolitana',
'comuna': 'Santiago',
'receipt': [file1, file2],
}
# Clean existing data to avoid conflicts and guarantee clean assertion
print("Cleaning any existing vendor/expense with target RUT to avoid duplication conflicts...")
Vendor.objects.filter(rut='77.888.999-5').delete()
# 3. Execute POST request
print("\nStep 3: Executing POST request to '/gastos/nuevo/'...")
response = client.post('/gastos/nuevo/', post_data, follow=False)
# 4. Verify results
print("\nStep 4: Verifying results...")
# 4.1 Response Status and Redirect
status_code = response.status_code
location = response.get('Location', '')
print(f" [Assertion 1] Response Status Code: {status_code} (Expected: 302)")
print(f" [Assertion 1] Redirect Location: {location} (Expected: /gastos/)")
assert status_code == 302, f"Expected status code 302, got {status_code}"
assert '/gastos/' in location, f"Expected redirect to '/gastos/', got '{location}'"
print(" => Response redirect validation: PASSED")
# 4.2 New Vendor creation
vendor = Vendor.objects.filter(rut='77.888.999-5').first()
assert vendor is not None, "Vendor was not created!"
print(f" [Assertion 2] Vendor Created: RUT={vendor.rut}, Name='{vendor.name}' (Expected: 'CONEXIONES SPA')")
assert vendor.name == 'CONEXIONES SPA', f"Expected name 'CONEXIONES SPA', got '{vendor.name}'"
print(" => Vendor DB validation: PASSED")
# 4.3 New Expense creation
expense = Expense.objects.filter(vendor=vendor, date='2026-08-21').first()
assert expense is not None, "Expense was not created!"
print(f" [Assertion 3] Expense Created with net amount={expense.amount_clp} CLP (Expected: 2500000.00 CLP)")
assert expense.amount_clp == Decimal('2500000.00'), f"Expected net amount 2500000.00, got {expense.amount_clp}"
print(" => Expense DB amount validation: PASSED")
# 4.4 Associated Document verification
documents = list(expense.documents.all())
print(f" [Assertion 4] Associated Documents Count: {len(documents)} (Expected: 2)")
assert len(documents) == 2, f"Expected 2 documents, found {len(documents)}"
filenames = [os.path.basename(doc.file.name) for doc in documents]
print(f" [Assertion 4] Document filenames in DB: {filenames} (Expected containing 'receipt1.pdf' and 'receipt2.png')")
assert any("receipt1" in fn and fn.endswith(".pdf") for fn in filenames), "receipt1.pdf not found in documents"
assert any("receipt2" in fn and fn.endswith(".png") for fn in filenames), "receipt2.png not found in documents"
print(" => Documents validation: PASSED")
# 4.5 USD conversion calculation
expected_usd = Decimal('2500000.00') / usd_rate
print(f" [Assertion 5] Expense USD Amount in DB: {expense.amount_usd} (Expected: {expected_usd})")
assert abs(expense.amount_usd - expected_usd) < Decimal('0.01'), f"USD conversion mismatch: DB={expense.amount_usd}, Expected={expected_usd}"
print(" => USD exchange rate conversion validation: PASSED")
# 5. Test bank transfer export
print("\nStep 5: Testing 'export_bank_transfer_csv' endpoint...")
# Change expense status to SOLICITADO_FINANZAS to make sure it is exported
expense.approval_status = ApprovalStatusEnum.SOLICITADO_FINANZAS
expense.save()
export_response = client.get('/gastos/exportar/transferencias/')
print(f" [Assertion 6] Export Status Code: {export_response.status_code} (Expected: 200)")
assert export_response.status_code == 200, f"Expected 200, got {export_response.status_code}"
# Decode using utf-8-sig to automatically handle BOM if present, or manually assert it
content_raw = export_response.content
assert content_raw.startswith(b'\xef\xbb\xbf'), "BOM bytes not found at the start of CSV!"
content_str = content_raw.decode('utf-8')
assert 'RUT Proveedor' in content_str, "CSV header mismatch!"
assert 'CONEXIONES SPA' in content_str, "CONEXIONES SPA should be present in the export!"
print(" => Bank Transfer CSV validation: PASSED")
print("\n" + "=" * 60)
print("🎉 ALL INTEGRATION VALIDATION STEPS PASSED SUCCESSFULLY! 🎉")
print("=" * 60)
if __name__ == '__main__':
try:
run_test()
except Exception as e:
import traceback
traceback.print_exc()
sys.exit(1)
+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">