From eb065909bdb2fba157a6575c8f8b4c830621afe5 Mon Sep 17 00:00:00 2001 From: Nachou-u Date: Wed, 19 Aug 2026 18:04:18 -0400 Subject: [PATCH] Initial commit: EFINTEL ERP Django Port --- .gitignore | 34 + README.md | 196 ++++ config/__init__.py | 0 config/asgi.py | 28 + config/settings.py | 148 +++ config/urls.py | 28 + config/wsgi.py | 16 + expenses/__init__.py | 0 expenses/admin.py | 35 + expenses/apps.py | 9 + expenses/consumers.py | 29 + expenses/decorators.py | 19 + expenses/forms.py | 133 +++ expenses/migrations/0001_initial.py | 155 ++++ expenses/migrations/__init__.py | 0 expenses/models.py | 163 ++++ expenses/routing.py | 6 + expenses/signals.py | 41 + expenses/tests.py | 3 + expenses/urls.py | 32 + expenses/views.py | 421 +++++++++ manage.py | 22 + requirements.txt | 28 + setup_roles.py | 46 + start.sh | 134 +++ static/css/style.css | 1050 ++++++++++++++++++++++ static/img/efintel_logo.png | Bin 0 -> 33435 bytes static/img/efintel_logo.svg | 28 + static/js/app.js | 142 +++ templates/base.html | 86 ++ templates/expenses/dashboard.html | 122 +++ templates/expenses/expense_form.html | 114 +++ templates/expenses/expense_list.html | 122 +++ templates/expenses/gestion.html | 213 +++++ templates/expenses/project_form.html | 51 ++ templates/expenses/project_list.html | 36 + templates/expenses/responsable_form.html | 72 ++ templates/expenses/settings_form.html | 33 + templates/registration/login.html | 57 ++ 39 files changed, 3852 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 config/__init__.py create mode 100644 config/asgi.py create mode 100644 config/settings.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 expenses/__init__.py create mode 100644 expenses/admin.py create mode 100644 expenses/apps.py create mode 100644 expenses/consumers.py create mode 100644 expenses/decorators.py create mode 100644 expenses/forms.py create mode 100644 expenses/migrations/0001_initial.py create mode 100644 expenses/migrations/__init__.py create mode 100644 expenses/models.py create mode 100644 expenses/routing.py create mode 100644 expenses/signals.py create mode 100644 expenses/tests.py create mode 100644 expenses/urls.py create mode 100644 expenses/views.py create mode 100755 manage.py create mode 100644 requirements.txt create mode 100644 setup_roles.py create mode 100755 start.sh create mode 100644 static/css/style.css create mode 100644 static/img/efintel_logo.png create mode 100644 static/img/efintel_logo.svg create mode 100644 static/js/app.js create mode 100644 templates/base.html create mode 100644 templates/expenses/dashboard.html create mode 100644 templates/expenses/expense_form.html create mode 100644 templates/expenses/expense_list.html create mode 100644 templates/expenses/gestion.html create mode 100644 templates/expenses/project_form.html create mode 100644 templates/expenses/project_list.html create mode 100644 templates/expenses/responsable_form.html create mode 100644 templates/expenses/settings_form.html create mode 100644 templates/registration/login.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..61eb086 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e17adfa --- /dev/null +++ b/README.md @@ -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 diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..8bc138e --- /dev/null +++ b/config/asgi.py @@ -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 + ) + ), +}) diff --git a/config/settings.py b/config/settings.py new file mode 100644 index 0000000..dbb1e22 --- /dev/null +++ b/config/settings.py @@ -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', + }, +} diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..b0782c1 --- /dev/null +++ b/config/urls.py @@ -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) diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..1b4cc01 --- /dev/null +++ b/config/wsgi.py @@ -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() diff --git a/expenses/__init__.py b/expenses/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/expenses/admin.py b/expenses/admin.py new file mode 100644 index 0000000..bbd0cc5 --- /dev/null +++ b/expenses/admin.py @@ -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') diff --git a/expenses/apps.py b/expenses/apps.py new file mode 100644 index 0000000..3d313e7 --- /dev/null +++ b/expenses/apps.py @@ -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 diff --git a/expenses/consumers.py b/expenses/consumers.py new file mode 100644 index 0000000..76805bf --- /dev/null +++ b/expenses/consumers.py @@ -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 + })) diff --git a/expenses/decorators.py b/expenses/decorators.py new file mode 100644 index 0000000..dfd1490 --- /dev/null +++ b/expenses/decorators.py @@ -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 diff --git a/expenses/forms.py b/expenses/forms.py new file mode 100644 index 0000000..65c119a --- /dev/null +++ b/expenses/forms.py @@ -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'] diff --git a/expenses/migrations/0001_initial.py b/expenses/migrations/0001_initial.py new file mode 100644 index 0000000..a115ea7 --- /dev/null +++ b/expenses/migrations/0001_initial.py @@ -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', + }, + ), + ] diff --git a/expenses/migrations/__init__.py b/expenses/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/expenses/models.py b/expenses/models.py new file mode 100644 index 0000000..bd767e0 --- /dev/null +++ b/expenses/models.py @@ -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" diff --git a/expenses/routing.py b/expenses/routing.py new file mode 100644 index 0000000..efb0ab0 --- /dev/null +++ b/expenses/routing.py @@ -0,0 +1,6 @@ +from django.urls import re_path +from . import consumers + +websocket_urlpatterns = [ + re_path(r'ws/expenses/$', consumers.ExpenseConsumer.as_asgi()), +] diff --git a/expenses/signals.py b/expenses/signals.py new file mode 100644 index 0000000..e8417ce --- /dev/null +++ b/expenses/signals.py @@ -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} + } + ) diff --git a/expenses/tests.py b/expenses/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/expenses/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/expenses/urls.py b/expenses/urls.py new file mode 100644 index 0000000..67b399f --- /dev/null +++ b/expenses/urls.py @@ -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//editar/', views.expense_edit, name='expense_edit'), + path('gastos//eliminar/', views.expense_delete, name='expense_delete'), + + # Flujo de aprobación + path('gastos//aprobar//', 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//editar/', views.project_edit, name='project_edit'), + path('gestion/proyectos//eliminar/', views.project_delete, name='project_delete'), + + path('gestion/responsables/nuevo/', views.responsable_create, name='responsable_create'), + path('gestion/responsables//editar/', views.responsable_edit, name='responsable_edit'), + path('gestion/responsables//eliminar/', views.responsable_delete, name='responsable_delete'), +] diff --git a/expenses/views.py b/expenses/views.py new file mode 100644 index 0000000..a3a42b3 --- /dev/null +++ b/expenses/views.py @@ -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') + diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..8e7ac79 --- /dev/null +++ b/manage.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b7bf9b6 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/setup_roles.py b/setup_roles.py new file mode 100644 index 0000000..ca6c333 --- /dev/null +++ b/setup_roles.py @@ -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.") diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..cd1de1e --- /dev/null +++ b/start.sh @@ -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 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..8b18b6f --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,1050 @@ +/* ========================================================================== + EFINTEL DIGITAL ECOSYSTEM - DESIGN SYSTEM & CORPORATE UX/UI SPECIFICATION + Brand Color System, Typography Tokens, Component System & Micro-interactions + ========================================================================== */ + +:root { + /* Official EFINTEL Brand Palette */ + --efintel-blue: #0078A8; /* Primary brand color, main navigation, interactive elements */ + --efintel-turquoise: #1EB8B0; /* R&D&I innovation accent, positive indicators, progress */ + --efintel-petroleum: #005B73; /* System modules, secondary navigation, tech headers */ + --efintel-dark: #4B4B4B; /* Titles, primary headers, heavy contrast text */ + --efintel-gray: #7A7F83; /* Secondary body text, captions, subtitles */ + --efintel-light: #F2F4F5; /* Backgrounds, secondary card surfaces */ + --efintel-white: #FFFFFF; /* Primary surfaces, cards, modals */ + --efintel-border: #DDE3E7; /* Clean subtle borders */ + --efintel-border-hover: #B8C4CE; /* Hover state borders */ + + /* State & Alert Palette */ + --efintel-success: #2E8B57; /* Positive state, compliance */ + --efintel-warning: #E3A008; /* Warnings, pending review */ + --efintel-danger: #D64545; /* Errors, critical risk */ + + /* Surface Shadows & Elevations (Extremely subtle, corporate) */ + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.04), 0 1px 2px rgba(0, 0, 0, 0.02); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.05); + --shadow-lg: 0 12px 28px rgba(0, 0, 0, 0.08); + + /* Radius Tokens */ + --radius-sm: 6px; + --radius-md: 10px; + --radius-lg: 14px; + + /* Typography Tokens */ + --font-main: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + --font-mono: 'JetBrains Mono', monospace; +} + +/* Base Styles & Resets */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-main); + background-color: var(--efintel-light); + color: var(--efintel-dark); + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} + +/* ========================================================================== + HEADER & TOP APPLICATION BAR + ========================================================================== */ + +.app-header { + background-color: var(--efintel-white); + border-bottom: 1px solid var(--efintel-border); + padding: 0.85rem 2rem; + display: flex; + justify-content: space-between; + align-items: center; + box-shadow: var(--shadow-sm); + position: sticky; + top: 0; + z-index: 100; +} + +.brand-section { + display: flex; + align-items: center; + gap: 1rem; +} + +.brand-logo-container { + background: var(--efintel-white); + padding: 0.35rem 0.75rem; + border-radius: var(--radius-sm); + border: 1px solid var(--efintel-border); + display: flex; + align-items: center; + justify-content: center; + height: 48px; +} + +.brand-logo-img { + height: 36px; + width: auto; + display: block; +} + +.brand-title h1 { + font-size: 1.15rem; + font-weight: 700; + color: var(--efintel-dark); + letter-spacing: -0.01em; +} + +.brand-title p { + font-size: 0.8rem; + color: var(--efintel-gray); + font-weight: 500; +} + +.header-actions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +/* ========================================================================== + ECOSYSTEM NAVIGATION BAR + ========================================================================== */ + +.nav-bar { + background-color: var(--efintel-petroleum); + padding: 0 2rem; + display: flex; + align-items: center; + gap: 0.25rem; + overflow-x: auto; +} + +.nav-tab { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.78); + padding: 0.85rem 1.25rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 0.5rem; + border-bottom: 3px solid transparent; + transition: all 0.2s ease; + white-space: nowrap; +} + +.nav-tab:hover { + color: var(--efintel-white); + background: rgba(255, 255, 255, 0.06); +} + +.nav-tab.active { + color: var(--efintel-white); + border-bottom-color: var(--efintel-turquoise); + background: rgba(255, 255, 255, 0.1); +} + +/* Pipeline Process Visual Bar */ +.ecosystem-process-bar { + background: var(--efintel-white); + border-bottom: 1px solid var(--efintel-border); + padding: 0.4rem 2rem; + display: flex; + align-items: center; + gap: 1.5rem; + font-size: 0.75rem; + color: var(--efintel-gray); +} + +.process-step { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; +} + +.process-step.active { + color: var(--efintel-blue); +} + +.process-divider { + color: var(--efintel-border-hover); +} + +/* ========================================================================== + MAIN CONTENT & LAYOUT STRUCTURE + ========================================================================== */ + +.main-container { + max-width: 1440px; + margin: 0 auto; + padding: 2rem; +} + +.page-view { + display: none; +} + +.page-view.active { + display: block; +} + +.view-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 1.5rem; +} + +.view-title h2 { + font-size: 1.5rem; + font-weight: 700; + color: var(--efintel-dark); + letter-spacing: -0.02em; + display: flex; + align-items: center; + gap: 0.6rem; +} + +.view-title p { + font-size: 0.88rem; + color: var(--efintel-gray); + margin-top: 0.2rem; +} + +/* ========================================================================== + BUTTONS & ACTIONS SYSTEM + ========================================================================== */ + +.btn-primary { + background-color: var(--efintel-blue); + color: var(--efintel-white); + border: none; + border-radius: var(--radius-sm); + padding: 0.6rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transition: background-color 0.2s ease, transform 0.1s ease; +} + +.btn-primary:hover { + background-color: #00648d; +} + +.btn-secondary { + background-color: var(--efintel-white); + color: var(--efintel-dark); + border: 1px solid var(--efintel-border); + border-radius: var(--radius-sm); + padding: 0.6rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease; +} + +.btn-secondary:hover { + border-color: var(--efintel-border-hover); + background-color: var(--efintel-light); +} + +.btn-danger { + background-color: var(--efintel-white); + color: var(--efintel-danger); + border: 1px solid var(--efintel-danger); + border-radius: var(--radius-sm); + padding: 0.6rem 1.1rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.5rem; + transition: all 0.2s ease; +} + +.btn-danger:hover { + background-color: #fdf2f2; +} + +/* ========================================================================== + REDESIGNED KPI CARDS SYSTEM + ========================================================================== */ + +.kpi-grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.grid-2-kpi { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +.kpi-card { + background: var(--efintel-white); + border: 1px solid var(--efintel-border); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); + position: relative; + overflow: hidden; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +/* Accent Lateral Bar */ +.kpi-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 5px; + height: 100%; + background-color: var(--efintel-blue); +} + +.kpi-card.accent-turquoise::before { + background-color: var(--efintel-turquoise); +} + +.kpi-card.accent-success::before { + background-color: var(--efintel-success); +} + +.kpi-header { + display: flex; + justify-content: space-between; + align-items: flex-start; +} + +.kpi-title { + font-size: 0.78rem; + font-weight: 700; + color: var(--efintel-gray); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.kpi-icon { + color: var(--efintel-blue); + background: rgba(0, 120, 168, 0.08); + padding: 0.5rem; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; +} + +.kpi-value { + font-size: 1.85rem; + font-weight: 700; + color: var(--efintel-dark); + margin: 0.75rem 0 0.5rem 0; + letter-spacing: -0.02em; + font-family: var(--font-main); +} + +.kpi-subtext { + font-size: 0.8rem; + color: var(--efintel-gray); + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* Clean CSS Status Dots System (No Emojis) */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.status-dot.green { + background-color: var(--efintel-success); +} + +.status-dot.amber { + background-color: var(--efintel-warning); +} + +.status-dot.red { + background-color: var(--efintel-danger); +} + +/* Badges */ +.badge { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.25rem 0.6rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 600; +} + +.badge-emerald { + background-color: rgba(46, 139, 87, 0.12); + color: var(--efintel-success); +} + +.badge-amber { + background-color: rgba(227, 160, 8, 0.12); + color: var(--efintel-warning); +} + +.badge-red { + background-color: rgba(214, 69, 69, 0.12); + color: var(--efintel-danger); +} + +.badge-blue { + background-color: rgba(0, 120, 168, 0.12); + color: var(--efintel-blue); +} + +/* ========================================================================== + CHARTS & CARDS + ========================================================================== */ + +.charts-grid-main { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1.5rem; + margin-bottom: 2rem; +} + +.chart-card { + background: var(--efintel-white); + border: 1px solid var(--efintel-border); + border-radius: var(--radius-md); + padding: 1.5rem; + box-shadow: var(--shadow-sm); +} + +.chart-card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.25rem; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--efintel-light); +} + +.chart-card-title { + font-size: 0.95rem; + font-weight: 700; + color: var(--efintel-dark); +} + +.chart-container { + position: relative; + height: 280px; + width: 100%; +} + +/* ========================================================================== + DATA TABLES SYSTEM + ========================================================================== */ + +.data-card { + background: var(--efintel-white); + border: 1px solid var(--efintel-border); + border-radius: var(--radius-md); + box-shadow: var(--shadow-sm); + overflow: hidden; + margin-bottom: 2rem; +} + +.data-card-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--efintel-border); + background: var(--efintel-white); +} + +.data-table-wrapper { + overflow-x: auto; +} + +.data-table { + width: 100%; + border-collapse: collapse; + text-align: left; + font-size: 0.85rem; +} + +.data-table th { + background-color: var(--efintel-light); + color: var(--efintel-gray); + font-weight: 700; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.85rem 1.25rem; + border-bottom: 1px solid var(--efintel-border); +} + +.data-table td { + padding: 1rem 1.25rem; + border-bottom: 1px solid var(--efintel-border); + color: var(--efintel-dark); + vertical-align: middle; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr:hover { + background-color: rgba(242, 244, 245, 0.5); +} + +.project-tag { + background: var(--efintel-light); + border: 1px solid var(--efintel-border); + color: var(--efintel-dark); + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 700; + padding: 0.2rem 0.5rem; + border-radius: 4px; +} + +.amount-cell { + font-family: var(--font-mono); + font-weight: 700; + color: var(--efintel-dark); +} + +/* User Bubble Avatar */ +.user-cell { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.avatar-bubble { + width: 34px; + height: 34px; + border-radius: 50%; + background: var(--efintel-petroleum); + color: var(--efintel-white); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.75rem; +} + +/* ========================================================================== + FILTER BARS & SLICERS + ========================================================================== */ + +.filter-bar { + background: var(--efintel-white); + border: 1px solid var(--efintel-border); + border-radius: var(--radius-md); + padding: 0.85rem 1.25rem; + margin-bottom: 1.5rem; + display: flex; + align-items: center; + gap: 1rem; + box-shadow: var(--shadow-sm); + flex-wrap: wrap; +} + +.filter-locked-tag { + font-size: 0.8rem; + font-weight: 700; + color: var(--efintel-dark); + display: flex; + align-items: center; + gap: 0.4rem; +} + +.filter-options { + display: flex; + align-items: center; + gap: 0.4rem; + flex-wrap: wrap; +} + +.filter-chip { + background: var(--efintel-light); + border: 1px solid var(--efintel-border); + color: var(--efintel-gray); + padding: 0.35rem 0.75rem; + border-radius: var(--radius-sm); + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.filter-chip:hover { + border-color: var(--efintel-border-hover); + color: var(--efintel-dark); +} + +.filter-chip.active { + background: var(--efintel-blue); + border-color: var(--efintel-blue); + color: var(--efintel-white); +} + +/* ========================================================================== + FORM INPUTS & MODALS + ========================================================================== */ + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(3px); + display: none; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-overlay.active { + display: flex; +} + +.modal-card { + background: var(--efintel-white); + border-radius: var(--radius-md); + width: 100%; + max-width: 680px; + max-height: 90vh; + overflow-y: auto; + box-shadow: var(--shadow-lg); + border: 1px solid var(--efintel-border); +} + +.modal-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--efintel-border); + display: flex; + justify-content: space-between; + align-items: center; +} + +.modal-body { + padding: 1.5rem; +} + +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: block; + font-size: 0.8rem; + font-weight: 600; + color: var(--efintel-dark); + margin-bottom: 0.4rem; +} + +.form-input { + width: 100%; + padding: 0.65rem 0.85rem; + border: 1px solid var(--efintel-border); + border-radius: var(--radius-sm); + font-size: 0.85rem; + font-family: var(--font-main); + color: var(--efintel-dark); + background-color: var(--efintel-white); + transition: border-color 0.2s ease; +} + +.form-input:focus { + outline: none; + border-color: var(--efintel-blue); + box-shadow: 0 0 0 3px rgba(0, 120, 168, 0.12); +} + +/* Evidence Slot Grid */ +.evidence-requirements-container { + background: var(--efintel-light); + padding: 1rem; + border-radius: var(--radius-sm); + border: 1px solid var(--efintel-border); + margin-top: 1rem; +} + +.evidence-req-title { + font-size: 0.8rem; + font-weight: 700; + color: var(--efintel-dark); + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.evidence-placeholders-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.evidence-slot { + background: var(--efintel-white); + border: 1px dashed var(--efintel-border-hover); + border-radius: var(--radius-sm); + padding: 0.85rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s ease; + text-align: center; +} + +.evidence-slot:hover { + border-color: var(--efintel-blue); + background: rgba(0, 120, 168, 0.02); +} + +.evidence-slot.loaded { + border-style: solid; + border-color: var(--efintel-success); + background: rgba(46, 139, 87, 0.05); +} + +.evidence-slot-label { + font-size: 0.78rem; + font-weight: 700; + color: var(--efintel-dark); + margin-top: 0.3rem; +} + +.evidence-slot-subtext { + font-size: 0.7rem; + color: var(--efintel-gray); +} + +/* AUDIT DRAWER */ +.drawer-overlay { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(0, 0, 0, 0.35); + display: none; + justify-content: flex-end; + z-index: 1100; +} + +.drawer-overlay.active { + display: flex; +} + +.drawer-panel { + width: 100%; + max-width: 540px; + height: 100%; + background: var(--efintel-white); + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; +} + +.drawer-header { + padding: 1.25rem 1.5rem; + background: var(--efintel-petroleum); + color: var(--efintel-white); + display: flex; + justify-content: space-between; + align-items: center; +} + +.drawer-body { + padding: 1.5rem; + flex: 1; + overflow-y: auto; +} + +.drawer-footer { + padding: 1.25rem 1.5rem; + border-top: 1px solid var(--efintel-border); + display: flex; + gap: 1rem; + background: var(--efintel-white); +} + +.checklist-box { + background: var(--efintel-light); + padding: 1rem; + border-radius: var(--radius-sm); + border: 1px solid var(--efintel-border); + margin-bottom: 1.25rem; +} + +.checklist-title { + font-size: 0.8rem; + font-weight: 700; + color: var(--efintel-dark); + margin-bottom: 0.75rem; + display: flex; + align-items: center; + gap: 0.4rem; +} + +.checklist-item { + display: flex; + align-items: center; + gap: 0.6rem; + font-size: 0.8rem; + color: var(--efintel-dark); + margin-bottom: 0.5rem; +} + +.doc-thumbnails-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.doc-thumb-card { + border: 1px solid var(--efintel-border); + border-radius: var(--radius-sm); + padding: 0.65rem; + display: flex; + align-items: center; + gap: 0.6rem; + cursor: pointer; +} + +.doc-thumb-card:hover { + border-color: var(--efintel-blue); +} + +.doc-icon { + width: 32px; + height: 32px; + border-radius: 4px; + background: rgba(0, 120, 168, 0.1); + color: var(--efintel-blue); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 0.65rem; +} + +.doc-info { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.doc-name { + font-size: 0.75rem; + font-weight: 600; + color: var(--efintel-dark); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.doc-size { + font-size: 0.68rem; + color: var(--efintel-gray); +} + +.doc-viewer-frame { + background: var(--efintel-light); + border: 1px solid var(--efintel-border); + border-radius: var(--radius-sm); + height: 160px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + padding: 1rem; + margin-bottom: 1.25rem; +} + +.dropzone-box { + border: 2px dashed var(--efintel-border-hover); + border-radius: var(--radius-sm); + padding: 1.25rem; + text-align: center; + cursor: pointer; +} + +.dropzone-box:hover { + border-color: var(--efintel-blue); + background: rgba(0, 120, 168, 0.02); +} + +/* Deep Dive Circular Progress Ring */ +.progress-ring-card { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.ring-container { + position: relative; + width: 160px; + height: 160px; + margin: 0.75rem 0; +} + +.ring-svg { + width: 100%; + height: 100%; + transform: rotate(-90deg); +} + +.ring-bg { + fill: none; + stroke: var(--efintel-light); + stroke-width: 12; +} + +.ring-circle { + fill: none; + stroke: var(--efintel-blue); + stroke-width: 12; + stroke-linecap: round; + stroke-dasharray: 440; + stroke-dashoffset: 440; + transition: stroke-dashoffset 0.8s ease; +} + +.ring-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; +} + +.ring-percent { + font-size: 1.75rem; + font-weight: 700; + color: var(--efintel-blue); +} + +.ring-label { + font-size: 0.72rem; + color: var(--efintel-gray); + text-transform: uppercase; +} + +.cat-breakdown-item { + margin-bottom: 0.85rem; +} + +.cat-breakdown-header { + display: flex; + justify-content: space-between; + font-size: 0.8rem; + font-weight: 600; + margin-bottom: 0.25rem; + color: var(--efintel-dark); +} + +.progress-bar-bg { + height: 6px; + background-color: var(--efintel-light); + border-radius: 3px; + overflow: hidden; +} + +.progress-bar-fill { + height: 100%; + background-color: var(--efintel-blue); + border-radius: 3px; + transition: width 0.5s ease; +} + +/* SaaS Grid */ +.software-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; +} + +.software-card { + background: var(--efintel-white); + border: 1px solid var(--efintel-border); + border-radius: var(--radius-sm); + padding: 1.15rem; + box-shadow: var(--shadow-sm); +} + +.software-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 0.75rem; +} + +.software-name { + font-size: 0.9rem; + font-weight: 700; + color: var(--efintel-dark); +} + +.software-vendor { + font-size: 0.75rem; + color: var(--efintel-gray); +} + +/* Responsive Rules */ +@media (max-width: 1024px) { + .kpi-grid-3, .charts-grid-main, .grid-2-kpi { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .app-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .header-actions { + width: 100%; + overflow-x: auto; + } + + .main-container { + padding: 1rem; + } +} diff --git a/static/img/efintel_logo.png b/static/img/efintel_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..db4337549ff5e3628b55a41de324055918bc9e23 GIT binary patch literal 33435 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 + + + + + + + + + + + + + + + + fintel + ® + + + eficiencia energetica inteligente + diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..62a9934 --- /dev/null +++ b/static/js/app.js @@ -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 = `
${message}
`; + 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 = ` + ${expense.date} + ${expense.project} + ${expense.category} + ${document.querySelector('th:nth-child(4)') && document.querySelector('th:nth-child(4)').textContent === 'Descripción' ? `${expense.description || ''}` : ''} + $${expense.amount} + ${expense.user} + ${document.querySelector('th:nth-child(7)') ? ` + +
+ Recarga para ver acciones +
+ ` : ''} + `; + + // 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'); +}); diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..b61a261 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,86 @@ +{% load static %} + + + + + + EFINTEL | ERP Interno I+D+i + + + + + + + + + + + +
+
+
+ EFINTEL Logo +
+
+

EFINTEL | ERP Interno I+D+i

+

Sistema Integrado de Control Financiero, Operaciones, Talento y Proyectos

+
+
+ + +
+
+ Usuario: {{ request.user.username }} | Rol: {% if request.user.groups.all %}{{ request.user.groups.all.0.name }}{% else %}Usuario{% endif %} +
+ + Exportar CSV + + + + Registrar Erogación + + + Salir + +
+
+ + + + +
+ {% if messages %} +
+ {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ {% endif %} + + {% block content %}{% endblock %} +
+ + + {% block extra_js %}{% endblock %} + + diff --git a/templates/expenses/dashboard.html b/templates/expenses/dashboard.html new file mode 100644 index 0000000..a1cabc3 --- /dev/null +++ b/templates/expenses/dashboard.html @@ -0,0 +1,122 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
+
+
+

EFINTEL ERP — Centro de Control Financiero

+

Supervisión consolidada de presupuestos, costos acumulados y flujo multimoneda ($ CLP / $ USD)

+
+
+ Multimoneda Integrado + Flujo 3 Pasos PMBOK +
+
+ +
+
+
+ EJECUCIÓN HISTÓRICA / MES +
+
+
${{ total_all|floatformat:"0" }} CLP
+
+ Mes actual: ${{ total_month|floatformat:"0" }} CLP +
+
+ +
+
+ PRESUPUESTO GENERAL I+D+i +
+
+
${{ global_budget|floatformat:"0" }} CLP
+
+ Saldo Restante: ${{ remaining_budget|floatformat:"0" }} CLP +
+
+ +
+
+ PROYECTOS ACTIVOS +
+
+
{{ project_count }}
+
+ En línea +
+
+
+ +
+
+
Últimas Erogaciones Registradas (Flujo ERP)
+
+
+ + + + + + + + + + + + + {% for expense in recent_expenses %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
FechaCentro / ProyectoDetalle ImputaciónMonto Neto ($ CLP / USD)Flujo Aprobación & PagoAcciones / Gestión
{{ expense.date|date:"d/m/Y" }} + + {{ expense.project.code }} + + + {{ expense.description|truncatechars:50 }}
+ {{ expense.responsible_person.username|default:expense.user.username }} +
+
${{ expense.amount_clp|floatformat:"0" }} CLP
+
${{ expense.amount_usd|floatformat:"2" }} USD
+
+ {% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %} + En Revisión + {% elif expense.approval_status == 'SOLICITADO_FINANZAS' %} + Solicitado Finanzas + {% elif expense.approval_status == 'LIBERADO_PAGADO' %} + Pagado + {% else %} + Rechazado + {% endif %} + +
+ + {% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %} + + {% endif %} + + {% if expense.approval_status == 'SOLICITADO_FINANZAS' and 'Administrador Finanzas' in request.user.groups.all.0.name or request.user.is_superuser %} + + + {% endif %} + + +
+
No hay erogaciones recientes.
+
+
+
+{% endblock %} diff --git a/templates/expenses/expense_form.html b/templates/expenses/expense_form.html new file mode 100644 index 0000000..67b73f2 --- /dev/null +++ b/templates/expenses/expense_form.html @@ -0,0 +1,114 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

+ + {% if form.instance.pk %}Modificar Gasto Registrado{% else %}Registro de Nueva Erogación{% endif %} +

+ +
+ {% csrf_token %} + +
+
+ + {{ form.project }} + {% if form.project.errors %}
{{ form.project.errors }}
{% endif %} +
+ +
+ + {{ form.category }} + {% if form.category.errors %}
{{ form.category.errors }}
{% endif %} +
+
+ +
+
+ + {{ form.amount_clp }} + El equivalente en USD será calculado automáticamente por el sistema. + {% if form.amount_clp.errors %}
{{ form.amount_clp.errors }}
{% endif %} +
+ +
+ + {{ form.currency }} + {% if form.currency.errors %}
{{ form.currency.errors }}
{% endif %} +
+
+ +
+
+ + {{ form.date }} + {% if form.date.errors %}
{{ form.date.errors }}
{% endif %} +
+ +
+ + {{ form.vendor }} + {% if form.vendor.errors %}
{{ form.vendor.errors }}
{% endif %} +
+
+ +
+ + {{ form.description }} + {% if form.description.errors %}
{{ form.description.errors }}
{% endif %} +
+ +
+ + {{ form.receipt }} + Formatos aceptados: Imágenes (.png, .jpg) o Documentos PDF. + {% if form.receipt.errors %}
{{ form.receipt.errors }}
{% endif %} + + {% if form.instance.pk and form.instance.documents.first %} +
+ Evidencia Actual: Ver Archivo Adjunto +
+ {% endif %} +
+ +
+

Datos Logísticos (Opcional)

+ +
+ + {{ form.region }} +
+
+ + {{ form.comuna }} +
+
+ + {{ form.vehicle_ownership }} +
+
+ +
+ Cancelar + +
+
+
+
+ + +{% endblock %} diff --git a/templates/expenses/expense_list.html b/templates/expenses/expense_list.html new file mode 100644 index 0000000..918337c --- /dev/null +++ b/templates/expenses/expense_list.html @@ -0,0 +1,122 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
+
+
+

Control de Erogaciones (Flujo Operativo)

+

Bandeja de gestión y aprobación de costos y gastos registrados.

+
+ +
+ +
+
+
+ + +
+
+ + +
+ +
+
+ +
+
+ + + + + + + + + + + + + + {% for expense in expenses %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
ID TransacciónFechaCentro / ProyectoDetalle ImputaciónMonto Neto ($ CLP / USD)Estado de AprobaciónAcciones
{{ expense.expense_code|truncatechars:12 }}{{ expense.date|date:"d/m/Y" }} + + {{ expense.project.code }} + + + {{ expense.description|truncatechars:40 }}
+ {{ expense.responsible_person.username|default:expense.user.username }} + {% if expense.documents.first %} +
Evidencia Adjunta + {% endif %} +
+
${{ expense.amount_clp|floatformat:"0" }} CLP
+
${{ expense.amount_usd|floatformat:"2" }} USD
+
+ {% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %} + En Revisión + {% elif expense.approval_status == 'SOLICITADO_FINANZAS' %} + Solicitado Finanzas + {% elif expense.approval_status == 'LIBERADO_PAGADO' %} + Pagado + {% else %} + Rechazado + {% endif %} + +
+ {% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %} + + {% endif %} + + {% if expense.approval_status == 'SOLICITADO_FINANZAS' and 'Administrador Finanzas' in request.user.groups.all.0.name or request.user.is_superuser %} + + + {% endif %} + + {% if request.user.groups.all.0.name == 'Administrador' or request.user.is_superuser or expense.user == request.user %} + + {% endif %} + + {% if request.user.groups.all.0.name == 'Administrador' or request.user.is_superuser %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
No hay gastos registrados para este filtro.
+
+
+
+{% endblock %} diff --git a/templates/expenses/gestion.html b/templates/expenses/gestion.html new file mode 100644 index 0000000..6764378 --- /dev/null +++ b/templates/expenses/gestion.html @@ -0,0 +1,213 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Panel de Gestión Administrativa

+
+ +
+ + + +
+
+

Proyectos del Sistema

+ ➕ Nuevo Proyecto +
+
+ + + + + + + + + + + + + {% for project in projects %} + + + + + + + + + {% empty %} + + + + {% endfor %} + +
NombreDescripciónEncargado (Responsable)Creado PorÚltima ModificaciónAcciones
{{ project.name }}{{ project.description|default:"Sin descripción" }} + {% if project.manager %} + {{ project.manager.username }} + {% else %} + No asignado + {% endif %} + {{ project.created_by.username|default:"Sistema" }}{{ project.updated_at|date:"d/m/Y H:i" }} +
+ ✏️ Editar +
+ {% csrf_token %} + +
+
+
No hay proyectos registrados.
+
+
+ + +
+
+

Responsables y Colaboradores

+ ➕ Nuevo Responsable +
+
+ + + + + + + + + + + + + + {% for resp in responsables %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
UsuarioCorreoRol / PermisoDescripción / BioF. RegistroF. ModificaciónAcciones
{{ resp.username }}{{ resp.email|default:"-" }} + {% if resp.groups.all %} + {% with role=resp.groups.all.0.name %} + {% if role == 'Administrador' %} + Administrador + {% elif role == 'Director de Proyecto' %} + Director + {% else %} + Gestor + {% endif %} + {% endwith %} + {% else %} + Sin Rol + {% endif %} + {{ resp.profile.description|default:"Sin descripción personal" }}{{ resp.profile.created_at|date:"d/m/Y"|default:"-" }}{{ resp.profile.updated_at|date:"d/m/Y H:i"|default:"-" }} +
+ ✏️ Editar + {% if resp != request.user %} +
+ {% csrf_token %} + +
+ {% endif %} +
+
No hay responsables registrados.
+
+
+ + +
+
+

Configuración General del Sistema

+
+
+
+
Presupuesto General Asignado
+
${{ settings.global_budget|default:"100000.00" }}
+
+ +
+
+ + +
+
+

Bitácora de Auditoría y Trazabilidad (Logs)

+

Historial detallado de operaciones realizadas por los administradores en el sistema.

+
+
+ {% for log in logs %} +
+
+
+ {% 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 %} +
+
{{ log.description }}
+
+
+
@{{ log.user.username|default:"Sistema" }}
+
{{ log.timestamp|date:"d/m/Y H:i:s" }}
+
+
+ {% empty %} +
No hay registros en la bitácora de auditoría.
+ {% endfor %} +
+
+
+{% endblock %} + +{% block extra_js %} + +{% endblock %} diff --git a/templates/expenses/project_form.html b/templates/expenses/project_form.html new file mode 100644 index 0000000..e18b9b8 --- /dev/null +++ b/templates/expenses/project_form.html @@ -0,0 +1,51 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

+ {% if form.instance.pk %}Editar Proyecto{% else %}Nuevo Proyecto{% endif %} +

+ +
+ {% csrf_token %} + +
+ + {{ form.name }} + {% if form.name.errors %} +
{{ form.name.errors }}
+ {% endif %} +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} +
+ +
+ + {{ form.manager }} + {% if form.manager.errors %} +
{{ form.manager.errors }}
+ {% endif %} +
+ +
+ + Cancelar +
+
+
+
+ + +{% endblock %} diff --git a/templates/expenses/project_list.html b/templates/expenses/project_list.html new file mode 100644 index 0000000..e9c57a7 --- /dev/null +++ b/templates/expenses/project_list.html @@ -0,0 +1,36 @@ +{% extends 'base.html' %} + +{% block content %} +
+

Proyectos

+
+ +
+ {% for project in projects %} +
+
+
{{ project.name }}
+
+
+ Gestor: {{ project.manager.username|default:"No asignado" }} +
+
+
+ Gastos + {{ project.expense_count|default:"0" }} +
+
+ Total + + ${{ project.total_expenses|default:"0.00" }} + +
+
+
+ {% empty %} +
+ No hay proyectos registrados. +
+ {% endfor %} +
+{% endblock %} diff --git a/templates/expenses/responsable_form.html b/templates/expenses/responsable_form.html new file mode 100644 index 0000000..4b97145 --- /dev/null +++ b/templates/expenses/responsable_form.html @@ -0,0 +1,72 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

+ {% if edit_mode %}Editar Colaborador (Responsable): {{ target_user.username }}{% else %}Nuevo Colaborador (Responsable){% endif %} +

+ +
+ {% csrf_token %} + +
+ + {{ form.username }} + {% if form.username.errors %} +
{{ form.username.errors }}
+ {% endif %} +
+ +
+ + {{ form.email }} + {% if form.email.errors %} +
{{ form.email.errors }}
+ {% endif %} +
+ +
+ + {{ form.password }} + {% if form.password.errors %} +
{{ form.password.errors }}
+ {% endif %} +
+ +
+ + {{ form.role }} + {% if form.role.errors %} +
{{ form.role.errors }}
+ {% endif %} +
+ +
+ + {{ form.description }} + {% if form.description.errors %} +
{{ form.description.errors }}
+ {% endif %} +
+ +
+ + Cancelar +
+
+
+
+ + +{% endblock %} diff --git a/templates/expenses/settings_form.html b/templates/expenses/settings_form.html new file mode 100644 index 0000000..1eb12a9 --- /dev/null +++ b/templates/expenses/settings_form.html @@ -0,0 +1,33 @@ +{% extends 'base.html' %} + +{% block content %} +
+
+

Editar Configuración del Sistema

+ +
+ {% csrf_token %} + +
+ + {{ form.global_budget }} + {% if form.global_budget.errors %} +
{{ form.global_budget.errors }}
+ {% endif %} +
+ +
+ + Cancelar +
+
+
+
+ + +{% endblock %} diff --git a/templates/registration/login.html b/templates/registration/login.html new file mode 100644 index 0000000..2cc1ba5 --- /dev/null +++ b/templates/registration/login.html @@ -0,0 +1,57 @@ +{% load static %} + + + + + + EFINTEL | Iniciar Sesión ERP + + + + + + + + +
+
+ EFINTEL Logo +

Portal ERP Interno

+

Control de Gastos & Operaciones

+
+ + {% if form.errors %} +
+ Credenciales incorrectas. +
+ {% endif %} + +
+ {% csrf_token %} +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+
+ + + +