feat: create/edit product views
This commit is contained in:
@@ -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 %}
|
||||
Reference in New Issue
Block a user