feat: Фаза 3 - Добавить индикаторы качества фото на фронтенд

Реализовано:
- Создан набор переиспользуемых шаблонных тегов для отображения качества
  - quality_badge_mini: маленький значок в углу фото
  - quality_badge_full: полный индикатор с размером фото
  - quality_indicator: включаемый тег с позиционированием
  - quality_icon_only: только символ качества для списков

- Добавлены шаблонные теги в:
  - product_detail.html: индикатор в углу миниатюр + в модальной галерее
  - product_list.html: иконка качества в таблице товаров
  - productkit_detail.html: индикатор в углу фото комплектов

- Создан CSS с ненавязчивыми стилями:
  - Полупрозрачные индикаторы (opacity: 0.8)
  - Компактные размеры (не отвлекает от фото)
  - Отзывчивость на мобильных устройствах
  - Анимации при наведении

- Обновлена админ панель:
  - Добавлены 3 новых экшена для поиска товаров по качеству
  - show_poor_quality_photos: фильтр на товары требующие обновления
  - show_excellent_quality_photos: фильтр на товары с хорошим качеством
  - show_all_quality_levels: статистика распределения качества

Интеграция в базу template tags:
- myproject/products/templatetags/quality_tags.py (новый файл)
- myproject/static/css/quality_indicator.css (новый файл)
- myproject/products/templates/products/includes/quality_badge.html (новый файл)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-02 15:13:49 +03:00
parent 622e17a775
commit 2d344ef53c
8 changed files with 845 additions and 40 deletions

View File

