mvp
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
# Dictionary mapping Chilean regions to their communes (simplified but covering major locations for engineering/project sites)
|
||||||
|
|
||||||
|
REGIONS_COMMUNES = {
|
||||||
|
"Arica y Parinacota": ["Arica", "Camarones", "Putre", "General Lagos"],
|
||||||
|
"Tarapacá": ["Iquique", "Alto Hospicio", "Pozo Almonte", "Camiña", "Colchane", "Huara", "Pica"],
|
||||||
|
"Antofagasta": ["Antofagasta", "Mejillones", "Sierra Gorda", "Taltal", "Calama", "Ollagüe", "San Pedro de Atacama", "Tocopilla", "María Elena"],
|
||||||
|
"Atacama": ["Copiapó", "Caldera", "Tierra Amarilla", "Chañaral", "Diego de Almagro", "Vallenar", "Alto del Carmen", "Freirina", "Huasco"],
|
||||||
|
"Coquimbo": ["La Serena", "Coquimbo", "Andacollo", "La Higuera", "Paiguano", "Vicuña", "Illapel", "Canela", "Los Vilos", "Salamanca", "Ovalle", "Combarbalá", "Monte Patria", "Punitaqui", "Río Hurtado"],
|
||||||
|
"Valparaíso": ["Valparaíso", "Casablanca", "Concón", "Juan Fernández", "Puchuncaví", "Quintero", "Viña del Mar", "Isla de Pascua", "Los Andes", "Calle Larga", "Rinconada", "San Esteban", "La Ligua", "Cabildo", "Papudo", "Petorca", "Zapallar", "Quillota", "Calera", "Hijuelas", "La Cruz", "Nogales", "San Antonio", "Algarrobo", "El Quisco", "El Tabo", "Cartagena", "Santo Domingo", "San Felipe", "Catemu", "Llay-Llay", "Panquehue", "Putaendo", "Santa María", "Quilpué", "Limache", "Olmué", "Villa Alemana"],
|
||||||
|
"Región Metropolitana": ["Santiago", "Cerrillos", "Cerro Navia", "Conchalí", "El Bosque", "Estación Central", "Huechuraba", "Independencia", "La Cisterna", "La Florida", "La Granja", "La Pintana", "La Reina", "Las Condes", "Lo Barnechea", "Lo Espejo", "Lo Prado", "Macul", "Maipú", "Ñuñoa", "Pedro Aguirre Cerda", "Peñalolén", "Providencia", "Pudahuel", "Quilicura", "Quinta Normal", "Recoleta", "Renca", "San Joaquín", "San Miguel", "San Ramón", "Vitacura", "Puente Alto", "Pirque", "San José de Maipo", "San Bernardo", "Calera de Tango", "Buin", "Paine", "Melipilla", "Alhué", "Curacaví", "María Pinto", "San Pedro", "Talagante", "El Monte", "Isla de Maipo", "Padre Hurtado", "Peñaflor", "Colina", "Lampa", "Tiltil"],
|
||||||
|
"O'Higgins": ["Rancagua", "Codegua", "Coinco", "Coltauco", "Doñihue", "Graneros", "Las Cabras", "Machalí", "Malloa", "Mostazal", "Olivar", "Peumo", "Pichidegua", "Quinta de Tilcoco", "Rengo", "Requínoa", "San Vicente", "Pichilemu", "La Estrella", "Litueche", "Marchihue", "Navidad", "Paredones", "San Fernando", "Chépica", "Chimbarongo", "Lolol", "Nancagua", "Palmilla", "Peralillo", "Placilla", "Pumanque", "Santa Cruz"],
|
||||||
|
"Maule": ["Talca", "Constitución", "Curepto", "Empedrado", "Maule", "Pelarco", "Pencahue", "Río Claro", "San Clemente", "San Rafael", "Cauquenes", "Chanco", "Pelluhue", "Curicó", "Hualañé", "Licantén", "Molina", "Rauco", "Romeral", "Sagrada Familia", "Teno", "Vichuquén", "Linares", "Colbún", "Longaví", "Parral", "Retiro", "San Javier", "Villa Alegre", "Yerbas Buenas"],
|
||||||
|
"Ñuble": ["Chillán", "Bulnes", "Cobquecura", "Coelemu", "Coihueco", "Chillán Viejo", "El Carmen", "Ninhue", "Ñiquén", "Pemuco", "Pinto", "Portezuelo", "Quillón", "Quirihue", "Ránquil", "San Carlos", "San Fabián", "San Ignacio", "San Nicolás", "Treguaco", "Yungay"],
|
||||||
|
"Biobío": ["Concepción", "Coronel", "Chiguayante", "Florida", "Hualpén", "Hualqui", "Lota", "Penco", "San Pedro de la Paz", "Santa Juana", "Talcahuano", "Tomé", "Lebu", "Arauco", "Cañete", "Contulmo", "Curanilahue", "Los Álamos", "Tirúa", "Los Ángeles", "Antuco", "Cabrero", "Laja", "Mulchén", "Nacimiento", "Negrete", "Quilaco", "Quilleco", "San Rosendo", "Santa Bárbara", "Tucapel", "Yumbel", "Alto Biobío"],
|
||||||
|
"Araucanía": ["Temuco", "Carahue", "Cholchol", "Cunco", "Curarrehue", "Freire", "Galvarino", "Gorbea", "Lautaro", "Loncoche", "Melipeuco", "Nueva Imperial", "Padre Las Casas", "Perquenco", "Pitrufquén", "Pucón", "Saavedra", "Teodoro Schmidt", "Toltén", "Vilcún", "Villarrica", "Angol", "Collipulli", "Curacautín", "Ercilla", "Lonquimay", "Los Sauces", "Lumaco", "Purén", "Renaico", "Traiguén", "Victoria"],
|
||||||
|
"Los Ríos": ["Valdivia", "Corral", "Lanco", "Los Lagos", "Máfil", "Mariquina", "Paillaco", "Panguipulli", "La Unión", "Futrono", "Lago Ranco", "Río Bueno"],
|
||||||
|
"Los Lagos": ["Puerto Montt", "Calbuco", "Cochamó", "Fresia", "Frutillar", "Los Muermos", "Llanquihue", "Maullín", "Puerto Varas", "Castro", "Ancud", "Chonchi", "Curaco de Vélez", "Dalcahue", "Puqueldón", "Queilén", "Quellón", "Quemchi", "Quinchao", "Osorno", "Puerto Octay", "Purranque", "Puyehue", "Río Negro", "San Juan de la Costa", "San Pablo", "Chaitén", "Futaleufú", "Hualaihué", "Palena"],
|
||||||
|
"Aysén": ["Coyhaique", "Lago Verde", "Aysén", "Cisnes", "Guaitecas", "Cochrane", "O'Higgins", "Tortel", "Chile Chico", "Río Ibáñez"],
|
||||||
|
"Magallanes": ["Punta Arenas", "Laguna Blanca", "Río Verde", "San Gregorio", "Cabo de Hornos", "Antártica", "Porvenir", "Primavera", "Timaukel", "Natales", "Torres del Paine"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Generate Choices tuples for Django
|
||||||
|
REGION_CHOICES = [(r, r) for r in REGIONS_COMMUNES.keys()]
|
||||||
|
|
||||||
|
# Flat list of all communes for validation
|
||||||
|
ALL_COMMUNES = sorted(list(set([c for communes in REGIONS_COMMUNES.values() for c in communes])))
|
||||||
|
COMMUNE_CHOICES = [(c, c) for c in ALL_COMMUNES]
|
||||||
+26
-4
@@ -1,8 +1,12 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.auth.models import User, Group
|
from django.contrib.auth.models import User, Group
|
||||||
from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum, Vendor
|
from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum, Vendor, VehicleOwnershipEnum
|
||||||
|
from .chile_regions import REGION_CHOICES, COMMUNE_CHOICES
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleFileInput(forms.FileInput):
|
||||||
|
allow_multiple_selected = True
|
||||||
|
|
||||||
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'}),
|
||||||
@@ -16,9 +20,27 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
)
|
)
|
||||||
|
|
||||||
receipt = forms.FileField(
|
receipt = forms.FileField(
|
||||||
label='Adjuntar Boleta/Factura',
|
label='Adjuntar Evidencia(s) (Boleta / Factura)',
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.FileInput(attrs={'accept': 'image/*,.pdf'})
|
widget=MultipleFileInput(attrs={'multiple': True, 'accept': 'image/*,.pdf'})
|
||||||
|
)
|
||||||
|
|
||||||
|
region = forms.ChoiceField(
|
||||||
|
choices=[('', '--- Seleccione Región ---')] + REGION_CHOICES,
|
||||||
|
label='Región Operativa',
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
comuna = forms.ChoiceField(
|
||||||
|
choices=[('', '--- Seleccione Comuna ---')] + COMMUNE_CHOICES,
|
||||||
|
label='Comuna',
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
|
||||||
|
vehicle_ownership = forms.ChoiceField(
|
||||||
|
choices=[('', '--- Seleccione Modalidad ---')] + VehicleOwnershipEnum.choices,
|
||||||
|
label='Modalidad / Vehículo',
|
||||||
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
tax_amount_clp = forms.DecimalField(
|
tax_amount_clp = forms.DecimalField(
|
||||||
@@ -36,7 +58,7 @@ class ExpenseForm(forms.ModelForm):
|
|||||||
class Meta:
|
class Meta:
|
||||||
model = Expense
|
model = Expense
|
||||||
fields = [
|
fields = [
|
||||||
'project', 'category', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'date',
|
'project', 'category', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'responsible_person', 'date',
|
||||||
'description', 'vendor', 'region', 'comuna', 'vehicle_ownership'
|
'description', 'vendor', 'region', 'comuna', 'vehicle_ownership'
|
||||||
]
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.1 on 2026-08-20 23:31
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('expenses', '0006_project_category_userprofile_specialties_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='expense',
|
||||||
|
name='vehicle_ownership',
|
||||||
|
field=models.CharField(blank=True, choices=[('ARRENDADO', 'Arrendado (4x4, etc.)'), ('PROPIO', 'Propio (Reembolso Kilometraje)'), ('BUS', 'Bus'), ('UBER_TAXI', 'Uber/Taxi')], max_length=50, null=True, verbose_name='Modalidad / Vehículo'),
|
||||||
|
),
|
||||||
|
]
|
||||||
+7
-1
@@ -27,6 +27,12 @@ class ProjectStatusEnum(models.TextChoices):
|
|||||||
PAUSADA = 'PAUSADA', 'En Pausa'
|
PAUSADA = 'PAUSADA', 'En Pausa'
|
||||||
TERMINADO = 'TERMINADO', 'Terminado'
|
TERMINADO = 'TERMINADO', 'Terminado'
|
||||||
|
|
||||||
|
class VehicleOwnershipEnum(models.TextChoices):
|
||||||
|
ARRENDADO = 'ARRENDADO', 'Arrendado (4x4, etc.)'
|
||||||
|
PROPIO = 'PROPIO', 'Propio (Reembolso Kilometraje)'
|
||||||
|
BUS = 'BUS', 'Bus'
|
||||||
|
UBER_TAXI = 'UBER_TAXI', 'Uber/Taxi'
|
||||||
|
|
||||||
class Project(models.Model):
|
class Project(models.Model):
|
||||||
# Added core project/costcenter fields mapped from Prisma
|
# Added core project/costcenter fields mapped from Prisma
|
||||||
code = models.CharField(max_length=50, unique=True, verbose_name="Código", default='PROY-000')
|
code = models.CharField(max_length=50, unique=True, verbose_name="Código", default='PROY-000')
|
||||||
@@ -115,7 +121,7 @@ class Expense(models.Model):
|
|||||||
# Geographic & Logistic fields (Operaciones en terreno)
|
# Geographic & Logistic fields (Operaciones en terreno)
|
||||||
region = models.CharField(max_length=100, blank=True, null=True, verbose_name="Región (Chile)")
|
region = models.CharField(max_length=100, blank=True, null=True, verbose_name="Región (Chile)")
|
||||||
comuna = models.CharField(max_length=100, blank=True, null=True, verbose_name="Comuna")
|
comuna = models.CharField(max_length=100, blank=True, null=True, verbose_name="Comuna")
|
||||||
vehicle_ownership = models.CharField(max_length=100, blank=True, null=True, verbose_name="Modalidad / Vehículo")
|
vehicle_ownership = models.CharField(max_length=50, blank=True, null=True, choices=VehicleOwnershipEnum.choices, verbose_name="Modalidad / Vehículo")
|
||||||
|
|
||||||
# 3-step Approval Flow Tracking
|
# 3-step Approval Flow Tracking
|
||||||
approval_status = models.CharField(max_length=50, choices=ApprovalStatusEnum.choices, default=ApprovalStatusEnum.EN_REVISION, verbose_name="Estado de Aprobación")
|
approval_status = models.CharField(max_length=50, choices=ApprovalStatusEnum.choices, default=ApprovalStatusEnum.EN_REVISION, verbose_name="Estado de Aprobación")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ urlpatterns = [
|
|||||||
path('gastos/nuevo/', views.expense_create, name='expense_create'),
|
path('gastos/nuevo/', views.expense_create, name='expense_create'),
|
||||||
path('gastos/<int:pk>/editar/', views.expense_edit, name='expense_edit'),
|
path('gastos/<int:pk>/editar/', views.expense_edit, name='expense_edit'),
|
||||||
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
|
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
|
||||||
|
path('gastos/documento/<int:pk>/eliminar/', views.delete_document, name='delete_document'),
|
||||||
|
|
||||||
# Flujo de aprobación y Cuentas por Pagar
|
# Flujo de aprobación y Cuentas por Pagar
|
||||||
path('gastos/cuentas-por-pagar/', views.accounts_payable, name='accounts_payable'),
|
path('gastos/cuentas-por-pagar/', views.accounts_payable, name='accounts_payable'),
|
||||||
|
|||||||
+50
-10
@@ -9,11 +9,13 @@ from django.utils import timezone
|
|||||||
from datetime import date
|
from datetime import date
|
||||||
from django.http import JsonResponse, HttpResponse
|
from django.http import JsonResponse, HttpResponse
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
from .models import Expense, Project, Category, SystemSetting, UserProfile, AuditLog, ApprovalStatusEnum, CurrencyEnum, Vendor
|
from .models import Expense, Project, Category, SystemSetting, UserProfile, AuditLog, ApprovalStatusEnum, CurrencyEnum, Vendor, Document
|
||||||
from .forms import ExpenseForm, ProjectForm, SystemSettingForm, ResponsableForm, CategoryForm, VendorForm
|
from .forms import ExpenseForm, ProjectForm, SystemSettingForm, ResponsableForm, CategoryForm, VendorForm
|
||||||
from .decorators import role_required
|
from .decorators import role_required
|
||||||
from django.contrib.auth.models import User, Group
|
from django.contrib.auth.models import User, Group
|
||||||
|
from .chile_regions import REGIONS_COMMUNES
|
||||||
|
|
||||||
def login_view(request):
|
def login_view(request):
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
@@ -126,10 +128,11 @@ def expense_create(request):
|
|||||||
|
|
||||||
expense.save()
|
expense.save()
|
||||||
|
|
||||||
receipt = request.FILES.get('receipt')
|
receipts = request.FILES.getlist('receipt')
|
||||||
if receipt:
|
if receipts:
|
||||||
from .models import Document
|
from .models import Document
|
||||||
Document.objects.create(expense=expense, file=receipt, title=receipt.name, uploaded_by=request.user)
|
for f in receipts:
|
||||||
|
Document.objects.create(expense=expense, file=f, title=f.name, uploaded_by=request.user)
|
||||||
|
|
||||||
AuditLog.objects.create(
|
AuditLog.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
@@ -142,7 +145,12 @@ def expense_create(request):
|
|||||||
return redirect('expense_list')
|
return redirect('expense_list')
|
||||||
else:
|
else:
|
||||||
form = ExpenseForm()
|
form = ExpenseForm()
|
||||||
return render(request, 'expenses/expense_form.html', {'form': form})
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'regions_communes_json': json.dumps(REGIONS_COMMUNES)
|
||||||
|
}
|
||||||
|
return render(request, 'expenses/expense_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -172,11 +180,11 @@ def expense_edit(request, pk):
|
|||||||
|
|
||||||
exp.save()
|
exp.save()
|
||||||
|
|
||||||
receipt = request.FILES.get('receipt')
|
receipts = request.FILES.getlist('receipt')
|
||||||
if receipt:
|
if receipts:
|
||||||
from .models import Document
|
from .models import Document
|
||||||
# Optionally delete old receipt: Document.objects.filter(expense=exp).delete()
|
for f in receipts:
|
||||||
Document.objects.create(expense=exp, file=receipt, title=receipt.name, uploaded_by=request.user)
|
Document.objects.create(expense=exp, file=f, title=f.name, uploaded_by=request.user)
|
||||||
|
|
||||||
AuditLog.objects.create(
|
AuditLog.objects.create(
|
||||||
user=request.user,
|
user=request.user,
|
||||||
@@ -192,7 +200,12 @@ def expense_edit(request, pk):
|
|||||||
return redirect('expense_list')
|
return redirect('expense_list')
|
||||||
else:
|
else:
|
||||||
form = ExpenseForm(instance=expense)
|
form = ExpenseForm(instance=expense)
|
||||||
return render(request, 'expenses/expense_form.html', {'form': form})
|
|
||||||
|
context = {
|
||||||
|
'form': form,
|
||||||
|
'regions_communes_json': json.dumps(REGIONS_COMMUNES)
|
||||||
|
}
|
||||||
|
return render(request, 'expenses/expense_form.html', context)
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
@@ -592,3 +605,30 @@ def category_delete(request, pk):
|
|||||||
category.delete()
|
category.delete()
|
||||||
messages.success(request, 'Categoría eliminada.')
|
messages.success(request, 'Categoría eliminada.')
|
||||||
return redirect('gestion_panel')
|
return redirect('gestion_panel')
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
def delete_document(request, pk):
|
||||||
|
if request.method == 'POST':
|
||||||
|
doc = get_object_or_404(Document, pk=pk)
|
||||||
|
|
||||||
|
# Permissions check
|
||||||
|
user_groups = request.user.groups.values_list('name', flat=True)
|
||||||
|
if not (request.user.is_superuser or 'Administrador' in user_groups or doc.expense.user == request.user):
|
||||||
|
return JsonResponse({'success': False, 'error': 'No tienes permisos para eliminar este documento.'}, status=403)
|
||||||
|
|
||||||
|
# Status check
|
||||||
|
if doc.expense.approval_status in [ApprovalStatusEnum.SOLICITADO_FINANZAS, ApprovalStatusEnum.LIBERADO_PAGADO]:
|
||||||
|
return JsonResponse({'success': False, 'error': 'No puedes eliminar evidencias de un gasto en finanzas o pagado.'}, status=400)
|
||||||
|
|
||||||
|
# Delete file
|
||||||
|
if doc.file:
|
||||||
|
try:
|
||||||
|
if os.path.exists(doc.file.path):
|
||||||
|
os.remove(doc.file.path)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Delete object
|
||||||
|
doc.delete()
|
||||||
|
return JsonResponse({'success': True})
|
||||||
|
return JsonResponse({'success': False, 'error': 'Método no permitido.'}, status=405)
|
||||||
|
|||||||
+56
-11
@@ -14,9 +14,27 @@ def run_seed():
|
|||||||
# This might cascade to expenses, which is expected for seeding a clean MVP/dev environment
|
# This might cascade to expenses, which is expected for seeding a clean MVP/dev environment
|
||||||
Project.objects.all().delete()
|
Project.objects.all().delete()
|
||||||
|
|
||||||
print("Seeding Portfolios and Projects...")
|
print("Seeding Root Cost Centers, Portfolios and Projects...")
|
||||||
|
|
||||||
# Portfolio definitions: name and number of projects
|
# 1. Create Root Cost Centers (Level 1)
|
||||||
|
cc_idi = Project.objects.create(
|
||||||
|
code="CC-IDI",
|
||||||
|
name="Centro de Costo I+D+i",
|
||||||
|
description="Centro de costo principal para Investigación, Desarrollo e Innovación tecnológica.",
|
||||||
|
assigned_budget_clp=350000000.00,
|
||||||
|
parent=None
|
||||||
|
)
|
||||||
|
cc_adf = Project.objects.create(
|
||||||
|
code="CC-ADF",
|
||||||
|
name="Centro de Costo Administración y Finanzas",
|
||||||
|
description="Centro de costo corporativo para Administración, Finanzas y Overhead general.",
|
||||||
|
assigned_budget_clp=150000000.00,
|
||||||
|
parent=None
|
||||||
|
)
|
||||||
|
print(f"Created Root Cost Center: {cc_idi.code} - {cc_idi.name}")
|
||||||
|
print(f"Created Root Cost Center: {cc_adf.code} - {cc_adf.name}")
|
||||||
|
|
||||||
|
# 2. Portfolio definitions (Level 2) - under CC-IDI
|
||||||
portfolio_def = {
|
portfolio_def = {
|
||||||
"Portafolio 1": 5,
|
"Portafolio 1": 5,
|
||||||
"Portafolio 2": 4,
|
"Portafolio 2": 4,
|
||||||
@@ -30,29 +48,56 @@ def run_seed():
|
|||||||
for index, (port_name, num_projects) in enumerate(portfolio_def.items(), 1):
|
for index, (port_name, num_projects) in enumerate(portfolio_def.items(), 1):
|
||||||
port_code = f"PORT-{index:03d}"
|
port_code = f"PORT-{index:03d}"
|
||||||
|
|
||||||
# Create portfolio (parent)
|
# Create portfolio under CC-IDI
|
||||||
portfolio = Project.objects.create(
|
portfolio = Project.objects.create(
|
||||||
code=port_code,
|
code=port_code,
|
||||||
name=port_name,
|
name=port_name,
|
||||||
description=f"Portafolio principal para agrupar {num_projects} proyectos contables.",
|
description=f"Portafolio tecnológico para agrupar {num_projects} proyectos.",
|
||||||
assigned_budget_clp=50000000.00, # 50M base portfolio budget
|
assigned_budget_clp=50000000.00,
|
||||||
parent=None
|
parent=cc_idi
|
||||||
)
|
)
|
||||||
print(f"Created Portfolio: {portfolio.code} - {portfolio.name}")
|
print(f" Created Portfolio: {portfolio.code} - {portfolio.name} (under CC-IDI)")
|
||||||
|
|
||||||
# Create subprojects
|
# Create subprojects (Level 3)
|
||||||
for sub_idx in range(1, num_projects + 1):
|
for sub_idx in range(1, num_projects + 1):
|
||||||
sub_code = f"PRY-{index}{sub_idx:02d}"
|
sub_code = f"PRY-{index}{sub_idx:02d}"
|
||||||
subproject = Project.objects.create(
|
subproject = Project.objects.create(
|
||||||
code=sub_code,
|
code=sub_code,
|
||||||
name=f"Proyecto {sub_idx} ({port_name})",
|
name=f"Proyecto {sub_idx} ({port_name})",
|
||||||
description=f"Subproyecto contable imputable de {port_name}.",
|
description=f"Subproyecto contable imputable de {port_name}.",
|
||||||
assigned_budget_clp=10000000.00, # 10M base subproject budget
|
assigned_budget_clp=10000000.00,
|
||||||
parent=portfolio
|
parent=portfolio
|
||||||
)
|
)
|
||||||
print(f" Created Project: {subproject.code} - {subproject.name}")
|
print(f" Created Project: {subproject.code} - {subproject.name}")
|
||||||
|
|
||||||
print("✅ Portfolios and Projects seeding completed successfully.")
|
# 3. Create Portfolios and Projects under CC-ADF
|
||||||
|
port_adm = Project.objects.create(
|
||||||
|
code="PORT-008",
|
||||||
|
name="Operaciones y Finanzas",
|
||||||
|
description="Portafolio administrativo para gastos fijos, soporte y legal.",
|
||||||
|
assigned_budget_clp=80000000.00,
|
||||||
|
parent=cc_adf
|
||||||
|
)
|
||||||
|
print(f" Created Portfolio: {port_adm.code} - {port_adm.name} (under CC-ADF)")
|
||||||
|
|
||||||
|
pry_adm = Project.objects.create(
|
||||||
|
code="PRY-801",
|
||||||
|
name="Sueldos y Overhead Administrativo",
|
||||||
|
description="Gasto operacional fijo de oficina, contabilidad y sueldos staff core.",
|
||||||
|
assigned_budget_clp=40000000.00,
|
||||||
|
parent=port_adm
|
||||||
|
)
|
||||||
|
pry_fin = Project.objects.create(
|
||||||
|
code="PRY-802",
|
||||||
|
name="Servicios Financieros y Legal",
|
||||||
|
description="Gastos de auditoría externa, patentes y asesoría legal B2B.",
|
||||||
|
assigned_budget_clp=40000000.00,
|
||||||
|
parent=port_adm
|
||||||
|
)
|
||||||
|
print(f" Created Project: {pry_adm.code} - {pry_adm.name}")
|
||||||
|
print(f" Created Project: {pry_fin.code} - {pry_fin.name}")
|
||||||
|
|
||||||
|
print("✅ Root Cost Centers, Portfolios and Projects seeding completed successfully.")
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
run_seed()
|
run_seed()
|
||||||
|
|||||||
@@ -72,6 +72,12 @@
|
|||||||
{{ form.currency }}
|
{{ form.currency }}
|
||||||
{% if form.currency.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.currency.errors }}</div>{% endif %}
|
{% if form.currency.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.currency.errors }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="{{ form.responsible_person.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Responsable Solicitante</label>
|
||||||
|
{{ form.responsible_person }}
|
||||||
|
{% 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>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
|
||||||
@@ -94,15 +100,29 @@
|
|||||||
{% 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: 1rem; 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 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 (Boleta / Factura)</label>
|
<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 }}
|
{{ form.receipt }}
|
||||||
<small style="color: var(--efintel-gray); font-size: 0.7rem; display:block; margin-top:0.3rem;">Formatos aceptados: Imágenes (.png, .jpg) o Documentos PDF.</small>
|
<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>
|
||||||
{% 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.first %}
|
{% if form.instance.pk and form.instance.documents.all %}
|
||||||
<div style="margin-top:0.8rem; font-size:0.8rem;">
|
<div style="margin-top: 1rem; border-top: 1px solid #e2e8f0; padding-top: 0.75rem;">
|
||||||
<strong>Evidencia Actual:</strong> <a href="{{ form.instance.documents.first.file.url }}" target="_blank" style="color:var(--efintel-blue);">Ver Archivo Adjunto</a>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -134,8 +154,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Chilean regions JSON data -->
|
||||||
|
<script id="regions-communes-data" type="application/json">
|
||||||
|
{{ regions_communes_json|safe }}
|
||||||
|
</script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
document.querySelectorAll('input, select, textarea').forEach(el => {
|
document.querySelectorAll('input:not([type="checkbox"]), select, textarea').forEach(el => {
|
||||||
el.style.width = '100%';
|
el.style.width = '100%';
|
||||||
el.style.padding = '0.65rem';
|
el.style.padding = '0.65rem';
|
||||||
el.style.border = '1px solid var(--efintel-border)';
|
el.style.border = '1px solid var(--efintel-border)';
|
||||||
@@ -177,10 +202,8 @@
|
|||||||
const dropdownList = document.getElementById('category-dropdown-list');
|
const dropdownList = document.getElementById('category-dropdown-list');
|
||||||
|
|
||||||
if (categorySelect && searchInput && dropdownList) {
|
if (categorySelect && searchInput && dropdownList) {
|
||||||
// Hide original select (make it display none so form data is still submitted)
|
|
||||||
categorySelect.style.display = 'none';
|
categorySelect.style.display = 'none';
|
||||||
|
|
||||||
// Extract options from the django-generated select
|
|
||||||
const options = Array.from(categorySelect.options)
|
const options = Array.from(categorySelect.options)
|
||||||
.filter(opt => opt.value !== '')
|
.filter(opt => opt.value !== '')
|
||||||
.map(opt => ({
|
.map(opt => ({
|
||||||
@@ -188,7 +211,6 @@
|
|||||||
text: opt.textContent
|
text: opt.textContent
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Render option list dynamically
|
|
||||||
function renderList(filteredOptions) {
|
function renderList(filteredOptions) {
|
||||||
dropdownList.innerHTML = '';
|
dropdownList.innerHTML = '';
|
||||||
if (filteredOptions.length === 0) {
|
if (filteredOptions.length === 0) {
|
||||||
@@ -200,66 +222,68 @@
|
|||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'dropdown-item';
|
item.className = 'dropdown-item';
|
||||||
item.style.padding = '0.65rem 1rem';
|
item.style.padding = '0.65rem 1rem';
|
||||||
item.style.cursor = 'pointer';
|
|
||||||
item.style.fontSize = '0.85rem';
|
item.style.fontSize = '0.85rem';
|
||||||
item.style.borderBottom = '1px solid #f1f5f9';
|
item.style.borderBottom = '1px solid #f1f5f9';
|
||||||
item.textContent = opt.text;
|
item.textContent = opt.text;
|
||||||
|
|
||||||
// Mark parent categories differently (they usually have a badge in representation or just end with standard code)
|
// Check if this is a parent category (1000, 2000, 3000, 4000)
|
||||||
// Note: we can detect if it's a main partida (ends in '000: ' or contains 'Partida')
|
|
||||||
const isParent = opt.text.includes('1000: ') || opt.text.includes('2000: ') || opt.text.includes('3000: ') || opt.text.includes('4000: ');
|
const isParent = opt.text.includes('1000: ') || opt.text.includes('2000: ') || opt.text.includes('3000: ') || opt.text.includes('4000: ');
|
||||||
if (isParent) {
|
if (isParent) {
|
||||||
item.style.fontWeight = '700';
|
item.style.fontWeight = '700';
|
||||||
item.style.background = '#f8fafc';
|
item.style.background = '#f1f5f9';
|
||||||
item.style.color = 'var(--efintel-petroleum)';
|
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';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = isParent ? '#f8fafc' : 'white';
|
|
||||||
item.style.color = isParent ? 'var(--efintel-petroleum)' : 'inherit';
|
|
||||||
});
|
|
||||||
|
|
||||||
dropdownList.appendChild(item);
|
dropdownList.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize input value if edit mode
|
|
||||||
const selectedOption = categorySelect.options[categorySelect.selectedIndex];
|
const selectedOption = categorySelect.options[categorySelect.selectedIndex];
|
||||||
if (selectedOption && selectedOption.value !== '') {
|
if (selectedOption && selectedOption.value !== '') {
|
||||||
searchInput.value = selectedOption.textContent;
|
searchInput.value = selectedOption.textContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show dropdown list on focus
|
|
||||||
searchInput.addEventListener('focus', () => {
|
searchInput.addEventListener('focus', () => {
|
||||||
renderList(options);
|
renderList(options);
|
||||||
dropdownList.style.display = 'block';
|
dropdownList.style.display = 'block';
|
||||||
});
|
});
|
||||||
|
|
||||||
// Dynamic filtering
|
|
||||||
searchInput.addEventListener('input', (e) => {
|
searchInput.addEventListener('input', (e) => {
|
||||||
const query = e.target.value.toLowerCase();
|
const query = e.target.value.toLowerCase();
|
||||||
const filtered = options.filter(opt => opt.text.toLowerCase().includes(query));
|
const filtered = options.filter(opt => opt.text.toLowerCase().includes(query));
|
||||||
renderList(filtered);
|
renderList(filtered);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close dropdown when clicking outside
|
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', (e) => {
|
||||||
if (!searchInput.contains(e.target) && !dropdownList.contains(e.target)) {
|
if (!searchInput.contains(e.target) && !dropdownList.contains(e.target)) {
|
||||||
dropdownList.style.display = 'none';
|
dropdownList.style.display = 'none';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom Searchable Dropdown for Project
|
// Custom Searchable Dropdown for Project
|
||||||
const projectSelect = document.getElementById('id_project');
|
const projectSelect = document.getElementById('id_project');
|
||||||
const projectSearchInput = document.getElementById('project-search-input');
|
const projectSearchInput = document.getElementById('project-search-input');
|
||||||
@@ -286,34 +310,41 @@
|
|||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'dropdown-item';
|
item.className = 'dropdown-item';
|
||||||
item.style.padding = '0.65rem 1rem';
|
item.style.padding = '0.65rem 1rem';
|
||||||
item.style.cursor = 'pointer';
|
|
||||||
item.style.fontSize = '0.85rem';
|
item.style.fontSize = '0.85rem';
|
||||||
item.style.borderBottom = '1px solid #f1f5f9';
|
item.style.borderBottom = '1px solid #f1f5f9';
|
||||||
item.textContent = opt.text;
|
item.textContent = opt.text;
|
||||||
|
|
||||||
|
// Check if this is a Portfolio (starts with PORT-). Cost Centers (CC-) are selectable.
|
||||||
const isPortfolio = opt.text.startsWith('PORT-');
|
const isPortfolio = opt.text.startsWith('PORT-');
|
||||||
if (isPortfolio) {
|
if (isPortfolio) {
|
||||||
item.style.fontWeight = '700';
|
item.style.fontWeight = '700';
|
||||||
item.style.background = '#f8fafc';
|
item.style.background = '#f1f5f9';
|
||||||
item.style.color = 'var(--efintel-petroleum)';
|
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';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = isPortfolio ? '#f8fafc' : 'white';
|
|
||||||
item.style.color = isPortfolio ? 'var(--efintel-petroleum)' : 'inherit';
|
|
||||||
});
|
|
||||||
|
|
||||||
projectDropdownList.appendChild(item);
|
projectDropdownList.appendChild(item);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -340,5 +371,148 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const fileInput = document.getElementById('id_receipt');
|
||||||
|
const newFilesList = document.getElementById('new-files-list');
|
||||||
|
|
||||||
|
if (fileInput && newFilesList) {
|
||||||
|
let dt = new DataTransfer();
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user