diff --git a/config/settings.py b/config/settings.py index bffc8b7..bfccd07 100644 --- a/config/settings.py +++ b/config/settings.py @@ -47,6 +47,7 @@ INSTALLED_APPS = [ 'django.contrib.staticfiles', 'django.contrib.humanize', 'channels', + 'widget_tweaks', 'expenses', 'payroll', 'procurement', diff --git a/expenses/consumers.py b/expenses/consumers.py index 76805bf..e182c86 100644 --- a/expenses/consumers.py +++ b/expenses/consumers.py @@ -19,11 +19,7 @@ class ExpenseConsumer(AsyncWebsocketConsumer): # Recibir actualización desde el Channel Layer async def expense_update(self, event): - expense_data = event['expense'] - action = event['action'] + html_payload = event['html'] - # Enviar actualización por WebSocket al cliente - await self.send(text_data=json.dumps({ - 'action': action, - 'expense': expense_data - })) + # Enviar actualización por WebSocket al cliente (HTMX espera HTML puro, no JSON) + await self.send(text_data=html_payload) diff --git a/expenses/signals.py b/expenses/signals.py index 26913ae..042143c 100644 --- a/expenses/signals.py +++ b/expenses/signals.py @@ -2,40 +2,42 @@ 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 django.template.loader import render_to_string 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_clp), - '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, - } + # Renderizamos la fila parcial + html = render_to_string('expenses/partials/expense_row.html', {'expense': instance}) + + # Envolvemos el HTML con las directivas OOB (Out-of-Band) de HTMX + if created: + # Si es nuevo, lo insertamos al principio del tbody con id "expenses-tbody" + payload = f'{html}' + else: + # Si ya existe, HTMX reemplazará automáticamente el tr con el mismo ID si usamos hx-swap-oob="true" + payload = html.replace('' + async_to_sync(channel_layer.group_send)( 'expenses_updates', { 'type': 'expense_update', - 'action': 'deleted', - 'expense': {'id': instance.id} + 'html': payload } ) diff --git a/requirements.txt b/requirements.txt index 642fe58..843defa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,3 +57,4 @@ boto3==1.34.69 requests==2.31.0 uvicorn[standard] gunicorn +django-widget-tweaks==1.5.0 diff --git a/static/js/app.js b/static/js/app.js index 62a9934..3f6be4e 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -33,110 +33,18 @@ function showToast(message, type = 'info') { }, 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); - } - }; -} - +// HTMX HTMX HTMX document.addEventListener('DOMContentLoaded', () => { - connectWebSocket(); - - // Convert CSRF token for fetch requests if needed later - const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content'); + // Re-initialize Lucide icons when HTMX swaps content + if (typeof lucide !== 'undefined' && document.body.addEventListener) { + document.body.addEventListener('htmx:afterSwap', function(evt) { + lucide.createIcons(); + }); + + document.body.addEventListener('htmx:wsAfterMessage', function(evt) { + lucide.createIcons(); + // Optional: trigger toast + showToast('Actualización en tiempo real recibida', 'info'); + }); + } }); diff --git a/templates/base.html b/templates/base.html index 2aaf8bc..5cd3b1e 100644 --- a/templates/base.html +++ b/templates/base.html @@ -11,6 +11,9 @@ + + + diff --git a/templates/expenses/expense_list.html b/templates/expenses/expense_list.html index 6a961f6..2595238 100644 --- a/templates/expenses/expense_list.html +++ b/templates/expenses/expense_list.html @@ -41,7 +41,7 @@
-
+
@@ -54,77 +54,9 @@ - + {% for expense in expenses %} - - - - - - - - - + {% include 'expenses/partials/expense_row.html' with expense=expense %} {% empty %} diff --git a/templates/expenses/partials/expense_row.html b/templates/expenses/partials/expense_row.html new file mode 100644 index 0000000..aec71cd --- /dev/null +++ b/templates/expenses/partials/expense_row.html @@ -0,0 +1,67 @@ +{% load humanize %} + + + + + + + + +
Acciones
{{ 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.all %} -
- {% for doc in expense.documents.all %} - - {% endfor %} -
- {% endif %} -
-
${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP
-
${{ expense.amount_usd|floatformat:"2"|intcomma }} 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 expense.approval_status != 'SOLICITADO_FINANZAS' and expense.approval_status != 'LIBERADO_PAGADO' %} - {% 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 %} - {% else %} - - {% endif %} -
-
No hay gastos registrados para este filtro.
{{ 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.all %} +
+ {% for doc in expense.documents.all %} + + {% endfor %} +
+ {% endif %} +
+
${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP
+
${{ expense.amount_usd|floatformat:"2"|intcomma }} 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 expense.approval_status != 'SOLICITADO_FINANZAS' and expense.approval_status != 'LIBERADO_PAGADO' %} + {% 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 %} + {% else %} + + {% endif %} +
+