Initial commit: EFINTEL ERP Django Port

This commit is contained in:
Nachou-u
2026-08-19 18:04:18 -04:00
parent 1cd6ff1a6f
commit eb065909bd
39 changed files with 3852 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Entorno virtual Python
venv/
.venv/
# Python bytecode
__pycache__/
*.py[cod]
*$py.class
*.pyo
# Base de datos SQLite (se regenera con migrate + setup_roles)
db.sqlite3
# Media uploads (evidencias subidas por usuarios)
media/
# macOS metadata
.DS_Store
__MACOSX/
._*
# IDE / Editor
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
# Environment variables
.env
.env.local
+196
View File
@@ -0,0 +1,196 @@
# EFINTEL | ERP Interno I+D+i — Sistema de Control de Gastos
**Sistema Django de Control Financiero, Operaciones, Talento y Proyectos**
Dashboard corporativo para la gestión financiera del Área de Investigación, Desarrollo e Innovación (I+D+i) de EFINTEL. Construido con Django 6.1, autenticación por roles, base de datos SQLite, y WebSockets en tiempo real.
---
## Requisitos del Sistema
| Componente | Versión | ¿Necesario? |
|---|---|---|
| **Python** | 3.10 o superior | ✅ Obligatorio |
| **pip** | (viene con Python) | ✅ Obligatorio |
| **macOS** | 12 Monterey o superior | 💡 Recomendado |
| Node.js / npm | — | ❌ No necesario |
| PostgreSQL | — | ❌ No necesario (usa SQLite) |
| Redis | — | ❌ No necesario (usa InMemoryChannelLayer) |
### Instalación de Python en macOS (si no lo tienes)
```bash
# 1. Instalar Homebrew (si no lo tienes)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Para Apple Silicon (M1/M2/M3/M4), agregar al PATH:
echo 'eval "$(/opt/homebrew/bin/brew shellenv)"' >> ~/.zprofile
eval "$(/opt/homebrew/bin/brew shellenv)"
# 2. Instalar Python 3.12
brew install python@3.12
# 3. Verificar
python3 --version && pip3 --version
```
---
## Arranque Rápido
### Opción A: Script automático (recomendado)
```bash
# Solo la primera vez — dar permisos:
chmod +x start.sh
# Iniciar el sistema:
./start.sh
```
El script automáticamente:
1. ✅ Verifica Python 3
2. ✅ Crea entorno virtual (`venv`) si no existe
3. ✅ Instala las 29 dependencias (Django, Channels, Daphne, etc.)
4. ✅ Ejecuta migraciones de base de datos
5. ✅ Crea usuarios y roles (primera ejecución)
6. ✅ Inicia el servidor Daphne en `http://localhost:8000`
7. ✅ Abre el navegador automáticamente (macOS)
### Opción B: Manual
```bash
# 1. Crear entorno virtual
python3 -m venv venv
# 2. Activar
source venv/bin/activate
# 3. Instalar dependencias
pip install -r requirements.txt
# 4. Migraciones
python manage.py migrate
# 5. Crear usuarios (solo la primera vez)
python setup_roles.py
# 6. Iniciar servidor
python -m daphne -b 0.0.0.0 -p 8000 config.asgi:application
```
Abrir en el navegador: **http://localhost:8000**
Para detener: **Ctrl + C**
---
## Cuentas de Usuario
| Usuario | Contraseña | Rol | Acceso |
|---|---|---|---|
| `admin` | `admin` | Administrador | ✅ Panel de Gestión + Admin Django |
| `ginevra_ortega` | `ginevra123` | Administrador | ✅ Panel de Gestión + Admin Django |
| `finanzas` | `finanzas123` | Administrador Finanzas | 💰 Liberación de pagos |
| `miguel_contreras` | `miguel123` | Director de Proyecto | 📋 Gestión de proyectos |
### Acceso al panel Admin Django
URL: **http://localhost:8000/admin/**
Usuarios: `admin` o `ginevra_ortega`
---
## Estructura del Proyecto
```
├── manage.py → CLI de Django
├── requirements.txt → 29 dependencias Python
├── setup_roles.py → Creación de usuarios y roles
├── start.sh → Script de arranque automático
├── db.sqlite3 → Base de datos SQLite (se genera automáticamente)
├── .gitignore → Exclusiones para Git
├── README.md → Este archivo
│
├── config/ → Configuración Django
│ ├── settings.py → Settings (SQLite, Channels, ASGI)
│ ├── urls.py → Rutas raíz
│ ├── asgi.py → Config ASGI (Daphne + WebSockets)
│ └── wsgi.py → Config WSGI (fallback)
│
├── expenses/ → App principal del ERP
│ ├── models.py → 8 modelos de datos
│ ├── views.py → 16 vistas (dashboard, CRUD, aprobación)
│ ├── forms.py → 5 formularios
│ ├── urls.py → 18 rutas
│ ├── admin.py → Admin Django registrado
│ ├── decorators.py → Decorator @role_required
│ ├── signals.py → WebSocket signals
│ ├── consumers.py → WebSocket consumer
│ └── routing.py → WebSocket routing
│
├── templates/ → Templates HTML
│ ├── base.html → Layout base con navegación
│ ├── registration/
│ │ └── login.html → Pantalla de login
│ └── expenses/
│ ├── dashboard.html → Resumen financiero
│ ├── expense_list.html → Lista de gastos con filtros
│ ├── expense_form.html → Formulario de gasto
│ ├── project_list.html → Lista de proyectos
│ ├── project_form.html → Formulario de proyecto
│ ├── gestion.html → Panel de gestión (admin)
│ ├── responsable_form.html→ Formulario de responsable
│ └── settings_form.html → Config del sistema
│
├── static/ → Archivos estáticos
│ ├── css/style.css → Estilos CSS corporativos EFINTEL
│ ├── js/app.js → JavaScript del frontend
│ └── img/
│ ├── efintel_logo.png
│ └── efintel_logo.svg
│
└── venv/ → Entorno virtual (generado, no se sube a Git)
```
---
## Módulos del ERP
| Ruta | Módulo | Descripción |
|---|---|---|
| `/` | Dashboard | KPIs financieros, gastos del mes, presupuesto restante |
| `/gastos/` | Operaciones y Gastos | Lista completa con filtros por proyecto y categoría |
| `/gastos/nuevo/` | Registrar Gasto | Formulario multimoneda CLP/USD |
| `/proyectos/` | Control de Proyectos | Proyectos con presupuesto y gastos acumulados |
| `/gestion/` | Panel de Gestión | CRUD de proyectos, responsables y configuración (solo Admin) |
| `/gastos/exportar/csv/` | Exportar CSV | Descarga matriz para Excel / Power BI |
| `/admin/` | Admin Django | Panel administrativo completo |
### Flujo de Aprobación de Gastos (3 pasos)
1. **Paso 1 — Revisión Documental**: El gasto entra en estado "En Revisión"
2. **Paso 2 — Elevar a Finanzas**: Un admin/director solicita el pago
3. **Paso 3 — Liberar Pago**: Finanzas aprueba y ejecuta la transferencia
---
## Stack Tecnológico
| Componente | Tecnología |
|---|---|
| **Backend** | Django 6.1 |
| **Servidor ASGI** | Daphne (para WebSockets) |
| **WebSockets** | Django Channels |
| **Base de datos** | SQLite3 |
| **Frontend** | HTML + CSS + JS vanilla |
| **Gráficos** | Chart.js (CDN) |
| **Iconos** | Lucide Icons (CDN) |
| **Fuentes** | Inter + JetBrains Mono (Google Fonts) |
---
## Equipo EFINTEL I+D+i
- **Miguel Angel Contreras Orozco** — Jefe de Área I+D+i
- **Ginevra Ortega Acuña** — Encargada de Contratos y Auditoría Documental
- **Samuel Abdias Contreras Orozco** — Encargado de Desarrollo Comercial
View File
+28
View File
@@ -0,0 +1,28 @@
"""
ASGI config for config project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.1/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.auth import AuthMiddlewareStack
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django_asgi_app = get_asgi_application()
import expenses.routing
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AuthMiddlewareStack(
URLRouter(
expenses.routing.websocket_urlpatterns
)
),
})
+148
View File
@@ -0,0 +1,148 @@
"""
Django settings for config project.
Generated by 'django-admin startproject' using Django 6.1.
For more information on this file, see
https://docs.djangoproject.com/en/6.1/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.1/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.1/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-r9n6j1_h%@uqoz0bjz^mq=t72o0t*j@84jpl%_i4^qu%&+**u7'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
'daphne',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'channels',
'expenses',
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'config.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'config.wsgi.application'
ASGI_APPLICATION = 'config.asgi.application'
# Configuracion de WebSockets (Channels)
# Para produccion (VPS), cambiar a 'channels_redis.core.RedisChannelLayer'
CHANNEL_LAYERS = {
"default": {
"BACKEND": "channels.layers.InMemoryChannelLayer"
}
}
# Database
# https://docs.djangoproject.com/en/6.1/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/6.1/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/6.1/topics/i18n/
LANGUAGE_CODE = 'es-es'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.1/howto/static-files/
STATIC_URL = 'static/'
STATICFILES_DIRS = [BASE_DIR / 'static']
# Media files (Uploads)
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Authentication
LOGIN_URL = '/login/'
LOGIN_REDIRECT_URL = '/'
LOGOUT_REDIRECT_URL = '/login/'
# Email
# https://docs.djangoproject.com/en/6.1/topics/email/#topic-email-configuration
MAILERS = {
'default': {
'BACKEND': 'django.core.mail.backends.console.EmailBackend',
},
}
+28
View File
@@ -0,0 +1,28 @@
"""
URL configuration for config project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/6.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('expenses.urls')),
]
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
+16
View File
@@ -0,0 +1,16 @@
"""
WSGI config for config project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/6.1/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
application = get_wsgi_application()
View File
+35
View File
@@ -0,0 +1,35 @@
from django.contrib import admin
from .models import Project, Category, Expense, UserProfile, AuditLog, SystemSetting, Document
@admin.register(Project)
class ProjectAdmin(admin.ModelAdmin):
list_display = ('code', 'name', 'manager', 'type', 'actual_cost_clp')
search_fields = ('code', 'name')
@admin.register(Category)
class CategoryAdmin(admin.ModelAdmin):
list_display = ('code', 'name', 'parent')
search_fields = ('code', 'name')
@admin.register(Expense)
class ExpenseAdmin(admin.ModelAdmin):
list_display = ('expense_code', 'date', 'project', 'amount_clp', 'approval_status', 'user')
list_filter = ('project', 'approval_status', 'currency', 'date')
search_fields = ('expense_code', 'description', 'vendor')
@admin.register(UserProfile)
class UserProfileAdmin(admin.ModelAdmin):
list_display = ('user', 'rut', 'job_title')
@admin.register(AuditLog)
class AuditLogAdmin(admin.ModelAdmin):
list_display = ('timestamp', 'user', 'action', 'entity_name', 'entity_id')
list_filter = ('action', 'entity_name', 'timestamp')
@admin.register(SystemSetting)
class SystemSettingAdmin(admin.ModelAdmin):
list_display = ('global_budget', 'usd_exchange_rate')
@admin.register(Document)
class DocumentAdmin(admin.ModelAdmin):
list_display = ('title', 'expense', 'uploaded_by', 'uploaded_at')
+9
View File
@@ -0,0 +1,9 @@
from django.apps import AppConfig
class ExpensesConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'expenses'
def ready(self):
import expenses.signals
+29
View File
@@ -0,0 +1,29 @@
import json
from channels.generic.websocket import AsyncWebsocketConsumer
class ExpenseConsumer(AsyncWebsocketConsumer):
async def connect(self):
# Todos los clientes se conectan a este grupo para escuchar actualizaciones de gastos
self.group_name = 'expenses_updates'
await self.channel_layer.group_add(
self.group_name,
self.channel_name
)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(
self.group_name,
self.channel_name
)
# Recibir actualización desde el Channel Layer
async def expense_update(self, event):
expense_data = event['expense']
action = event['action']
# Enviar actualización por WebSocket al cliente
await self.send(text_data=json.dumps({
'action': action,
'expense': expense_data
}))
+19
View File
@@ -0,0 +1,19 @@
from functools import wraps
from django.shortcuts import redirect
from django.contrib import messages
def role_required(*group_names):
"""Decorator that checks if the user belongs to at least one of the specified groups."""
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if request.user.is_superuser:
return view_func(request, *args, **kwargs)
user_groups = request.user.groups.values_list('name', flat=True)
if any(group in user_groups for group in group_names):
return view_func(request, *args, **kwargs)
messages.error(request, 'No tienes permisos para acceder a esta sección.')
return redirect('dashboard')
return wrapper
return decorator
+133
View File
@@ -0,0 +1,133 @@
from django import forms
from django.contrib.auth.models import User, Group
from .models import Expense, Project, Category, SystemSetting, UserProfile, Document, CurrencyEnum, ExpenseTypeEnum
class ExpenseForm(forms.ModelForm):
date = forms.DateField(
widget=forms.DateInput(attrs={'type': 'date'}),
label='Fecha del Gasto'
)
amount_clp = forms.DecimalField(
label='Monto Neto (CLP)',
required=True,
widget=forms.NumberInput(attrs={'placeholder': 'Ej: 1450000'})
)
receipt = forms.FileField(
label='Adjuntar Boleta/Factura',
required=False,
widget=forms.FileInput(attrs={'accept': 'image/*,.pdf'})
)
class Meta:
model = Expense
fields = [
'project', 'category', 'amount_clp', 'currency', 'date',
'description', 'vendor', 'region', 'comuna', 'vehicle_ownership'
]
widgets = {
'description': forms.Textarea(attrs={'rows': 3, 'placeholder': 'Ej: Carga combustible Copec / Pasajes bus terreno'}),
}
class ProjectForm(forms.ModelForm):
class Meta:
model = Project
fields = ['code', 'name', 'type', 'description', 'manager', 'assigned_budget_clp']
widgets = {
'description': forms.Textarea(attrs={'rows': 3}),
}
class SystemSettingForm(forms.ModelForm):
class Meta:
model = SystemSetting
fields = ['global_budget', 'usd_exchange_rate']
class ResponsableForm(forms.Form):
username = forms.CharField(max_length=150, label="Nombre de Usuario")
password = forms.CharField(widget=forms.PasswordInput(), label="Contraseña", required=False)
email = forms.EmailField(label="Correo Electrónico", required=False)
role = forms.ModelChoiceField(queryset=Group.objects.all(), label="Rol / Grupo")
# Perfil Fields
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)
def __init__(self, *args, **kwargs):
self.instance = kwargs.pop('instance', None)
super().__init__(*args, **kwargs)
if self.instance:
self.fields['username'].initial = self.instance.username
self.fields['email'].initial = self.instance.email
# Set role
groups = self.instance.groups.all()
if groups.exists():
self.fields['role'].initial = groups.first()
# Set profile info
try:
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
except Exception:
pass
# Password is optional for edit
self.fields['password'].required = False
self.fields['password'].help_text = "Dejar en blanco si no desea cambiarla."
def clean_username(self):
username = self.cleaned_data.get('username')
qs = User.objects.filter(username=username)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise forms.ValidationError("Este nombre de usuario ya está registrado.")
return username
def save(self):
username = self.cleaned_data.get('username')
password = self.cleaned_data.get('password')
email = self.cleaned_data.get('email')
role = self.cleaned_data.get('role')
description = self.cleaned_data.get('description')
rut = self.cleaned_data.get('rut')
job_title = self.cleaned_data.get('job_title')
if self.instance:
user = self.instance
user.username = username
user.email = email
if password:
user.set_password(password)
else:
user = User.objects.create_user(username=username, email=email, password=password)
if role.name == 'Administrador' or role.name == 'Administrador Finanzas':
user.is_staff = True
user.is_superuser = True
else:
user.is_staff = False
user.is_superuser = False
user.save()
user.groups.clear()
user.groups.add(role)
profile, _ = UserProfile.objects.get_or_create(user=user)
profile.description = description
profile.rut = rut
profile.job_title = job_title
profile.save()
return user
class DocumentForm(forms.ModelForm):
class Meta:
model = Document
fields = ['file', 'title']
+155
View File
@@ -0,0 +1,155 @@
# Generated by Django 6.1 on 2026-08-14 17:39
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SystemSetting',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('global_budget', models.DecimalField(decimal_places=2, default=25000000.0, max_digits=15, verbose_name='Presupuesto General I+D+i')),
('usd_exchange_rate', models.DecimalField(decimal_places=2, default=950.0, max_digits=10, verbose_name='Tasa USD/CLP por Defecto')),
],
options={
'verbose_name': 'Configuración del Sistema',
'verbose_name_plural': 'Configuraciones del Sistema',
},
),
migrations.CreateModel(
name='AuditLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(max_length=100, verbose_name='Acción')),
('entity_name', models.CharField(blank=True, max_length=100, null=True, verbose_name='Entidad (Tabla)')),
('entity_id', models.CharField(blank=True, max_length=100, null=True, verbose_name='ID Entidad')),
('field_changed', models.CharField(blank=True, max_length=100, null=True, verbose_name='Campo Modificado')),
('old_value', models.TextField(blank=True, null=True, verbose_name='Valor Anterior')),
('new_value', models.TextField(blank=True, null=True, verbose_name='Valor Nuevo')),
('description', models.TextField(verbose_name='Detalle del Cambio')),
('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='Fecha y Hora')),
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Usuario')),
],
options={
'verbose_name': 'Log de Auditoría (SAP CDHDR/CDPOS)',
'verbose_name_plural': 'Logs de Auditoría',
'ordering': ['-timestamp'],
},
),
migrations.CreateModel(
name='Category',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subcategories', to='expenses.category')),
],
options={
'verbose_name': 'Categoría',
'verbose_name_plural': 'Categorías',
},
),
migrations.CreateModel(
name='Expense',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('expense_code', models.CharField(default=uuid.uuid4, max_length=50, unique=True, verbose_name='Código Transacción')),
('amount_clp', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto Neto (CLP)')),
('amount_usd', models.DecimalField(decimal_places=2, max_digits=15, verbose_name='Monto Neto (USD)')),
('currency', models.CharField(choices=[('CLP', 'Pesos Chilenos'), ('USD', 'Dólares Estadounidenses')], default='CLP', max_length=10, verbose_name='Moneda Original')),
('exchange_rate', models.DecimalField(decimal_places=2, default=950.0, max_digits=10, 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(blank=True, max_length=200, null=True, verbose_name='Proveedor / Emisor')),
('region', models.CharField(blank=True, max_length=100, null=True, verbose_name='Región (Chile)')),
('comuna', models.CharField(blank=True, max_length=100, null=True, verbose_name='Comuna')),
('vehicle_ownership', models.CharField(blank=True, max_length=100, null=True, verbose_name='Modalidad / Vehículo')),
('approval_status', models.CharField(choices=[('EN_REVISION_DOCUMENTAL', 'En Revisión Documental (Paso 1)'), ('SOLICITADO_FINANZAS', 'Solicitado a Finanzas (Paso 2)'), ('LIBERADO_PAGADO', 'Pago Liberado (Paso 3)'), ('OBSERVADO_RECHAZADO', 'Observado / Rechazado')], default='EN_REVISION_DOCUMENTAL', max_length=50, verbose_name='Estado de Aprobación')),
('rejection_reason', models.TextField(blank=True, null=True, verbose_name='Motivo de Observación/Rechazo')),
('paid_at', models.DateTimeField(blank=True, null=True, verbose_name='Fecha de Pago')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('category', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses', to='expenses.category', verbose_name='Subcategoría')),
('elevated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses_elevated', to=settings.AUTH_USER_MODEL, verbose_name='Elevado por (Paso 2)')),
('paid_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses_paid', to=settings.AUTH_USER_MODEL, verbose_name='Pagado por (Paso 3)')),
('responsible_person', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='expenses_responsible', to=settings.AUTH_USER_MODEL, verbose_name='Responsable Solicitante')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses_created', to=settings.AUTH_USER_MODEL, verbose_name='Registrado por')),
],
options={
'verbose_name': 'Gasto',
'verbose_name_plural': 'Gastos',
'ordering': ['-date', '-created_at'],
},
),
migrations.CreateModel(
name='Document',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to='evidences/', verbose_name='Archivo Adjunto')),
('file_type', models.CharField(blank=True, max_length=50, verbose_name='Tipo de Archivo')),
('title', models.CharField(blank=True, max_length=200, verbose_name='Título del Documento')),
('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Fecha de Subida')),
('uploaded_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL, verbose_name='Subido por')),
('expense', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='expenses.expense', verbose_name='Gasto Relacionado')),
],
options={
'verbose_name': 'Documento Evidencia',
'verbose_name_plural': 'Documentos Evidencias',
},
),
migrations.CreateModel(
name='Project',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(default='PROY-000', max_length=50, unique=True, verbose_name='Código')),
('name', models.CharField(max_length=200, verbose_name='Nombre del Proyecto/Centro')),
('description', models.TextField(blank=True, verbose_name='Descripción')),
('assigned_budget_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Presupuesto Asignado (CLP)')),
('assigned_budget_usd', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Presupuesto Asignado (USD)')),
('actual_cost_clp', models.DecimalField(decimal_places=2, default=0, max_digits=15, verbose_name='Costo Real Acumulado (CLP)')),
('type', models.CharField(choices=[('COSTO_DIRECTO_PROYECTO', 'Costo Directo Proyecto'), ('GASTO_OPERACIONAL_OVERHEAD', 'Gasto Operacional Overhead')], default='COSTO_DIRECTO_PROYECTO', max_length=50, verbose_name='Tipo de Centro')),
('status', models.CharField(default='on-budget', max_length=50, verbose_name='Estado de Presupuesto')),
('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')),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_projects', to=settings.AUTH_USER_MODEL, verbose_name='Creado por')),
('manager', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='managed_projects', to=settings.AUTH_USER_MODEL, verbose_name='Encargado')),
],
options={
'verbose_name': 'Proyecto / Centro de Costo',
'verbose_name_plural': 'Proyectos y Centros',
},
),
migrations.AddField(
model_name='expense',
name='project',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='expenses', to='expenses.project', verbose_name='Proyecto'),
),
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('rut', models.CharField(blank=True, max_length=20, null=True, verbose_name='RUT')),
('job_title', models.CharField(blank=True, max_length=150, null=True, verbose_name='Cargo')),
('description', models.TextField(blank=True, verbose_name='Descripción del Responsable')),
('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')),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL, verbose_name='Usuario')),
],
options={
'verbose_name': 'Perfil de Usuario',
'verbose_name_plural': 'Perfiles de Usuario',
},
),
]
View File
+163
View File
@@ -0,0 +1,163 @@
from django.db import models
from django.contrib.auth.models import User
import uuid
# Enums
class CurrencyEnum(models.TextChoices):
CLP = 'CLP', 'Pesos Chilenos'
USD = 'USD', 'Dólares Estadounidenses'
class ExpenseTypeEnum(models.TextChoices):
COSTO_DIRECTO = 'COSTO_DIRECTO_PROYECTO', 'Costo Directo Proyecto'
GASTO_OPERACIONAL = 'GASTO_OPERACIONAL_OVERHEAD', 'Gasto Operacional Overhead'
class ApprovalStatusEnum(models.TextChoices):
EN_REVISION = 'EN_REVISION_DOCUMENTAL', 'En Revisión Documental (Paso 1)'
SOLICITADO_FINANZAS = 'SOLICITADO_FINANZAS', 'Solicitado a Finanzas (Paso 2)'
LIBERADO_PAGADO = 'LIBERADO_PAGADO', 'Pago Liberado (Paso 3)'
OBSERVADO_RECHAZADO = 'OBSERVADO_RECHAZADO', 'Observado / Rechazado'
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')
name = models.CharField(max_length=200, verbose_name="Nombre del Proyecto/Centro")
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")
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")
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")
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='created_projects', verbose_name="Creado por")
def __str__(self):
return f"{self.code} - {self.name}"
class Meta:
verbose_name = "Proyecto / Centro de Costo"
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')
def __str__(self):
return f"{self.code}: {self.name}"
class Meta:
verbose_name = "Categoría"
verbose_name_plural = "Categorías"
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")
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses', verbose_name="Subcategoría")
# 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)")
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")
# Geographic & Logistic fields (Operaciones en terreno)
region = models.CharField(max_length=100, blank=True, null=True, verbose_name="Región (Chile)")
comuna = models.CharField(max_length=100, blank=True, null=True, verbose_name="Comuna")
vehicle_ownership = models.CharField(max_length=100, blank=True, null=True, verbose_name="Modalidad / Vehículo")
# 3-step Approval Flow Tracking
approval_status = models.CharField(max_length=50, choices=ApprovalStatusEnum.choices, default=ApprovalStatusEnum.EN_REVISION, verbose_name="Estado de Aprobación")
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='expenses_created', verbose_name="Registrado por")
responsible_person = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='expenses_responsible', verbose_name="Responsable Solicitante")
elevated_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_elevated', verbose_name="Elevado por (Paso 2)")
paid_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, related_name='expenses_paid', verbose_name="Pagado por (Paso 3)")
rejection_reason = models.TextField(blank=True, null=True, verbose_name="Motivo de Observación/Rechazo")
paid_at = models.DateTimeField(null=True, blank=True, verbose_name="Fecha de Pago")
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
def __str__(self):
return f"{self.expense_code} - {self.amount_clp} CLP ({self.date})"
class Meta:
verbose_name = "Gasto"
verbose_name_plural = "Gastos"
ordering = ['-date', '-created_at']
class Document(models.Model):
expense = models.ForeignKey(Expense, on_delete=models.CASCADE, related_name='documents', verbose_name="Gasto Relacionado")
file = models.FileField(upload_to='evidences/', verbose_name="Archivo Adjunto")
file_type = models.CharField(max_length=50, blank=True, verbose_name="Tipo de Archivo")
title = models.CharField(max_length=200, blank=True, verbose_name="Título del Documento")
uploaded_at = models.DateTimeField(auto_now_add=True, verbose_name="Fecha de Subida")
uploaded_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, verbose_name="Subido por")
def __str__(self):
return f"Evidencia para {self.expense.expense_code}"
class Meta:
verbose_name = "Documento Evidencia"
verbose_name_plural = "Documentos Evidencias"
class UserProfile(models.Model):
user = models.OneToOneField(User, on_delete=models.CASCADE, related_name='profile', verbose_name="Usuario")
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")
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")
def __str__(self):
return f"Perfil de {self.user.username}"
class Meta:
verbose_name = "Perfil de Usuario"
verbose_name_plural = "Perfiles de Usuario"
class AuditLog(models.Model):
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True, verbose_name="Usuario")
action = models.CharField(max_length=100, verbose_name="Acción")
entity_name = models.CharField(max_length=100, blank=True, null=True, verbose_name="Entidad (Tabla)")
entity_id = models.CharField(max_length=100, blank=True, null=True, verbose_name="ID Entidad")
field_changed = models.CharField(max_length=100, blank=True, null=True, verbose_name="Campo Modificado")
old_value = models.TextField(blank=True, null=True, verbose_name="Valor Anterior")
new_value = models.TextField(blank=True, null=True, verbose_name="Valor Nuevo")
description = models.TextField(verbose_name="Detalle del Cambio")
timestamp = models.DateTimeField(auto_now_add=True, verbose_name="Fecha y Hora")
def __str__(self):
return f"{self.timestamp} - {self.user.username if self.user else 'Sistema'}: {self.action}"
class Meta:
ordering = ['-timestamp']
verbose_name = "Log de Auditoría (SAP CDHDR/CDPOS)"
verbose_name_plural = "Logs de Auditoría"
class SystemSetting(models.Model):
global_budget = models.DecimalField(max_digits=15, decimal_places=2, default=25000000.00, verbose_name="Presupuesto General I+D+i")
usd_exchange_rate = models.DecimalField(max_digits=10, decimal_places=2, default=950.0, verbose_name="Tasa USD/CLP por Defecto")
def __str__(self):
return "Configuración Global EFINTEL"
class Meta:
verbose_name = "Configuración del Sistema"
verbose_name_plural = "Configuraciones del Sistema"
+6
View File
@@ -0,0 +1,6 @@
from django.urls import re_path
from . import consumers
websocket_urlpatterns = [
re_path(r'ws/expenses/$', consumers.ExpenseConsumer.as_asgi()),
]
+41
View File
@@ -0,0 +1,41 @@
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from channels.layers import get_channel_layer
from asgiref.sync import async_to_sync
from .models import Expense
@receiver(post_save, sender=Expense)
def expense_saved(sender, instance, created, **kwargs):
channel_layer = get_channel_layer()
action = 'created' if created else 'updated'
expense_data = {
'id': instance.id,
'amount': str(instance.amount),
'category': instance.category.name if instance.category else None,
'project': instance.project.name if instance.project else None,
'user': instance.user.username if instance.user else None,
'date': instance.date.strftime('%Y-%m-%d'),
'description': instance.description,
}
async_to_sync(channel_layer.group_send)(
'expenses_updates',
{
'type': 'expense_update',
'action': action,
'expense': expense_data
}
)
@receiver(post_delete, sender=Expense)
def expense_deleted(sender, instance, **kwargs):
channel_layer = get_channel_layer()
async_to_sync(channel_layer.group_send)(
'expenses_updates',
{
'type': 'expense_update',
'action': 'deleted',
'expense': {'id': instance.id}
}
)
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+32
View File
@@ -0,0 +1,32 @@
from django.urls import path
from . import views
urlpatterns = [
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
path('', views.dashboard, name='dashboard'),
path('gastos/', views.expense_list, name='expense_list'),
path('gastos/nuevo/', views.expense_create, name='expense_create'),
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
path('gastos/<int:pk>/aprobar/<str:action>/', views.approve_expense, name='approve_expense'),
# Export / Import
path('gastos/exportar/csv/', views.export_expenses_csv, name='export_csv'),
path('proyectos/', views.project_list, name='project_list'),
path('gestion/', views.gestion_panel, name='gestion_panel'),
path('gestion/configuracion/', views.settings_edit, name='settings_edit'),
path('gestion/proyectos/nuevo/', views.project_create, name='project_create'),
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/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'),
]
+421
View File
@@ -0,0 +1,421 @@
import csv
import io
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from django.contrib.auth import login, authenticate, logout
from django.contrib import messages
from django.db.models import Sum, Count, F
from django.utils import timezone
from datetime import date
from django.http import JsonResponse, HttpResponse
from .models import Expense, Project, Category, SystemSetting, UserProfile, AuditLog, ApprovalStatusEnum, CurrencyEnum
from .forms import ExpenseForm, ProjectForm, SystemSettingForm, ResponsableForm
from .decorators import role_required
from django.contrib.auth.models import User, Group
def login_view(request):
if request.user.is_authenticated:
return redirect('dashboard')
if request.method == 'POST':
username = request.POST.get('username')
password = request.POST.get('password')
user = authenticate(request, username=username, password=password)
if user is not None:
login(request, user)
return redirect('dashboard')
else:
messages.error(request, 'Usuario o contraseña incorrectos.')
return render(request, 'registration/login.html')
def logout_view(request):
logout(request)
return redirect('login')
@login_required
def dashboard(request):
today = date.today()
first_day_of_month = today.replace(day=1)
total_month_clp = Expense.objects.filter(
date__gte=first_day_of_month
).aggregate(total=Sum('amount_clp'))['total'] or 0
total_all_clp = Expense.objects.aggregate(
total=Sum('amount_clp')
)['total'] or 0
project_count = Project.objects.count()
today_count = Expense.objects.filter(date=today).count()
recent_expenses = Expense.objects.select_related(
'project', 'category', 'user', 'responsible_person'
).order_by('-date')[:10]
# Presupuesto Global
setting, _ = SystemSetting.objects.get_or_create(id=1)
global_budget = setting.global_budget
remaining_budget = global_budget - total_all_clp
context = {
'total_month': total_month_clp,
'total_all': total_all_clp,
'project_count': project_count,
'today_count': today_count,
'recent_expenses': recent_expenses,
'global_budget': global_budget,
'remaining_budget': remaining_budget,
}
return render(request, 'expenses/dashboard.html', context)
@login_required
def expense_list(request):
expenses = Expense.objects.select_related('project', 'category', 'user').order_by('-date')
# Filtros
project_id = request.GET.get('project')
category_id = request.GET.get('category')
if project_id:
expenses = expenses.filter(project_id=project_id)
if category_id:
expenses = expenses.filter(category_id=category_id)
context = {
'expenses': expenses,
'projects': Project.objects.all(),
'categories': Category.objects.all(),
'selected_project': project_id,
'selected_category': category_id,
}
return render(request, 'expenses/expense_list.html', context)
@login_required
def expense_create(request):
# Only allow creation if user is admin or allowed to
if request.method == 'POST':
form = ExpenseForm(request.POST, request.FILES)
if form.is_valid():
expense = form.save(commit=False)
expense.user = request.user
# Auto convert to USD if CLP
setting, _ = SystemSetting.objects.get_or_create(id=1)
if expense.currency == CurrencyEnum.CLP:
expense.amount_usd = expense.amount_clp / setting.usd_exchange_rate
else:
expense.amount_usd = expense.amount_clp
expense.amount_clp = expense.amount_usd * setting.usd_exchange_rate
expense.save()
receipt = request.FILES.get('receipt')
if receipt:
from .models import Document
Document.objects.create(expense=expense, file=receipt, title=receipt.name, uploaded_by=request.user)
AuditLog.objects.create(
user=request.user,
action="Gasto Creado",
entity_name="Expense",
entity_id=expense.expense_code,
description=f"Se creó el gasto por {expense.amount_clp} CLP"
)
messages.success(request, 'Gasto registrado exitosamente.')
return redirect('expense_list')
else:
form = ExpenseForm()
return render(request, 'expenses/expense_form.html', {'form': form})
@login_required
def expense_edit(request, pk):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
if not (request.user.is_superuser or 'Administrador' in user_groups or expense.user == request.user):
messages.error(request, 'No tienes permisos para editar este gasto.')
return redirect('expense_list')
old_amount = expense.amount_clp
if request.method == 'POST':
form = ExpenseForm(request.POST, request.FILES, instance=expense)
if form.is_valid():
exp = form.save(commit=False)
setting, _ = SystemSetting.objects.get_or_create(id=1)
exp.amount_usd = exp.amount_clp / setting.usd_exchange_rate
exp.save()
receipt = request.FILES.get('receipt')
if receipt:
from .models import Document
# Optionally delete old receipt: Document.objects.filter(expense=exp).delete()
Document.objects.create(expense=exp, file=receipt, title=receipt.name, uploaded_by=request.user)
AuditLog.objects.create(
user=request.user,
action="Gasto Editado",
entity_name="Expense",
entity_id=exp.expense_code,
field_changed="amount_clp",
old_value=str(old_amount),
new_value=str(exp.amount_clp),
description=f"Se editó el monto del gasto"
)
messages.success(request, 'Gasto actualizado exitosamente.')
return redirect('expense_list')
else:
form = ExpenseForm(instance=expense)
return render(request, 'expenses/expense_form.html', {'form': form})
@login_required
def expense_delete(request, pk):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
if not (request.user.is_superuser or 'Administrador' in user_groups):
messages.error(request, 'Solo administradores pueden eliminar gastos.')
return redirect('expense_list')
if request.method == 'POST':
code = expense.expense_code
expense.delete()
AuditLog.objects.create(
user=request.user,
action="Gasto Eliminado",
entity_name="Expense",
entity_id=code,
description="Se eliminó el gasto de la base de datos"
)
messages.success(request, 'Gasto eliminado.')
return redirect('expense_list')
# Flujo de 3 pasos
@login_required
def approve_expense(request, pk, action):
expense = get_object_or_404(Expense, pk=pk)
user_groups = request.user.groups.values_list('name', flat=True)
is_admin = request.user.is_superuser or 'Administrador' in user_groups
is_finanzas = 'Administrador Finanzas' in user_groups or is_admin
if action == 'solicitar':
# Step 1 -> 2
expense.approval_status = ApprovalStatusEnum.SOLICITADO_FINANZAS
expense.elevated_by = request.user
expense.save()
AuditLog.objects.create(
user=request.user,
action="ELEVATE_REQUEST",
entity_name="Expense",
entity_id=expense.expense_code,
description="Solicitud elevada a finanzas"
)
messages.success(request, "Solicitud elevada a finanzas.")
elif action == 'pagar':
# Step 2 -> 3
if not is_finanzas:
messages.error(request, "Solo Finanzas puede liberar el pago.")
return redirect('dashboard')
expense.approval_status = ApprovalStatusEnum.LIBERADO_PAGADO
expense.paid_by = request.user
expense.paid_at = timezone.now()
expense.save()
# Update Project Cost
proj = expense.project
proj.actual_cost_clp = proj.actual_cost_clp + expense.amount_clp
proj.save()
AuditLog.objects.create(
user=request.user,
action="APPROVE_PAYMENT",
entity_name="Expense",
entity_id=expense.expense_code,
description="Pago liberado"
)
messages.success(request, "Pago liberado con éxito.")
elif action == 'rechazar':
expense.approval_status = ApprovalStatusEnum.OBSERVADO_RECHAZADO
expense.save()
AuditLog.objects.create(
user=request.user,
action="REJECT",
entity_name="Expense",
entity_id=expense.expense_code,
description="Gasto observado/rechazado"
)
messages.warning(request, "Gasto rechazado.")
return redirect(request.META.get('HTTP_REFERER', 'dashboard'))
@login_required
def export_expenses_csv(request):
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = f'attachment; filename="Gastos_EFINTEL_{date.today()}.csv"'
writer = csv.writer(response, delimiter=';')
writer.writerow([
'ID_Gasto', 'Codigo_Transaccion', 'Fecha', 'Proyecto_Codigo', 'Proyecto_Nombre',
'Monto_CLP', 'Monto_USD', 'Moneda_Original', 'Estado_Aprobacion',
'Responsable', 'Detalle', 'Region', 'Comuna', 'Proveedor'
])
expenses = Expense.objects.all().select_related('project', 'responsible_person')
for exp in expenses:
writer.writerow([
exp.id,
exp.expense_code,
exp.date,
exp.project.code,
exp.project.name,
exp.amount_clp,
exp.amount_usd,
exp.currency,
exp.get_approval_status_display(),
exp.responsible_person.username if exp.responsible_person else '',
exp.description,
exp.region,
exp.comuna,
exp.vendor
])
AuditLog.objects.create(
user=request.user,
action="EXPORT_CSV",
description="Se descargó la matriz de gastos en formato CSV."
)
return response
@login_required
def project_list(request):
projects = Project.objects.annotate(
expense_count=Count('expenses'),
total_expenses=Sum('expenses__amount_clp')
).select_related('manager')
context = {
'projects': projects,
}
return render(request, 'expenses/project_list.html', context)
@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')
settings_obj, _ = SystemSetting.objects.get_or_create(id=1)
logs = AuditLog.objects.all().select_related('user')[:100]
context = {
'projects': projects,
'responsables': responsables,
'settings': settings_obj,
'logs': logs,
}
return render(request, 'expenses/gestion.html', context)
@login_required
@role_required('Administrador')
def settings_edit(request):
setting, _ = SystemSetting.objects.get_or_create(id=1)
if request.method == 'POST':
form = SystemSettingForm(request.POST, instance=setting)
if form.is_valid():
form.save()
messages.success(request, 'Configuración actualizada.')
return redirect('gestion_panel')
else:
form = SystemSettingForm(instance=setting)
return render(request, 'expenses/settings_form.html', {'form': form})
@login_required
@role_required('Administrador')
def project_create(request):
if request.method == 'POST':
form = ProjectForm(request.POST)
if form.is_valid():
project = form.save(commit=False)
project.created_by = request.user
project.save()
messages.success(request, 'Proyecto creado exitosamente.')
return redirect('gestion_panel')
else:
form = ProjectForm()
return render(request, 'expenses/project_form.html', {'form': form})
@login_required
@role_required('Administrador')
def project_edit(request, pk):
project = get_object_or_404(Project, pk=pk)
if request.method == 'POST':
form = ProjectForm(request.POST, instance=project)
if form.is_valid():
form.save()
messages.success(request, 'Proyecto actualizado exitosamente.')
return redirect('gestion_panel')
else:
form = ProjectForm(instance=project)
return render(request, 'expenses/project_form.html', {'form': form})
@login_required
@role_required('Administrador')
def project_delete(request, pk):
project = get_object_or_404(Project, pk=pk)
if request.method == 'POST':
project.delete()
messages.success(request, 'Proyecto eliminado.')
return redirect('gestion_panel')
@login_required
@role_required('Administrador')
def responsable_create(request):
if request.method == 'POST':
form = ResponsableForm(request.POST)
if form.is_valid():
form.save()
messages.success(request, 'Responsable creado exitosamente.')
return redirect('gestion_panel')
else:
form = ResponsableForm()
return render(request, 'expenses/responsable_form.html', {'form': form})
@login_required
@role_required('Administrador')
def responsable_edit(request, pk):
user = get_object_or_404(User, pk=pk)
if request.method == 'POST':
form = ResponsableForm(request.POST, instance=user)
if form.is_valid():
form.save()
messages.success(request, 'Responsable actualizado exitosamente.')
return redirect('gestion_panel')
else:
form = ResponsableForm(instance=user)
return render(request, 'expenses/responsable_form.html', {'form': form, 'edit_mode': True, 'target_user': user})
@login_required
@role_required('Administrador')
def responsable_delete(request, pk):
user = get_object_or_404(User, pk=pk)
if request.method == 'POST':
user.delete()
messages.success(request, 'Responsable eliminado.')
return redirect('gestion_panel')
Executable
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()
+28
View File
@@ -0,0 +1,28 @@
asgiref==3.12.1
attrs==26.1.0
autobahn==26.7.1
Automat==25.4.16
cbor2==6.1.4
cffi==2.1.1
channels==4.3.2
constantly==23.10.4
cryptography==50.0.0
daphne==4.2.3
distlib==0.3.8
Django==6.1
hyperlink==21.0.0
idna==3.18
Incremental==24.11.0
msgpack==1.2.1
packaging==24.0
pycparser==3.0
pyOpenSSL==26.4.0
service-identity==26.1.0
setuptools==68.1.2
sqlparse==0.6.0
Twisted==26.4.0
txaio==26.6.1
typing_extensions==4.16.0
ujson==5.13.0
wheel==0.42.0
zope.interface==8.5
+46
View File
@@ -0,0 +1,46 @@
import os
import django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth.models import User, Group
from expenses.models import UserProfile
# Limpiar grupos anteriores si existen
Group.objects.all().delete()
# Roles: Administrador, Administrador Finanzas, Director de Proyecto
admin_group, _ = Group.objects.get_or_create(name='Administrador')
finanzas_group, _ = Group.objects.get_or_create(name='Administrador Finanzas')
director_group, _ = Group.objects.get_or_create(name='Director de Proyecto')
# Create test users
users_to_create = [
('admin', 'admin', admin_group, True),
('finanzas', 'finanzas123', finanzas_group, False),
('miguel_contreras', 'miguel123', director_group, False),
('ginevra_ortega', 'ginevra123', admin_group, True),
]
for username, password, group, is_staff in users_to_create:
user, created = User.objects.get_or_create(username=username)
user.is_staff = is_staff
user.is_superuser = is_staff
user.set_password(password)
user.save()
user.groups.clear()
user.groups.add(group)
if created:
print(f"Created user: {username} with role {group.name}")
else:
print(f"Updated user: {username} with role {group.name}")
UserProfile.objects.get_or_create(user=user, defaults={
'description': f'Perfil corporativo de {username} ({group.name})',
'job_title': group.name
})
print("Roles y usuarios EFINTEL creados exitosamente.")
Executable
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env bash
# ============================================================
# EFINTEL ERP Interno (Django) — Script de Arranque Local
# ============================================================
# Uso en Mac / Linux:
# chmod +x start.sh (solo la primera vez)
# ./start.sh
#
# Para detener: Ctrl+C
# ============================================================
set -e
# Colores
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
YELLOW='\033[1;33m'
BOLD='\033[1m'
NC='\033[0m'
echo ""
echo -e "${CYAN}${BOLD}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}${BOLD}║ EFINTEL ERP Interno I+D+i — Django Server ║${NC}"
echo -e "${CYAN}${BOLD}║ Sistema de Control de Gastos & Proyectos ║${NC}"
echo -e "${CYAN}${BOLD}╚══════════════════════════════════════════════════════╝${NC}"
echo ""
# Cambiar al directorio del script
cd "$(dirname "$0")"
# ---- Paso 1: Verificar Python 3 ----
echo -e "${BLUE}[1/6]${NC} Verificando Python 3..."
if ! command -v python3 &> /dev/null; then
echo -e "${RED}❌ Python 3 no está instalado.${NC}"
echo ""
echo " Para instalar en macOS:"
echo " 1. Instala Homebrew:"
echo " /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
echo ""
echo " 2. Instala Python:"
echo " brew install python@3.12"
echo ""
exit 1
fi
PYTHON_VERSION=$(python3 --version 2>&1)
echo -e " ${GREEN}✅ ${PYTHON_VERSION}${NC}"
# ---- Paso 2: Crear venv si no existe o si es de otro OS ----
echo -e "${BLUE}[2/6]${NC} Verificando entorno virtual (venv)..."
NEEDS_VENV=false
if [ ! -d "venv" ]; then
NEEDS_VENV=true
echo -e " ${YELLOW}⚠ No existe venv, creando uno nuevo...${NC}"
elif [ ! -f "venv/bin/activate" ]; then
# venv corrupto o de Windows
NEEDS_VENV=true
echo -e " ${YELLOW}⚠ venv inválido detectado, recreando...${NC}"
rm -rf venv
fi
if [ "$NEEDS_VENV" = true ]; then
python3 -m venv venv
echo -e " ${GREEN}✅ venv creado exitosamente${NC}"
else
echo -e " ${GREEN}✅ venv ya existe${NC}"
fi
# ---- Paso 3: Activar venv ----
echo -e "${BLUE}[3/6]${NC} Activando entorno virtual..."
source venv/bin/activate
echo -e " ${GREEN}✅ venv activado ($(which python))${NC}"
# ---- Paso 4: Instalar dependencias ----
echo -e "${BLUE}[4/6]${NC} Instalando dependencias de Django..."
pip install --quiet --upgrade pip
pip install --quiet -r requirements.txt
echo -e " ${GREEN}✅ 29 paquetes instalados (Django 6.1, Channels, Daphne, etc.)${NC}"
# ---- Paso 5: Migraciones y datos iniciales ----
echo -e "${BLUE}[5/6]${NC} Configurando base de datos..."
# Ejecutar migraciones
python manage.py migrate --run-syncdb --verbosity 0
echo -e " ${GREEN}✅ Migraciones aplicadas${NC}"
# Crear roles y usuarios si no existen
if ! python -c "
import os, django
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings')
django.setup()
from django.contrib.auth.models import User
exit(0 if User.objects.filter(username='admin').exists() else 1)
" 2>/dev/null; then
echo -e " ${YELLOW}⚠ Primera ejecución — creando usuarios y roles EFINTEL...${NC}"
python setup_roles.py
echo -e " ${GREEN}✅ Usuarios creados:${NC}"
echo -e " ${BOLD}admin${NC} / admin (Administrador)"
echo -e " ${BOLD}ginevra_ortega${NC} / ginevra123 (Administrador)"
echo -e " ${BOLD}finanzas${NC} / finanzas123 (Adm. Finanzas)"
echo -e " ${BOLD}miguel_contreras${NC} / miguel123 (Director Proyecto)"
else
echo -e " ${GREEN}✅ Usuarios ya configurados${NC}"
fi
# ---- Paso 6: Iniciar servidor ----
PORT=8000
URL="http://localhost:${PORT}"
echo ""
echo -e "${BLUE}[6/6]${NC} Iniciando servidor Django (Daphne)..."
echo ""
echo -e "${GREEN}${BOLD} ┌──────────────────────────────────────────────────────┐${NC}"
echo -e "${GREEN}${BOLD} │ 🚀 EFINTEL ERP corriendo en: ${URL} │${NC}"
echo -e "${GREEN}${BOLD} │ 🔑 Admin Django: ${URL}/admin/ │${NC}"
echo -e "${GREEN}${BOLD} │ 📋 Presiona Ctrl+C para detener │${NC}"
echo -e "${GREEN}${BOLD} └──────────────────────────────────────────────────────┘${NC}"
echo ""
echo -e "${CYAN} Cuentas de acceso:${NC}"
echo -e " admin / admin | finanzas / finanzas123"
echo -e " ginevra_ortega / ginevra123 | miguel_contreras / miguel123"
echo ""
# Abrir navegador automáticamente
if [[ "$OSTYPE" == "darwin"* ]]; then
sleep 2 && open "${URL}" &
elif command -v xdg-open &> /dev/null; then
sleep 2 && xdg-open "${URL}" &
fi
# Ejecutar con Daphne (ASGI server para WebSockets)
python -m daphne -b 0.0.0.0 -p ${PORT} config.asgi:application
+1050
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

