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/