feat: create/edit product views

This commit is contained in:
2026-07-23 16:19:19 +02:00
parent 2a9407e676
commit 1d11ea9b6e
16 changed files with 574 additions and 175 deletions
+51
View File
@@ -63,6 +63,57 @@
document.body.addEventListener('backoffice:modal-close', function () {
document.getElementById('backoffice-modal').close();
});
// Combobox de búsqueda (DaisyUI + HTMX): el <input> de texto busca vía htmx
// (ver _combobox.html); seleccionar/quitar un resultado es JS puro, sin ida
// y vuelta al servidor, igual que ya se hace para cerrar el modal.
function backofficeComboboxSelect(containerId, value, label) {
const container = document.getElementById(containerId);
const multiple = container.dataset.multiple === 'true';
const fieldName = container.dataset.fieldName;
const chips = container.querySelector('.combobox-chips');
const hiddenInputs = container.querySelector('.combobox-hidden-inputs');
const search = container.querySelector('.combobox-search');
const results = container.querySelector('.combobox-results');
if (hiddenInputs.querySelector('input[value="' + value + '"]')) {
search.value = '';
results.innerHTML = '';
return;
}
if (!multiple) {
chips.innerHTML = '';
hiddenInputs.innerHTML = '';
}
const hidden = document.createElement('input');
hidden.type = 'hidden';
hidden.name = fieldName;
hidden.value = value;
hiddenInputs.appendChild(hidden);
const chip = document.createElement('span');
chip.className = 'badge badge-primary gap-1';
chip.dataset.value = value;
chip.appendChild(document.createTextNode(label));
const removeButton = document.createElement('button');
removeButton.type = 'button';
removeButton.className = 'leading-none';
removeButton.textContent = '×';
removeButton.onclick = function () { backofficeComboboxRemove(containerId, value); };
chip.appendChild(removeButton);
chips.appendChild(chip);
search.value = '';
results.innerHTML = '';
}
function backofficeComboboxRemove(containerId, value) {
const container = document.getElementById(containerId);
container.querySelector('.combobox-hidden-inputs input[value="' + value + '"]').remove();
container.querySelector('.combobox-chips [data-value="' + value + '"]').remove();
}
</script>
</body>
</html>
@@ -6,7 +6,11 @@
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-semibold">{{ title }}</h1>
{% if create_url %}
<button class="btn btn-primary" hx-get="{{ create_url }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir</button>
{% if create_is_page %}
<a class="btn btn-primary" href="{{ create_url }}">Añadir</a>
{% else %}
<button class="btn btn-primary" hx-get="{{ create_url }}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir</button>
{% endif %}
{% endif %}
</div>
@@ -0,0 +1,28 @@
<div id="combobox-{{ field_name }}" class="combobox" data-field-name="{{ field_name }}" data-multiple="{{ multiple|yesno:'true,false' }}">
<div class="combobox-chips flex flex-wrap gap-1 mb-2">
{% for item in selected %}
<span class="badge badge-primary gap-1" data-value="{{ item.pk }}">
{{ item }}
<button type="button" class="leading-none" onclick="backofficeComboboxRemove('combobox-{{ field_name }}', '{{ item.pk }}')">×</button>
</span>
{% endfor %}
</div>
<div class="combobox-hidden-inputs">
{% for item in selected %}
<input type="hidden" name="{{ field_name }}" value="{{ item.pk }}">
{% endfor %}
</div>
<div class="dropdown w-full">
<input
type="text"
class="input input-bordered w-full combobox-search"
placeholder="Buscar..."
autocomplete="off"
hx-get="{% url 'backoffice:product_field_search' field_name %}"
hx-trigger="keyup changed delay:300ms, focus"
hx-target="next .combobox-results"
hx-swap="innerHTML"
name="q">
<ul class="combobox-results menu dropdown-content bg-base-100 rounded-box z-10 w-full p-2 shadow-sm"></ul>
</div>
</div>
@@ -0,0 +1,9 @@
{% for result in results %}
<li>
<button type="button" onclick="backofficeComboboxSelect('combobox-{{ field_name }}', '{{ result.pk }}', '{{ result|escapejs }}')">
{{ result }}
</button>
</li>
{% empty %}
<li><span class="opacity-50 px-2 py-1">Sin resultados</span></li>
{% endfor %}
@@ -0,0 +1,14 @@
<div id="product-inline-{{ field_name }}">
<form hx-post="{{ request.path }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML">
{% csrf_token %}
{% if field_name == 'brand' or field_name == 'categories' or field_name == 'tags' %}
{% include 'backoffice/products/_combobox.html' %}
{% else %}
{{ form.as_p }}
{% endif %}
<div class="flex gap-2 mt-1">
<button class="btn btn-primary btn-xs">Guardar</button>
<button type="button" class="btn btn-xs" hx-get="{{ view_url }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML">Cancelar</button>
</div>
</form>
</div>
@@ -0,0 +1,26 @@
<div id="product-inline-{{ field_name }}" class="flex items-start gap-2">
{% if field_name == 'name' %}
<h1 class="text-2xl font-semibold">{{ product.name }}</h1>
{% elif field_name == 'description' %}
<p class="whitespace-pre-line {% if not product.description %}opacity-50{% endif %}">{{ product.description|default:'Sin descripción.' }}</p>
{% elif field_name == 'brand' %}
<p class="{% if not product.brand %}opacity-50{% endif %}">{{ product.brand|default:'Sin marca' }}</p>
{% elif field_name == 'categories' %}
<div class="flex flex-wrap gap-1">
{% for category in product.categories.all %}
<span class="badge badge-ghost">{{ category }}</span>
{% empty %}
<span class="opacity-50">Sin categorías</span>
{% endfor %}
</div>
{% elif field_name == 'tags' %}
<div class="flex flex-wrap gap-1">
{% for tag in product.tags.all %}
<span class="badge badge-ghost">{{ tag }}</span>
{% empty %}
<span class="opacity-50">Sin etiquetas</span>
{% endfor %}
</div>
{% endif %}
<button class="btn btn-ghost btn-xs" hx-get="{{ edit_url }}" hx-target="#product-inline-{{ field_name }}" hx-swap="outerHTML" title="Editar"></button>
</div>
@@ -5,31 +5,81 @@
hx-target="#backoffice-detail"
hx-swap="outerHTML">
{% url 'backoffice:product_inline_view' product.pk 'name' as name_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'name' as name_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'description' as description_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'description' as description_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'brand' as brand_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'brand' as brand_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'categories' as categories_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'categories' as categories_edit_url %}
{% url 'backoffice:product_inline_view' product.pk 'tags' as tags_view_url %}
{% url 'backoffice:product_inline_edit' product.pk 'tags' as tags_edit_url %}
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl font-semibold">{{ product.name }}</h1>
{% include 'backoffice/products/_inline_view.html' with field_name='name' view_url=name_view_url edit_url=name_edit_url %}
<div class="flex gap-2">
<button class="btn btn-sm" hx-get="{% url 'backoffice:product_update' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Editar</button>
<button class="btn btn-sm btn-error" hx-get="{% url 'backoffice:product_delete' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Borrar</button>
</div>
</div>
<div class="stats shadow mb-6 bg-base-100">
<div class="stat">
<div class="stat-title">SKU</div>
<div class="stat-value text-lg">{{ product.sku }}</div>
</div>
<div class="stat">
<div class="stat-title">Stock</div>
<div class="stat-value text-lg">{{ product.stock }}</div>
</div>
<div class="stat">
<div class="stat-title">Precio actual</div>
<div class="stat-value text-lg">
{% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %}
<div class="flex gap-6 mb-6">
{% with images.first as main_image %}
<div class="shrink-0 w-64">
{% if main_image %}
<img class="w-64 h-64 object-cover rounded-box border border-base-300 shadow" src="{{ main_image.l.url }}" alt="{{ product.name }}">
{% else %}
<div class="w-64 h-64 rounded-box border border-base-300 border-dashed flex items-center justify-center text-sm opacity-50">
Sin imagen
</div>
{% endif %}
</div>
</div>
{% endwith %}
<section class="w-full">
<div class="stats shadow bg-base-100 self-start">
<div class="stat">
<div class="stat-title">SKU</div>
<div class="stat-value text-lg">{{ product.sku }}</div>
</div>
<div class="stat">
<div class="stat-title">Stock</div>
<div class="stat-value text-lg">{{ product.stock }}</div>
</div>
<div class="stat">
<div class="stat-title">Precio actual</div>
<div class="stat-value text-lg">
{% if product.price %}{{ product.price.price_with_tax }} €{% else %}—{% endif %}
</div>
</div>
</div>
<div class="m-4">
{% include 'backoffice/products/_inline_view.html' with field_name='description' view_url=description_view_url edit_url=description_edit_url %}
</div>
<div class="grid grid-cols-3 gap-4 m-4">
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='brand' view_url=brand_view_url edit_url=brand_edit_url %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Categorías</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='categories' view_url=categories_view_url edit_url=categories_edit_url %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Etiquetas</h3>
{% include 'backoffice/products/_inline_view.html' with field_name='tags' view_url=tags_view_url edit_url=tags_edit_url %}
</div>
</div>
</section>
</div>
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-semibold">Imágenes</h2>
<button class="btn btn-sm btn-primary" hx-get="{% url 'backoffice:product_image_create' product.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML">Añadir imagen</button>
@@ -37,7 +87,10 @@
<div class="flex flex-wrap gap-4 mb-6">
{% for image in images %}
<div class="relative">
<img class="w-24 h-24 object-cover rounded-box border border-base-300" src="{{ image.s.url }}" alt="">
<img class="w-24 h-24 object-cover rounded-box border {% if forloop.first %}border-primary border-2{% else %}border-base-300{% endif %}" src="{{ image.s.url }}" alt="">
{% if forloop.first %}
<span class="badge badge-primary badge-xs absolute -top-2 -left-2">Principal</span>
{% endif %}
<button class="btn btn-xs btn-error absolute -top-2 -right-2" hx-get="{% url 'backoffice:product_image_delete' image.pk %}" hx-target="#backoffice-modal-box" hx-swap="innerHTML"></button>
</div>
{% empty %}
@@ -0,0 +1,63 @@
{% extends 'backoffice/base.html' %}
{% block title %}Nuevo producto{% endblock %}
{% block main %}
<form method="post" enctype="multipart/form-data" class="max-w-3xl">
{% csrf_token %}
<div class="mb-4">
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.name.id_for_label }}">Nombre</label>
{{ form.name }}
{{ form.name.errors }}
</div>
<div class="mb-6">
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.description.id_for_label }}">Descripción</label>
{{ form.description }}
{{ form.description.errors }}
</div>
<div class="grid grid-cols-3 gap-4 mb-6">
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Marca</h3>
{% include 'backoffice/products/_combobox.html' with field_name='brand' multiple=False %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Categorías</h3>
{% include 'backoffice/products/_combobox.html' with field_name='categories' multiple=True %}
</div>
<div>
<h3 class="text-sm font-medium opacity-70 mb-1">Etiquetas</h3>
{% include 'backoffice/products/_combobox.html' with field_name='tags' multiple=True %}
</div>
</div>
<div class="grid grid-cols-2 gap-4 mb-4 max-w-md">
<div>
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.sku.id_for_label }}">Código de referencia</label>
{{ form.sku }}
{{ form.sku.errors }}
</div>
<div>
<label class="text-sm font-medium opacity-70 mb-1 block" for="{{ form.stock.id_for_label }}">Stock</label>
{{ form.stock }}
{{ form.stock.errors }}
</div>
</div>
<div class="flex gap-6 mb-6">
<label class="flex items-center gap-2">
{{ form.hidden }} Oculto
</label>
<label class="flex items-center gap-2">
{{ form.is_shipping_method }} Es forma de envío
</label>
</div>
<div class="flex gap-2">
<button class="btn btn-primary">Crear producto</button>
<a class="btn" href="{% url 'backoffice:product_list' %}">Cancelar</a>
</div>
</form>
{% endblock %}