MVP final
This commit is contained in:
+1
-1
@@ -115,7 +115,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/6.1/topics/i18n/
|
# https://docs.djangoproject.com/en/6.1/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'es-es'
|
LANGUAGE_CODE = 'es-cl'
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
|||||||
+98
-9
@@ -4,25 +4,40 @@ from .models import Expense, Project, Category, SystemSetting, UserProfile, Docu
|
|||||||
from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES
|
from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES
|
||||||
|
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
import os
|
||||||
|
|
||||||
class MultipleFileInput(forms.FileInput):
|
class MultipleFileInput(forms.FileInput):
|
||||||
allow_multiple_selected = True
|
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):
|
class ExpenseForm(forms.ModelForm):
|
||||||
date = forms.DateField(
|
date = forms.DateField(
|
||||||
widget=forms.DateInput(attrs={'type': 'date'}),
|
widget=forms.DateInput(attrs={'type': 'date'}),
|
||||||
label='Fecha del Gasto'
|
label='Fecha del Gasto'
|
||||||
)
|
)
|
||||||
|
|
||||||
amount_clp = forms.DecimalField(
|
amount_clp = forms.CharField(
|
||||||
label='Monto Neto (CLP)',
|
label='Monto Neto (CLP)',
|
||||||
required=True,
|
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)',
|
label='Adjuntar Evidencia(s) (Boleta / Factura)',
|
||||||
required=False,
|
required=False
|
||||||
widget=MultipleFileInput(attrs={'multiple': True, 'accept': 'image/*,.pdf'})
|
|
||||||
)
|
)
|
||||||
|
|
||||||
region = forms.ChoiceField(
|
region = forms.ChoiceField(
|
||||||
@@ -43,16 +58,28 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
tax_amount_clp = forms.DecimalField(
|
tax_amount_clp = forms.CharField(
|
||||||
label='Monto Impuesto (CLP)',
|
label='Monto Impuesto (CLP)',
|
||||||
required=False,
|
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)',
|
label='Monto Total (CLP)',
|
||||||
required=False,
|
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:
|
class Meta:
|
||||||
@@ -65,6 +92,68 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno'}),
|
'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 ProjectForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|||||||
+1
-1
@@ -11,7 +11,7 @@ def expense_saved(sender, instance, created, **kwargs):
|
|||||||
|
|
||||||
expense_data = {
|
expense_data = {
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'amount': str(instance.amount),
|
'amount': str(instance.amount_clp),
|
||||||
'category': instance.category.name if instance.category else None,
|
'category': instance.category.name if instance.category else None,
|
||||||
'project': instance.project.name if instance.project else None,
|
'project': instance.project.name if instance.project else None,
|
||||||
'user': instance.user.username if instance.user else None,
|
'user': instance.user.username if instance.user else None,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ urlpatterns = [
|
|||||||
|
|
||||||
# Export / Import
|
# Export / Import
|
||||||
path('gastos/exportar/csv/', views.export_expenses_csv, name='export_csv'),
|
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'),
|
path('proyectos/', views.project_list, name='project_list'),
|
||||||
|
|
||||||
|
|||||||
@@ -343,6 +343,59 @@ def export_expenses_csv(request):
|
|||||||
return response
|
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
|
@login_required
|
||||||
def project_list(request):
|
def project_list(request):
|
||||||
projects = Project.objects.annotate(
|
projects = Project.objects.annotate(
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -2,11 +2,18 @@
|
|||||||
{% load humanize %}
|
{% load humanize %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="view-title">
|
||||||
<h2>Cuentas por Pagar (CXP)</h2>
|
<h2>Cuentas por Pagar (CXP)</h2>
|
||||||
<p>Consolidado de deudas por proveedor en estado Solicitado a Finanzas.</p>
|
<p>Consolidado de deudas por proveedor en estado Solicitado a Finanzas.</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<section class="main-content">
|
<section class="main-content">
|
||||||
|
|||||||
@@ -97,6 +97,15 @@
|
|||||||
<td style="font-weight:600; color:var(--efintel-dark);">
|
<td style="font-weight:600; color:var(--efintel-dark);">
|
||||||
{{ expense.description|truncatechars:50 }}<br>
|
{{ expense.description|truncatechars:50 }}<br>
|
||||||
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
|
<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>
|
||||||
<td class="amount-cell">
|
<td class="amount-cell">
|
||||||
<div>${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP</div>
|
<div>${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP</div>
|
||||||
|
|||||||
@@ -90,31 +90,66 @@
|
|||||||
<div class="form-group">
|
<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>
|
<label for="{{ form.vendor.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Proveedor (Opcional)</label>
|
||||||
{{ form.vendor }}
|
{{ 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 %}
|
{% 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>
|
</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;">
|
<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>
|
<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 }}
|
{{ form.description }}
|
||||||
{% if form.description.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.description.errors }}</div>{% endif %}
|
{% if form.description.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.description.errors }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" style="margin-bottom: 1.5rem; background: #f8fafc; padding: 1.5rem; border-radius: 6px; border: 1px dashed var(--efintel-blue);">
|
<div class="form-group" style="margin-bottom: 1.5rem; background: #f8fafc; padding: 1.5rem; border-radius: 6px; border: 1px solid var(--efintel-border);">
|
||||||
<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>
|
<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 }}
|
{{ form.receipt }}
|
||||||
<div id="new-files-list" style="margin-top: 0.75rem; display: flex; flex-direction: column; gap: 0.4rem;"></div>
|
</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>
|
|
||||||
|
<!-- 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.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 %}
|
{% 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>
|
<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;">
|
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||||
{% for doc in form.instance.documents.all %}
|
{% 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;">
|
<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;">
|
<span style="font-weight: 500; color: var(--efintel-dark); display: flex; align-items: center; gap: 0.5rem;">
|
||||||
<i data-lucide="file-text" style="width: 14px; color: var(--efintel-blue);"></i>
|
{% 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>
|
<a href="{{ doc.file.url }}" target="_blank" style="color: var(--efintel-blue); text-decoration: none;">{{ doc.title|default:doc.file.name }}</a>
|
||||||
</span>
|
</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">
|
<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 taxAmountInput = document.getElementById('id_tax_amount_clp');
|
||||||
const totalAmountInput = document.getElementById('id_total_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() {
|
function calculateTaxes() {
|
||||||
const amount = parseFloat(amountInput.value) || 0;
|
const cleanVal = cleanNumberString(amountInput.value);
|
||||||
|
const amount = parseFloat(cleanVal) || 0;
|
||||||
const taxType = taxTypeSelect.value;
|
const taxType = taxTypeSelect.value;
|
||||||
let taxAmount = 0;
|
let taxAmount = 0;
|
||||||
|
|
||||||
@@ -186,14 +230,35 @@
|
|||||||
taxAmount = amount * 0.1375;
|
taxAmount = amount * 0.1375;
|
||||||
}
|
}
|
||||||
|
|
||||||
taxAmountInput.value = taxAmount.toFixed(0);
|
taxAmountInput.value = formatNumber(Math.round(taxAmount));
|
||||||
totalAmountInput.value = (amount + taxAmount).toFixed(0);
|
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);
|
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();
|
calculateTaxes();
|
||||||
|
|
||||||
// Custom Searchable Dropdown for Category
|
// 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 fileInput = document.getElementById('id_receipt');
|
||||||
const newFilesList = document.getElementById('new-files-list');
|
const newFilesList = document.getElementById('new-files-list');
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
|
||||||
if (fileInput && newFilesList) {
|
if (fileInput && newFilesList && dropZone) {
|
||||||
let dt = new DataTransfer();
|
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', () => {
|
fileInput.addEventListener('change', () => {
|
||||||
// Append newly selected files to our ongoing list
|
|
||||||
for (let i = 0; i < fileInput.files.length; i++) {
|
for (let i = 0; i < fileInput.files.length; i++) {
|
||||||
dt.items.add(fileInput.files[i]);
|
dt.items.add(fileInput.files[i]);
|
||||||
}
|
}
|
||||||
// Update input files list
|
|
||||||
fileInput.files = dt.files;
|
fileInput.files = dt.files;
|
||||||
renderNewFiles();
|
renderNewFiles();
|
||||||
});
|
});
|
||||||
@@ -479,11 +596,42 @@
|
|||||||
row.style.border = '1px solid var(--efintel-border)';
|
row.style.border = '1px solid var(--efintel-border)';
|
||||||
row.style.marginBottom = '0.3rem';
|
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');
|
const nameSpan = document.createElement('span');
|
||||||
nameSpan.style.color = 'var(--efintel-petroleum)';
|
nameSpan.style.color = 'var(--efintel-petroleum)';
|
||||||
nameSpan.style.fontWeight = '600';
|
nameSpan.style.fontWeight = '600';
|
||||||
nameSpan.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
|
nameSpan.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
|
||||||
row.appendChild(nameSpan);
|
infoWrapper.appendChild(nameSpan);
|
||||||
|
row.appendChild(infoWrapper);
|
||||||
|
|
||||||
const deleteBtn = document.createElement('button');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.type = '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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -67,8 +67,14 @@
|
|||||||
<td style="font-weight:600; color:var(--efintel-dark); font-size: 0.85rem;">
|
<td style="font-weight:600; color:var(--efintel-dark); font-size: 0.85rem;">
|
||||||
{{ expense.description|truncatechars:40 }}<br>
|
{{ expense.description|truncatechars:40 }}<br>
|
||||||
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
|
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
|
||||||
{% if expense.documents.first %}
|
{% if expense.documents.all %}
|
||||||
<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>
|
<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 %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="amount-cell">
|
<td class="amount-cell">
|
||||||
|
|||||||
Reference in New Issue
Block a user