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:
2025-11-24 00:31:37 +03:00
parent 157bd50082
commit 4549b2c2c2
8 changed files with 382 additions and 9 deletions

View 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 %}

View 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>

View File

@@ -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>

View File

@@ -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 %}

View File

@@ -9,6 +9,9 @@ urlpatterns = [
# Main unified list for products and kits (default view) # Main unified list for products and kits (default view)
path('', views.CombinedProductListView.as_view(), name='products-list'), path('', views.CombinedProductListView.as_view(), name='products-list'),
# Каталог с drag-n-drop
path('catalog/', views.CatalogView.as_view(), name='catalog'),
# Legacy URLs for backward compatibility # Legacy URLs for backward compatibility
path('all/', views.CombinedProductListView.as_view(), name='all-products'), path('all/', views.CombinedProductListView.as_view(), name='all-products'),
path('products/', views.ProductListView.as_view(), name='product-list-legacy'), path('products/', views.ProductListView.as_view(), name='product-list-legacy'),

View File

@@ -95,6 +95,9 @@ from .configurablekit_views import (
# API представления # API представления
from .api_views import search_products_and_variants, validate_kit_cost, create_temporary_kit_api, create_tag_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__ = [ __all__ = [
# Утилиты # Утилиты
@@ -174,4 +177,7 @@ __all__ = [
'validate_kit_cost', 'validate_kit_cost',
'create_temporary_kit_api', 'create_temporary_kit_api',
'create_tag_api', 'create_tag_api',
# Каталог
'CatalogView',
] ]

View 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

View File

@@ -1,4 +1,4 @@
<!-- navbar.html - Reusable navigation bar component --> <!-- navbar.html - Компонент навигационной панели -->
<style> <style>
.navbar .dropdown:hover > .dropdown-menu { .navbar .dropdown:hover > .dropdown-menu {
display: block; display: block;
@@ -7,20 +7,23 @@
</style> </style>
<nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top"> <nav class="navbar navbar-expand-lg navbar-light bg-light fixed-top">
<div class="container"> <div class="container">
<!-- Toggler for mobile view --> <!-- Кнопка для мобильного вида -->
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"
aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation"> aria-controls="navbarNav" aria-expanded="false" aria-label="Переключить навигацию">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<!-- Navbar content --> <!-- Содержимое навигации -->
<div class="collapse navbar-collapse" id="navbarNav"> <div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto"> <ul class="navbar-nav me-auto">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<!-- Main navigation links --> <!-- Основные ссылки навигации -->
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.resolver_match.namespace == 'products' and request.resolver_match.url_name in 'all-products,product-list,productkit-list,product-detail,product-create,product-update,productkit-detail,productkit-create,productkit-update' %}active{% endif %}" href="{% url 'products:all-products' %}">Товары</a> <a class="nav-link {% if request.resolver_match.namespace == 'products' and request.resolver_match.url_name in 'all-products,product-list,productkit-list,product-detail,product-create,product-update,productkit-detail,productkit-create,productkit-update' %}active{% endif %}" href="{% url 'products:all-products' %}">Товары</a>
</li> </li>
<li class="nav-item">
<a class="nav-link {% if request.resolver_match.url_name == 'catalog' %}active{% endif %}" href="{% url 'products:catalog' %}"><i class="bi bi-grid-3x3-gap"></i> Каталог</a>
</li>
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {% if request.resolver_match.namespace == 'products' and 'configurablekit' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'products:configurablekit-list' %}">Вариативные товары</a> <a class="nav-link {% if request.resolver_match.namespace == 'products' and 'configurablekit' in request.resolver_match.url_name %}active{% endif %}" href="{% url 'products:configurablekit-list' %}">Вариативные товары</a>
</li> </li>
@@ -55,7 +58,7 @@
<ul class="navbar-nav align-items-center"> <ul class="navbar-nav align-items-center">
{% if user.is_authenticated %} {% if user.is_authenticated %}
<!-- Show profile button and logout button for authenticated users --> <!-- Кнопки профиля и выхода для авторизованных пользователей -->
<li class="nav-item"> <li class="nav-item">
<a class="btn btn-outline-primary me-2" href="{% url 'accounts:profile' %}">Профиль</a> <a class="btn btn-outline-primary me-2" href="{% url 'accounts:profile' %}">Профиль</a>
</li> </li>
@@ -68,7 +71,7 @@
<a class="btn btn-outline-secondary ms-2" href="{% url 'accounts:logout' %}">Выйти</a> <a class="btn btn-outline-secondary ms-2" href="{% url 'accounts:logout' %}">Выйти</a>
</li> </li>
{% else %} {% else %}
<!-- Show login and register buttons for non-authenticated users --> <!-- Кнопки входа и регистрации для неавторизованных пользователей -->
<li class="nav-item"> <li class="nav-item">
<a class="btn btn-outline-primary me-2" href="{% url 'accounts:login' %}">Вход</a> <a class="btn btn-outline-primary me-2" href="{% url 'accounts:login' %}">Вход</a>
</li> </li>