+28
View File
@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 520 180" width="100%" height="100%">
<defs>
<style>
.bg-symbol { fill: #0078A8; }
.e-cutout { fill: #FFFFFF; }
.text-fintel { fill: #4B4B4B; font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-weight: 700; font-size: 110px; }
.text-reg { fill: #4B4B4B; font-family: 'Inter', sans-serif; font-size: 24px; font-weight: 600; }
.text-tagline { fill: #4B4B4B; font-family: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-weight: 400; font-size: 32px; letter-spacing: -0.5px; }
</style>
</defs>
<!-- Symbol: Circle with stylized 'e' -->
<g transform="translate(10, 10)">
<!-- Blue outer circle -->
<circle cx="65" cy="65" r="62" class="bg-symbol" />
<!-- White Inner 'e' shape -->
<!-- Top inner curve & horizontal bar -->
<path d="M 32 65 C 32 45, 48 33, 65 33 C 82 33, 98 45, 98 65 L 42 65 C 43 78, 52 86, 66 86 C 76 86, 85 81, 91 74 L 97 82 C 89 92, 77 98, 65 98 C 45 98, 32 83, 32 65 Z M 42 54 L 88 54 C 86 44, 77 41, 65 41 C 53 41, 44 44, 42 54 Z" class="e-cutout" />
</g>
<!-- Wordmark 'fintel' -->
<text x="145" y="105" class="text-fintel">fintel</text>
<text x="475" y="55" class="text-reg">®</text>
<!-- Tagline 'eficiencia energetica inteligente' -->
<text x="5" y="165" class="text-tagline">eficiencia energetica inteligente</text>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+142
View File
@@ -0,0 +1,142 @@
// App Logic for Control de Gastos
// Toast notification system
function showToast(message, type = 'info') {
const container = document.getElementById('toast-container');
if (!container) return;
const toast = document.createElement('div');
toast.className = 'toast';
if (type === 'success') {
toast.style.borderLeftColor = 'var(--accent-secondary)';
} else if (type === 'danger') {
toast.style.borderLeftColor = 'var(--danger)';
}
toast.innerHTML = `<div>${message}</div>`;
container.appendChild(toast);
// Trigger animation
setTimeout(() => {
toast.classList.add('show');
}, 10);
// Remove toast after 5 seconds
setTimeout(() => {
toast.classList.remove('show');
setTimeout(() => {
if (container.contains(toast)) {
container.removeChild(toast);
}
}, 300);
}, 5000);
}
// WebSocket connection for real-time updates
let wsReconnectAttempts = 0;
const maxWsReconnectAttempts = 5;
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws/expenses/`;
// We only connect if we are not on the login page
if (document.querySelector('.login-page')) return;
const ws = new WebSocket(wsUrl);
ws.onopen = function(e) {
console.log('WebSocket connected');
wsReconnectAttempts = 0;
};
ws.onmessage = function(e) {
const data = JSON.parse(e.data);
const action = data.action;
const expense = data.expense;
const tbody = document.getElementById('expenses-table-body');
if (!tbody || !expense) return;
if (action === 'created') {
const tr = document.createElement('tr');
tr.id = `expense-row-${expense.id}`;
tr.innerHTML = `
<td>${expense.date}</td>
<td>${expense.project}</td>
<td>${expense.category}</td>
${document.querySelector('th:nth-child(4)') && document.querySelector('th:nth-child(4)').textContent === 'Descripción' ? `<td>${expense.description || ''}</td>` : ''}
<td>$${expense.amount}</td>
<td>${expense.user}</td>
${document.querySelector('th:nth-child(7)') ? `
<td>
<div style="display: flex; gap: 0.5rem;">
<span style="font-size:0.8rem; color:var(--text-secondary);">Recarga para ver acciones</span>
</div>
</td>` : ''}
`;
// Add animation class
tr.style.backgroundColor = 'rgba(0, 212, 170, 0.2)';
tr.style.transition = 'background-color 2s ease';
tbody.insertBefore(tr, tbody.firstChild);
// Remove highlight after animation
setTimeout(() => {
tr.style.backgroundColor = '';
}, 2000);
showToast('Nuevo gasto registrado', 'success');
}
else if (action === 'updated') {
const row = document.getElementById(`expense-row-${expense.id}`);
if (row) {
// Flash the row
row.style.backgroundColor = 'rgba(108, 99, 255, 0.2)';
row.style.transition = 'background-color 2s ease';
setTimeout(() => {
row.style.backgroundColor = '';
}, 2000);
showToast('Gasto actualizado', 'info');
}
}
else if (action === 'deleted') {
const row = document.getElementById(`expense-row-${expense.id}`);
if (row) {
row.style.transform = 'translateX(100%)';
row.style.opacity = '0';
row.style.transition = 'all 0.3s ease';
setTimeout(() => {
if (row.parentNode) {
row.parentNode.removeChild(row);
}
}, 300);
showToast('Gasto eliminado', 'danger');
}
}
};
ws.onclose = function(e) {
console.error('WebSocket closed unexpectedly');
if (wsReconnectAttempts < maxWsReconnectAttempts) {
wsReconnectAttempts++;
const timeout = Math.min(1000 * Math.pow(2, wsReconnectAttempts), 30000);
console.log(`Attempting reconnect in ${timeout}ms...`);
setTimeout(connectWebSocket, timeout);
}
};
}
document.addEventListener('DOMContentLoaded', () => {
connectWebSocket();
// Convert CSRF token for fetch requests if needed later
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
});
+86
View File
@@ -0,0 +1,86 @@
{% load static %}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EFINTEL | ERP Interno I+D+i</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@500;700;800&display=swap" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="{% static 'css/style.css' %}">
<meta name="csrf-token" content="{{ csrf_token }}">
</head>
<body>
<!-- Top Corporate ERP Header -->
<header class="app-header">
<div class="brand-section">
<div class="brand-logo-container">
<img src="{% static 'img/efintel_logo.png' %}" alt="EFINTEL Logo" class="brand-logo-img" />
</div>
<div class="brand-title">
<h1>EFINTEL | ERP Interno I+D+i</h1>
<p>Sistema Integrado de Control Financiero, Operaciones, Talento y Proyectos</p>
</div>
</div>
<!-- Normalized Action Bar for ERP -->
<div class="header-actions">
<div style="font-size:0.8rem; font-weight: 600; color: white; margin-right: 15px;">
Usuario: {{ request.user.username }} | Rol: {% if request.user.groups.all %}{{ request.user.groups.all.0.name }}{% else %}Usuario{% endif %}
</div>
<a href="{% url 'export_csv' %}" class="btn-primary" style="background:#107c41; text-decoration: none;">
<i data-lucide="file-spreadsheet" style="width:16px;"></i> Exportar CSV
</a>
<a href="{% url 'expense_create' %}" class="btn-primary" style="text-decoration: none;">
<i data-lucide="plus-circle" style="width:16px;"></i> + Registrar Erogación
</a>
<a href="{% url 'logout' %}" class="btn-danger" style="text-decoration: none;">
<i data-lucide="log-out" style="width:16px;"></i> Salir
</a>
</div>
</header>
<!-- Corporate Ecosystem Navigation Bar -->
<nav class="nav-bar">
<a href="{% url 'dashboard' %}" class="nav-tab {% if request.resolver_match.url_name == 'dashboard' %}active{% endif %}">
<i data-lucide="pie-chart" style="width:16px;"></i> Resumen Financiero
</a>
<a href="{% url 'expense_list' %}" class="nav-tab {% if request.resolver_match.url_name == 'expense_list' %}active{% endif %}">
<i data-lucide="truck" style="width:16px;"></i> Operaciones y Gastos
</a>
<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' 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
</a>
{% endif %}
</nav>
<main class="main-container">
{% if messages %}
<div class="messages-container" style="margin-bottom: 1rem;">
{% for message in messages %}
<div class="alert alert-{{ message.tags }}" style="padding: 10px; border-radius: 4px; background: var(--efintel-light); border-left: 4px solid var(--efintel-blue); font-weight: 600;">
{{ message }}
</div>
{% endfor %}
</div>
{% endif %}
{% block content %}{% endblock %}
</main>
<script>
if (window.lucide) {
lucide.createIcons();
}
</script>
{% block extra_js %}{% endblock %}
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<section class="page-view active">
<div class="view-header">
<div class="view-title">
<h2><i data-lucide="pie-chart" style="color:var(--efintel-blue);"></i> EFINTEL ERP — Centro de Control Financiero</h2>
<p>Supervisión consolidada de presupuestos, costos acumulados y flujo multimoneda ($ CLP / $ USD)</p>
</div>
<div style="display:flex; gap:0.5rem; align-items:center;">
<span class="badge badge-blue"><i data-lucide="coins" style="width:12px;"></i> Multimoneda Integrado</span>
<span class="badge badge-emerald"><i data-lucide="shield-check" style="width:12px;"></i> Flujo 3 Pasos PMBOK</span>
</div>
</div>
<div class="kpi-grid-3">
<div class="kpi-card accent-blue">
<div class="kpi-header">
<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-subtext">
<span>Mes actual: ${{ total_month|floatformat:"0" }} CLP</span>
</div>
</div>
<div class="kpi-card accent-turquoise">
<div class="kpi-header">
<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-subtext">
<span>Saldo Restante: ${{ remaining_budget|floatformat:"0" }} CLP</span>
</div>
</div>
<div class="kpi-card accent-success">
<div class="kpi-header">
<span class="kpi-title">PROYECTOS ACTIVOS</span>
<div class="kpi-icon"><i data-lucide="trending-up"></i></div>
</div>
<div class="kpi-value" id="kpi-evm">{{ project_count }}</div>
<div class="kpi-subtext">
<span class="status-dot green"></span> <span style="font-weight:600; color:var(--efintel-dark);">En línea</span>
</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>
</div>
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>Fecha</th>
<th>Centro / Proyecto</th>
<th>Detalle Imputación</th>
<th>Monto Neto ($ CLP / USD)</th>
<th>Flujo Aprobación & Pago</th>
<th>Acciones / Gestión</th>
</tr>
</thead>
<tbody>
{% for expense in recent_expenses %}
<tr>
<td style="font-size:0.75rem; font-weight: 600;">{{ expense.date|date:"d/m/Y" }}</td>
<td>
<span class="project-tag" {% if expense.project.type == 'GASTO_OPERACIONAL_OVERHEAD' %}style="background:#e2e8f0; color:#475569;"{% endif %}>
{{ expense.project.code }}
</span>
</td>
<td style="font-weight:600; color:var(--efintel-dark);">
{{ expense.description|truncatechars:50 }}<br>
<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>
</td>
<td>
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
<span class="badge badge-amber"><i data-lucide="clock" style="width:12px;"></i> En Revisión</span>
{% elif expense.approval_status == 'SOLICITADO_FINANZAS' %}
<span class="badge badge-blue"><i data-lucide="send" style="width:12px;"></i> Solicitado Finanzas</span>
{% elif expense.approval_status == 'LIBERADO_PAGADO' %}
<span class="badge badge-emerald"><i data-lucide="check-check" style="width:12px;"></i> Pagado</span>
{% else %}
<span class="badge badge-red"><i data-lucide="x-circle" style="width:12px;"></i> Rechazado</span>
{% endif %}
</td>
<td>
<div style="display:flex; gap:0.3rem;">
<!-- Flujo de 3 pasos (Aprobaciones) -->
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
<a href="{% url 'approve_expense' expense.id 'solicitar' %}" class="btn-primary" style="padding:0.3rem; font-size:0.65rem; background: var(--efintel-blue);" title="Elevar a Finanzas"><i data-lucide="arrow-right"></i></a>
{% endif %}
{% if expense.approval_status == 'SOLICITADO_FINANZAS' and 'Administrador Finanzas' in request.user.groups.all.0.name or request.user.is_superuser %}
<a href="{% url 'approve_expense' expense.id 'pagar' %}" class="btn-primary" style="padding:0.3rem; font-size:0.65rem; background: var(--efintel-success);" title="Liberar Pago"><i data-lucide="check"></i></a>
<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 %}
<a href="{% url 'expense_edit' expense.id %}" class="btn-secondary" style="padding:0.3rem; font-size:0.65rem; background:var(--efintel-blue); color:white;" title="Editar"><i data-lucide="pencil" style="width:12px;"></i></a>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" style="text-align:center; padding: 2rem; color: var(--efintel-gray);">No hay erogaciones recientes.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+114
View File
@@ -0,0 +1,114 @@
{% 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), 0 2px 4px -1px rgba(0,0,0,0.03);">
<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 %}Modificar Gasto Registrado{% else %}Registro de Nueva Erogación{% endif %}
</h2>
<form method="post" enctype="multipart/form-data">
{% 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.project.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Centro de Costo / Proyecto</label>
{{ form.project }}
{% 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">
<label for="{{ form.category.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Categoría Contable</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>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<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.currency.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Moneda Original</label>
{{ form.currency }}
{% if form.currency.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.currency.errors }}</div>{% endif %}
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 1.5rem;">
<div class="form-group">
<label for="{{ form.date.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Fecha de Emisión / Gasto</label>
{{ form.date }}
{% if form.date.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.date.errors }}</div>{% endif %}
</div>
<div class="form-group">
<label for="{{ form.vendor.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Proveedor (Opcional)</label>
{{ form.vendor }}
{% if form.vendor.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.vendor.errors }}</div>{% endif %}
</div>
</div>
<div class="form-group" style="margin-bottom: 1.5rem;">
<label for="{{ form.description.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-dark);">Detalle de Imputación (Justificación)</label>
{{ 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 class="form-group" style="margin-bottom: 1.5rem; background: #f8fafc; padding: 1rem; border-radius: 6px; border: 1px dashed var(--efintel-blue);">
<label for="{{ form.receipt.id_for_label }}" style="font-weight: 600; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem;"><i data-lucide="paperclip" style="width:14px;"></i> Adjuntar Evidencia (Boleta / Factura)</label>
{{ form.receipt }}
<small style="color: var(--efintel-gray); font-size: 0.7rem; display:block; margin-top:0.3rem;">Formatos aceptados: Imágenes (.png, .jpg) o Documentos PDF.</small>
{% if form.receipt.errors %}<div style="color: var(--efintel-danger); font-size: 0.75rem; margin-top: 4px;">{{ form.receipt.errors }}</div>{% endif %}
{% if form.instance.pk and form.instance.documents.first %}
<div style="margin-top:0.8rem; font-size:0.8rem;">
<strong>Evidencia Actual:</strong> <a href="{{ form.instance.documents.first.file.url }}" target="_blank" style="color:var(--efintel-blue);">Ver Archivo Adjunto</a>
</div>
{% endif %}
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; margin-bottom: 2rem; background: var(--efintel-light); padding: 1.5rem; border-radius: 6px;">
<h4 style="grid-column: 1 / -1; margin: 0 0 1rem 0; font-size: 0.85rem; color: var(--efintel-petroleum); display:flex; align-items:center; gap:0.3rem;"><i data-lucide="map-pin" style="width:14px;"></i> Datos Logísticos (Opcional)</h4>
<div class="form-group">
<label for="{{ form.region.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Región Operativa</label>
{{ form.region }}
</div>
<div class="form-group">
<label for="{{ form.comuna.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Comuna</label>
{{ form.comuna }}
</div>
<div class="form-group">
<label for="{{ form.vehicle_ownership.id_for_label }}" style="font-size: 0.75rem; font-weight: 600;">Vehículo Asignado</label>
{{ form.vehicle_ownership }}
</div>
</div>
<div style="display: flex; gap: 1rem; justify-content: flex-end; border-top: 1px solid var(--efintel-border); padding-top: 1.5rem;">
<a href="{% url 'expense_list' %}" class="btn-outline" style="padding: 0.75rem 1.5rem; text-decoration: none; color: var(--efintel-gray);">Cancelar</a>
<button type="submit" class="btn-primary" style="padding: 0.75rem 2rem; background: var(--efintel-blue); font-weight: 600;">
<i data-lucide="save" style="width: 16px; margin-right: 6px;"></i> Guardar Registro
</button>
</div>
</form>
</div>
</div>
<script>
document.querySelectorAll('input, select, textarea').forEach(el => {
el.style.width = '100%';
el.style.padding = '0.65rem';
el.style.border = '1px solid var(--efintel-border)';
el.style.borderRadius = '4px';
el.style.fontFamily = "'Inter', sans-serif";
el.style.fontSize = '0.9rem';
el.style.boxSizing = 'border-box';
});
</script>
{% endblock %}
+122
View File
@@ -0,0 +1,122 @@
{% extends 'base.html' %}
{% load static %}
{% block content %}
<section class="page-view active">
<div class="view-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 2rem;">
<div class="view-title">
<h2 style="margin:0; color:var(--efintel-petroleum); display:flex; align-items:center; gap:0.5rem;"><i data-lucide="truck" style="color:var(--efintel-blue);"></i> Control de Erogaciones (Flujo Operativo)</h2>
<p style="margin-top:0.25rem; font-size:0.9rem; color:var(--efintel-gray);">Bandeja de gestión y aprobación de costos y gastos registrados.</p>
</div>
<div>
<a href="{% url 'expense_create' %}" class="btn-primary" style="text-decoration: none;">
<i data-lucide="plus-circle" style="width:16px;"></i> Registrar Nuevo
</a>
</div>
</div>
<div class="data-card" style="margin-bottom: 2rem;">
<form method="get" id="filter-form" style="display: flex; gap: 1rem; align-items: flex-end; flex-wrap: wrap;">
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 200px;">
<label for="project" style="font-size: 0.8rem; font-weight: 600;">Centro de Costo</label>
<select name="project" id="project" style="width: 100%; padding: 0.65rem; border: 1px solid var(--efintel-border); border-radius: 4px; outline: none; font-family: 'Inter';" onchange="document.getElementById('filter-form').submit();">
<option value="">Todos los Centros</option>
{% for p in projects %}
<option value="{{ p.id }}" {% if request.GET.project == p.id|stringformat:"s" %}selected{% endif %}>{{ p.code }} - {{ p.name }}</option>
{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom: 0; flex: 1; min-width: 200px;">
<label for="category" style="font-size: 0.8rem; font-weight: 600;">Categoría</label>
<select name="category" id="category" style="width: 100%; padding: 0.65rem; border: 1px solid var(--efintel-border); border-radius: 4px; outline: none; font-family: 'Inter';" onchange="document.getElementById('filter-form').submit();">
<option value="">Todas las Categorías</option>
{% for c in categories %}
<option value="{{ c.id }}" {% if request.GET.category == c.id|stringformat:"s" %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</div>
<button type="submit" class="btn-outline" style="height: 42px;"><i data-lucide="filter" style="width:16px;"></i> Filtrar</button>
</form>
</div>
<div class="data-card">
<div class="data-table-wrapper">
<table class="data-table">
<thead>
<tr>
<th>ID Transacción</th>
<th>Fecha</th>
<th>Centro / Proyecto</th>
<th>Detalle Imputación</th>
<th>Monto Neto ($ CLP / USD)</th>
<th>Estado de Aprobación</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for expense in expenses %}
<tr>
<td style="font-family: var(--font-mono); font-size: 0.75rem; color: var(--efintel-gray);">{{ expense.expense_code|truncatechars:12 }}</td>
<td style="font-size:0.8rem; font-weight: 600;">{{ expense.date|date:"d/m/Y" }}</td>
<td>
<span class="project-tag" {% if expense.project.type == 'GASTO_OPERACIONAL_OVERHEAD' %}style="background:#e2e8f0; color:#475569;"{% endif %}>
{{ expense.project.code }}
</span>
</td>
<td style="font-weight:600; color:var(--efintel-dark); font-size: 0.85rem;">
{{ expense.description|truncatechars:40 }}<br>
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
{% if expense.documents.first %}
<br><a href="{{ expense.documents.first.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none; display:inline-flex; align-items:center; gap:0.2rem; margin-top:0.2rem;"><i data-lucide="paperclip" style="width:10px;"></i> Evidencia Adjunta</a>
{% 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>
</td>
<td>
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
<span class="badge badge-amber"><i data-lucide="clock" style="width:12px;"></i> En Revisión</span>
{% elif expense.approval_status == 'SOLICITADO_FINANZAS' %}
<span class="badge badge-blue"><i data-lucide="send" style="width:12px;"></i> Solicitado Finanzas</span>
{% elif expense.approval_status == 'LIBERADO_PAGADO' %}
<span class="badge badge-emerald"><i data-lucide="check-check" style="width:12px;"></i> Pagado</span>
{% else %}
<span class="badge badge-red"><i data-lucide="x-circle" style="width:12px;"></i> Rechazado</span>
{% endif %}
</td>
<td>
<div style="display: flex; gap: 0.3rem;">
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
<a href="{% url 'approve_expense' expense.id 'solicitar' %}" class="btn-primary" style="padding:0.3rem; font-size:0.65rem; background: var(--efintel-blue);" title="Elevar a Finanzas"><i data-lucide="arrow-right"></i></a>
{% endif %}
{% if expense.approval_status == 'SOLICITADO_FINANZAS' and 'Administrador Finanzas' in request.user.groups.all.0.name or request.user.is_superuser %}
<a href="{% url 'approve_expense' expense.id 'pagar' %}" class="btn-primary" style="padding:0.3rem; font-size:0.65rem; background: var(--efintel-success);" title="Liberar Pago"><i data-lucide="check"></i></a>
<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 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 %}
{% if request.user.groups.all.0.name == 'Administrador' or request.user.is_superuser %}
<form method="post" action="/gastos/{{ expense.id }}/eliminar/" style="display:inline;">
{% csrf_token %}
<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 %}
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--efintel-gray);">No hay gastos registrados para este filtro.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</section>
{% endblock %}
+213
View File
@@ -0,0 +1,213 @@
{% extends 'base.html' %}
{% 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>
</nav>
<!-- 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>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Nombre</th>
<th>Descripción</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>
{% if project.manager %}
<span style="font-weight: 500;">{{ project.manager.username }}</span>
{% else %}
<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>
<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>
</form>
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="6" style="text-align: center;">No hay proyectos registrados.</td>
</tr>
{% endfor %}
</tbody>
</table>
</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>
<div class="table-container">
<table>
<thead>
<tr>
<th>Usuario</th>
<th>Correo</th>
<th>Rol / Permiso</th>
<th>Descripción / Bio</th>
<th>F. Registro</th>
<th>F. Modificación</th>
<th>Acciones</th>
</tr>
</thead>
<tbody>
{% for resp in responsables %}
<tr>
<td style="font-weight: 600; color: var(--text-primary);">{{ resp.username }}</td>
<td>{{ resp.email|default:"-" }}</td>
<td>
{% if resp.groups.all %}
{% with role=resp.groups.all.0.name %}
{% if role == 'Administrador' %}
<span class="badge badge-admin">Administrador</span>
{% elif role == 'Director de Proyecto' %}
<span class="badge badge-director">Director</span>
{% else %}
<span class="badge badge-gestor">Gestor</span>
{% endif %}
{% endwith %}
{% else %}
<span class="badge badge-gestor">Sin Rol</span>
{% endif %}
</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>
{% 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>
</form>
{% endif %}
</div>
</td>
</tr>
{% empty %}
<tr>
<td colspan="7" style="text-align: center;">No hay responsables registrados.</td>
</tr>
{% endfor %}
</tbody>
</table>
</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>
</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 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>
<div>
<a href="{% url 'settings_edit' %}" class="btn btn-primary">✏️ Modificar Presupuesto General</a>
</div>
</div>
</div>
<!-- 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>
<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">
{% 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 }}
{% elif "Eliminado" in log.action or "Eliminación" in log.action %}
🔴 {{ log.action }}
{% else %}
🟡 {{ log.action }}
{% endif %}
</div>
<div class="log-desc">{{ log.description }}</div>
</div>
<div class="log-meta">
<div style="font-weight: 500; color: var(--accent-primary);">@{{ log.user.username|default:"Sistema" }}</div>
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">{{ log.timestamp|date:"d/m/Y H:i:s" }}</div>
</div>
</div>
{% empty %}
<div style="text-align: center; color: var(--text-secondary); padding: 2rem;">No hay registros en la bitácora de auditoría.</div>
{% endfor %}
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
function switchTab(evt, tabId) {
// Ocultar todos los contenidos de pestaña
const contents = document.querySelectorAll('.tab-content');
contents.forEach(content => content.classList.remove('active'));
// Desactivar todos los botones de pestaña
const buttons = document.querySelectorAll('.tab-btn');
buttons.forEach(btn => btn.classList.remove('active'));
// Activar pestaña actual
document.getElementById(tabId).classList.add('active');
evt.currentTarget.classList.add('active');
// Guardar la pestaña activa en localStorage
localStorage.setItem('activeTab', tabId);
}
// Restaurar pestaña activa al cargar la página
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));
if (btn) {
// Simular clic en el botón
btn.click();
}
}
});
</script>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% 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>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="{{ form.name.id_for_label }}">Nombre del Proyecto</label>
{{ form.name }}
{% if form.name.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.name.errors }}</div>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.description.id_for_label }}">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>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.manager.id_for_label }}">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>
{% 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>
</form>
</div>
</div>
<script>
// Apply form-control class to all inputs
document.querySelectorAll('input, select, textarea').forEach(el => {
el.classList.add('form-control');
});
</script>
{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends 'base.html' %}
{% block content %}
<div style="margin-bottom: 2rem;">
<h1>Proyectos</h1>
</div>
<div class="project-grid">
{% for project in projects %}
<div class="glass-card project-card">
<div class="project-header">
<div class="project-title">{{ project.name }}</div>
</div>
<div style="color: var(--text-secondary); font-size: 0.9rem;">
<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" }}
</span>
</div>
</div>
</div>
{% empty %}
<div class="glass-card" style="padding: 2rem; text-align: center; grid-column: 1 / -1;">
No hay proyectos registrados.
</div>
{% endfor %}
</div>
{% endblock %}
+72
View File
@@ -0,0 +1,72 @@
{% extends 'base.html' %}
{% block content %}
<div class="form-container">
<div class="glass-card" style="padding: 2.5rem;">
<h1 style="margin-bottom: 2rem;">
{% if edit_mode %}Editar Colaborador (Responsable): {{ target_user.username }}{% else %}Nuevo Colaborador (Responsable){% endif %}
</h1>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="{{ form.username.id_for_label }}">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>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.email.id_for_label }}">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>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.password.id_for_label }}">
Contraseña
{% if edit_mode %}
<span style="font-size: 0.8rem; color: var(--text-secondary);">(Opcional: Dejar en blanco para no cambiarla)</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>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.role.id_for_label }}">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>
{% endif %}
</div>
<div class="form-group">
<label for="{{ form.description.id_for_label }}">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>
{% 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>
</form>
</div>
</div>
<script>
// Apply form-control class to all inputs
document.querySelectorAll('input, select, textarea').forEach(el => {
el.classList.add('form-control');
});
</script>
{% endblock %}
+33
View File
@@ -0,0 +1,33 @@
{% extends 'base.html' %}
{% block content %}
<div class="form-container">
<div class="glass-card" style="padding: 2.5rem;">
<h1 style="margin-bottom: 2rem;">Editar Configuración del Sistema</h1>
<form method="post">
{% csrf_token %}
<div class="form-group">
<label for="{{ form.global_budget.id_for_label }}">Presupuesto General Asignado ($)</label>
{{ form.global_budget }}
{% if form.global_budget.errors %}
<div style="color: var(--danger); font-size: 0.8rem; margin-top: 0.25rem;">{{ form.global_budget.errors }}</div>
{% endif %}
</div>
<div style="display: flex; gap: 1rem; margin-top: 2rem;">
<button type="submit" class="btn btn-primary">Guardar Configuración</button>
<a href="{% url 'gestion_panel' %}" class="btn btn-outline">Cancelar</a>
</div>
</form>
</div>
</div>
<script>
// Apply form-control class to all inputs
document.querySelectorAll('input, select, textarea').forEach(el => {
el.classList.add('form-control');
});
</script>
{% endblock %}
+57
View File
@@ -0,0 +1,57 @@
{% load static %}
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>EFINTEL | Iniciar Sesión ERP</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<script src="https://unpkg.com/lucide@latest"></script>
<link rel="stylesheet" href="{% static 'css/style.css' %}">
</head>
<body style="background-color: var(--efintel-white); display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; font-family: 'Inter', sans-serif;">
<div style="background: white; border-radius: 12px; padding: 2.5rem; width: 100%; max-width: 400px; box-shadow: 0 10px 25px rgba(0,0,0,0.05); border: 1px solid var(--efintel-border);">
<div style="text-align: center; margin-bottom: 2rem;">
<img src="{% static 'img/efintel_logo.png' %}" alt="EFINTEL Logo" style="height: 40px; margin-bottom: 1rem;">
<h1 style="font-size: 1.25rem; color: var(--efintel-petroleum); margin: 0;">Portal ERP Interno</h1>
<p style="font-size: 0.85rem; color: var(--efintel-gray); margin-top: 0.5rem;">Control de Gastos & Operaciones</p>
</div>
{% if form.errors %}
<div style="background: #FEF2F2; color: #DC2626; padding: 0.85rem; border-radius: 6px; font-size: 0.85rem; margin-bottom: 1.5rem; text-align: center; font-weight: 600;">
<i data-lucide="alert-circle" style="width:16px; display:inline-block; vertical-align:middle; margin-right:4px;"></i> Credenciales incorrectas.
</div>
{% endif %}
<form method="post" action="{% url 'login' %}">
{% csrf_token %}
<div style="margin-bottom: 1.25rem;">
<label for="id_username" style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--efintel-dark); margin-bottom: 0.5rem;">Usuario Institucional</label>
<div style="position: relative;">
<i data-lucide="user" style="position: absolute; left: 12px; top: 10px; width: 18px; color: var(--efintel-gray);"></i>
<input type="text" name="username" id="id_username" required autofocus style="width: 100%; padding: 0.65rem 0.65rem 0.65rem 2.5rem; border: 1px solid var(--efintel-border); border-radius: 6px; font-family: 'Inter', sans-serif; font-size: 0.9rem; box-sizing: border-box; outline: none;">
</div>
</div>
<div style="margin-bottom: 1.5rem;">
<label for="id_password" style="display: block; font-size: 0.8rem; font-weight: 700; color: var(--efintel-dark); margin-bottom: 0.5rem;">Contraseña de Acceso</label>
<div style="position: relative;">
<i data-lucide="lock" style="position: absolute; left: 12px; top: 10px; width: 18px; color: var(--efintel-gray);"></i>
<input type="password" name="password" id="id_password" required style="width: 100%; padding: 0.65rem 0.65rem 0.65rem 2.5rem; border: 1px solid var(--efintel-border); border-radius: 6px; font-family: 'Inter', sans-serif; font-size: 0.9rem; box-sizing: border-box; outline: none;">
</div>
</div>
<button type="submit" class="btn-primary" style="width: 100%; justify-content: center; padding: 0.75rem; font-size: 0.95rem; background: var(--efintel-blue);">
Ingresar al ERP <i data-lucide="arrow-right" style="width: 16px; margin-left: 6px;"></i>
</button>
</form>
</div>
<script>
lucide.createIcons();
</script>
</body>
</html>