Feat: dashboard con gráficos, estructura CBS/Portafolios, semáforo de proyectos y cartera de especialistas
This commit is contained in:
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user