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 0000000..db43375 Binary files /dev/null and b/static/img/efintel_logo.png differ diff --git a/static/img/efintel_logo.svg b/static/img/efintel_logo.svg new file mode 100644 index 0000000..4d76d62 --- /dev/null +++ b/static/img/efintel_logo.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + 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 %} +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ + +
+
+ + + +