MEJORA: Refactorizacion a HTMX para WebSockets en tiempo real y agregado widget-tweaks
This commit is contained in:
@@ -47,6 +47,7 @@ INSTALLED_APPS = [
|
|||||||
'django.contrib.staticfiles',
|
'django.contrib.staticfiles',
|
||||||
'django.contrib.humanize',
|
'django.contrib.humanize',
|
||||||
'channels',
|
'channels',
|
||||||
|
'widget_tweaks',
|
||||||
'expenses',
|
'expenses',
|
||||||
'payroll',
|
'payroll',
|
||||||
'procurement',
|
'procurement',
|
||||||
|
|||||||
@@ -19,11 +19,7 @@ class ExpenseConsumer(AsyncWebsocketConsumer):
|
|||||||
|
|
||||||
# Recibir actualización desde el Channel Layer
|
# Recibir actualización desde el Channel Layer
|
||||||
async def expense_update(self, event):
|
async def expense_update(self, event):
|
||||||
expense_data = event['expense']
|
html_payload = event['html']
|
||||||
action = event['action']
|
|
||||||
|
|
||||||
# Enviar actualización por WebSocket al cliente
|
# Enviar actualización por WebSocket al cliente (HTMX espera HTML puro, no JSON)
|
||||||
await self.send(text_data=json.dumps({
|
await self.send(text_data=html_payload)
|
||||||
'action': action,
|
|
||||||
'expense': expense_data
|
|
||||||
}))
|
|
||||||
|
|||||||
+16
-14
@@ -2,40 +2,42 @@ from django.db.models.signals import post_save, post_delete
|
|||||||
from django.dispatch import receiver
|
from django.dispatch import receiver
|
||||||
from channels.layers import get_channel_layer
|
from channels.layers import get_channel_layer
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
|
from django.template.loader import render_to_string
|
||||||
from .models import Expense
|
from .models import Expense
|
||||||
|
|
||||||
@receiver(post_save, sender=Expense)
|
@receiver(post_save, sender=Expense)
|
||||||
def expense_saved(sender, instance, created, **kwargs):
|
def expense_saved(sender, instance, created, **kwargs):
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
action = 'created' if created else 'updated'
|
|
||||||
|
|
||||||
expense_data = {
|
# Renderizamos la fila parcial
|
||||||
'id': instance.id,
|
html = render_to_string('expenses/partials/expense_row.html', {'expense': instance})
|
||||||
'amount': str(instance.amount_clp),
|
|
||||||
'category': instance.category.name if instance.category else None,
|
# Envolvemos el HTML con las directivas OOB (Out-of-Band) de HTMX
|
||||||
'project': instance.project.name if instance.project else None,
|
if created:
|
||||||
'user': instance.user.username if instance.user else None,
|
# Si es nuevo, lo insertamos al principio del tbody con id "expenses-tbody"
|
||||||
'date': instance.date.strftime('%Y-%m-%d'),
|
payload = f'<tbody id="expenses-tbody" hx-swap-oob="afterbegin">{html}</tbody>'
|
||||||
'description': instance.description,
|
else:
|
||||||
}
|
# Si ya existe, HTMX reemplazará automáticamente el tr con el mismo ID si usamos hx-swap-oob="true"
|
||||||
|
payload = html.replace('<tr ', '<tr hx-swap-oob="true" ')
|
||||||
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(channel_layer.group_send)(
|
||||||
'expenses_updates',
|
'expenses_updates',
|
||||||
{
|
{
|
||||||
'type': 'expense_update',
|
'type': 'expense_update',
|
||||||
'action': action,
|
'html': payload
|
||||||
'expense': expense_data
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@receiver(post_delete, sender=Expense)
|
@receiver(post_delete, sender=Expense)
|
||||||
def expense_deleted(sender, instance, **kwargs):
|
def expense_deleted(sender, instance, **kwargs):
|
||||||
channel_layer = get_channel_layer()
|
channel_layer = get_channel_layer()
|
||||||
|
# Para borrar, mandamos un elemento vacío con swap out-of-band que reemplace la fila eliminada
|
||||||
|
payload = f'<tr id="expense-{instance.id}" hx-swap-oob="outerHTML"></tr>'
|
||||||
|
|
||||||
async_to_sync(channel_layer.group_send)(
|
async_to_sync(channel_layer.group_send)(
|
||||||
'expenses_updates',
|
'expenses_updates',
|
||||||
{
|
{
|
||||||
'type': 'expense_update',
|
'type': 'expense_update',
|
||||||
'action': 'deleted',
|
'html': payload
|
||||||
'expense': {'id': instance.id}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,3 +57,4 @@ boto3==1.34.69
|
|||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
gunicorn
|
gunicorn
|
||||||
|
django-widget-tweaks==1.5.0
|
||||||
|
|||||||
+12
-104
@@ -33,110 +33,18 @@ function showToast(message, type = 'info') {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket connection for real-time updates
|
// HTMX HTMX HTMX
|
||||||
let wsReconnectAttempts = 0;
|
|
||||||
const maxWsReconnectAttempts = 5;
|
|
||||||
|
|
||||||
function connectWebSocket() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/ws/expenses/`;
|
|
||||||
|
|
||||||
// We only connect if we are not on the login page
|
|
||||||
if (document.querySelector('.login-page')) return;
|
|
||||||
|
|
||||||
const ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = function(e) {
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
wsReconnectAttempts = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = function(e) {
|
|
||||||
const data = JSON.parse(e.data);
|
|
||||||
const action = data.action;
|
|
||||||
const expense = data.expense;
|
|
||||||
const tbody = document.getElementById('expenses-table-body');
|
|
||||||
|
|
||||||
if (!tbody || !expense) return;
|
|
||||||
|
|
||||||
if (action === 'created') {
|
|
||||||
const tr = document.createElement('tr');
|
|
||||||
tr.id = `expense-row-${expense.id}`;
|
|
||||||
tr.innerHTML = `
|
|
||||||
<td>${expense.date}</td>
|
|
||||||
<td>${expense.project}</td>
|
|
||||||
<td>${expense.category}</td>
|
|
||||||
${document.querySelector('th:nth-child(4)') && document.querySelector('th:nth-child(4)').textContent === 'Descripción' ? `<td>${expense.description || ''}</td>` : ''}
|
|
||||||
<td>$${expense.amount}</td>
|
|
||||||
<td>${expense.user}</td>
|
|
||||||
${document.querySelector('th:nth-child(7)') ? `
|
|
||||||
<td>
|
|
||||||
<div style="display: flex; gap: 0.5rem;">
|
|
||||||
<span style="font-size:0.8rem; color:var(--text-secondary);">Recarga para ver acciones</span>
|
|
||||||
</div>
|
|
||||||
</td>` : ''}
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add animation class
|
|
||||||
tr.style.backgroundColor = 'rgba(0, 212, 170, 0.2)';
|
|
||||||
tr.style.transition = 'background-color 2s ease';
|
|
||||||
|
|
||||||
tbody.insertBefore(tr, tbody.firstChild);
|
|
||||||
|
|
||||||
// Remove highlight after animation
|
|
||||||
setTimeout(() => {
|
|
||||||
tr.style.backgroundColor = '';
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
showToast('Nuevo gasto registrado', 'success');
|
|
||||||
}
|
|
||||||
else if (action === 'updated') {
|
|
||||||
const row = document.getElementById(`expense-row-${expense.id}`);
|
|
||||||
if (row) {
|
|
||||||
// Flash the row
|
|
||||||
row.style.backgroundColor = 'rgba(108, 99, 255, 0.2)';
|
|
||||||
row.style.transition = 'background-color 2s ease';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
row.style.backgroundColor = '';
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
showToast('Gasto actualizado', 'info');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (action === 'deleted') {
|
|
||||||
const row = document.getElementById(`expense-row-${expense.id}`);
|
|
||||||
if (row) {
|
|
||||||
row.style.transform = 'translateX(100%)';
|
|
||||||
row.style.opacity = '0';
|
|
||||||
row.style.transition = 'all 0.3s ease';
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
if (row.parentNode) {
|
|
||||||
row.parentNode.removeChild(row);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
showToast('Gasto eliminado', 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = function(e) {
|
|
||||||
console.error('WebSocket closed unexpectedly');
|
|
||||||
|
|
||||||
if (wsReconnectAttempts < maxWsReconnectAttempts) {
|
|
||||||
wsReconnectAttempts++;
|
|
||||||
const timeout = Math.min(1000 * Math.pow(2, wsReconnectAttempts), 30000);
|
|
||||||
console.log(`Attempting reconnect in ${timeout}ms...`);
|
|
||||||
setTimeout(connectWebSocket, timeout);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
connectWebSocket();
|
// Re-initialize Lucide icons when HTMX swaps content
|
||||||
|
if (typeof lucide !== 'undefined' && document.body.addEventListener) {
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
lucide.createIcons();
|
||||||
|
});
|
||||||
|
|
||||||
// Convert CSRF token for fetch requests if needed later
|
document.body.addEventListener('htmx:wsAfterMessage', function(evt) {
|
||||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
lucide.createIcons();
|
||||||
|
// Optional: trigger toast
|
||||||
|
showToast('Actualización en tiempo real recibida', 'info');
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,9 @@
|
|||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<!-- HTMX & WebSockets Extension -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.2"></script>
|
||||||
|
<script src="https://unpkg.com/htmx-ext-ws@2.0.1/ws.js"></script>
|
||||||
<link rel="stylesheet" href="{% static 'css/style.css' %}?v=3.0">
|
<link rel="stylesheet" href="{% static 'css/style.css' %}?v=3.0">
|
||||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="data-card">
|
<div class="data-card">
|
||||||
<div class="data-table-wrapper">
|
<div class="data-table-wrapper" hx-ext="ws" ws-connect="/ws/expenses/">
|
||||||
<table class="data-table">
|
<table class="data-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -54,77 +54,9 @@
|
|||||||
<th>Acciones</th>
|
<th>Acciones</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody id="expenses-tbody">
|
||||||
{% for expense in expenses %}
|
{% for expense in expenses %}
|
||||||
<tr>
|
{% include 'expenses/partials/expense_row.html' with expense=expense %}
|
||||||
<td style="font-family: var(--font-mono); font-size: 0.75rem; color: var(--efintel-gray);">{{ expense.expense_code|truncatechars:12 }}</td>
|
|
||||||
<td style="font-size:0.8rem; font-weight: 600;">{{ expense.date|date:"d/m/Y" }}</td>
|
|
||||||
<td>
|
|
||||||
<span class="project-tag" {% if expense.project.type == 'GASTO_OPERACIONAL_OVERHEAD' %}style="background:#e2e8f0; color:#475569;"{% endif %}>
|
|
||||||
{{ expense.project.code }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td style="font-weight:600; color:var(--efintel-dark); font-size: 0.85rem;">
|
|
||||||
{{ expense.description|truncatechars:40 }}<br>
|
|
||||||
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
|
|
||||||
{% if expense.documents.all %}
|
|
||||||
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
|
|
||||||
{% for doc in expense.documents.all %}
|
|
||||||
<div style="display:inline-flex; align-items:center; gap:0.3rem;">
|
|
||||||
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none;" title="{{ doc.title|default:doc.file.name }}">
|
|
||||||
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:20 }}
|
|
||||||
</a>
|
|
||||||
<button type="button" onclick="previewDocument('{{ doc.file.url }}', '{{ doc.title|escapejs }}', {% if doc.is_pdf %}true{% else %}false{% endif %})" style="background:none; border:none; color:#0284c7; cursor:pointer; padding:0; font-size:0.65rem;" title="Vista Previa Modal">
|
|
||||||
<i data-lucide="eye" style="width:12px;"></i> Ver
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="amount-cell">
|
|
||||||
<div>${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP</div>
|
|
||||||
<div style="font-size:0.68rem; color:var(--efintel-gray); font-family:var(--font-mono);">${{ expense.amount_usd|floatformat:"2"|intcomma }} USD</div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
|
|
||||||
<span class="badge badge-amber"><i data-lucide="clock" style="width:12px;"></i> En Revisión</span>
|
|
||||||
{% elif expense.approval_status == 'SOLICITADO_FINANZAS' %}
|
|
||||||
<span class="badge badge-blue"><i data-lucide="send" style="width:12px;"></i> Solicitado Finanzas</span>
|
|
||||||
{% elif expense.approval_status == 'LIBERADO_PAGADO' %}
|
|
||||||
<span class="badge badge-emerald"><i data-lucide="check-check" style="width:12px;"></i> Pagado</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge badge-red"><i data-lucide="x-circle" style="width:12px;"></i> Rechazado</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style="display: flex; gap: 0.3rem;">
|
|
||||||
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
|
|
||||||
<a href="{% url 'approve_expense' expense.id 'solicitar' %}" class="btn-primary" style="padding:0.3rem; font-size:0.65rem; background: var(--efintel-blue);" title="Elevar a Finanzas"><i data-lucide="arrow-right"></i></a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if expense.approval_status == 'SOLICITADO_FINANZAS' and 'Administrador Finanzas' in request.user.groups.all.0.name or request.user.is_superuser %}
|
|
||||||
<a href="{% url 'approve_expense' expense.id 'pagar' %}" class="btn-primary" style="padding:0.3rem; font-size:0.65rem; background: var(--efintel-success);" title="Liberar Pago"><i data-lucide="check"></i></a>
|
|
||||||
<a href="{% url 'approve_expense' expense.id 'rechazar' %}" class="btn-danger" style="padding:0.3rem; font-size:0.65rem;" title="Rechazar"><i data-lucide="x"></i></a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if 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 %}
|
|
||||||
<a href="/gastos/{{ expense.id }}/editar/" class="btn-secondary" style="padding: 0.3rem; background: var(--efintel-light); color: var(--efintel-petroleum);" title="Editar"><i data-lucide="pencil" style="width:14px;"></i></a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if request.user.groups.all.0.name == 'Administrador' or request.user.is_superuser %}
|
|
||||||
<form method="post" action="/gastos/{{ expense.id }}/eliminar/" style="display:inline;">
|
|
||||||
{% csrf_token %}
|
|
||||||
<button type="submit" class="btn-danger" style="padding: 0.3rem;" onclick="return confirm('¿Seguro que desea eliminar esta erogación? Esta acción quedará en el Log de Auditoría.');" title="Eliminar"><i data-lucide="trash-2" style="width:14px;"></i></button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% else %}
|
|
||||||
<span style="font-size:0.75rem; color:var(--efintel-gray); padding: 0.3rem;" title="Comprobante Bloqueado"><i data-lucide="lock" style="width:14px;"></i></span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--efintel-gray);">No hay gastos registrados para este filtro.</td>
|
<td colspan="7" style="text-align: center; padding: 2rem; color: var(--efintel-gray);">No hay gastos registrados para este filtro.</td>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
{% load humanize %}
|
||||||
|
<tr id="expense-{{ expense.id }}" class="fade-in">
|
||||||
|
<td style="font-family: var(--font-mono); font-size: 0.75rem; color: var(--efintel-gray);">{{ expense.expense_code|truncatechars:12 }}</td>
|
||||||
|
<td style="font-size:0.8rem; font-weight: 600;">{{ expense.date|date:"d/m/Y" }}</td>
|
||||||
|
<td>
|
||||||
|
<span class="project-tag" {% if expense.project.type == 'GASTO_OPERACIONAL_OVERHEAD' %}style="background:#e2e8f0; color:#475569;"{% endif %}>
|
||||||
|
{{ expense.project.code }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-weight:600; color:var(--efintel-dark); font-size: 0.85rem;">
|
||||||
|
{{ expense.description|truncatechars:40 }}<br>
|
||||||
|
<span style="font-size:0.65rem; color:var(--efintel-gray);">{{ expense.responsible_person.username|default:expense.user.username }}</span>
|
||||||
|
{% if expense.documents.all %}
|
||||||
|
<div style="margin-top: 0.25rem; display: flex; flex-direction: column; gap: 0.2rem;">
|
||||||
|
{% for doc in expense.documents.all %}
|
||||||
|
<div style="display:inline-flex; align-items:center; gap:0.3rem;">
|
||||||
|
<a href="{{ doc.file.url }}" target="_blank" style="font-size:0.65rem; color:var(--efintel-blue); text-decoration:none;" title="{{ doc.title|default:doc.file.name }}">
|
||||||
|
<i data-lucide="paperclip" style="width:10px;"></i> {{ doc.title|default:doc.file.name|truncatechars:20 }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="amount-cell">
|
||||||
|
<div>${{ expense.amount_clp|floatformat:"0"|intcomma }} CLP</div>
|
||||||
|
<div style="font-size:0.68rem; color:var(--efintel-gray); font-family:var(--font-mono);">${{ expense.amount_usd|floatformat:"2"|intcomma }} USD</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
|
||||||
|
<span class="badge badge-amber"><i data-lucide="clock" style="width:12px;"></i> En Revisión</span>
|
||||||
|
{% elif expense.approval_status == 'SOLICITADO_FINANZAS' %}
|
||||||
|
<span class="badge badge-blue"><i data-lucide="send" style="width:12px;"></i> Solicitado Finanzas</span>
|
||||||
|
{% elif expense.approval_status == 'LIBERADO_PAGADO' %}
|
||||||
|
<span class="badge badge-emerald"><i data-lucide="check-check" style="width:12px;"></i> Pagado</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge badge-red"><i data-lucide="x-circle" style="width:12px;"></i> Rechazado</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display: flex; gap: 0.3rem;">
|
||||||
|
{% if expense.approval_status == 'EN_REVISION_DOCUMENTAL' %}
|
||||||
|
<a href="{% url 'approve_expense' expense.id 'solicitar' %}" class="btn-primary" style="padding:0.3rem; font-size:0.65rem; background: var(--efintel-blue);" title="Elevar a Finanzas"><i data-lucide="arrow-right"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if expense.approval_status == 'SOLICITADO_FINANZAS' and 'Administrador Finanzas' in request.user.groups.all.0.name or request.user.is_superuser %}
|
||||||
|
<a href="{% url 'approve_expense' expense.id 'pagar' %}" class="btn-primary" style="padding:0.3rem; font-size:0.65rem; background: var(--efintel-success);" title="Liberar Pago"><i data-lucide="check"></i></a>
|
||||||
|
<a href="{% url 'approve_expense' expense.id 'rechazar' %}" class="btn-danger" style="padding:0.3rem; font-size:0.65rem;" title="Rechazar"><i data-lucide="x"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if 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 %}
|
||||||
|
<a href="/gastos/{{ expense.id }}/editar/" class="btn-secondary" style="padding: 0.3rem; background: var(--efintel-light); color: var(--efintel-petroleum);" title="Editar"><i data-lucide="pencil" style="width:14px;"></i></a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if request.user.groups.all.0.name == 'Administrador' or request.user.is_superuser %}
|
||||||
|
<form method="post" action="/gastos/{{ expense.id }}/eliminar/" style="display:inline;">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn-danger" style="padding: 0.3rem;" onclick="return confirm('¿Seguro que desea eliminar esta erogación? Esta acción quedará en el Log de Auditoría.');" title="Eliminar"><i data-lucide="trash-2" style="width:14px;"></i></button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
<span style="font-size:0.75rem; color:var(--efintel-gray); padding: 0.3rem;" title="Comprobante Bloqueado"><i data-lucide="lock" style="width:14px;"></i></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
Reference in New Issue
Block a user