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:
@@ -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 %}
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load quality_tags %}
|
||||
|
||||
{% block title %}{{ product.name }}{% endblock %}
|
||||
|
||||
@@ -28,9 +29,9 @@
|
||||
<div class="row g-2">
|
||||
{% for photo in product_photos %}
|
||||
<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 в модальном окне -->
|
||||
<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-target="#photoGalleryModal"
|
||||
data-bs-slide-to="{{ forloop.counter0 }}"
|
||||
@@ -39,6 +40,9 @@
|
||||
<img src="{{ photo.get_thumbnail_url }}"
|
||||
alt="Фото товара"
|
||||
style="max-width: 100%; max-height: 100%; object-fit: contain;">
|
||||
|
||||
<!-- Индикатор качества в углу -->
|
||||
{% quality_indicator photo %}
|
||||
</div>
|
||||
<div class="card-body p-2 text-center">
|
||||
{% if photo.order == 0 %}
|
||||
@@ -46,6 +50,11 @@
|
||||
{% else %}
|
||||
<small class="text-muted">Позиция: {{ photo.order }}</small>
|
||||
{% endif %}
|
||||
|
||||
<!-- Статус качества под фото -->
|
||||
<div class="mt-1">
|
||||
{{ photo|quality_badge_full }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,6 +114,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div id="galleryQualityStatus" class="me-auto">
|
||||
<!-- Индикатор качества текущего фото в галерее -->
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -155,8 +167,85 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<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>
|
||||
{% 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>
|
||||
<th>Цена:</th>
|
||||
<td>
|
||||
@@ -228,10 +317,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const currentSlideEl = document.getElementById('currentSlide');
|
||||
const mainBadgeEl = document.getElementById('mainBadge');
|
||||
|
||||
// Массив с информацией о фотографиях
|
||||
// Массив с информацией о фотографиях (включая качество)
|
||||
const 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 %}
|
||||
];
|
||||
|
||||
@@ -244,14 +340,44 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Обновление счетчика и бейджа при переключении слайдов
|
||||
// Обновление счетчика, бейджа и статуса качества при переключении слайдов
|
||||
photoCarousel.addEventListener('slid.bs.carousel', function (event) {
|
||||
const activeIndex = event.to;
|
||||
const photoInfo = photos[activeIndex];
|
||||
|
||||
// Обновляем счетчик слайдов
|
||||
if (currentSlideEl) {
|
||||
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 () {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// Удаляем обработчик клавиш при закрытии модального окна
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load quality_tags %}
|
||||
|
||||
{% block title %}Список товаров{% endblock %}
|
||||
|
||||
@@ -30,8 +31,11 @@
|
||||
<td>
|
||||
{% if product.photos.all %}
|
||||
{% 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 %}
|
||||
{% else %}
|
||||
<span class="text-muted">Нет фото</span>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load inventory_filters %}
|
||||
{% load quality_tags %}
|
||||
|
||||
{% block title %}{{ kit.name }} - Комплект{% endblock %}
|
||||
|
||||
@@ -50,17 +52,17 @@
|
||||
<dt class="col-sm-4">Цена:</dt>
|
||||
<dd class="col-sm-8">
|
||||
{% if kit.sale_price %}
|
||||
<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>
|
||||
<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>
|
||||
<span class="badge bg-danger ms-2">Акция</span>
|
||||
{% 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 %}
|
||||
</dd>
|
||||
|
||||
<dt class="col-sm-4">Себестоимость:</dt>
|
||||
<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>
|
||||
|
||||
<dt class="col-sm-4">Ценообразование:</dt>
|
||||
@@ -70,7 +72,7 @@
|
||||
|
||||
{% if kit.price %}
|
||||
<dt class="col-sm-4">Ручная цена:</dt>
|
||||
<dd class="col-sm-8">{{ kit.price }} ₽</dd>
|
||||
<dd class="col-sm-8">{{ kit.price }} руб.</dd>
|
||||
{% endif %}
|
||||
|
||||
{% if kit.markup_percent %}
|
||||
@@ -80,7 +82,7 @@
|
||||
|
||||
{% if kit.markup_amount %}
|
||||
<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 %}
|
||||
|
||||
<dt class="col-sm-4">Статус:</dt>
|
||||
@@ -155,7 +157,7 @@
|
||||
<span class="badge bg-primary">Варианты</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ item.quantity }}</td>
|
||||
<td>{{ item.quantity|smart_quantity }}</td>
|
||||
<td>
|
||||
{% if item.notes %}
|
||||
{{ item.notes }}
|
||||
@@ -187,14 +189,24 @@
|
||||
<div class="row g-2">
|
||||
{% for photo in productkit_photos %}
|
||||
<div class="col-6">
|
||||
<div class="card">
|
||||
<div class="card photo-card-with-quality">
|
||||
<!-- Миниатюра 200x200 для быстрой загрузки, при клике открывает large -->
|
||||
<img src="{{ photo.get_thumbnail_url }}" class="card-img-top" alt="{{ kit.name }}"
|
||||
style="height: 120px; object-fit: cover; cursor: pointer;"
|
||||
<div class="photo-container"
|
||||
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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user