@@ -1,10 +1,18 @@
from django.contrib import admin from django.contrib import admin
from django.utils.html import format_html from django.utils.html import format_html
from django.utils import timezone from django.utils import timezone
from django.db.models import Q
import nested_admin import nested_admin
from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem from .models import ProductCategory, ProductTag, Product, ProductKit, KitItem
from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto from .models import ProductPhoto, ProductKitPhoto, ProductCategoryPhoto
from .models import ProductVariantGroup, KitItemPriority, SKUCounter from .models import ProductVariantGroup, KitItemPriority, SKUCounter
from .admin_displays import (
format_quality_badge,
format_quality_display,
format_photo_quality_column,
format_photo_inline_quality,
format_photo_preview_with_quality,
)
class DeletedFilter(admin.SimpleListFilter): class DeletedFilter(admin.SimpleListFilter):
@@ -27,6 +35,37 @@ class DeletedFilter(admin.SimpleListFilter):
return queryset return queryset
class QualityLevelFilter(admin.SimpleListFilter):
"""Фильтр для отображения товаров по качеству их фотографий"""
title = 'Качество фото'
parameter_name = 'photo_quality'
def lookups(self, request, model_admin):
return (
('excellent', '🟢 Отлично'),
('good', '🟡 Хорошо'),
('acceptable', '🟠 Приемлемо'),
('poor', '🔴 Плохо'),
('very_poor', '🔴🔴 Очень плохо'),
('warning', '⚠️ Требует обновления'),
('no_warning', '✓ Готово к выгрузке'),
)
def queryset(self, request, queryset):
if self.value() == 'warning':
# Товары, у которых есть фото с quality_warning=True
return queryset.filter(photos__quality_warning=True).distinct()
elif self.value() == 'no_warning':
# Товары, у которых есть фото БЕЗ warning (excellent или good)
return queryset.filter(
photos__quality_level__in=['excellent', 'good']
).distinct()
elif self.value():
# По конкретному уровню качества
return queryset.filter(photos__quality_level=self.value()).distinct()
return queryset
def restore_items(modeladmin, request, queryset): def restore_items(modeladmin, request, queryset):
"""Action для восстановления удаленных элементов""" """Action для восстановления удаленных элементов"""
updated = queryset.update(is_deleted=False, deleted_at=None, deleted_by=None) updated = queryset.update(is_deleted=False, deleted_at=None, deleted_by=None)
@@ -144,6 +183,87 @@ def hard_delete_selected(modeladmin, request, queryset):
hard_delete_selected.short_description = '🔴 Безопасно удалить навсегда (только без связей)' hard_delete_selected.short_description = '🔴 Безопасно удалить навсегда (только без связей)'
def show_poor_quality_photos(modeladmin, request, queryset):
"""
Action для фильтрации товаров с фото требующими обновления.
Перенаправляет на список с применённым фильтром по качеству.
"""
from django.shortcuts import redirect
from django.urls import reverse
model_name = modeladmin.model._meta.model_name
app_name = modeladmin.model._meta.app_label
# Перенаправляем на список товаров с фильтром по warning фото
url = reverse(f'admin:{app_name}_{model_name}_changelist')
return redirect(f'{url}?photo_quality=warning')
show_poor_quality_photos.short_description = '⚠️ Показать товары с фото требующими обновления'
def show_excellent_quality_photos(modeladmin, request, queryset):
"""
Action для фильтрации товаров с фото отличного и хорошего качества.
Перенаправляет на список с применённым фильтром по качеству.
"""
from django.shortcuts import redirect
from django.urls import reverse
model_name = modeladmin.model._meta.model_name
app_name = modeladmin.model._meta.app_label
# Перенаправляем на список товаров с фильтром по excellent/good
url = reverse(f'admin:{app_name}_{model_name}_changelist')
return redirect(f'{url}?photo_quality=no_warning')
show_excellent_quality_photos.short_description = '✓ Показать товары с хорошим качеством фото'
def show_all_quality_levels(modeladmin, request, queryset):
"""
Action для показа распределения товаров по качеству фотографий.
Выводит статистику в сообщении admin.
"""
from django.contrib import messages
from django.db.models import Count
# Получаем статистику по качеству фотографий
quality_stats = queryset.filter(photos__isnull=False).values(
'photos__quality_level'
).annotate(count=Count('id', distinct=True)).order_by('-count')
warning_count = queryset.filter(photos__quality_warning=True).distinct().count()
total_with_photos = queryset.filter(photos__isnull=False).distinct().count()
if not quality_stats:
modeladmin.message_user(
request,
f'⚠️ В выбранных товарах ({queryset.count()}) нет фотографий.',
messages.WARNING
)
return
# Формируем сообщение со статистикой
quality_names = {
'excellent': '🟢 Отлично',
'good': '🟡 Хорошо',
'acceptable': '🟠 Приемлемо',
'poor': '🔴 Плохо',
'very_poor': '🔴🔴 Очень плохо',
}
stats_text = f'📊 Статистика качества фото в выбранных товарах:\n'
stats_text += f'Всего товаров с фото: {total_with_photos}\n'
stats_text += f'Требуют обновления: {warning_count}\n\n'
for item in quality_stats:
level = item['photos__quality_level']
count = item['count']
label = quality_names.get(level, level)
stats_text += f' {label}: {count} товар(ов)\n'
modeladmin.message_user(request, stats_text, messages.INFO)
show_all_quality_levels.short_description = '📊 Показать статистику качества фото'
def disable_delete_selected(admin_class): def disable_delete_selected(admin_class):
""" """
Декоратор для отключения стандартного Django delete_selected action. Декоратор для отключения стандартного Django delete_selected action.
@@ -167,12 +287,19 @@ class ProductVariantGroupAdmin(admin.ModelAdmin):
class ProductCategoryAdmin(admin.ModelAdmin): class ProductCategoryAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status') list_display = ('photo_with_quality', 'name', 'sku', 'slug', 'parent', 'is_active', 'get_deleted_status')
list_filter = (DeletedFilter, 'is_active', 'parent') list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'parent')
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
search_fields = ('name', 'sku') search_fields = ('name', 'sku')
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by') readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
actions = [restore_items, delete_selected, hard_delete_selected] actions = [
restore_items,
delete_selected,
hard_delete_selected,
show_poor_quality_photos,
show_excellent_quality_photos,
show_all_quality_levels,
]
def get_queryset(self, request): def get_queryset(self, request):
"""Переопределяем queryset для доступа ко всем категориям (включая удаленные)""" """Переопределяем queryset для доступа ко всем категориям (включая удаленные)"""
@@ -191,8 +318,26 @@ class ProductCategoryAdmin(admin.ModelAdmin):
return format_html('<span style="color: green;">✓ Активна</span>') return format_html('<span style="color: green;">✓ Активна</span>')
get_deleted_status.short_description = 'Статус' get_deleted_status.short_description = 'Статус'
def photo_with_quality(self, obj):
"""Превью фото с индикатором качества в списке категорий"""
first_photo = obj.photos.first()
if not first_photo or not first_photo.image:
return format_html('<span style="color: #999;">Нет фото</span>')
quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True)
return format_html(
'<div style="display: flex; align-items: center; gap: 8px;">'
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />'
'{}'
'</div>',
first_photo.image.url,
quality_indicator
)
photo_with_quality.short_description = "Фото"
def photo_preview(self, obj): def photo_preview(self, obj):
"""Превью фото в списке категорий""" """Превью фото в списке категорий (старый метод, сохранен для совместимости)"""
first_photo = obj.photos.first() first_photo = obj.photos.first()
if first_photo and first_photo.image: if first_photo and first_photo.image:
return format_html( return format_html(
@@ -241,13 +386,20 @@ class ProductTagAdmin(admin.ModelAdmin):
class ProductAdmin(admin.ModelAdmin): class ProductAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status') list_display = ('photo_with_quality', 'name', 'sku', 'get_categories_display', 'cost_price', 'price', 'sale_price', 'get_variant_groups_display', 'is_active', 'get_deleted_status')
list_filter = (DeletedFilter, 'is_active', 'categories', 'tags', 'variant_groups') list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'categories', 'tags', 'variant_groups')
search_fields = ('name', 'sku', 'description', 'search_keywords') search_fields = ('name', 'sku', 'description', 'search_keywords')
filter_horizontal = ('categories', 'tags', 'variant_groups') filter_horizontal = ('categories', 'tags', 'variant_groups')
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by') readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
autocomplete_fields = [] autocomplete_fields = []
actions = [restore_items, delete_selected, hard_delete_selected] actions = [
restore_items,
delete_selected,
hard_delete_selected,
show_poor_quality_photos,
show_excellent_quality_photos,
show_all_quality_levels,
]
fieldsets = ( fieldsets = (
('Основная информация', { ('Основная информация', {
@@ -313,8 +465,27 @@ class ProductAdmin(admin.ModelAdmin):
return result return result
get_variant_groups_display.short_description = 'Группы вариантов' get_variant_groups_display.short_description = 'Группы вариантов'
def photo_with_quality(self, obj):
"""Превью фото с индикатором качества в списке товаров"""
first_photo = obj.photos.first()
if not first_photo or not first_photo.image:
return format_html('<span style="color: #999;">Нет фото</span>')
# Показываем фото с индикатором качества справа
quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True)
return format_html(
'<div style="display: flex; align-items: center; gap: 8px;">'
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />'
'{}'
'</div>',
first_photo.image.url,
quality_indicator
)
photo_with_quality.short_description = "Фото"
def photo_preview(self, obj): def photo_preview(self, obj):
"""Превью фото в списке товаров""" """Превью фото в списке товаров (старый метод, сохранен для совместимости)"""
first_photo = obj.photos.first() first_photo = obj.photos.first()
if first_photo and first_photo.image: if first_photo and first_photo.image:
return format_html( return format_html(
@@ -337,12 +508,19 @@ class ProductAdmin(admin.ModelAdmin):
class ProductKitAdmin(admin.ModelAdmin): class ProductKitAdmin(admin.ModelAdmin):
list_display = ('photo_preview', 'name', 'slug', 'get_categories_display', 'pricing_method', 'get_price_display', 'is_active', 'get_deleted_status') list_display = ('photo_with_quality', 'name', 'slug', 'get_categories_display', 'pricing_method', 'get_price_display', 'is_active', 'get_deleted_status')
list_filter = (DeletedFilter, 'is_active', 'pricing_method', 'categories', 'tags') list_filter = (DeletedFilter, 'is_active', QualityLevelFilter, 'pricing_method', 'categories', 'tags')
prepopulated_fields = {'slug': ('name',)} prepopulated_fields = {'slug': ('name',)}
filter_horizontal = ('categories', 'tags') filter_horizontal = ('categories', 'tags')
readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by') readonly_fields = ('photo_preview_large', 'deleted_at', 'deleted_by')
actions = [restore_items, delete_selected, hard_delete_selected] actions = [
restore_items,
delete_selected,
hard_delete_selected,
show_poor_quality_photos,
show_excellent_quality_photos,
show_all_quality_levels,
]
fieldsets = ( fieldsets = (
('Основная информация', { ('Основная информация', {
@@ -369,7 +547,7 @@ class ProductKitAdmin(admin.ModelAdmin):
def get_price_display(self, obj): def get_price_display(self, obj):
"""Отображение финальной цены комплекта""" """Отображение финальной цены комплекта"""
try: try:
return f"{obj.actual_price} " return f"{obj.actual_price} руб."
except Exception: except Exception:
return "-" return "-"
get_price_display.short_description = "Цена" get_price_display.short_description = "Цена"
@@ -401,8 +579,26 @@ class ProductKitAdmin(admin.ModelAdmin):
return result return result
get_categories_display.short_description = 'Категории' get_categories_display.short_description = 'Категории'
def photo_with_quality(self, obj):
"""Превью фото с индикатором качества в списке комплектов"""
first_photo = obj.photos.first()
if not first_photo or not first_photo.image:
return format_html('<span style="color: #999;">Нет фото</span>')
quality_indicator = format_quality_badge(first_photo.quality_level, show_icon=True)
return format_html(
'<div style="display: flex; align-items: center; gap: 8px;">'
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />'
'{}'
'</div>',
first_photo.image.url,
quality_indicator
)
photo_with_quality.short_description = "Фото"
def photo_preview(self, obj): def photo_preview(self, obj):
"""Превью фото в списке комплектов""" """Превью фото в списке комплектов (старый метод, сохранен для совместимости)"""
first_photo = obj.photos.first() first_photo = obj.photos.first()
if first_photo and first_photo.image: if first_photo and first_photo.image:
return format_html( return format_html(
@@ -459,8 +655,8 @@ class KitItemInline(nested_admin.NestedStackedInline):
class ProductPhotoInline(admin.TabularInline): class ProductPhotoInline(admin.TabularInline):
model = ProductPhoto model = ProductPhoto
extra = 1 extra = 1
readonly_fields = ('image_preview',) readonly_fields = ('image_preview', 'quality_display')
fields = ('image', 'image_preview', 'order') fields = ('image', 'image_preview', 'quality_display', 'order')
def image_preview(self, obj): def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)""" """Превью основного фото (большой размер 800×800)"""
@@ -472,11 +668,23 @@ class ProductPhotoInline(admin.TabularInline):
return "Нет изображения" return "Нет изображения"
image_preview.short_description = "Превью" image_preview.short_description = "Превью"
def quality_display(self, obj):
"""Отображение качества фото в inline таблице"""
if not obj.pk:
return format_html('<span style="color: #999;">Сохраните фото</span>')
return format_quality_display(
obj.quality_level,
width=getattr(obj, 'width', None),
height=getattr(obj, 'height', None),
warning=obj.quality_warning
)
quality_display.short_description = "Качество"
class ProductKitPhotoInline(nested_admin.NestedTabularInline): class ProductKitPhotoInline(nested_admin.NestedTabularInline):
model = ProductKitPhoto model = ProductKitPhoto
extra = 0 # Не показывать пустые формы extra = 0 # Не показывать пустые формы
readonly_fields = ('image_preview',) readonly_fields = ('image_preview', 'quality_display')
fields = ('image', 'image_preview', 'order') fields = ('image', 'image_preview', 'quality_display', 'order')
def image_preview(self, obj): def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)""" """Превью основного фото (большой размер 800×800)"""
@@ -488,11 +696,23 @@ class ProductKitPhotoInline(nested_admin.NestedTabularInline):
return "Нет изображения" return "Нет изображения"
image_preview.short_description = "Превью" image_preview.short_description = "Превью"
def quality_display(self, obj):
"""Отображение качества фото в inline таблице"""
if not obj.pk:
return format_html('<span style="color: #999;">Сохраните фото</span>')
return format_quality_display(
obj.quality_level,
width=getattr(obj, 'width', None),
height=getattr(obj, 'height', None),
warning=obj.quality_warning
)
quality_display.short_description = "Качество"
class ProductCategoryPhotoInline(admin.TabularInline): class ProductCategoryPhotoInline(admin.TabularInline):
model = ProductCategoryPhoto model = ProductCategoryPhoto
extra = 1 extra = 1
readonly_fields = ('image_preview',) readonly_fields = ('image_preview', 'quality_display')
fields = ('image', 'image_preview', 'order') fields = ('image', 'image_preview', 'quality_display', 'order')
def image_preview(self, obj): def image_preview(self, obj):
"""Превью основного фото (большой размер 800×800)""" """Превью основного фото (большой размер 800×800)"""
@@ -504,6 +724,18 @@ class ProductCategoryPhotoInline(admin.TabularInline):
return "Нет изображения" return "Нет изображения"
image_preview.short_description = "Превью" image_preview.short_description = "Превью"
def quality_display(self, obj):
"""Отображение качества фото в inline таблице"""
if not obj.pk:
return format_html('<span style="color: #999;">Сохраните фото</span>')
return format_quality_display(
obj.quality_level,
width=getattr(obj, 'width', None),
height=getattr(obj, 'height', None),
warning=obj.quality_warning
)
quality_display.short_description = "Качество"
class ProductKitAdminWithItems(ProductKitAdmin): class ProductKitAdminWithItems(ProductKitAdmin):
inlines = [KitItemInline] inlines = [KitItemInline]

View File

@@ -0,0 +1,17 @@
{% if show %}
<div class="quality-indicator position-absolute top-0 end-0 m-2"
title="{{ tooltip }}"
data-bs-toggle="tooltip"
data-bs-placement="left">
{% if has_warning %}
<span class="badge bg-danger fs-5">⚠️</span>
{% else %}
<span class="badge bg-{{ color }} fs-6" style="opacity: 0.9;">
{{ symbol }}
</span>
{% endif %}
{% if size_text %}
<small class="d-block text-muted mt-1" style="font-size: 0.7rem;">{{ size_text }}</small>
{% endif %}
</div>
{% endif %}

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load quality_tags %}
{% block title %}{{ product.name }}{% endblock %} {% block title %}{{ product.name }}{% endblock %}
@@ -28,9 +29,9 @@
<div class="row g-2"> <div class="row g-2">
{% for photo in product_photos %} {% for photo in product_photos %}
<div class="col-md-3 col-sm-4 col-6"> <div class="col-md-3 col-sm-4 col-6">
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100 photo-card-with-quality">
<!-- Миниатюра для отображения в сетке, при клике открывает large в модальном окне --> <!-- Миниатюра для отображения в сетке, при клике открывает large в модальном окне -->
<div style="width: 100%; height: 150px; display: flex; align-items: center; justify-content: center; background-color: #f8f9fa; cursor: pointer; overflow: hidden;" <div class="photo-container"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#photoGalleryModal" data-bs-target="#photoGalleryModal"
data-bs-slide-to="{{ forloop.counter0 }}" data-bs-slide-to="{{ forloop.counter0 }}"
@@ -39,6 +40,9 @@
<img src="{{ photo.get_thumbnail_url }}" <img src="{{ photo.get_thumbnail_url }}"
alt="Фото товара" alt="Фото товара"
style="max-width: 100%; max-height: 100%; object-fit: contain;"> style="max-width: 100%; max-height: 100%; object-fit: contain;">
<!-- Индикатор качества в углу -->
{% quality_indicator photo %}
</div> </div>
<div class="card-body p-2 text-center"> <div class="card-body p-2 text-center">
{% if photo.order == 0 %} {% if photo.order == 0 %}
@@ -46,6 +50,11 @@
{% else %} {% else %}
<small class="text-muted">Позиция: {{ photo.order }}</small> <small class="text-muted">Позиция: {{ photo.order }}</small>
{% endif %} {% endif %}
<!-- Статус качества под фото -->
<div class="mt-1">
{{ photo|quality_badge_full }}
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -105,6 +114,9 @@
{% endif %} {% endif %}
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div id="galleryQualityStatus" class="me-auto">
<!-- Индикатор качества текущего фото в галерее -->
</div>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
</div> </div>
</div> </div>
@@ -155,8 +167,85 @@
</tr> </tr>
<tr> <tr>
<th>Себестоимость:</th> <th>Себестоимость:</th>
<td>{{ product.cost_price }} руб.</td> <td>
<strong class="fs-5">{{ product.cost_price }} руб.</strong>
{% if product.cost_price_details.batches %}
<button class="btn btn-sm btn-outline-info ms-2" type="button" data-bs-toggle="collapse" data-bs-target="#costDetails" aria-expanded="false" aria-controls="costDetails">
<i class="bi bi-info-circle"></i> Детали расчета
</button>
{% else %}
<span class="badge bg-warning text-dark ms-2">Нет партий на складе</span>
{% endif %}
</td>
</tr> </tr>
{% if product.cost_price_details.batches %}
<tr>
<td colspan="2">
<div class="collapse" id="costDetails">
<div class="card card-body bg-light">
<h6 class="mb-3">Разбивка себестоимости по партиям (FIFO)</h6>
<div class="row mb-3">
<div class="col-md-6">
<div class="alert alert-info mb-0">
<small><strong>Кешированная стоимость:</strong> {{ product.cost_price_details.cached_cost }} руб.</small>
</div>
</div>
<div class="col-md-6">
<div class="alert alert-{% if product.cost_price_details.is_synced %}success{% else %}warning{% endif %} mb-0">
<small><strong>Рассчитанная стоимость:</strong> {{ product.cost_price_details.calculated_cost }} руб.</small>
{% if not product.cost_price_details.is_synced %}
<br><small class="text-danger">⚠ Требуется синхронизация!</small>
{% endif %}
</div>
</div>
</div>
<div class="table-responsive">
<table class="table table-sm table-bordered table-hover mb-0">
<thead class="table-light">
<tr>
<th>Склад</th>
<th class="text-end">Количество</th>
<th class="text-end">Себестоимость за ед.</th>
<th class="text-end">Общая стоимость</th>
<th>Дата создания</th>
</tr>
</thead>
<tbody>
{% for batch in product.cost_price_details.batches %}
<tr>
<td>{{ batch.warehouse_name }}</td>
<td class="text-end">{{ batch.quantity }}</td>
<td class="text-end">{{ batch.cost_price }} руб.</td>
<td class="text-end"><strong>{{ batch.total_value }} руб.</strong></td>
<td>{{ batch.created_at|date:"d.m.Y H:i" }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="table-secondary">
<tr>
<th>Итого:</th>
<th class="text-end">{{ product.cost_price_details.total_quantity }}</th>
<th class="text-end" colspan="3">
<strong>Средневзвешенная: {{ product.cost_price_details.calculated_cost }} руб.</strong>
</th>
</tr>
</tfoot>
</table>
</div>
<div class="mt-3">
<small class="text-muted">
<i class="bi bi-info-circle"></i>
Средневзвешенная себестоимость рассчитывается как: (Σ количество × стоимость) / Σ количество
</small>
</div>
</div>
</div>
</td>
</tr>
{% endif %}
<tr> <tr>
<th>Цена:</th> <th>Цена:</th>
<td> <td>
@@ -228,10 +317,17 @@ document.addEventListener('DOMContentLoaded', function() {
const currentSlideEl = document.getElementById('currentSlide'); const currentSlideEl = document.getElementById('currentSlide');
const mainBadgeEl = document.getElementById('mainBadge'); const mainBadgeEl = document.getElementById('mainBadge');
// Массив с информацией о фотографиях // Массив с информацией о фотографиях (включая качество)
const photos = [ const photos = [
{% for photo in product_photos %} {% for photo in product_photos %}
{ order: {{ photo.order }}, index: {{ forloop.counter0 }} }{% if not forloop.last %},{% endif %} {
order: {{ photo.order }},
index: {{ forloop.counter0 }},
quality_level: '{{ photo.quality_level }}',
quality_warning: {{ photo.quality_warning|lower }},
width: {{ photo.width|default:0 }},
height: {{ photo.height|default:0 }}
}{% if not forloop.last %},{% endif %}
{% endfor %} {% endfor %}
]; ];
@@ -244,14 +340,44 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// Обновление счетчика и бейджа при переключении слайдов // Обновление счетчика, бейджа и статуса качества при переключении слайдов
photoCarousel.addEventListener('slid.bs.carousel', function (event) { photoCarousel.addEventListener('slid.bs.carousel', function (event) {
const activeIndex = event.to; const activeIndex = event.to;
const photoInfo = photos[activeIndex];
// Обновляем счетчик слайдов
if (currentSlideEl) { if (currentSlideEl) {
currentSlideEl.textContent = activeIndex + 1; currentSlideEl.textContent = activeIndex + 1;
} }
if (mainBadgeEl && photos[activeIndex]) {
mainBadgeEl.style.display = photos[activeIndex].order === 0 ? 'inline' : 'none'; // Обновляем бейдж "Главное фото"
if (mainBadgeEl && photoInfo) {
mainBadgeEl.style.display = photoInfo.order === 0 ? 'inline' : 'none';
}
// Обновляем статус качества
const qualityStatusEl = document.getElementById('galleryQualityStatus');
if (qualityStatusEl && photoInfo) {
let qualityHTML = '';
if (photoInfo.quality_warning) {
qualityHTML = '<span class="badge bg-danger"><i class="bi bi-exclamation-circle"></i> Требует обновления</span>';
} else {
const qualityInfo = {
'excellent': { symbol: '🟢', label: 'Отлично', color: 'success' },
'good': { symbol: '🟡', label: 'Хорошо', color: 'info' },
'acceptable': { symbol: '🟠', label: 'Приемлемо', color: 'warning' },
'poor': { symbol: '🔴', label: 'Плохо', color: 'danger' },
'very_poor': { symbol: '🔴', label: 'Очень плохо', color: 'danger' },
};
const info = qualityInfo[photoInfo.quality_level] || { symbol: '⚪', label: 'Неизвестно', color: 'secondary' };
const sizeInfo = photoInfo.width && photoInfo.height ? ` (${photoInfo.width}×${photoInfo.height}px)` : '';
qualityHTML = `<span class="badge bg-${info.color}">${info.symbol} ${info.label}${sizeInfo}</span>`;
}
qualityStatusEl.innerHTML = qualityHTML;
} }
}); });
@@ -269,6 +395,32 @@ document.addEventListener('DOMContentLoaded', function() {
// Добавляем обработчик клавиш при открытии модального окна // Добавляем обработчик клавиш при открытии модального окна
photoGalleryModal.addEventListener('shown.bs.modal', function () { photoGalleryModal.addEventListener('shown.bs.modal', function () {
document.addEventListener('keydown', handleKeydown); document.addEventListener('keydown', handleKeydown);
// Инициализируем статус качества первого фото
const qualityStatusEl = document.getElementById('galleryQualityStatus');
if (qualityStatusEl && photos[0]) {
const photoInfo = photos[0];
let qualityHTML = '';
if (photoInfo.quality_warning) {
qualityHTML = '<span class="badge bg-danger"><i class="bi bi-exclamation-circle"></i> Требует обновления</span>';
} else {
const qualityInfo = {
'excellent': { symbol: '🟢', label: 'Отлично', color: 'success' },
'good': { symbol: '🟡', label: 'Хорошо', color: 'info' },
'acceptable': { symbol: '🟠', label: 'Приемлемо', color: 'warning' },
'poor': { symbol: '🔴', label: 'Плохо', color: 'danger' },
'very_poor': { symbol: '🔴', label: 'Очень плохо', color: 'danger' },
};
const info = qualityInfo[photoInfo.quality_level] || { symbol: '⚪', label: 'Неизвестно', color: 'secondary' };
const sizeInfo = photoInfo.width && photoInfo.height ? ` (${photoInfo.width}×${photoInfo.height}px)` : '';
qualityHTML = `<span class="badge bg-${info.color}">${info.symbol} ${info.label}${sizeInfo}</span>`;
}
qualityStatusEl.innerHTML = qualityHTML;
}
}); });
// Удаляем обработчик клавиш при закрытии модального окна // Удаляем обработчик клавиш при закрытии модального окна

View File

@@ -1,4 +1,5 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load quality_tags %}
{% block title %}Список товаров{% endblock %} {% block title %}Список товаров{% endblock %}
@@ -30,8 +31,11 @@
<td> <td>
{% if product.photos.all %} {% if product.photos.all %}
{% with photo=product.photos.first %} {% with photo=product.photos.first %}
<!-- Миниатюра 200x200 для списков --> <!-- Миниатюра с индикатором качества -->
<img src="{{ photo.get_thumbnail_url }}" alt="{{ product.name }}" style="width: 60px; height: 60px; object-fit: cover;" class="img-thumbnail rounded"> <div class="photo-list-item">
<img src="{{ photo.get_thumbnail_url }}" alt="{{ product.name }}" class="img-thumbnail rounded">
<span class="quality-icon" title="{{ photo.get_quality_level_display }}">{{ photo|quality_icon_only }}</span>
</div>
{% endwith %} {% endwith %}
{% else %} {% else %}
<span class="text-muted">Нет фото</span> <span class="text-muted">Нет фото</span>

View File

@@ -1,4 +1,6 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load inventory_filters %}
{% load quality_tags %}
{% block title %}{{ kit.name }} - Комплект{% endblock %} {% block title %}{{ kit.name }} - Комплект{% endblock %}
@@ -50,17 +52,17 @@
<dt class="col-sm-4">Цена:</dt> <dt class="col-sm-4">Цена:</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
{% if kit.sale_price %} {% if kit.sale_price %}
<span class="text-decoration-line-through text-muted">{{ kit.calculated_price|floatformat:2 }} </span> <span class="text-decoration-line-through text-muted">{{ kit.calculated_price|floatformat:2 }} руб.</span>
<strong class="text-danger fs-5">{{ kit.sale_price|floatformat:2 }} </strong> <strong class="text-danger fs-5">{{ kit.sale_price|floatformat:2 }} руб.</strong>
<span class="badge bg-danger ms-2">Акция</span> <span class="badge bg-danger ms-2">Акция</span>
{% else %} {% else %}
<strong class="text-success fs-5">{{ kit.actual_price|floatformat:2 }} </strong> <strong class="text-success fs-5">{{ kit.actual_price|floatformat:2 }} руб.</strong>
{% endif %} {% endif %}
</dd> </dd>
<dt class="col-sm-4">Себестоимость:</dt> <dt class="col-sm-4">Себестоимость:</dt>
<dd class="col-sm-8"> <dd class="col-sm-8">
<strong class="text-danger fs-5">{{ kit.calculate_cost|floatformat:2 }} </strong> <strong class="text-danger fs-5">{{ kit.calculate_cost|floatformat:2 }} руб.</strong>
</dd> </dd>
<dt class="col-sm-4">Ценообразование:</dt> <dt class="col-sm-4">Ценообразование:</dt>
@@ -70,7 +72,7 @@
{% if kit.price %} {% if kit.price %}
<dt class="col-sm-4">Ручная цена:</dt> <dt class="col-sm-4">Ручная цена:</dt>
<dd class="col-sm-8">{{ kit.price }} </dd> <dd class="col-sm-8">{{ kit.price }} руб.</dd>
{% endif %} {% endif %}
{% if kit.markup_percent %} {% if kit.markup_percent %}
@@ -80,7 +82,7 @@
{% if kit.markup_amount %} {% if kit.markup_amount %}
<dt class="col-sm-4">Фиксированная наценка:</dt> <dt class="col-sm-4">Фиксированная наценка:</dt>
<dd class="col-sm-8">{{ kit.markup_amount }} </dd> <dd class="col-sm-8">{{ kit.markup_amount }} руб.</dd>
{% endif %} {% endif %}
<dt class="col-sm-4">Статус:</dt> <dt class="col-sm-4">Статус:</dt>
@@ -155,7 +157,7 @@
<span class="badge bg-primary">Варианты</span> <span class="badge bg-primary">Варианты</span>
{% endif %} {% endif %}
</td> </td>
<td>{{ item.quantity }}</td> <td>{{ item.quantity|smart_quantity }}</td>
<td> <td>
{% if item.notes %} {% if item.notes %}
{{ item.notes }} {{ item.notes }}
@@ -187,14 +189,24 @@
<div class="row g-2"> <div class="row g-2">
{% for photo in productkit_photos %} {% for photo in productkit_photos %}
<div class="col-6"> <div class="col-6">
<div class="card"> <div class="card photo-card-with-quality">
<!-- Миниатюра 200x200 для быстрой загрузки, при клике открывает large --> <!-- Миниатюра 200x200 для быстрой загрузки, при клике открывает large -->
<img src="{{ photo.get_thumbnail_url }}" class="card-img-top" alt="{{ kit.name }}" <div class="photo-container"
style="height: 120px; object-fit: cover; cursor: pointer;"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#photoModal{{ photo.pk }}"> data-bs-target="#photoModal{{ photo.pk }}"
title="Нажмите для увеличения">
<img src="{{ photo.get_thumbnail_url }}" class="card-img-top" alt="{{ kit.name }}"
style="height: 100%; width: 100%; object-fit: cover;">
<!-- Индикатор качества в углу -->
{% quality_indicator photo %}
</div>
{% if photo.order == 0 %} {% if photo.order == 0 %}
<div class="card-footer bg-success text-white text-center small">⭐ Главное</div> <div class="card-footer bg-success text-white text-center small">⭐ Главное</div>
{% else %}
<div class="card-footer text-center">
{{ photo|quality_badge_full }}
</div>
{% endif %} {% endif %}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,193 @@
"""
Template tags для отображения индикаторов качества фотографий.
Используется в шаблонах для ненавязчивого показа качества фото товаров.
"""
from django import template
from django.utils.html import format_html
from django.conf import settings
register = template.Library()
@register.filter
def quality_badge_mini(photo):
"""
Возвращает маленький индикатор качества для вывода в уголке фото.
Используется в сетках фотографий товаров.
Выводит маленький кружочек-значок в правом верхнем углу:
- 🟢 зелёный - excellent/good (отлично/хорошо)
- 🟡 жёлтый - acceptable (приемлемо)
- 🟠 оранжевый - poor (плохо)
- 🔴 красный - very_poor (очень плохо)
Если есть warning - показывает ⚠️
"""
if not photo or not hasattr(photo, 'quality_level'):
return ''
quality_level = photo.quality_level
has_warning = getattr(photo, 'quality_warning', False)
# Если нужно обновление фото - показываем warning
if has_warning:
return format_html(
'<span class="quality-badge-mini" '
'title="Фото требует обновления" '
'data-bs-toggle="tooltip" '
'data-bs-placement="top">'
'⚠️'
'</span>'
)
# Символы для разных уровней качества
quality_symbols = {
'excellent': ('🟢', 'Отлично'),
'good': ('🟡', 'Хорошо'),
'acceptable': ('🟠', 'Приемлемо'),
'poor': ('🔴', 'Плохо'),
'very_poor': ('🔴', 'Очень плохо'),
}
symbol, label = quality_symbols.get(quality_level, ('', 'Неизвестно'))
return format_html(
'<span class="quality-badge-mini" '
'title="{}" '
'data-bs-toggle="tooltip" '
'data-bs-placement="top">'
'{}'
'</span>',
label,
symbol
)
@register.filter
def quality_badge_full(photo):
"""
Возвращает полный индикатор качества с размером фото.
Выводит: "🟢 Отлично (2150×2150px)" или "⚠️ Требует обновления"
"""
if not photo or not hasattr(photo, 'quality_level'):
return ''
quality_level = photo.quality_level
has_warning = getattr(photo, 'quality_warning', False)
width = getattr(photo, 'width', None)
height = getattr(photo, 'height', None)
# Если нужно обновление - показываем warning
if has_warning:
return format_html(
'<span class="badge bg-danger">'
'⚠️ Требует обновления'
'</span>'
)
# Символы и названия
quality_info = {
'excellent': ('🟢', 'Отлично', 'success'),
'good': ('🟡', 'Хорошо', 'info'),
'acceptable': ('🟠', 'Приемлемо', 'warning'),
'poor': ('🔴', 'Плохо', 'danger'),
'very_poor': ('🔴', 'Очень плохо', 'danger'),
}
symbol, label, color = quality_info.get(quality_level, ('', 'Неизвестно', 'secondary'))
# Если есть размеры - добавляем их
size_info = ''
if width and height:
size_info = f' ({width}×{height}px)'
return format_html(
'<span class="badge bg-{}">{} {}{}</span>',
color,
symbol,
label,
size_info
)
@register.inclusion_tag('products/includes/quality_badge.html')
def quality_indicator(photo, show_size=False):
"""
Включаемый тег для вывода индикатора качества в углу фото.
Используется с позиционированием в углу через CSS.
Параметры:
- photo: объект фото (ProductPhoto, ProductKitPhoto или ProductCategoryPhoto)
- show_size: показывать ли размер фото (по умолчанию False)
"""
if not photo or not hasattr(photo, 'quality_level'):
return {
'show': False,
'quality_level': None,
'has_warning': False,
}
quality_level = photo.quality_level
has_warning = getattr(photo, 'quality_warning', False)
width = getattr(photo, 'width', None)
height = getattr(photo, 'height', None)
# Информация о качестве
quality_info = {
'excellent': {'symbol': '🟢', 'label': 'Отлично', 'color': 'success', 'tooltip': 'Отличное качество'},
'good': {'symbol': '🟡', 'label': 'Хорошо', 'color': 'info', 'tooltip': 'Хорошее качество'},
'acceptable': {'symbol': '🟠', 'label': 'Приемлемо', 'color': 'warning', 'tooltip': 'Приемлемое качество'},
'poor': {'symbol': '🔴', 'label': 'Плохо', 'color': 'danger', 'tooltip': 'Плохое качество'},
'very_poor': {'symbol': '🔴', 'label': 'Очень плохо', 'color': 'danger', 'tooltip': 'Очень плохое качество'},
}
info = quality_info.get(quality_level, {
'symbol': '',
'label': 'Неизвестно',
'color': 'secondary',
'tooltip': 'Качество не определено'
})
# Размер фото если требуется
size_text = ''
if show_size and width and height:
size_text = f'{width}×{height}px'
return {
'show': True,
'quality_level': quality_level,
'has_warning': has_warning,
'symbol': info['symbol'],
'label': info['label'],
'color': info['color'],
'tooltip': info['tooltip'],
'size_text': size_text,
}
@register.filter
def quality_icon_only(photo):
"""
Возвращает только символ качества (например: 🟢)
для компактного отображения в списках.
"""
if not photo or not hasattr(photo, 'quality_level'):
return ''
quality_level = photo.quality_level
has_warning = getattr(photo, 'quality_warning', False)
if has_warning:
return '⚠️'
symbols = {
'excellent': '🟢',
'good': '🟡',
'acceptable': '🟠',
'poor': '🔴',
'very_poor': '🔴',
}
return symbols.get(quality_level, '')

View File

@@ -0,0 +1,192 @@
/**
* Стили для индикаторов качества фотографий товаров
* Ненавязчивое отображение в углу фото без отвлечения внимания
*/
/* Миниатюрный индикатор в правом верхнем углу фото */
.quality-badge-mini {
display: inline-block;
font-size: 1rem;
opacity: 0.8;
transition: opacity 0.2s ease-in-out;
cursor: help;
}
.quality-badge-mini:hover {
opacity: 1;
}
/* Контейнер с позиционированием для индикатора качества */
.quality-indicator {
font-size: 0.9rem;
z-index: 10;
pointer-events: none; /* Не блокирует клики на фото */
}
.quality-indicator .badge {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
font-weight: 500;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
border-radius: 4px;
}
.quality-indicator .badge.fs-5 {
font-size: 1.2rem !important;
}
.quality-indicator .badge.fs-6 {
font-size: 1rem !important;
}
/* Фотографии в сетке на странице товара */
.photo-card-with-quality {
position: relative;
overflow: hidden;
border-radius: 4px;
}
.photo-card-with-quality .photo-container {
position: relative;
width: 100%;
height: 150px;
display: flex;
align-items: center;
justify-content: center;
background-color: #f8f9fa;
cursor: pointer;
overflow: hidden;
}
.photo-card-with-quality .photo-container img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.photo-card-with-quality .quality-indicator {
pointer-events: none;
}
/* В списках товаров - компактный вид */
.photo-list-item {
position: relative;
display: inline-block;
}
.photo-list-item img {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: 4px;
}
.photo-list-item .quality-icon {
position: absolute;
top: -4px;
right: -4px;
font-size: 0.9rem;
display: inline-block;
background: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
}
/* Для таблиц - строка со статусом качества */
.quality-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
font-size: 0.75rem;
}
.quality-status-row .badge {
padding: 0.25rem 0.5rem;
font-size: 0.65rem;
}
/* Tooltip для качества */
.bs-tooltip-top .tooltip-inner {
background-color: rgba(33, 37, 41, 0.95);
padding: 0.5rem 0.75rem;
border-radius: 4px;
font-size: 0.875rem;
}
.bs-tooltip-auto[data-popper-placement^="top"] .tooltip-arrow::before,
.bs-tooltip-top .tooltip-arrow::before {
border-top-color: rgba(33, 37, 41, 0.95);
}
/* Ненавязчивые цвета для indirectных уровней качества */
.quality-indicator .badge.bg-success {
background-color: #28a745 !important;
}
.quality-indicator .badge.bg-info {
background-color: #17a2b8 !important;
}
.quality-indicator .badge.bg-warning {
background-color: #ffc107 !important;
color: #333 !important;
}
.quality-indicator .badge.bg-danger {
background-color: #dc3545 !important;
}
/* Адаптивность для мобильных устройств */
@media (max-width: 576px) {
.quality-indicator {
font-size: 0.8rem;
}
.quality-indicator .badge {
padding: 0.3rem 0.5rem;
font-size: 0.75rem;
}
.photo-list-item .quality-icon {
width: 18px;
height: 18px;
font-size: 0.8rem;
}
}
/* Анимация при наведении для модальной галереи */
.carousel-item .quality-indicator {
animation: fadeIn 0.3s ease-in-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
/* Для скрытия warning значка если фото в порядке */
.quality-indicator .badge.bg-danger:not(:has(.badge)) {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% {
opacity: 0.8;
}
50% {
opacity: 1;
}
}

View File

@@ -11,6 +11,9 @@
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet" />
<link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" /> <link href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css" rel="stylesheet" />
<!-- Качество фото индикаторы -->
<link rel="stylesheet" href="/static/css/quality_indicator.css">
<style> <style>
body { body {
background-color: #f8f9fa; background-color: #f8f9fa;