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
+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'),
+178 -5
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
proj.actual_cost_clp = proj.actual_cost_clp + expense.amount_clp
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')