Initial commit: EFINTEL ERP Django Port
This commit is contained in:
+34
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
),
|
||||
})
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
}))
|
||||
@@ -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
|
||||
@@ -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']
|
||||
@@ -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',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"
|
||||
@@ -0,0 +1,6 @@
|
||||
from django.urls import re_path
|
||||
from . import consumers
|
||||
|
||||
websocket_urlpatterns = [
|
||||
re_path(r'ws/expenses/$', consumers.ExpenseConsumer.as_asgi()),
|
||||
]
|
||||
@@ -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}
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -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'),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 33 KiB |
@@ -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 |
@@ -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');
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user