Feat: Add catalog page with category tree and product grid
- Create catalog view with recursive category tree building - Add left-side category tree with expand/collapse functionality - Add right-side product/kit grid with filtering and search - Include category navigation with product/kit counts 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
217
myproject/products/templates/products/catalog.html
Normal file
217
myproject/products/templates/products/catalog.html
Normal file
@@ -0,0 +1,217 @@
|
||||
{% extends 'base.html' %}
|
||||
|
||||
{% block title %}Каталог{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.category-tree {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
/* Корневые категории без отступов */
|
||||
.category-tree > .category-node {
|
||||
border-left: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
/* Вложенные категории с отступами */
|
||||
.category-children .category-node {
|
||||
border-left: 2px solid #e9ecef;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.category-header {
|
||||
cursor: pointer;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.category-header:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
.category-header.active {
|
||||
background-color: #e7f1ff;
|
||||
color: #0d6efd;
|
||||
}
|
||||
.category-toggle {
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
.category-toggle.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.category-children {
|
||||
padding-left: 1rem;
|
||||
}
|
||||
.category-items {
|
||||
padding-left: 2rem;
|
||||
border-left: 1px dashed #dee2e6;
|
||||
margin-left: 0.6rem;
|
||||
}
|
||||
.category-item {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #495057;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.category-item:hover {
|
||||
color: #0d6efd;
|
||||
}
|
||||
.category-item a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
.item-badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.35rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4 px-4">
|
||||
<div class="row">
|
||||
<!-- Дерево категорий слева -->
|
||||
<div class="col-lg-3 col-md-4">
|
||||
<div class="card shadow-sm border-0 mb-3">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-folder-tree text-primary"></i> Категории</strong>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-outline-secondary btn-sm py-0" id="expand-all" title="Развернуть"><i class="bi bi-plus-square"></i></button>
|
||||
<button class="btn btn-outline-secondary btn-sm py-0" id="collapse-all" title="Свернуть"><i class="bi bi-dash-square"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-2 category-tree">
|
||||
{% if category_tree %}
|
||||
{% for node in category_tree %}
|
||||
{% include 'products/catalog_tree_node.html' with node=node %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-muted text-center py-3">
|
||||
<i class="bi bi-folder-x"></i> Категорий нет
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Сетка товаров справа -->
|
||||
<div class="col-lg-9 col-md-8">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-header bg-white py-2 d-flex justify-content-between align-items-center">
|
||||
<strong><i class="bi bi-grid-3x3-gap text-primary"></i> Товары и комплекты</strong>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<input type="text" class="form-control form-control-sm" placeholder="Поиск..." id="catalog-search" style="width: 180px;">
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-primary btn-sm active" data-filter="all">Все</button>
|
||||
<button class="btn btn-outline-primary btn-sm" data-filter="product">Товары</button>
|
||||
<button class="btn btn-outline-primary btn-sm" data-filter="kit">Комплекты</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3" id="catalog-grid">
|
||||
{% for item in items %}
|
||||
<div class="col-6 col-lg-4 col-xl-3 catalog-item" data-type="{{ item.item_type }}">
|
||||
<div class="card h-100 shadow-sm border-0">
|
||||
<div class="position-relative">
|
||||
{% if item.main_photo %}
|
||||
<img src="{{ item.main_photo.image.url }}" class="card-img-top" alt="{{ item.name }}" style="height: 120px; object-fit: cover;">
|
||||
{% else %}
|
||||
<div class="bg-light d-flex align-items-center justify-content-center" style="height: 120px;">
|
||||
<i class="bi bi-image text-muted fs-2"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="badge position-absolute top-0 end-0 m-1 {% if item.item_type == 'kit' %}bg-info{% else %}bg-secondary{% endif %}" style="font-size: 0.65rem;">
|
||||
{% if item.item_type == 'kit' %}К{% else %}Т{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body p-2">
|
||||
<div class="small text-truncate fw-medium">{{ item.name }}</div>
|
||||
<div class="d-flex justify-content-between align-items-center mt-1">
|
||||
<span class="fw-bold text-primary small">{{ item.actual_price|floatformat:0 }} ₽</span>
|
||||
<small class="text-muted">{{ item.sku }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
<div class="col-12 text-center text-muted py-5">
|
||||
<i class="bi bi-box-seam fs-1 opacity-25"></i>
|
||||
<p class="mb-0 mt-2">Каталог пуст</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Раскрытие/сворачивание категорий
|
||||
document.querySelectorAll('.category-header').forEach(header => {
|
||||
header.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
const node = this.closest('.category-node');
|
||||
const children = node.querySelector('.category-children');
|
||||
const items = node.querySelector('.category-items');
|
||||
const toggle = this.querySelector('.category-toggle');
|
||||
|
||||
if (children) {
|
||||
children.classList.toggle('d-none');
|
||||
}
|
||||
if (items) {
|
||||
items.classList.toggle('d-none');
|
||||
}
|
||||
if (toggle) {
|
||||
toggle.classList.toggle('collapsed');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Развернуть все
|
||||
document.getElementById('expand-all').addEventListener('click', function() {
|
||||
document.querySelectorAll('.category-children, .category-items').forEach(el => el.classList.remove('d-none'));
|
||||
document.querySelectorAll('.category-toggle').forEach(el => el.classList.remove('collapsed'));
|
||||
});
|
||||
|
||||
// Свернуть все
|
||||
document.getElementById('collapse-all').addEventListener('click', function() {
|
||||
document.querySelectorAll('.category-children, .category-items').forEach(el => el.classList.add('d-none'));
|
||||
document.querySelectorAll('.category-toggle').forEach(el => el.classList.add('collapsed'));
|
||||
});
|
||||
|
||||
// Фильтр по типу
|
||||
document.querySelectorAll('[data-filter]').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('[data-filter]').forEach(b => {
|
||||
b.classList.remove('active', 'btn-primary');
|
||||
b.classList.add('btn-outline-primary');
|
||||
});
|
||||
this.classList.add('active', 'btn-primary');
|
||||
this.classList.remove('btn-outline-primary');
|
||||
|
||||
const filter = this.dataset.filter;
|
||||
document.querySelectorAll('.catalog-item').forEach(item => {
|
||||
item.style.display = (filter === 'all' || item.dataset.type === filter) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Поиск
|
||||
document.getElementById('catalog-search').addEventListener('input', function() {
|
||||
const query = this.value.toLowerCase();
|
||||
document.querySelectorAll('.catalog-item').forEach(item => {
|
||||
const text = item.textContent.toLowerCase();
|
||||
item.style.display = text.includes(query) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
45
myproject/products/templates/products/catalog_tree_node.html
Normal file
45
myproject/products/templates/products/catalog_tree_node.html
Normal file
@@ -0,0 +1,45 @@
|
||||
{% comment %}
|
||||
Рекурсивный узел дерева категорий.
|
||||
При клике раскрывается и показывает товары/комплекты текстом.
|
||||
{% endcomment %}
|
||||
<div class="category-node" data-category-id="{{ node.category.pk }}">
|
||||
<div class="category-header">
|
||||
{% if node.children or node.category.products.exists or node.category.kits.exists %}
|
||||
<i class="bi bi-chevron-down category-toggle"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-dot text-muted"></i>
|
||||
{% endif %}
|
||||
<i class="bi bi-folder{% if node.children %}-fill text-warning{% else %}2 text-secondary{% endif %}"></i>
|
||||
<span class="flex-grow-1">{{ node.category.name }}</span>
|
||||
{% with products_count=node.category.products.count kits_count=node.category.kits.count %}
|
||||
{% if products_count or kits_count %}
|
||||
<span class="badge bg-light text-dark border" style="font-size: 0.7rem;">{{ products_count|add:kits_count }}</span>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
{% if node.children %}
|
||||
<div class="category-children d-none">
|
||||
{% for child in node.children %}
|
||||
{% include 'products/catalog_tree_node.html' with node=child %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if node.category.products.exists or node.category.kits.exists %}
|
||||
<div class="category-items d-none">
|
||||
{% for product in node.category.products.all %}
|
||||
<div class="category-item">
|
||||
<span class="badge bg-secondary item-badge">Т</span>
|
||||
<a href="{% url 'products:product-detail' product.pk %}">{{ product.name }}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% for kit in node.category.kits.all %}
|
||||
<div class="category-item">
|
||||
<span class="badge bg-info item-badge">К</span>
|
||||
<a href="{% url 'products:productkit-detail' kit.pk %}">{{ kit.name }}</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,22 @@
|
||||
{% comment %}
|
||||
Строка товара/комплекта в дереве каталога.
|
||||
{% endcomment %}
|
||||
<div class="product-row d-flex align-items-center py-1 px-2 border-bottom" data-type="{{ item.item_type }}" data-id="{{ item.pk }}">
|
||||
<i class="bi bi-grip-vertical drag-handle me-2 text-muted"></i>
|
||||
{% if item.main_photo %}
|
||||
<img src="{{ item.main_photo.image.url }}" class="product-thumb me-2" alt="">
|
||||
{% else %}
|
||||
<div class="product-thumb me-2 bg-light d-flex align-items-center justify-content-center">
|
||||
<i class="bi bi-image text-muted"></i>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="flex-grow-1 text-truncate">
|
||||
<a href="{% if item.item_type == 'kit' %}{% url 'products:productkit-detail' item.pk %}{% else %}{% url 'products:product-detail' item.pk %}{% endif %}" class="text-decoration-none small">
|
||||
{{ item.name }}
|
||||
</a>
|
||||
<div class="text-muted" style="font-size: 0.75rem;">{{ item.sku }}</div>
|
||||
</div>
|
||||
<span class="badge {% if item.item_type == 'kit' %}bg-info{% else %}bg-secondary{% endif %} ms-2">
|
||||
{% if item.item_type == 'kit' %}К{% else %}Т{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,25 @@
|
||||
{% comment %}
|
||||
Рекурсивный шаблон для отображения узла дерева категорий.
|
||||
Параметры: nodes - список узлов, level - уровень вложенности
|
||||
{% endcomment %}
|
||||
{% for node in nodes %}
|
||||
<div class="category-node" data-category-id="{{ node.category.pk }}">
|
||||
<div class="category-item d-flex align-items-center p-2 {% if level > 0 %}ps-{{ level|add:2 }}{% endif %}">
|
||||
{% if node.children %}
|
||||
<i class="bi bi-chevron-down category-icon me-2 text-muted"></i>
|
||||
{% else %}
|
||||
<i class="bi bi-dot me-2 text-muted"></i>
|
||||
{% endif %}
|
||||
<i class="bi bi-folder{% if node.children %}-fill text-warning{% else %}2 text-secondary{% endif %} me-2"></i>
|
||||
<span class="flex-grow-1">{{ node.category.name }}</span>
|
||||
<span class="badge bg-light text-dark border ms-2" title="Товаров в категории">
|
||||
{{ node.category.products.count|default:0 }}
|
||||
</span>
|
||||
</div>
|
||||
{% if node.children %}
|
||||
<div class="subcategories">
|
||||
{% include 'products/partials/catalog_tree_node.html' with nodes=node.children level=level|add:1 %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -8,7 +8,10 @@ app_name = 'products'
|
||||
urlpatterns = [
|
||||
# Main unified list for products and kits (default view)
|
||||
path('', views.CombinedProductListView.as_view(), name='products-list'),
|
||||
|
||||
|
||||
# Каталог с drag-n-drop
|
||||
path('catalog/', views.CatalogView.as_view(), name='catalog'),
|
||||
|
||||
# Legacy URLs for backward compatibility
|
||||
path('all/', views.CombinedProductListView.as_view(), name='all-products'),
|
||||
path('products/', views.ProductListView.as_view(), name='product-list-legacy'),
|
||||
|
||||
@@ -95,6 +95,9 @@ from .configurablekit_views import (
|
||||
# API представления
|
||||
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_api
|
||||
|
||||
# Каталог
|
||||
from .catalog_views import CatalogView
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Утилиты
|
||||
@@ -174,4 +177,7 @@ __all__ = [
|
||||
'validate_kit_cost',
|
||||
'create_temporary_kit_api',
|
||||
'create_tag_api',
|
||||
|
||||
# Каталог
|
||||
'CatalogView',
|
||||
]
|
||||
|
||||
52
myproject/products/views/catalog_views.py
Normal file
52
myproject/products/views/catalog_views.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Представление для каталога товаров и комплектов.
|
||||
"""
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.views.generic import TemplateView
|
||||
|
||||
from ..models import Product, ProductKit, ProductCategory
|
||||
|
||||
|
||||
class CatalogView(LoginRequiredMixin, TemplateView):
|
||||
"""Каталог с деревом категорий слева и сеткой товаров справа."""
|
||||
template_name = 'products/catalog.html'
|
||||
|
||||
def build_category_tree(self, categories, parent=None):
|
||||
"""Рекурсивно строит дерево категорий с товарами."""
|
||||
tree = []
|
||||
for cat in categories:
|
||||
if cat.parent_id == (parent.pk if parent else None):
|
||||
children = self.build_category_tree(categories, cat)
|
||||
tree.append({
|
||||
'category': cat,
|
||||
'children': children,
|
||||
})
|
||||
return tree
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
|
||||
# Все активные категории
|
||||
categories = list(ProductCategory.objects.filter(
|
||||
is_active=True, is_deleted=False
|
||||
).prefetch_related('products', 'kits').order_by('name'))
|
||||
|
||||
# Строим дерево
|
||||
category_tree = self.build_category_tree(categories, parent=None)
|
||||
|
||||
# Товары и комплекты для правой панели
|
||||
products = Product.objects.filter(status='active').prefetch_related('photos').order_by('name')
|
||||
for p in products:
|
||||
p.item_type = 'product'
|
||||
p.main_photo = p.photos.order_by('order').first()
|
||||
|
||||
kits = ProductKit.objects.filter(status='active', is_temporary=False).prefetch_related('photos').order_by('name')
|
||||
for k in kits:
|
||||
k.item_type = 'kit'
|
||||
k.main_photo = k.photos.order_by('order').first()
|
||||
|
||||
items = sorted(list(products) + list(kits), key=lambda x: x.name)
|
||||
|
||||
context['category_tree'] = category_tree
|
||||
context['items'] = items
|
||||
return context
|
||||
Reference in New Issue
Block a user