Feat: dashboard con gráficos, estructura CBS/Portafolios, semáforo de proyectos y cartera de especialistas

This commit is contained in:
Nachou-u
2026-08-20 18:54:58 -04:00
parent 8c0b435555
commit b692614857
25 changed files with 1717 additions and 159 deletions
+3
View File
@@ -38,6 +38,7 @@ INSTALLED_APPS = [
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django.contrib.humanize',
'channels',
'expenses',
]
@@ -122,6 +123,8 @@ USE_I18N = True
USE_TZ = True
USE_THOUSAND_SEPARATOR = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.1/howto/static-files/
+1 -1
View File
@@ -8,7 +8,7 @@ class ProjectAdmin(admin.ModelAdmin):
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('code', 'name', 'parent')
list_display = ('code', 'name')
search_fields = ('code', 'name')
@admin.register(Expense)
+42 -3
View File
@@ -1,6 +1,6 @@
from django import forms
from django.contrib.auth.models import User, Group
from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum
from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum, Vendor
class ExpenseForm(forms.ModelForm):
@@ -21,10 +21,22 @@ class ExpenseForm(forms.ModelForm):
widget=forms.FileInput(attrs={'accept': 'image/*,.pdf'})
)
tax_amount_clp = forms.DecimalField(
label='Monto Impuesto (CLP)',
required=False,
widget=forms.NumberInput(attrs={'readonly': 'readonly'})
)
total_amount_clp = forms.DecimalField(
label='Monto Total (CLP)',
required=False,
widget=forms.NumberInput(attrs={'readonly': 'readonly'})
)
class Meta:
model = Expense
fields = [
'project', 'category', 'amount_clp', 'currency', 'date',
'project', 'category', 'amount_clp', 'tax_type', 'tax_amount_clp', 'total_amount_clp', 'currency', 'date',
'description', 'vendor', 'region', 'comuna', 'vehicle_ownership'
]
widgets = {
@@ -35,12 +47,27 @@ class ExpenseForm(forms.ModelForm):
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
fields = ['code', 'name', 'type', 'description', 'manager', 'assigned_budget_clp']
fields = ['code', 'name', 'parent', 'category', 'status', 'type', 'description', 'manager', 'assigned_budget_clp']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class CategoryForm(forms.ModelForm):
class Meta:
model = Category
fields = ['code', 'name', 'parent', 'description']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class VendorForm(forms.ModelForm):
class Meta:
model = Vendor
fields = ['rut', 'name', 'business_line', 'bank', 'account_type', 'account_number', 'email', 'payment_terms']
class SystemSettingForm(forms.ModelForm):
class Meta:
model = SystemSetting
@@ -57,6 +84,12 @@ class ResponsableForm(forms.Form):
rut = forms.CharField(max_length=20, label="RUT", required=False)
job_title = forms.CharField(max_length=150, label="Cargo", required=False)
description = forms.CharField(widget=forms.Textarea(attrs={'rows': 3}), label="Descripción / Bio", required=False)
specialties = forms.ModelMultipleChoiceField(
queryset=Category.objects.all(),
required=False,
widget=forms.CheckboxSelectMultiple(),
label="Especialidades Técnicas (Categorías)"
)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop('instance', None)
@@ -73,6 +106,7 @@ class ResponsableForm(forms.Form):
self.fields['description'].initial = self.instance.profile.description
self.fields['rut'].initial = self.instance.profile.rut
self.fields['job_title'].initial = self.instance.profile.job_title
self.fields['specialties'].initial = self.instance.profile.specialties.all()
except Exception:
pass
# Password is optional for edit
@@ -124,6 +158,11 @@ class ResponsableForm(forms.Form):
profile.job_title = job_title
profile.save()
# Save specialties
specialties = self.cleaned_data.get('specialties')
if specialties is not None:
profile.specialties.set(specialties)
return user
@@ -0,0 +1,60 @@
# Generated by Django 6.1 on 2026-08-19 22:57
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Vendor',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rut', models.CharField(max_length=15, unique=True, verbose_name='RUT Proveedor')),
('name', models.CharField(max_length=200, verbose_name='Razón Social / Nombre')),
('business_line', models.CharField(blank=True, max_length=200, null=True, verbose_name='Giro Comercial')),
('bank', models.CharField(blank=True, max_length=100, null=True, verbose_name='Banco')),
('account_type', models.CharField(blank=True, max_length=50, null=True, verbose_name='Tipo de Cuenta')),
('account_number', models.CharField(blank=True, max_length=50, null=True, verbose_name='Número de Cuenta')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='Correo de Pago/Facturación')),
('payment_terms', models.IntegerField(default=30, verbose_name='Plazo de Pago (Días)')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Proveedor',
'verbose_name_plural': 'Proveedores',
'ordering': ['name'],
},
),
migrations.AddField(
model_name='expense',
name='tax_amount_clp',
field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Impuesto (CLP)'),
),
migrations.AddField(
model_name='expense',
name='tax_type',
field=models.CharField(choices=[('EXENTO', 'Exento / No Afecto'), ('IVA_19', 'Afecto a IVA (19%)'), ('HONORARIOS_13_75', 'Boleta de Honorarios (13.75%)')], default='IVA_19', max_length=20, verbose_name='Tipo de Impuesto'),
),
migrations.AddField(
model_name='expense',
name='total_amount_clp',
field=models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Monto Total (CLP)'),
),
migrations.AlterField(
model_name='expense',
name='amount_usd',
field=models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto Total (USD)'),
),
migrations.AlterField(
model_name='expense',
name='vendor',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses', to='expenses.vendor', verbose_name='Proveedor / Emisor'),
),
]
@@ -0,0 +1,17 @@
# Generated by Django 6.1 on 2026-08-19 23:47
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('expenses', '0002_vendor_expense_tax_amount_clp_expense_tax_type_and_more'),
]
operations = [
migrations.RemoveField(
model_name='category',
name='parent',
),
]
@@ -0,0 +1,19 @@
# Generated by Django 6.1 on 2026-08-20 15:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0003_remove_category_parent'),
]
operations = [
migrations.AddField(
model_name='category',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='expenses.category', verbose_name='Categoría Padre'),
),
]
@@ -0,0 +1,19 @@
# Generated by Django 6.1 on 2026-08-20 22:30
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0004_category_parent'),
]
operations = [
migrations.AddField(
model_name='project',
name='parent',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='expenses.project', verbose_name='Portafolio / Proyecto Padre'),
),
]
@@ -0,0 +1,29 @@
# Generated by Django 6.1 on 2026-08-20 22:44
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('expenses', '0005_project_parent'),
]
operations = [
migrations.AddField(
model_name='project',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projects', to='expenses.category', verbose_name='Categoría del Proyecto'),
),
migrations.AddField(
model_name='userprofile',
name='specialties',
field=models.ManyToManyField(blank=True, related_name='specialists', to='expenses.category', verbose_name='Especialidades / Habilidades'),
),
migrations.AlterField(
model_name='project',
name='status',
field=models.CharField(choices=[('ACTIVO', 'Activo'), ('PAUSADA', 'En Pausa'), ('TERMINADO', 'Terminado')], default='ACTIVO', max_length=50, verbose_name='Estado de Proyecto'),
),
]
+45 -5
View File
@@ -7,6 +7,11 @@ class CurrencyEnum(models.TextChoices):
CLP = 'CLP', 'Pesos Chilenos'
USD = 'USD', 'Dólares Estadounidenses'
class TaxTypeEnum(models.TextChoices):
EXENTO = 'EXENTO', 'Exento / No Afecto'
IVA_19 = 'IVA_19', 'Afecto a IVA (19%)'
HONORARIOS = 'HONORARIOS_13_75', 'Boleta de Honorarios (13.75%)'
class ExpenseTypeEnum(models.TextChoices):
COSTO_DIRECTO = 'COSTO_DIRECTO_PROYECTO', 'Costo Directo Proyecto'
GASTO_OPERACIONAL = 'GASTO_OPERACIONAL_OVERHEAD', 'Gasto Operacional Overhead'
@@ -17,6 +22,11 @@ class ApprovalStatusEnum(models.TextChoices):
LIBERADO_PAGADO = 'LIBERADO_PAGADO', 'Pago Liberado (Paso 3)'
OBSERVADO_RECHAZADO = 'OBSERVADO_RECHAZADO', 'Observado / Rechazado'
class ProjectStatusEnum(models.TextChoices):
ACTIVO = 'ACTIVO', 'Activo'
PAUSADA = 'PAUSADA', 'En Pausa'
TERMINADO = 'TERMINADO', 'Terminado'
class Project(models.Model):
# Added core project/costcenter fields mapped from Prisma
code = models.CharField(max_length=50, unique=True, verbose_name="Código", default='PROY-000')
@@ -24,13 +34,15 @@ class Project(models.Model):
description = models.TextField(blank=True, verbose_name="Descripción")
manager = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='managed_projects', verbose_name="Encargado")
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Portafolio / Proyecto Padre")
assigned_budget_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (CLP)")
assigned_budget_usd = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Presupuesto Asignado (USD)")
actual_cost_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Costo Real Acumulado (CLP)")
type = models.CharField(max_length=50, choices=ExpenseTypeEnum.choices, default=ExpenseTypeEnum.COSTO_DIRECTO, verbose_name="Tipo de Centro")
status = models.CharField(max_length=50, default="on-budget", verbose_name="Estado de Presupuesto")
status = models.CharField(max_length=50, choices=ProjectStatusEnum.choices, default=ProjectStatusEnum.ACTIVO, verbose_name="Estado de Proyecto")
category = models.ForeignKey('Category', on_delete=models.SET_NULL, null=True, blank=True, related_name='projects', verbose_name="Categoría del Proyecto")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Creación")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación")
@@ -44,11 +56,10 @@ class Project(models.Model):
verbose_name_plural = "Proyectos y Centros"
class Category(models.Model):
# Category / Subcategory hierarchy
code = models.CharField(max_length=10, unique=True, verbose_name="Código de Categoría")
name = models.CharField(max_length=100, verbose_name="Nombre de Categoría")
description = models.TextField(blank=True, verbose_name="Descripción")
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='subcategories')
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children', verbose_name="Categoría Padre")
def __str__(self):
return f"{self.code}: {self.name}"
@@ -57,6 +68,30 @@ class Category(models.Model):
verbose_name = "Categoría"
verbose_name_plural = "Categorías"
class Vendor(models.Model):
rut = models.CharField(max_length=15, unique=True, verbose_name="RUT Proveedor")
name = models.CharField(max_length=200, verbose_name="Razón Social / Nombre")
business_line = models.CharField(max_length=200, blank=True, null=True, verbose_name="Giro Comercial")
# Banking details
bank = models.CharField(max_length=100, blank=True, null=True, verbose_name="Banco")
account_type = models.CharField(max_length=50, blank=True, null=True, verbose_name="Tipo de Cuenta")
account_number = models.CharField(max_length=50, blank=True, null=True, verbose_name="Número de Cuenta")
email = models.EmailField(blank=True, null=True, verbose_name="Correo de Pago/Facturación")
payment_terms = models.IntegerField(default=30, verbose_name="Plazo de Pago (Días)")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.rut} - {self.name}"
class Meta:
verbose_name = "Proveedor"
verbose_name_plural = "Proveedores"
ordering = ['name']
class Expense(models.Model):
expense_code = models.CharField(max_length=50, unique=True, default=uuid.uuid4, verbose_name="Código Transacción")
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name='expenses', verbose_name="Proyecto")
@@ -64,14 +99,18 @@ class Expense(models.Model):
# Financial fields
amount_clp = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Neto (CLP)")
amount_usd = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Neto (USD)")
tax_type = models.CharField(max_length=20, choices=TaxTypeEnum.choices, default=TaxTypeEnum.IVA_19, verbose_name="Tipo de Impuesto")
tax_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Impuesto (CLP)")
total_amount_clp = models.DecimalField(max_digits=15, decimal_places=2, default=0, verbose_name="Monto Total (CLP)")
amount_usd = models.DecimalField(max_digits=15, decimal_places=2, verbose_name="Monto Total (USD)")
currency = models.CharField(max_length=10, choices=CurrencyEnum.choices, default=CurrencyEnum.CLP, verbose_name="Moneda Original")
exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tipo de Cambio")
description = models.TextField(verbose_name="Detalle de Imputación")
date = models.DateField(verbose_name="Fecha del Gasto")
vendor = models.CharField(max_length=200, blank=True, null=True, verbose_name="Proveedor / Emisor")
vendor = models.ForeignKey(Vendor, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Proveedor / Emisor")
# Geographic & Logistic fields (Operaciones en terreno)
region = models.CharField(max_length=100, blank=True, null=True, verbose_name="Región (Chile)")
@@ -120,6 +159,7 @@ class UserProfile(models.Model):
rut = models.CharField(max_length=20, blank=True, null=True, verbose_name="RUT")
job_title = models.CharField(max_length=150, blank=True, null=True, verbose_name="Cargo")
description = models.TextField(blank=True, verbose_name="Descripción del Responsable")
specialties = models.ManyToManyField('Category', blank=True, related_name='specialists', verbose_name="Especialidades / Habilidades")
created_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Registro")
updated_at = models.DateTimeField(auto_now=True, verbose_name="Última Modificación")
+10 -1
View File
@@ -11,7 +11,8 @@ urlpatterns = [
path('gastos/<int:pk>/editar/', views.expense_edit, name='expense_edit'),
path('gastos/<int:pk>/eliminar/', views.expense_delete, name='expense_delete'),
# Flujo de aprobación
# Flujo de aprobación y Cuentas por Pagar
path('gastos/cuentas-por-pagar/', views.accounts_payable, name='accounts_payable'),
path('gastos/<int:pk>/aprobar/<str:action>/', views.approve_expense, name='approve_expense'),
# Export / Import
@@ -26,6 +27,14 @@ urlpatterns = [
path('gestion/proyectos/<int:pk>/editar/', views.project_edit, name='project_edit'),
path('gestion/proyectos/<int:pk>/eliminar/', views.project_delete, name='project_delete'),
path('gestion/categorias/nuevo/', views.category_create, name='category_create'),
path('gestion/categorias/<int:pk>/editar/', views.category_edit, name='category_edit'),
path('gestion/categorias/<int:pk>/eliminar/', views.category_delete, name='category_delete'),
path('gestion/proveedores/nuevo/', views.vendor_create, name='vendor_create'),
path('gestion/proveedores/<int:pk>/editar/', views.vendor_edit, name='vendor_edit'),
path('gestion/proveedores/<int:pk>/eliminar/', views.vendor_delete, name='vendor_delete'),
path('gestion/responsables/nuevo/', views.responsable_create, name='responsable_create'),
path('gestion/responsables/<int:pk>/editar/', views.responsable_edit, name='responsable_edit'),
path('gestion/responsables/<int:pk>/eliminar/', views.responsable_delete, name='responsable_delete'),
+177 -4
View File
@@ -8,9 +8,10 @@ from django.db.models import Sum, Count, F
from django.utils import timezone
from datetime import date
from django.http import JsonResponse, HttpResponse
import json
from .models import Expense, Project, Category, SystemSetting, UserProfile, AuditLog, ApprovalStatusEnum, CurrencyEnum
from .forms import ExpenseForm, ProjectForm, SystemSettingForm, ResponsableForm
from .models import Expense, Project, Category, SystemSetting, UserProfile, AuditLog, ApprovalStatusEnum, CurrencyEnum, Vendor
from .forms import ExpenseForm, ProjectForm, SystemSettingForm, ResponsableForm, CategoryForm, VendorForm
from .decorators import role_required
from django.contrib.auth.models import User, Group
@@ -58,6 +59,16 @@ def dashboard(request):
global_budget = setting.global_budget
remaining_budget = global_budget - total_all_clp
# Chart Data: Gastos por Categoría
cat_expenses = Expense.objects.values('category__name').annotate(total=Sum('amount_clp')).order_by('-total')
category_labels = [item['category__name'] or 'Sin Categoría' for item in cat_expenses]
category_data = [float(item['total'] or 0) for item in cat_expenses]
# Chart Data: Gastos por Proyecto
proj_expenses = Expense.objects.values('project__name').annotate(total=Sum('amount_clp')).order_by('-total')
project_labels = [item['project__name'] or 'Sin Proyecto' for item in proj_expenses]
project_data = [float(item['total'] or 0) for item in proj_expenses]
context = {
'total_month': total_month_clp,
'total_all': total_all_clp,
@@ -66,6 +77,10 @@ def dashboard(request):
'recent_expenses': recent_expenses,
'global_budget': global_budget,
'remaining_budget': remaining_budget,
'category_labels_json': json.dumps(category_labels),
'category_data_json': json.dumps(category_data),
'project_labels_json': json.dumps(project_labels),
'project_data_json': json.dumps(project_data),
}
return render(request, 'expenses/dashboard.html', context)
@@ -138,6 +153,10 @@ def expense_edit(request, pk):
messages.error(request, 'No tienes permisos para editar este gasto.')
return redirect('expense_list')
if expense.approval_status in [ApprovalStatusEnum.SOLICITADO_FINANZAS, ApprovalStatusEnum.LIBERADO_PAGADO]:
messages.error(request, 'Gasto bloqueado. No puedes editar un comprobante que ya está en finanzas o pagado.')
return redirect('expense_list')
old_amount = expense.amount_clp
if request.method == 'POST':
@@ -146,6 +165,11 @@ def expense_edit(request, pk):
exp = form.save(commit=False)
setting, _ = SystemSetting.objects.get_or_create(id=1)
exp.amount_usd = exp.amount_clp / setting.usd_exchange_rate
# Reset status if it was rejected
if exp.approval_status == ApprovalStatusEnum.OBSERVADO_RECHAZADO:
exp.approval_status = ApprovalStatusEnum.EN_REVISION
exp.save()
receipt = request.FILES.get('receipt')
@@ -179,6 +203,10 @@ def expense_delete(request, pk):
messages.error(request, 'Solo administradores pueden eliminar gastos.')
return redirect('expense_list')
if expense.approval_status in [ApprovalStatusEnum.SOLICITADO_FINANZAS, ApprovalStatusEnum.LIBERADO_PAGADO]:
messages.error(request, 'Gasto bloqueado. No puedes eliminar un comprobante que ya está en finanzas o pagado.')
return redirect('expense_list')
if request.method == 'POST':
code = expense.expense_code
expense.delete()
@@ -203,6 +231,10 @@ def approve_expense(request, pk, action):
if action == 'solicitar':
# Step 1 -> 2
if not is_admin and expense.project.manager != request.user:
messages.error(request, "Solo el Administrador o el Encargado del Proyecto pueden solicitar pago a finanzas.")
return redirect('expense_list')
expense.approval_status = ApprovalStatusEnum.SOLICITADO_FINANZAS
expense.elevated_by = request.user
expense.save()
@@ -228,7 +260,10 @@ def approve_expense(request, pk, action):
# Update Project Cost
proj = expense.project
if expense.tax_type == TaxTypeEnum.IVA_19:
proj.actual_cost_clp = proj.actual_cost_clp + expense.amount_clp
else:
proj.actual_cost_clp = proj.actual_cost_clp + expense.total_amount_clp
proj.save()
AuditLog.objects.create(
@@ -311,13 +346,17 @@ def project_list(request):
@login_required
@role_required('Administrador')
def gestion_panel(request):
projects = Project.objects.all().select_related('manager', 'created_by')
responsables = User.objects.all().select_related('profile').prefetch_related('groups')
projects = Project.objects.all().select_related('manager', 'created_by', 'parent', 'category')
categories = Category.objects.all().select_related('parent')
vendors = Vendor.objects.all()
responsables = User.objects.all().select_related('profile').prefetch_related('groups', 'profile__specialties')
settings_obj, _ = SystemSetting.objects.get_or_create(id=1)
logs = AuditLog.objects.all().select_related('user')[:100]
context = {
'projects': projects,
'categories': categories,
'vendors': vendors,
'responsables': responsables,
'settings': settings_obj,
'logs': logs,
@@ -381,6 +420,84 @@ def project_delete(request, pk):
return redirect('gestion_panel')
@login_required
@role_required('Administrador')
def category_create(request):
if request.method == 'POST':
form = CategoryForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Categoría creada exitosamente.')
return redirect('gestion_panel')
else:
form = CategoryForm()
return render(request, 'expenses/category_form.html', {'form': form})
@login_required
@role_required('Administrador')
def category_edit(request, pk):
category = get_object_or_404(Category, pk=pk)
if request.method == 'POST':
form = CategoryForm(request.POST, instance=category)
if form.is_valid():
form.save()
messages.success(request, 'Categoría actualizada exitosamente.')
return redirect('gestion_panel')
else:
form = CategoryForm(instance=category)
return render(request, 'expenses/category_form.html', {'form': form})
@login_required
@role_required('Administrador')
def category_delete(request, pk):
category = get_object_or_404(Category, pk=pk)
if request.method == 'POST':
category.delete()
messages.success(request, 'Categoría eliminada.')
return redirect('gestion_panel')
@login_required
@role_required('Administrador')
def vendor_create(request):
if request.method == 'POST':
form = VendorForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Proveedor creado exitosamente.')
return redirect('gestion_panel')
else:
form = VendorForm()
return render(request, 'expenses/vendor_form.html', {'form': form})
@login_required
@role_required('Administrador')
def vendor_edit(request, pk):
vendor = get_object_or_404(Vendor, pk=pk)
if request.method == 'POST':
form = VendorForm(request.POST, instance=vendor)
if form.is_valid():
form.save()
messages.success(request, 'Proveedor actualizado exitosamente.')
return redirect('gestion_panel')
else:
form = VendorForm(instance=vendor)
return render(request, 'expenses/vendor_form.html', {'form': form})
@login_required
@role_required('Administrador')
def vendor_delete(request, pk):
vendor = get_object_or_404(Vendor, pk=pk)
if request.method == 'POST':
vendor.delete()
messages.success(request, 'Proveedor eliminado.')
return redirect('gestion_panel')
@login_required
@role_required('Administrador')
def responsable_create(request):
@@ -419,3 +536,59 @@ def responsable_delete(request, pk):
messages.success(request, 'Responsable eliminado.')
return redirect('gestion_panel')
from django.db.models import Sum, Count
@login_required
def accounts_payable(request):
user_groups = request.user.groups.values_list('name', flat=True)
is_finanzas = 'Administrador Finanzas' in user_groups or request.user.is_superuser or 'Administrador' in user_groups
if not is_finanzas:
messages.error(request, 'No tienes permisos para ver Cuentas por Pagar.')
return redirect('dashboard')
debts = Expense.objects.filter(approval_status=ApprovalStatusEnum.SOLICITADO_FINANZAS).exclude(vendor__isnull=True).values(
'vendor__id', 'vendor__rut', 'vendor__name', 'vendor__bank', 'vendor__account_type', 'vendor__account_number'
).annotate(
total_clp=Sum('total_amount_clp'),
total_usd=Sum('amount_usd'),
expense_count=Count('id')
).order_by('-total_clp')
return render(request, 'expenses/accounts_payable.html', {'debts': debts})
@login_required
@role_required('Administrador')
def category_create(request):
if request.method == 'POST':
form = CategoryForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Categoría creada exitosamente.')
return redirect('gestion_panel')
else:
form = CategoryForm()
return render(request, 'expenses/category_form.html', {'form': form})
@login_required
@role_required('Administrador')
def category_edit(request, pk):
category = get_object_or_404(Category, pk=pk)
if request.method == 'POST':
form = CategoryForm(request.POST, instance=category)
if form.is_valid():
form.save()
messages.success(request, 'Categoría actualizada exitosamente.')
return redirect('gestion_panel')
else:
form = CategoryForm(instance=category)
return render(request, 'expenses/category_form.html', {'form': form})
@login_required
@role_required('Administrador')
def category_delete(request, pk):
category = get_object_or_404(Category, pk=pk)
if request.method == 'POST':
category.delete()
messages.success(request, 'Categoría eliminada.')
return redirect('gestion_panel')
+84
View File
@@ -0,0 +1,84 @@
import os
import django
import sys
# Set up Django environment
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from expenses.models import Category
def run_seed():
print("Clearing existing categories to ensure clean state...")
Category.objects.all().delete()
print("Seeding professional CBS categories...")
# Define the structure
cbs_structure = {
"1000": {
"name": "Recursos Humanos y Especialidades",
"children": {
"1100": "Personal Staff Core (Fijo / Sueldos Base)",
"1201": "Electricidad de Potencia e Infraestructura",
"1202": "Automatización y Control Industrial",
"1203": "Domótica e Inmótica (BMS & Climatización)",
"1204": "Electrónica y Sistemas de Hardware (PCB / Firmware)",
"1205": "Redes, Conectividad e Informática (TI & Ciberseguridad)",
"1206": "Tecnologías Inmersivas y Gemelos Digitales (VR/AR/LiDAR)",
"1207": "Gestión Administrativa, Dir. de Proyectos (PMP/Agile)"
}
},
"2000": {
"name": "Materiales e Insumos",
"children": {
"2100": "Componentes Electrónicos Prototipado",
"2200": "Equipamiento Industrial de Campo",
"2300": "Materiales Eléctricos y de Montaje",
"2400": "Insumos de Oficina, Aseo, Cafetería y Papelería"
}
},
"3000": {
"name": "Logística y Desplazamiento Terreno",
"children": {
"3101": "Combustibles / Bencina",
"3102": "Arriendo Vehículos 4x4",
"3103": "Pasajes de Bus Interurbano",
"3104": "Pasajes de Micro / Urbano",
"3105": "Pasajes de Colectivos / Taxis",
"3106": "Uber / Cabify / Didi",
"3107": "Peajes & Tag Nacionales",
"3108": "Reembolso Kilometraje Particular",
"3200": "Viáticos Alimentación y Alojamiento Terreno"
}
},
"4000": {
"name": "Software y Herramientas Digitales",
"children": {
"4100": "Licencias de Ingeniería & Diseño (Altium/Unity/Unreal)",
"4200": "Infraestructura y Servidores Cloud (AWS/Azure)",
"4300": "Herramientas de Gestión de Proyectos / Software General"
}
}
}
# Create Parents and Children
for p_code, p_data in cbs_structure.items():
parent_cat, _ = Category.objects.get_or_create(
code=p_code,
defaults={'name': p_data['name'], 'description': 'Partida Principal (Macro-categoría)'}
)
print(f"Created Parent: {parent_cat.code} - {parent_cat.name}")
for c_code, c_name in p_data['children'].items():
child_cat, _ = Category.objects.get_or_create(
code=c_code,
defaults={'name': c_name, 'parent': parent_cat}
)
print(f" Created Child: {child_cat.code} - {child_cat.name}")
print("✅ CBS Seeding completed successfully.")
if __name__ == "__main__":
run_seed()
+58
View File
@@ -0,0 +1,58 @@
import os
import django
import sys
# Set up Django environment
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from expenses.models import Project
def run_seed():
print("Clearing existing projects to ensure clean portfolio seeding...")
# This might cascade to expenses, which is expected for seeding a clean MVP/dev environment
Project.objects.all().delete()
print("Seeding Portfolios and Projects...")
# Portfolio definitions: name and number of projects
portfolio_def = {
"Portafolio 1": 5,
"Portafolio 2": 4,
"Portafolio 3": 3,
"Portafolio 4": 1,
"Portafolio 5": 1,
"Portafolio 6": 1,
"Portafolio 7": 2,
}
for index, (port_name, num_projects) in enumerate(portfolio_def.items(), 1):
port_code = f"PORT-{index:03d}"
# Create portfolio (parent)
portfolio = Project.objects.create(
code=port_code,
name=port_name,
description=f"Portafolio principal para agrupar {num_projects} proyectos contables.",
assigned_budget_clp=50000000.00, # 50M base portfolio budget
parent=None
)
print(f"Created Portfolio: {portfolio.code} - {portfolio.name}")
# Create subprojects
for sub_idx in range(1, num_projects + 1):
sub_code = f"PRY-{index}{sub_idx:02d}"
subproject = Project.objects.create(
code=sub_code,
name=f"Proyecto {sub_idx} ({port_name})",
description=f"Subproyecto contable imputable de {port_name}.",
assigned_budget_clp=10000000.00, # 10M base subproject budget
parent=portfolio
)
print(f" Created Project: {subproject.code} - {subproject.name}")
print("✅ Portfolios and Projects seeding completed successfully.")
if __name__ == "__main__":
run_seed()
+63
View File
@@ -0,0 +1,63 @@
import os
import django
import sys
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth.models import User
from expenses.models import Project, Category, UserProfile
def seed():
print("Seeding project categories and user specialties...")
# Load categories
cat_1201 = Category.objects.filter(code="1201").first()
cat_1202 = Category.objects.filter(code="1202").first()
cat_1203 = Category.objects.filter(code="1203").first()
cat_1204 = Category.objects.filter(code="1204").first()
cat_1205 = Category.objects.filter(code="1205").first()
# Link categories to some projects
projs = Project.objects.filter(parent__isnull=False)
if projs.exists():
# Set category on a few subprojects
mapping = {
"PRY-101": (cat_1201, "ACTIVO"),
"PRY-102": (cat_1202, "PAUSADA"),
"PRY-103": (cat_1203, "ACTIVO"),
"PRY-104": (cat_1204, "TERMINADO"),
"PRY-201": (cat_1201, "ACTIVO"),
"PRY-202": (cat_1202, "ACTIVO"),
"PRY-301": (cat_1205, "PAUSADA"),
}
for code, (cat, status) in mapping.items():
proj = Project.objects.filter(code=code).first()
if proj and cat:
proj.category = cat
proj.status = status
proj.save()
print(f"Set Project {proj.code} Category to {cat.code} and Status to {status}")
# Set specialties on users
miguel = User.objects.filter(username="miguel_contreras").first()
ginevra = User.objects.filter(username="ginevra_ortega").first()
admin = User.objects.filter(username="admin").first()
if miguel and hasattr(miguel, 'profile'):
miguel.profile.specialties.set([cat_1201, cat_1202])
print(f"Set miguel_contreras specialties to 1201, 1202")
if ginevra and hasattr(ginevra, 'profile'):
ginevra.profile.specialties.set([cat_1202, cat_1203, cat_1204])
print(f"Set ginevra_ortega specialties to 1202, 1203, 1204")
if admin and hasattr(admin, 'profile'):
admin.profile.specialties.set([cat_1205])
print(f"Set admin specialties to 1205")
print("✅ Project categories and user specialties seeding completed.")
if __name__ == "__main__":
seed()
+5
View File
@@ -55,6 +55,11 @@
<a href="{% url 'project_list' %}" class="nav-tab {% if request.resolver_match.url_name == 'project_list' %}active{% endif %}">
<i data-lucide="folder-kanban" style="width:16px;"></i> Control de Proyectos
</a>
{% if 'Administrador Finanzas' in request.user.groups.all.0.name or 'Administrador' in request.user.groups.all.0.name or request.user.is_superuser %}
<a href="{% url 'accounts_payable' %}" class="nav-tab {% if request.resolver_match.url_name == 'accounts_payable' %}active{% endif %}">
<i data-lucide="calculator" style="width:16px;"></i> Cuentas por Pagar (CXP)
</a>
{% endif %}
{% if 'Administrador' in request.user.groups.all.0.name or request.user.is_superuser %}
<a href="{% url 'gestion_panel' %}" class="nav-tab {% if request.resolver_match.url_name == 'gestion_panel' %}active{% endif %}" style="margin-left:auto; background:var(--efintel-petroleum); color:white;">
<i data-lucide="settings" style="width:16px;"></i> Gestión ERP
+63
View File
@@ -0,0 +1,63 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<div class="view-header">
<div class="view-title">
<h2>Cuentas por Pagar (CXP)</h2>
<p>Consolidado de deudas por proveedor en estado Solicitado a Finanzas.</p>
</div>
</div>
<section class="main-content">
<div class="data-card">
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>RUT</th>
<th>Razón Social</th>
<th>Datos de Pago</th>
<th>Cantidad de Facturas</th>
<th>Total Deuda (CLP)</th>
<th>Total Deuda (USD)</th>
<th>Acción Sugerida</th>
</tr>
</thead>
<tbody>
{% for debt in debts %}
<tr>
<td style="font-weight: 600; color: var(--efintel-blue);">{{ debt.vendor__rut }}</td>
<td style="font-weight: 600;">{{ debt.vendor__name }}</td>
<td>
{% if debt.vendor__bank %}
<div style="font-size: 0.85rem;">{{ debt.vendor__bank }}</div>
<div style="font-size: 0.75rem; color: var(--efintel-gray);">{{ debt.vendor__account_type }} - {{ debt.vendor__account_number }}</div>
{% else %}
<em style="color: var(--efintel-gray);">No registrados</em>
{% endif %}
</td>
<td style="text-align: center;"><span class="badge badge-amber">{{ debt.expense_count }} pendientes</span></td>
<td class="amount-cell" style="font-size: 1rem; color: var(--efintel-danger);">
${{ debt.total_clp|floatformat:"0"|intcomma }}
</td>
<td class="amount-cell" style="font-size: 0.85rem; color: var(--efintel-gray);">
${{ debt.total_usd|floatformat:"2"|intcomma }}
</td>
<td>
<a href="{% url 'expense_list' %}?vendor={{ debt.vendor__id }}" class="btn-primary" style="font-size: 0.75rem;">Ver Detalle</a>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" style="text-align: center; padding: 3rem; color: var(--efintel-gray);">
No hay cuentas por pagar pendientes en este momento.
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
{% extends 'base.html' %}
{% block content %}
<div class="form-container" style="max-width: 800px; margin: 0 auto;">
<div class="data-card" style="padding: 2.5rem; background: white; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);">
<h2 style="color: var(--efintel-petroleum); margin-bottom: 2rem; border-bottom: 2px solid var(--efintel-light); padding-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<i data-lucide="{% if form.instance.pk %}edit-3{% else %}plus-circle{% endif %}"></i>
{% if form.instance.pk %}Editar Categoría Contable{% else %}Nueva Categoría Contable{% endif %}
</h2>
<form method="post">
{% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.code.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Código</label>
{{ form.code }}
{% if form.code.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.code.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.name.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Nombre de Categoría</label>
{{ form.name }}
{% if form.name.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.name.errors }}</div>
{% endif %}
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label for="{{ form.parent.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Partida (Categoría Padre)</label>
<p style="font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 0.25rem;">Dejar en blanco si esta es una Partida principal.</p>
{{ form.parent }}
{% if form.parent.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.parent.errors }}</div>
{% endif %}
</div>
<div class="form-group" style="margin-bottom: 2rem;">
<label for="{{ form.description.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Descripción (Opcional)</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 style="display: flex; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; font-size: 0.9rem;">Guardar Categoría</button>
<a href="{% url 'gestion_panel' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
</div>
</form>
</div>
</div>
<script>
document.querySelectorAll('input, select, textarea').forEach(el => {
el.classList.add('form-control');
});
</script>
{% endblock %}
+135 -6
View File
@@ -1,5 +1,6 @@
{% extends 'base.html' %}
{% load static %}
{% load humanize %}
{% block content %}
<section class="page-view active">
@@ -20,9 +21,9 @@
<span class="kpi-title">EJECUCIÓN HISTÓRICA / MES</span>
<div class="kpi-icon"><i data-lucide="credit-card"></i></div>
</div>
<div class="kpi-value" id="kpi-spend">${{ total_all|floatformat:"0" }} CLP</div>
<div class="kpi-value" id="kpi-spend">${{ total_all|floatformat:"0"|intcomma }} CLP</div>
<div class="kpi-subtext">
<span>Mes actual: ${{ total_month|floatformat:"0" }} CLP</span>
<span>Mes actual: ${{ total_month|floatformat:"0"|intcomma }} CLP</span>
</div>
</div>
@@ -31,9 +32,9 @@
<span class="kpi-title">PRESUPUESTO GENERAL I+D+i</span>
<div class="kpi-icon"><i data-lucide="wallet"></i></div>
</div>
<div class="kpi-value" id="kpi-remaining">${{ global_budget|floatformat:"0" }} CLP</div>
<div class="kpi-value" id="kpi-remaining">${{ global_budget|floatformat:"0"|intcomma }} CLP</div>
<div class="kpi-subtext">
<span>Saldo Restante: ${{ remaining_budget|floatformat:"0" }} CLP</span>
<span>Saldo Restante: ${{ remaining_budget|floatformat:"0"|intcomma }} CLP</span>
</div>
</div>
@@ -49,6 +50,25 @@
</div>
</div>
<!-- Gráficos de Inteligencia de Negocios (BI) -->
<div class="charts-grid" style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; margin-bottom: 2rem;">
<!-- Gráfico 1: Categorías -->
<div class="data-card" style="padding: 1.5rem;">
<h3 style="color: var(--efintel-petroleum); font-size: 1rem; margin-bottom: 1rem; border-bottom: 1px solid var(--efintel-light); padding-bottom: 0.5rem;">Distribución por Categoría</h3>
<div style="position: relative; height: 250px;">
<canvas id="categoryChart"></canvas>
</div>
</div>
<!-- Gráfico 2: Proyectos -->
<div class="data-card" style="padding: 1.5rem;">
<h3 style="color: var(--efintel-petroleum); font-size: 1rem; margin-bottom: 1rem; border-bottom: 1px solid var(--efintel-light); padding-bottom: 0.5rem;">Ejecución por Proyectos / Centros</h3>
<div style="position: relative; height: 250px;">
<canvas id="projectChart"></canvas>
</div>
</div>
</div>
<div class="data-card">
<div class="data-card-header">
<div style="font-weight:700; font-size:0.95rem; color:var(--efintel-dark);">Últimas Erogaciones Registradas (Flujo ERP)</div>
@@ -79,8 +99,8 @@
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
</td>
<td class="amount-cell">
<div>${{ expense.amount_clp|floatformat:"0" }} CLP</div>
<div style="font-size:0.68rem; color:var(--efintel-gray); font-family:var(--font-mono);">${{ expense.amount_usd|floatformat:"2" }} USD</div>
<div>${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP</div>
<div style="font-size:0.68rem; color:var(--efintel-gray); font-family:var(--font-mono);">${{ expense.amount_usd|floatformat:"2"|intcomma }} USD</div>
</td>
<td>
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
@@ -120,3 +140,112 @@
</div>
</section>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
// Colores corporativos EFINTEL
const colors = [
'#053B50', // petroleum
'#176B87', // blue
'#64CCC5', // turquoise
'#107c41', // emerald
'#d97706', // amber
'#475569', // slate
'#0f172a' // dark
];
// Data inyectada desde el backend (JSON)
const catLabels = JSON.parse('{{ category_labels_json|safe }}');
const catData = JSON.parse('{{ category_data_json|safe }}');
const projLabels = JSON.parse('{{ project_labels_json|safe }}');
const projData = JSON.parse('{{ project_data_json|safe }}');
// 1. Gráfico de Dona: Categorías
if (document.getElementById('categoryChart') && catData.length > 0) {
new Chart(document.getElementById('categoryChart'), {
type: 'doughnut',
data: {
labels: catLabels,
datasets: [{
data: catData,
backgroundColor: colors,
borderWidth: 0,
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { position: 'right', labels: { boxWidth: 12, font: { family: 'Inter', size: 11 } } },
tooltip: {
callbacks: {
label: function(context) {
let value = context.raw || 0;
return ' $' + value.toLocaleString('es-CL') + ' CLP';
}
}
}
},
cutout: '70%'
}
});
} else if(document.getElementById('categoryChart')) {
document.getElementById('categoryChart').parentElement.innerHTML = '<div style="display:flex; height:100%; align-items:center; justify-content:center; color:#94a3b8;">Sin datos suficientes</div>';
}
// 2. Gráfico de Barras: Proyectos
if (document.getElementById('projectChart') && projData.length > 0) {
new Chart(document.getElementById('projectChart'), {
type: 'bar',
data: {
labels: projLabels,
datasets: [{
label: 'Gasto Acumulado (CLP)',
data: projData,
backgroundColor: '#176B87',
borderRadius: 4,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: function(context) {
let value = context.raw || 0;
return ' $' + value.toLocaleString('es-CL') + ' CLP';
}
}
}
},
scales: {
y: {
beginAtZero: true,
grid: { color: '#f1f5f9', drawBorder: false },
ticks: {
callback: function(value) {
if (value >= 1000000) return '$' + (value / 1000000).toFixed(1) + 'M';
if (value >= 1000) return '$' + (value / 1000).toFixed(0) + 'k';
return '$' + value;
},
font: { family: 'Inter', size: 10 }
}
},
x: {
grid: { display: false },
ticks: { font: { family: 'Inter', size: 11 } }
}
}
}
});
} else if(document.getElementById('projectChart')) {
document.getElementById('projectChart').parentElement.innerHTML = '<div style="display:flex; height:100%; align-items:center; justify-content:center; color:#94a3b8;">Sin datos suficientes</div>';
}
});
</script>
{% endblock %}
+233 -3
View File
@@ -12,15 +12,27 @@
{% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<div class="form-group" style="position: relative;">
<label for="{{ form.project.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Centro de Costo / Proyecto</label>
<div class="custom-select-wrapper" style="position: relative;">
{{ form.project }}
<input type="text" id="project-search-input" placeholder="Escriba código o nombre del proyecto..." autocomplete="off">
<div id="project-dropdown-list" class="custom-dropdown-list" style="display: none; position: absolute; background: white; border: 1px solid var(--efintel-border); border-radius: 4px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 100%; z-index: 1000; max-height: 250px; overflow-y: auto; margin-top: 4px; box-sizing: border-box;">
<!-- Items dynamically populated -->
</div>
</div>
{% if form.project.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.project.errors }}</div>{% endif %}
</div>
<div class="form-group">
<div class="form-group" style="position: relative;">
<label for="{{ form.category.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Categoría Contable</label>
<div class="custom-select-wrapper" style="position: relative;">
{{ form.category }}
<input type="text" id="category-search-input" placeholder="Escriba código o nombre para buscar..." autocomplete="off">
<div id="category-dropdown-list" class="custom-dropdown-list" style="display: none; position: absolute; background: white; border: 1px solid var(--efintel-border); border-radius: 4px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); width: 100%; z-index: 1000; max-height: 250px; overflow-y: auto; margin-top: 4px; box-sizing: border-box;">
<!-- Items dynamically populated -->
</div>
</div>
{% if form.category.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.category.errors }}</div>{% endif %}
</div>
</div>
@@ -29,10 +41,32 @@
<div class="form-group">
<label for="{{ form.amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Neto (CLP)</label>
{{ form.amount_clp }}
<small style="color: var(--efintel-gray); font-size: 0.7rem;">El equivalente en USD será calculado automáticamente por el sistema.</small>
{% if form.amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.amount_clp.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.tax_type.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Tipo de Impuesto</label>
{{ form.tax_type }}
{% if form.tax_type.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.tax_type.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.tax_amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Impuesto (CLP)</label>
{{ form.tax_amount_clp }}
{% if form.tax_amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.tax_amount_clp.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.total_amount_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Monto Total (CLP)</label>
{{ form.total_amount_clp }}
<small style="color: var(--efintel-gray); font-size: 0.7rem;">El equivalente en USD será calculado automáticamente por el sistema.</small>
{% if form.total_amount_clp.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.total_amount_clp.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.currency.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Moneda Original</label>
{{ form.currency }}
@@ -110,5 +144,201 @@
el.style.fontSize = '0.9rem';
el.style.boxSizing = 'border-box';
});
const amountInput = document.getElementById('id_amount_clp');
const taxTypeSelect = document.getElementById('id_tax_type');
const taxAmountInput = document.getElementById('id_tax_amount_clp');
const totalAmountInput = document.getElementById('id_total_amount_clp');
function calculateTaxes() {
const amount = parseFloat(amountInput.value) || 0;
const taxType = taxTypeSelect.value;
let taxAmount = 0;
if (taxType === 'IVA_19') {
taxAmount = amount * 0.19;
} else if (taxType === 'HONORARIOS_13_75') {
taxAmount = amount * 0.1375;
}
taxAmountInput.value = taxAmount.toFixed(0);
totalAmountInput.value = (amount + taxAmount).toFixed(0);
}
amountInput.addEventListener('input', calculateTaxes);
taxTypeSelect.addEventListener('change', calculateTaxes);
// Initial calculation
calculateTaxes();
// Custom Searchable Dropdown for Category
const categorySelect = document.getElementById('id_category');
const searchInput = document.getElementById('category-search-input');
const dropdownList = document.getElementById('category-dropdown-list');
if (categorySelect && searchInput && dropdownList) {
// Hide original select (make it display none so form data is still submitted)
categorySelect.style.display = 'none';
// Extract options from the django-generated select
const options = Array.from(categorySelect.options)
.filter(opt => opt.value !== '')
.map(opt => ({
value: opt.value,
text: opt.textContent
}));
// Render option list dynamically
function renderList(filteredOptions) {
dropdownList.innerHTML = '';
if (filteredOptions.length === 0) {
dropdownList.innerHTML = '<div style="padding: 0.65rem; color: var(--efintel-gray); font-size: 0.85rem; text-align: center;">No se encontraron categorías</div>';
return;
}
filteredOptions.forEach(opt => {
const item = document.createElement('div');
item.className = 'dropdown-item';
item.style.padding = '0.65rem 1rem';
item.style.cursor = 'pointer';
item.style.fontSize = '0.85rem';
item.style.borderBottom = '1px solid #f1f5f9';
item.textContent = opt.text;
// Mark parent categories differently (they usually have a badge in representation or just end with standard code)
// 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: ');
if (isParent) {
item.style.fontWeight = '700';
item.style.background = '#f8fafc';
item.style.color = 'var(--efintel-petroleum)';
}
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);
});
}
// Initialize input value if edit mode
const selectedOption = categorySelect.options[categorySelect.selectedIndex];
if (selectedOption && selectedOption.value !== '') {
searchInput.value = selectedOption.textContent;
}
// Show dropdown list on focus
searchInput.addEventListener('focus', () => {
renderList(options);
dropdownList.style.display = 'block';
});
// Dynamic filtering
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = options.filter(opt => opt.text.toLowerCase().includes(query));
renderList(filtered);
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!searchInput.contains(e.target) && !dropdownList.contains(e.target)) {
dropdownList.style.display = 'none';
}
});
}
// Custom Searchable Dropdown for Project
const projectSelect = document.getElementById('id_project');
const projectSearchInput = document.getElementById('project-search-input');
const projectDropdownList = document.getElementById('project-dropdown-list');
if (projectSelect && projectSearchInput && projectDropdownList) {
projectSelect.style.display = 'none';
const projectOptions = Array.from(projectSelect.options)
.filter(opt => opt.value !== '')
.map(opt => ({
value: opt.value,
text: opt.textContent
}));
function renderProjectList(filteredOptions) {
projectDropdownList.innerHTML = '';
if (filteredOptions.length === 0) {
projectDropdownList.innerHTML = '<div style="padding: 0.65rem; color: var(--efintel-gray); font-size: 0.85rem; text-align: center;">No se encontraron proyectos</div>';
return;
}
filteredOptions.forEach(opt => {
const item = document.createElement('div');
item.className = 'dropdown-item';
item.style.padding = '0.65rem 1rem';
item.style.cursor = 'pointer';
item.style.fontSize = '0.85rem';
item.style.borderBottom = '1px solid #f1f5f9';
item.textContent = opt.text;
const isPortfolio = opt.text.startsWith('PORT-');
if (isPortfolio) {
item.style.fontWeight = '700';
item.style.background = '#f8fafc';
item.style.color = 'var(--efintel-petroleum)';
}
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);
});
}
const selectedProjectOption = projectSelect.options[projectSelect.selectedIndex];
if (selectedProjectOption && selectedProjectOption.value !== '') {
projectSearchInput.value = selectedProjectOption.textContent;
}
projectSearchInput.addEventListener('focus', () => {
renderProjectList(projectOptions);
projectDropdownList.style.display = 'block';
});
projectSearchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase();
const filtered = projectOptions.filter(opt => opt.text.toLowerCase().includes(query));
renderProjectList(filtered);
});
document.addEventListener('click', (e) => {
if (!projectSearchInput.contains(e.target) && !projectDropdownList.contains(e.target)) {
projectDropdownList.style.display = 'none';
}
});
}
</script>
{% endblock %}
+7 -2
View File
@@ -1,5 +1,6 @@
{% extends 'base.html' %}
{% load static %}
{% load humanize %}
{% block content %}
<section class="page-view active">
@@ -71,8 +72,8 @@
{% endif %}
</td>
<td class="amount-cell">
<div>${{ expense.amount_clp|floatformat:"0" }} CLP</div>
<div style="font-size:0.68rem; color:var(--efintel-gray); font-family:var(--font-mono);">${{ expense.amount_usd|floatformat:"2" }} USD</div>
<div>${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP</div>
<div style="font-size:0.68rem; color:var(--efintel-gray); font-family:var(--font-mono);">${{ expense.amount_usd|floatformat:"2"|intcomma }} USD</div>
</td>
<td>
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
@@ -96,6 +97,7 @@
<a href="{% url 'approve_expense' expense.id 'rechazar' %}" class="btn-danger" style="padding:0.3rem; font-size:0.65rem;" title="Rechazar"><i data-lucide="x"></i></a>
{% endif %}
{% if expense.approval_status != 'SOLICITADO_FINANZAS' and expense.approval_status != 'LIBERADO_PAGADO' %}
{% if request.user.groups.all.0.name == 'Administrador' or request.user.is_superuser or expense.user == request.user %}
<a href="/gastos/{{ expense.id }}/editar/" class="btn-secondary" style="padding: 0.3rem; background: var(--efintel-light); color: var(--efintel-petroleum);" title="Editar"><i data-lucide="pencil" style="width:14px;"></i></a>
{% endif %}
@@ -106,6 +108,9 @@
<button type="submit" class="btn-danger" style="padding: 0.3rem;" onclick="return confirm('¿Seguro que desea eliminar esta erogación? Esta acción quedará en el Log de Auditoría.');" title="Eliminar"><i data-lucide="trash-2" style="width:14px;"></i></button>
</form>
{% endif %}
{% else %}
<span style="font-size:0.75rem; color:var(--efintel-gray); padding: 0.3rem;" title="Comprobante Bloqueado"><i data-lucide="lock" style="width:14px;"></i></span>
{% endif %}
</div>
</td>
</tr>
+306 -46
View File
@@ -1,41 +1,149 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<div class="header-section" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<h1>Panel de Gestión Administrativa</h1>
</div>
<div class="glass-card tabs-container" style="padding: 2rem;">
<nav class="tabs-nav">
<button class="tab-btn active" onclick="switchTab(event, 'tab-proyectos')">📁 Proyectos</button>
<button class="tab-btn" onclick="switchTab(event, 'tab-responsables')">👥 Colaboradores / Responsables</button>
<button class="tab-btn" onclick="switchTab(event, 'tab-config')">⚙️ Configuración</button>
<button class="tab-btn" onclick="switchTab(event, 'tab-bitacora')">📜 Bitácora de Auditoría</button>
<style>
.sidebar-btn {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
text-align: left;
padding: 0.8rem 1rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--efintel-dark);
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.sidebar-btn:hover {
background: rgba(0, 0, 0, 0.05);
color: var(--efintel-blue);
}
.sidebar-btn.active {
background: rgba(5, 59, 80, 0.08); /* efintel-petroleum with opacity */
color: var(--efintel-petroleum);
font-weight: 600;
border-left: 4px solid var(--efintel-petroleum);
}
.sidebar-btn i {
width: 18px;
height: 18px;
}
.tab-content {
display: none;
animation: fadeIn 0.3s ease;
}
.tab-content.active {
display: block;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<div class="gestion-layout" style="display: grid; grid-template-columns: 260px 1fr; gap: 2rem; align-items: start;">
<!-- Sidebar Navigation -->
<div class="data-card" style="padding: 1rem; position: sticky; top: 2rem; background: white; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);">
<nav class="sidebar-nav" style="display: flex; flex-direction: column; gap: 0.25rem;">
<button class="sidebar-btn active" onclick="switchTab(event, 'tab-proyectos')">
<i data-lucide="folder-kanban"></i> Proyectos y Centros
</button>
<button class="sidebar-btn" onclick="switchTab(event, 'tab-categorias')">
<i data-lucide="tags"></i> Categorías Contables
</button>
<button class="sidebar-btn" onclick="switchTab(event, 'tab-proveedores')">
<i data-lucide="building-2"></i> Proveedores
</button>
<button class="sidebar-btn" onclick="switchTab(event, 'tab-responsables')">
<i data-lucide="users"></i> Responsables
</button>
<hr style="border: none; border-top: 1px solid var(--efintel-light); margin: 0.75rem 0;" />
<button class="sidebar-btn" onclick="switchTab(event, 'tab-config')">
<i data-lucide="settings"></i> Configuración General
</button>
<button class="sidebar-btn" onclick="switchTab(event, 'tab-bitacora')">
<i data-lucide="scroll-text"></i> Bitácora de Auditoría
</button>
</nav>
</div>
<!-- Main Content Area -->
<div class="gestion-content">
<!-- Pestaña Proyectos -->
<div id="tab-proyectos" class="tab-content active">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Proyectos del Sistema</h2>
<a href="{% url 'project_create' %}" class="btn btn-primary">➕ Nuevo Proyecto</a>
<h2>Proyectos y Centros de Costo</h2>
<a href="{% url 'project_create' %}" class="btn btn-primary">Nuevo Proyecto / Centro</a>
</div>
<div class="table-container">
<table>
<div class="data-card">
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Nombre</th>
<th>Descripción</th>
<th>Portafolio / Jerarquía</th>
<th>Categoría / Especialidad</th>
<th>Estado</th>
<th>Especialistas Habilitados</th>
<th>Encargado (Responsable)</th>
<th>Creado Por</th>
<th>Última Modificación</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr>
<td style="font-weight: 600; color: var(--accent-secondary);">{{ project.name }}</td>
<td>{{ project.description|default:"Sin descripción" }}</td>
<td style="font-weight: 600; color: var(--efintel-blue);">{{ project.code }}</td>
<td style="font-weight: 600; color: var(--accent-secondary);">
{% if project.parent %}<span style="color:var(--efintel-gray); margin-right:5px;">↳</span>{% endif %}
{{ project.name }}
</td>
<td>
{% if project.parent %}
<span style="font-size: 0.85rem; color: var(--efintel-blue); font-weight: 500;">{{ project.parent.code }} - {{ project.parent.name }}</span>
{% else %}
<span class="badge badge-emerald" style="font-size: 0.75rem;">Portafolio Principal</span>
{% endif %}
</td>
<td>
{% if project.category %}
<span style="font-size: 0.8rem; font-weight: 600; color: var(--efintel-petroleum);">{{ project.category.code }} - {{ project.category.name }}</span>
{% else %}
<span style="color: var(--efintel-gray); font-size: 0.75rem; font-style: italic;">Sin asignar</span>
{% endif %}
</td>
<td>
{% if project.status == 'ACTIVO' %}
<span class="badge badge-emerald"><i class="status-dot green"></i> Activo</span>
{% elif project.status == 'PAUSADA' %}
<span class="badge badge-amber"><i class="status-dot orange"></i> En Pausa</span>
{% else %}
<span class="badge badge-red"><i class="status-dot red"></i> Terminado</span>
{% endif %}
</td>
<td>
{% if project.category %}
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem; max-width: 250px;">
{% for spec in project.category.specialists.all %}
<span class="badge badge-blue" style="font-size: 0.7rem; font-weight: 600; padding: 0.15rem 0.35rem; border-radius: 4px;">{{ spec.user.username }}</span>
{% empty %}
<span style="color: var(--efintel-gray); font-size: 0.75rem; font-style: italic;">Sin especialistas</span>
{% endfor %}
</div>
{% else %}
<span style="color: var(--efintel-gray); font-size: 0.75rem; font-style: italic;">Asigna una categoría para ver especialistas</span>
{% endif %}
</td>
<td>
{% if project.manager %}
<span style="font-weight: 500;">{{ project.manager.username }}</span>
@@ -43,44 +151,181 @@
<em style="color: var(--text-secondary);">No asignado</em>
{% endif %}
</td>
<td>{{ project.created_by.username|default:"Sistema" }}</td>
<td>{{ project.updated_at|date:"d/m/Y H:i" }}</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<a href="{% url 'project_edit' project.id %}" class="btn btn-outline" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;">✏️ Editar</a>
<div style="display: flex; gap: 0.25rem;">
<a href="{% url 'project_edit' project.id %}" class="btn-icon" title="Editar" style="color: var(--efintel-blue); padding: 0.4rem; border-radius: 4px; transition: background 0.2s;">
<i data-lucide="edit-3" style="width: 16px;"></i>
</a>
<form action="{% url 'project_delete' project.id %}" method="POST" style="display: inline;" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este proyecto? Se eliminarán todos los gastos asociados.');">
{% csrf_token %}
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;">🗑️ Eliminar</button>
<button type="submit" class="btn-icon" title="Eliminar" style="color: var(--efintel-danger); padding: 0.4rem; border-radius: 4px; transition: background 0.2s; background: transparent; border: none; cursor: pointer;">
<i data-lucide="trash-2" style="width: 16px;"></i>
</button>
</form>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" style="text-align: center;">No hay proyectos registrados.</td>
<td colspan="8" style="text-align: center;">No hay proyectos registrados.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pestaña Categorías -->
<div id="tab-categorias" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2 style="color: var(--efintel-petroleum); margin-bottom: 0.25rem;">Categorías Contables</h2>
<p style="color: var(--efintel-gray); font-size: 0.9rem;">Estructura de imputación financiera.</p>
</div>
<a href="{% url 'category_create' %}" class="btn-primary" style="text-decoration: none;">
<i data-lucide="plus-circle" style="width: 16px;"></i> Nueva Categoría
</a>
</div>
<div class="data-card" style="padding: 0;">
<div class="data-table-wrapper" style="margin: 0; padding: 1.5rem;">
<table class="data-table">
<thead>
<tr>
<th>Código</th>
<th>Nombre</th>
<th>Partida / Jerarquía</th>
<th>Descripción</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for cat in categories %}
<tr>
<td style="font-weight: 600; color: var(--accent-primary);">{{ cat.code }}</td>
<td style="font-weight: 600; color: var(--text-primary);">
{% if cat.parent %}<span style="color:var(--efintel-gray); margin-right:5px;">↳</span>{% endif %}
{{ cat.name }}
</td>
<td>
{% if cat.parent %}
<span style="font-size: 0.85rem; color: var(--efintel-blue); font-weight: 500;">{{ cat.parent.code }} - {{ cat.parent.name }}</span>
{% else %}
<span class="badge badge-amber" style="font-size: 0.75rem;">Partida Principal</span>
{% endif %}
</td>
<td>{{ cat.description|default:"Sin descripción" }}</td>
<td>
<div style="display: flex; gap: 0.25rem;">
<a href="{% url 'category_edit' cat.id %}" class="btn-icon" title="Editar" style="color: var(--efintel-blue); padding: 0.4rem; border-radius: 4px; transition: background 0.2s;">
<i data-lucide="edit-3" style="width: 16px;"></i>
</a>
<form action="{% url 'category_delete' cat.id %}" method="POST" style="display: inline;" onsubmit="return confirm('¿Estás seguro de que deseas eliminar esta categoría? Se podrían perder asociaciones con los gastos.');">
{% csrf_token %}
<button type="submit" class="btn-icon" title="Eliminar" style="color: var(--efintel-danger); padding: 0.4rem; border-radius: 4px; transition: background 0.2s; background: transparent; border: none; cursor: pointer;">
<i data-lucide="trash-2" style="width: 16px;"></i>
</button>
</form>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" style="text-align: center;">No hay categorías registradas.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pestaña Proveedores -->
<div id="tab-proveedores" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<div>
<h2 style="color: var(--efintel-petroleum); margin-bottom: 0.25rem;">Directorio de Proveedores</h2>
<p style="color: var(--efintel-gray); font-size: 0.9rem;">Registro centralizado de proveedores habilitados.</p>
</div>
<a href="{% url 'vendor_create' %}" class="btn-primary" style="text-decoration: none;">
<i data-lucide="plus-circle" style="width: 16px;"></i> Nuevo Proveedor
</a>
</div>
<div class="data-card" style="padding: 0;">
<div class="data-table-wrapper" style="margin: 0; padding: 1.5rem;">
<table class="data-table">
<thead>
<tr>
<th>RUT</th>
<th>Razón Social</th>
<th>Giro Comercial</th>
<th>Datos Bancarios</th>
<th>Plazo Pago</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for vendor in vendors %}
<tr>
<td style="font-weight: 600; color: var(--accent-primary);">{{ vendor.rut }}</td>
<td style="font-weight: 600; color: var(--text-primary);">{{ vendor.name }}</td>
<td>{{ vendor.business_line|default:"-" }}</td>
<td>
{% if vendor.bank %}
<div style="font-size: 0.85rem;">{{ vendor.bank }}</div>
<div style="font-size: 0.75rem; color: var(--text-secondary);">{{ vendor.account_type }} - {{ vendor.account_number }}</div>
{% else %}
<em style="color: var(--text-secondary);">No registrados</em>
{% endif %}
</td>
<td>{{ vendor.payment_terms }} días</td>
<td>
<div style="display: flex; gap: 0.25rem;">
<a href="{% url 'vendor_edit' vendor.id %}" class="btn-icon" title="Editar" style="color: var(--efintel-blue); padding: 0.4rem; border-radius: 4px; transition: background 0.2s;">
<i data-lucide="edit-3" style="width: 16px;"></i>
</a>
<form action="{% url 'vendor_delete' vendor.id %}" method="POST" style="display: inline;" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este proveedor? Sus gastos quedarán huérfanos.');">
{% csrf_token %}
<button type="submit" class="btn-icon" title="Eliminar" style="color: var(--efintel-danger); padding: 0.4rem; border-radius: 4px; transition: background 0.2s; background: transparent; border: none; cursor: pointer;">
<i data-lucide="trash-2" style="width: 16px;"></i>
</button>
</form>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" style="text-align: center;">No hay proveedores registrados.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pestaña Responsables -->
<div id="tab-responsables" class="tab-content">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem;">
<h2>Responsables y Colaboradores</h2>
<a href="{% url 'responsable_create' %}" class="btn btn-primary">➕ Nuevo Responsable</a>
<div>
<h2 style="color: var(--efintel-petroleum); margin-bottom: 0.25rem;">Responsables y Colaboradores</h2>
<p style="color: var(--efintel-gray); font-size: 0.9rem;">Gestión de acceso y roles del personal.</p>
</div>
<div class="table-container">
<table>
<a href="{% url 'responsable_create' %}" class="btn-primary" style="text-decoration: none;">
<i data-lucide="plus-circle" style="width: 16px;"></i> Nuevo Responsable
</a>
</div>
<div class="data-card" style="padding: 0;">
<div class="data-table-wrapper" style="margin: 0; padding: 1.5rem;">
<table class="data-table">
<thead>
<tr>
<th>Usuario</th>
<th>Correo</th>
<th>Rol / Permiso</th>
<th>Especialidades Habilitadas (CBS)</th>
<th>Descripción / Bio</th>
<th>F. Registro</th>
<th>F. Modificación</th>
<th>Acciones</th>
</tr>
</thead>
@@ -93,27 +338,39 @@
{% if resp.groups.all %}
{% with role=resp.groups.all.0.name %}
{% if role == 'Administrador' %}
<span class="badge badge-admin">Administrador</span>
<span class="badge badge-red">Administrador</span>
{% elif role == 'Director de Proyecto' %}
<span class="badge badge-director">Director</span>
<span class="badge badge-blue">Director</span>
{% else %}
<span class="badge badge-gestor">Gestor</span>
<span class="badge badge-emerald">Gestor</span>
{% endif %}
{% endwith %}
{% else %}
<span class="badge badge-gestor">Sin Rol</span>
<span class="badge badge-amber">Sin Rol</span>
{% endif %}
</td>
<td>
<div style="display: flex; flex-wrap: wrap; gap: 0.25rem; max-width: 250px;">
{% for spec in resp.profile.specialties.all %}
<span class="badge badge-blue" style="font-size: 0.7rem; padding: 0.15rem 0.35rem;" title="{{ spec.name }}">{{ spec.code }}</span>
{% empty %}
<span style="color: var(--efintel-gray); font-size: 0.75rem; font-style: italic;">Ninguna</span>
{% endfor %}
</div>
</td>
<td>{{ resp.profile.description|default:"Sin descripción personal" }}</td>
<td>{{ resp.profile.created_at|date:"d/m/Y"|default:"-" }}</td>
<td>{{ resp.profile.updated_at|date:"d/m/Y H:i"|default:"-" }}</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<a href="{% url 'responsable_edit' resp.id %}" class="btn btn-outline" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;">✏️ Editar</a>
<div style="display: flex; gap: 0.25rem;">
<a href="{% url 'responsable_edit' resp.id %}" class="btn-icon" title="Editar" style="color: var(--efintel-blue); padding: 0.4rem; border-radius: 4px; transition: background 0.2s;">
<i data-lucide="edit-3" style="width: 16px;"></i>
</a>
{% if resp != request.user %}
<form action="{% url 'responsable_delete' resp.id %}" method="POST" style="display: inline;" onsubmit="return confirm('¿Estás seguro de que deseas eliminar este responsable?');">
{% csrf_token %}
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.75rem; font-size: 0.8rem;">🗑️ Eliminar</button>
<button type="submit" class="btn-icon" title="Eliminar" style="color: var(--efintel-danger); padding: 0.4rem; border-radius: 4px; transition: background 0.2s; background: transparent; border: none; cursor: pointer;">
<i data-lucide="trash-2" style="width: 16px;"></i>
</button>
</form>
{% endif %}
</div>
@@ -128,19 +385,21 @@
</table>
</div>
</div>
</div>
<!-- Pestaña Configuración -->
<div id="tab-config" class="tab-content">
<div style="margin-bottom: 1.5rem;">
<h2>Configuración General del Sistema</h2>
<h2 style="color: var(--efintel-petroleum); margin-bottom: 0.25rem;">Configuración General del Sistema</h2>
<p style="color: var(--efintel-gray); font-size: 0.9rem;">Parámetros y límites globales del ERP.</p>
</div>
<div style="max-width: 500px; display: flex; flex-direction: column; gap: 1.5rem;">
<div class="glass-card" style="padding: 1.5rem; background: rgba(0, 0, 0, 0.2);">
<div class="data-card" style="padding: 2rem;">
<div style="color: var(--text-secondary); font-size: 0.9rem; margin-bottom: 0.5rem;">Presupuesto General Asignado</div>
<div style="font-size: 2rem; font-weight: 700; color: var(--accent-secondary);">${{ settings.global_budget|default:"100000.00" }}</div>
<div style="font-size: 2.5rem; font-weight: 700; color: var(--efintel-petroleum);">${{ settings.global_budget|floatformat:"0"|intcomma|default:"100000" }}</div>
</div>
<div>
<a href="{% url 'settings_edit' %}" class="btn btn-primary">✏️ Modificar Presupuesto General</a>
<a href="{% url 'settings_edit' %}" class="btn-primary" style="text-decoration: none;">Modificar Presupuesto General</a>
</div>
</div>
</div>
@@ -148,20 +407,20 @@
<!-- Pestaña Bitácora -->
<div id="tab-bitacora" class="tab-content">
<div style="margin-bottom: 1.5rem;">
<h2>Bitácora de Auditoría y Trazabilidad (Logs)</h2>
<h2 style="color: var(--efintel-petroleum); margin-bottom: 0.25rem;">Bitácora de Auditoría y Trazabilidad (Logs)</h2>
<p style="color: var(--text-secondary); font-size: 0.9rem;">Historial detallado de operaciones realizadas por los administradores en el sistema.</p>
</div>
<div class="log-list">
<div class="data-card" style="padding: 1.5rem;">
{% for log in logs %}
<div class="log-item">
<div class="log-info">
<div class="log-action">
{% if "Creado" in log.action or "Creación" in log.action %}
🟢 {{ log.action }}
{{ log.action }}
{% elif "Eliminado" in log.action or "Eliminación" in log.action %}
🔴 {{ log.action }}
{{ log.action }}
{% else %}
🟡 {{ log.action }}
{{ log.action }}
{% endif %}
</div>
<div class="log-desc">{{ log.description }}</div>
@@ -176,7 +435,8 @@
{% endfor %}
</div>
</div>
</div>
</div> <!-- End Main Content Area -->
</div> <!-- End Gestion Layout -->
{% endblock %}
{% block extra_js %}
@@ -187,7 +447,7 @@
contents.forEach(content => content.classList.remove('active'));
// Desactivar todos los botones de pestaña
const buttons = document.querySelectorAll('.tab-btn');
const buttons = document.querySelectorAll('.sidebar-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Activar pestaña actual
@@ -202,7 +462,7 @@
document.addEventListener("DOMContentLoaded", function() {
const activeTab = localStorage.getItem('activeTab');
if (activeTab && document.getElementById(activeTab)) {
const btn = Array.from(document.querySelectorAll('.tab-btn')).find(b => b.getAttribute('onclick').includes(activeTab));
const btn = Array.from(document.querySelectorAll('.sidebar-btn')).find(b => b.getAttribute('onclick').includes(activeTab));
if (btn) {
// Simular clic en el botón
btn.click();
+71 -15
View File
@@ -1,42 +1,98 @@
{% extends 'base.html' %}
{% block content %}
<div class="form-container">
<div class="glass-card" style="padding: 2.5rem;">
<h1 style="margin-bottom: 2rem;">
{% if form.instance.pk %}Editar Proyecto{% else %}Nuevo Proyecto{% endif %}
</h1>
<div class="form-container" style="max-width: 800px; margin: 0 auto;">
<div class="data-card" style="padding: 2.5rem; background: white; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);">
<h2 style="color: var(--efintel-petroleum); margin-bottom: 2rem; border-bottom: 2px solid var(--efintel-light); padding-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<i data-lucide="{% if form.instance.pk %}edit-3{% else %}plus-circle{% endif %}"></i>
{% if form.instance.pk %}Editar Proyecto o Centro de Costo{% else %}Nuevo Proyecto o Centro de Costo{% endif %}
</h2>
<form method="post">
{% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.name.id_for_label }}">Nombre del Proyecto</label>
<label for="{{ form.code.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Código</label>
{{ form.code }}
{% if form.code.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.code.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.name.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Nombre del Proyecto / Centro</label>
{{ form.name }}
{% if form.name.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.name.errors }}</div>
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.name.errors }}</div>
{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.type.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Tipo de Centro</label>
{{ form.type }}
{% if form.type.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.type.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.description.id_for_label }}">Descripción</label>
<label for="{{ form.assigned_budget_clp.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Presupuesto Asignado (CLP)</label>
{{ form.assigned_budget_clp }}
{% if form.assigned_budget_clp.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.assigned_budget_clp.errors }}</div>
{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.category.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Categoría del Proyecto (Especialidad)</label>
{{ form.category }}
{% if form.category.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.category.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.status.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Estado del Proyecto</label>
{{ form.status }}
{% if form.status.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.status.errors }}</div>
{% endif %}
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label for="{{ form.parent.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Portafolio Padre (Opcional)</label>
<p style="font-size: 0.75rem; color: var(--text-secondary); margin-bottom: 0.25rem;">Dejar en blanco si este proyecto actúa como un Portafolio principal.</p>
{{ form.parent }}
{% if form.parent.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.parent.errors }}</div>
{% endif %}
</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);">Descripción</label>
{{ form.description }}
{% if form.description.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.description.errors }}</div>
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.description.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.manager.id_for_label }}">Encargado / Responsable del Proyecto</label>
<div class="form-group" style="margin-bottom: 2rem;">
<label for="{{ form.manager.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Encargado / Responsable del Proyecto</label>
{{ form.manager }}
{% if form.manager.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.manager.errors }}</div>
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.manager.errors }}</div>
{% endif %}
</div>
<div style="display: flex; gap: 1rem; margin-top: 2rem;">
<button type="submit" class="btn btn-primary">Guardar</button>
<a href="{% url 'gestion_panel' %}" class="btn btn-outline">Cancelar</a>
<div style="display: flex; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; font-size: 0.9rem;">Guardar Proyecto</button>
<a href="{% url 'gestion_panel' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
</div>
</form>
</div>
+23 -10
View File
@@ -1,8 +1,13 @@
{% extends 'base.html' %}
{% load humanize %}
{% block content %}
<div style="margin-bottom: 2rem;">
<h1>Proyectos</h1>
<div class="view-header">
<div class="view-title">
<h2>Proyectos</h2>
<p>Monitoreo de Centros de Costo</p>
</div>
<a href="{% url 'project_create' %}" class="btn-primary">Nuevo Proyecto / Centro</a>
</div>
<div class="project-grid">
@@ -15,16 +20,24 @@
<strong>Gestor:</strong> {{ project.manager.username|default:"No asignado" }}
</div>
<div class="project-stats">
<div style="display: flex; flex-direction: column;">
<span style="font-size: 0.8rem; color: var(--text-secondary);">Gastos</span>
<span style="font-weight: 600; font-size: 1.1rem;">{{ project.expense_count|default:"0" }}</span>
</div>
<div style="display: flex; flex-direction: column; text-align: right;">
<span style="font-size: 0.8rem; color: var(--text-secondary);">Total</span>
<span style="font-weight: 600; font-size: 1.1rem; color: var(--accent-secondary);">
${{ project.total_expenses|default:"0.00" }}
<div style="display: flex; flex-direction: column; gap: 0.5rem; width: 100%;">
<div style="display: flex; justify-content: space-between;">
<span style="font-size: 0.8rem; color: var(--efintel-gray);">Presupuesto Asignado</span>
<span style="font-weight: 700; font-family: var(--font-mono); color: var(--efintel-petroleum);">
${{ project.assigned_budget_clp|floatformat:"0"|intcomma }}
</span>
</div>
<div style="display: flex; justify-content: space-between;">
<span style="font-size: 0.8rem; color: var(--efintel-gray);">Costo Real Acumulado</span>
<span style="font-weight: 700; font-family: var(--font-mono); color: var(--efintel-danger);">
${{ project.actual_cost_clp|floatformat:"0"|intcomma }}
</span>
</div>
<div style="display: flex; justify-content: space-between; margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid var(--efintel-border);">
<span style="font-size: 0.8rem; color: var(--efintel-gray);">Nº Erogaciones (Facturas)</span>
<span style="font-weight: 700;">{{ project.expense_count|default:"0" }}</span>
</div>
</div>
</div>
</div>
{% empty %}
+58 -21
View File
@@ -1,71 +1,108 @@
{% extends 'base.html' %}
{% block content %}
<div class="form-container">
<div class="glass-card" style="padding: 2.5rem;">
<h1 style="margin-bottom: 2rem;">
<div class="form-container" style="max-width: 800px; margin: 0 auto;">
<div class="data-card" style="padding: 2.5rem; background: white; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);">
<h2 style="color: var(--efintel-petroleum); margin-bottom: 2rem; border-bottom: 2px solid var(--efintel-light); padding-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<i data-lucide="{% if edit_mode %}edit-3{% else %}plus-circle{% endif %}"></i>
{% if edit_mode %}Editar Colaborador (Responsable): {{ target_user.username }}{% else %}Nuevo Colaborador (Responsable){% endif %}
</h1>
</h2>
<form method="post">
{% csrf_token %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.username.id_for_label }}">Nombre de Usuario</label>
<label for="{{ form.username.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Nombre de Usuario</label>
{{ form.username }}
{% if form.username.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.username.errors }}</div>
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.username.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.email.id_for_label }}">Correo Electrónico</label>
<label for="{{ form.email.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Correo Electrónico</label>
{{ form.email }}
{% if form.email.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.email.errors }}</div>
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.email.errors }}</div>
{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.password.id_for_label }}">
<label for="{{ form.password.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">
Contraseña
{% if edit_mode %}
<span style="font-size: 0.8rem; color: var(--text-secondary);">(Opcional: Dejar en blanco para no cambiarla)</span>
<span style="font-size: 0.75rem; color: var(--efintel-gray); font-weight: 400;">(Opcional)</span>
{% endif %}
</label>
{{ form.password }}
{% if form.password.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.password.errors }}</div>
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.password.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.role.id_for_label }}">Rol / Grupo del Sistema</label>
<label for="{{ form.role.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Rol / Grupo del Sistema</label>
{{ form.role }}
{% if form.role.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.role.errors }}</div>
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.role.errors }}</div>
{% endif %}
</div>
</div>
<div class="form-group">
<label for="{{ form.description.id_for_label }}">Descripción / Bio del Responsable</label>
<div class="form-group" style="margin-bottom: 2rem;">
<label for="{{ form.description.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Descripción / Bio del Responsable</label>
{{ form.description }}
{% if form.description.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.description.errors }}</div>
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.description.errors }}</div>
{% endif %}
</div>
<div style="display: flex; gap: 1rem; margin-top: 2rem;">
<button type="submit" class="btn btn-primary">Guardar</button>
<a href="{% url 'gestion_panel' %}" class="btn btn-outline">Cancelar</a>
<div class="form-group" style="margin-bottom: 2rem;">
<label style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Especialidades Técnicas (Categorías Asociadas)</label>
<p style="font-size: 0.75rem; color: var(--efintel-gray); margin-bottom: 0.75rem;">Selecciona las áreas de especialidad de este colaborador en la cartera técnica.</p>
<div class="specialties-container" style="max-height: 220px; overflow-y: auto; padding: 1rem; border: 1px solid var(--efintel-border); border-radius: 4px; background: #f8fafc;">
<style>
.specialties-container ul {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.75rem;
}
.specialties-container li {
display: flex;
align-items: flex-start;
gap: 0.4rem;
font-size: 0.8rem;
color: var(--efintel-dark);
}
.specialties-container input[type="checkbox"] {
margin-top: 0.2rem;
cursor: pointer;
}
</style>
{{ form.specialties }}
</div>
{% if form.specialties.errors %}
<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.specialties.errors }}</div>
{% endif %}
</div>
<div style="display: flex; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; font-size: 0.9rem;">Guardar Responsable</button>
<a href="{% url 'gestion_panel' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
</div>
</form>
</div>
</div>
<script>
// Apply form-control class to all inputs
document.querySelectorAll('input, select, textarea').forEach(el => {
// Apply form-control class to all inputs except checkboxes
document.querySelectorAll('input:not([type="checkbox"]), select, textarea').forEach(el => {
el.classList.add('form-control');
});
</script>
+85
View File
@@ -0,0 +1,85 @@
{% extends 'base.html' %}
{% block content %}
<div class="form-container" style="max-width: 800px; margin: 0 auto;">
<div class="data-card" style="padding: 2.5rem; background: white; border-radius: 8px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.05);">
<h2 style="color: var(--efintel-petroleum); margin-bottom: 2rem; border-bottom: 2px solid var(--efintel-light); padding-bottom: 1rem; display: flex; align-items: center; gap: 0.5rem;">
<i data-lucide="{% if form.instance.pk %}edit-3{% else %}plus-circle{% endif %}"></i>
{% if form.instance.pk %}Editar Proveedor{% else %}Nuevo Proveedor{% endif %}
</h2>
<form method="post">
{% csrf_token %}
<h3 style="margin-bottom: 1rem; color: var(--efintel-dark); font-size: 1rem;">Información General</h3>
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.rut.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">RUT Proveedor</label>
{{ form.rut }}
{% if form.rut.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.rut.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.name.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Razón Social / Nombre</label>
{{ form.name }}
{% if form.name.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.name.errors }}</div>{% endif %}
</div>
</div>
<div class="form-group" style="margin-bottom: 2rem;">
<label for="{{ form.business_line.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Giro Comercial</label>
{{ form.business_line }}
{% if form.business_line.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.business_line.errors }}</div>{% endif %}
</div>
<h3 style="margin-top: 2.5rem; margin-bottom: 1rem; color: var(--efintel-dark); font-size: 1rem; border-top: 1px solid var(--efintel-light); padding-top: 1.5rem;">Datos Bancarios y Facturación</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.bank.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Banco</label>
{{ form.bank }}
{% if form.bank.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.bank.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.account_type.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Tipo de Cuenta</label>
{{ form.account_type }}
{% if form.account_type.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.account_type.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.account_number.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Número de Cuenta</label>
{{ form.account_number }}
{% if form.account_number.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.account_number.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.email.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Correo de Pago/Facturación</label>
{{ form.email }}
{% if form.email.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.email.errors }}</div>{% endif %}
</div>
</div>
<div class="form-group" style="margin-bottom: 2rem; width: 50%;">
<label for="{{ form.payment_terms.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Plazo de Pago (Días)</label>
{{ form.payment_terms }}
{% if form.payment_terms.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.payment_terms.errors }}</div>{% endif %}
</div>
<div style="display: flex; gap: 1rem; padding-top: 1.5rem; border-top: 1px solid var(--efintel-light);">
<button type="submit" class="btn-primary" style="padding: 0.6rem 2rem; font-size: 0.9rem;">Guardar Proveedor</button>
<a href="{% url 'gestion_panel' %}" class="btn-secondary" style="padding: 0.6rem 2rem; font-size: 0.9rem; text-decoration: none;">Cancelar</a>
</div>
</form>
</div>
</div>
<script>
document.querySelectorAll('input, select, textarea').forEach(el => {
el.classList.add('form-control');
});
</script>
{% endblock %}