Receta
CRUD
Tabulator
Drawer
Modal
KPIs
Receta: CRUD Funcional
Aplicación completa de gestión de inventario con tabla Tabulator, KPIs, drawer de creación/edición y modal de confirmación de borrado.
Código fuente del template
template.html
{% extends "showcase/base_empty.html" %}
{% load components_ui %}
{% block title %}Receta: CRUD Funcional — Django Components UI{% endblock %}
{% block content %}
<div class="h-full flex flex-col overflow-hidden bg-gray-50">
<!-- Navbar -->
<div class="z-30">
{% comp_navbar title="Catálogo de Productos" show_user=True %}
</div>
<div class="flex flex-1 overflow-hidden">
<!-- Sidebar -->
<div class="w-64 flex-shrink-0 bg-gray-900 hidden lg:block shadow-xl relative z-20">
{% comp_sidebar_menu menu_items=menu_items title="Tienda" is_open=True %}
</div>
<!-- Main content -->
<main class="flex-1 overflow-y-auto p-6 lg:p-10 space-y-6">
<!-- Page header -->
<header class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-black text-gray-900 tracking-tight">Catálogo de Productos</h1>
<p class="text-gray-500 mt-1 text-sm font-medium">Gestiona el inventario: alta, edición y baja de productos.</p>
</div>
<div class="flex items-center gap-3">
{% comp_badge text="CRUD Funcional" color="success" %}
{% comp_button text="Nuevo Producto" color="primary" icon="PlusIcon" onclick="abrirDrawerNuevo()" %}
</div>
</header>
<!-- KPIs -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
{% comp_data_card title="Total Productos" value="24" trend=8.3 color="primary" icon="CubeIcon" sparkline_data=sparkline %}
{% comp_data_card title="En Stock" value="21" trend=5.1 color="success" icon="CheckCircleIcon" %}
{% comp_data_card title="Sin Stock" value="3" trend=-2.0 color="error" icon="ExclamationCircleIcon" %}
{% comp_data_card title="Valor Inventario" value="18.450 €" trend=12.4 color="warning" icon="CurrencyEuroIcon" %}
</div>
<!-- Table section -->
<div class="bg-white rounded-3xl border border-gray-200 shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-gray-100 flex items-center gap-3">
{% comp_icon name="CubeIcon" size="w-4 h-4 text-primary-600" %}
<h2 class="text-sm font-bold text-gray-700">Inventario</h2>
<span id="productos-count" class="ml-auto text-xs text-gray-400 font-medium">Cargando...</span>
</div>
{% comp_tabla id="productos" data_url="/api/mock-products/" columns=columnas_productos searchable=True deletable=True exportable=True page_size=10 height="420px" %}
</div>
</main>
</div>
</div>
<!-- ═══ DRAWER: Create/Edit Form ═══ -->
{% capture "drawer_form" %}
<div id="product-form-fields" class="space-y-4">
{% comp_alert text="Los campos marcados con * son obligatorios." type="info" dismissible=False %}
<div class="grid grid-cols-2 gap-4">
<div class="col-span-2">{% comp_input_text name="p_nombre" label="Nombre del Producto *" placeholder="Ej. Teclado Mecánico" %}</div>
<div>{% comp_input_text name="p_sku" label="SKU / Código" placeholder="Ej. TEC-001" %}</div>
<div>{% comp_input_text name="p_categoria" label="Categoría" placeholder="Ej. Periféricos" %}</div>
<div>{% comp_input_number name="p_precio" label="Precio (€) *" min_val=0 step="0.01" %}</div>
<div>{% comp_input_number name="p_stock" label="Stock *" min_val=0 step="1" %}</div>
</div>
{% comp_toggle name="p_activo" label="Producto activo en el catálogo" checked=True %}
<input type="hidden" id="p_id" value="">
<div class="pt-4 border-t border-gray-100 flex gap-3">
{% comp_button text="Cancelar" type="button" appearance="outlined" color="secondary" onclick="hideDrawer('producto-form')" %}
{% comp_button text="Guardar Producto" type="button" color="primary" icon="CheckIcon" onclick="guardarProducto()" id="btn-guardar" %}
</div>
</div>
{% endcapture %}
{% comp_drawer id="producto-form" title="Nuevo Producto" subtitle="Rellena los datos del producto" icon="CubeIcon" icon_color="primary" size="md" content=drawer_form %}
<!-- ═══ MODAL: Delete confirmation ═══ -->
{% comp_modal id="delete-producto" title="¿Eliminar producto?" icon="TrashIcon" icon_color="error" size="sm" confirm_text="Sí, eliminar" cancel_text="Cancelar" content="Esta acción no se puede deshacer. El producto será eliminado permanentemente del catálogo." %}
{% endblock %}
{% block scripts %}
<script>
'use strict';
let _editingProductId = null;
// ── Listen for table edit/delete events (dispatched by comp_tabla actions column) ──
window.addEventListener('tabulator-edit', e => {
if (e.detail.id !== 'productos') return;
abrirDrawerEditar(e.detail.row);
});
window.addEventListener('tabulator-delete', e => {
if (e.detail.id !== 'productos') return;
_editingProductId = e.detail.row.id;
showModal('delete-producto');
});
// ── Wire delete modal confirm button ──
document.addEventListener('DOMContentLoaded', () => {
// Tabulator fires dataLoaded — update count badge
const waitTable = setInterval(() => {
if (tabulators['productos']) {
clearInterval(waitTable);
tabulators['productos'].on('dataLoaded', updateCount);
tabulators['productos'].on('rowAdded', updateCount);
tabulators['productos'].on('rowDeleted', updateCount);
}
}, 200);
// Delete modal confirm
const confirmBtn = document.querySelector('[data-modal-confirm="delete-producto"], #delete-producto [data-confirm]');
if (confirmBtn) {
confirmBtn.addEventListener('click', confirmarEliminar);
} else {
// fallback: wait for modal to render
document.getElementById('delete-producto')?.addEventListener('click', e => {
if (e.target.matches('[data-confirm], .btn-modal-confirm')) confirmarEliminar();
});
}
});
function updateCount() {
const t = tabulators['productos'];
if (!t) return;
const n = t.getDataCount();
document.getElementById('productos-count').textContent = n + ' producto' + (n !== 1 ? 's' : '');
}
function abrirDrawerNuevo() {
_editingProductId = null;
document.getElementById('drawer-title-producto-form').textContent = 'Nuevo Producto';
// Clear form
['p_nombre','p_sku','p_categoria','p_precio','p_stock'].forEach(f => {
const el = document.getElementById(f) || document.querySelector('[name="' + f + '"]');
if (el) el.value = '';
});
document.getElementById('p_id').value = '';
const toggle = document.querySelector('[name="p_activo"]');
if (toggle) toggle.checked = true;
showDrawer('producto-form');
}
function abrirDrawerEditar(rowData) {
_editingProductId = rowData.id;
document.getElementById('drawer-title-producto-form').textContent = 'Editar Producto';
// Populate form
const map = { p_nombre: 'nombre', p_sku: 'sku', p_categoria: 'categoria', p_precio: 'precio', p_stock: 'stock' };
Object.entries(map).forEach(([formField, dataField]) => {
const el = document.getElementById(formField) || document.querySelector('[name="' + formField + '"]');
if (el) el.value = rowData[dataField] ?? '';
});
document.getElementById('p_id').value = rowData.id;
const toggle = document.querySelector('[name="p_activo"]');
if (toggle) toggle.checked = rowData.activo !== false;
showDrawer('producto-form');
}
function guardarProducto() {
const get = name => (document.getElementById(name) || document.querySelector('[name="' + name + '"]'))?.value ?? '';
const nombre = get('p_nombre').trim();
if (!nombre) {
Toast.show('El nombre del producto es obligatorio', 'error');
return;
}
const data = {
nombre: nombre,
sku: get('p_sku'),
categoria: get('p_categoria'),
precio: parseFloat(get('p_precio')) || 0,
stock: parseInt(get('p_stock')) || 0,
activo: document.querySelector('[name="p_activo"]')?.checked !== false,
};
const tabla = tabulators['productos'];
if (!tabla) return;
const id = document.getElementById('p_id').value;
if (id) {
// Edit existing
const row = tabla.getRow(parseInt(id));
if (row) row.update(data);
Toast.show('Producto actualizado correctamente', 'success');
} else {
// Create new
data.id = Date.now();
tabla.addData([data], true); // true = prepend
Toast.show('Producto creado correctamente', 'success');
}
hideDrawer('producto-form');
}
function confirmarEliminar() {
if (!_editingProductId) return;
const tabla = tabulators['productos'];
if (tabla) {
const row = tabla.getRow(_editingProductId);
if (row) row.delete();
}
closeModal('delete-producto');
Toast.show('Producto eliminado', 'error', 3000);
_editingProductId = null;
}
</script>
{% endblock %}
Vista previa
Pantalla completa
/recipes/crud/