feat(pos): add editable showcase creation date for kits

- Add showcase_created_at field to ProductKit model
- Display days ago as badge in product card (0 дней, 1 день, etc.)
- Add date input field in edit modal
- Auto-set current date/time for new showcase kits

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-24 01:37:27 +03:00
parent fce8d9eb6e
commit 977ee91fee
6 changed files with 184 additions and 42 deletions

View File

@@ -98,6 +98,37 @@ function formatMoney(v) {
return (Number(v)).toFixed(2); return (Number(v)).toFixed(2);
} }
/**
* Форматирует дату как относительное время в русском языке
* @param {string|null} isoDate - ISO дата или null
* @returns {string} - "0 дней", "1 день", "2 дня", "5 дней", и т.д.
*/
function formatDaysAgo(isoDate) {
if (!isoDate) return '';
const created = new Date(isoDate);
const now = new Date();
const diffMs = now - created;
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
// Русские формы множественного числа
const lastTwo = diffDays % 100;
const lastOne = diffDays % 10;
let suffix;
if (lastTwo >= 11 && lastTwo <= 19) {
suffix = 'дней';
} else if (lastOne === 1) {
suffix = 'день';
} else if (lastOne >= 2 && lastOne <= 4) {
suffix = 'дня';
} else {
suffix = 'дней';
}
return `${diffDays} ${suffix}`;
}
// ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ ===== // ===== ФУНКЦИИ ДЛЯ РАБОТЫ С КЛИЕНТОМ =====
/** /**
@@ -911,7 +942,7 @@ function renderProducts() {
const stock = document.createElement('div'); const stock = document.createElement('div');
stock.className = 'product-stock'; stock.className = 'product-stock';
// Для витринных комплектов показываем название витрины И количество (доступно/всего) // Для витринных комплектов показываем количество (доступно/всего) и дней на витрине
if (item.type === 'showcase_kit') { if (item.type === 'showcase_kit') {
const availableCount = item.available_count || 0; const availableCount = item.available_count || 0;
const totalCount = item.total_count || availableCount; const totalCount = item.total_count || availableCount;
@@ -922,7 +953,14 @@ function renderProducts() {
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`; let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
let cartInfo = inCart > 0 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : ''; let cartInfo = inCart > 0 ? ` <span class="badge bg-warning text-dark">🛒${inCart}</span>` : '';
stock.innerHTML = `🌺 ${item.showcase_name} <span class="badge ${badgeClass} ms-1">${badgeText}</span>${cartInfo}`; // Добавляем отображение дней с момента создания как бейдж справа
const daysAgo = formatDaysAgo(item.showcase_created_at);
const daysBadge = daysAgo ? ` <span class="badge bg-info ms-auto">${daysAgo}</span>` : '';
stock.innerHTML = `<span class="badge ${badgeClass}" style="font-size: 0.9rem;">${badgeText}</span>${daysBadge}${cartInfo}`;
stock.style.display = 'flex';
stock.style.justifyContent = 'space-between';
stock.style.alignItems = 'center';
stock.style.color = '#856404'; stock.style.color = '#856404';
stock.style.fontWeight = 'bold'; stock.style.fontWeight = 'bold';
} else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) { } else if (item.type === 'product' && item.available_qty !== undefined && item.reserved_qty !== undefined) {
@@ -1837,6 +1875,19 @@ async function openEditKitModal(kitId) {
// Заполняем поля формы // Заполняем поля формы
document.getElementById('tempKitName').value = kit.name; document.getElementById('tempKitName').value = kit.name;
document.getElementById('tempKitDescription').value = kit.description; document.getElementById('tempKitDescription').value = kit.description;
// Заполняем поле даты размещения на витрине
if (kit.showcase_created_at) {
// Конвертируем ISO в формат datetime-local (YYYY-MM-DDTHH:MM)
const date = new Date(kit.showcase_created_at);
// Компенсация смещения часового пояса
const offset = date.getTimezoneOffset() * 60000;
const localDate = new Date(date.getTime() - offset);
document.getElementById('showcaseCreatedAt').value = localDate.toISOString().slice(0, 16);
} else {
document.getElementById('showcaseCreatedAt').value = '';
}
document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type; document.getElementById('priceAdjustmentType').value = kit.price_adjustment_type;
document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value; document.getElementById('priceAdjustmentValue').value = kit.price_adjustment_value;
@@ -2275,8 +2326,9 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
const kitName = document.getElementById('tempKitName').value.trim(); const kitName = document.getElementById('tempKitName').value.trim();
const showcaseId = document.getElementById('showcaseSelect').value; const showcaseId = document.getElementById('showcaseSelect').value;
const description = document.getElementById('tempKitDescription').value.trim(); const description = document.getElementById('tempKitDescription').value.trim();
const showcaseCreatedAt = document.getElementById('showcaseCreatedAt').value;
const photoFile = document.getElementById('tempKitPhoto').files[0]; const photoFile = document.getElementById('tempKitPhoto').files[0];
// Валидация // Валидация
if (!kitName) { if (!kitName) {
alert('Введите название комплекта'); alert('Введите название комплекта');
@@ -2329,6 +2381,12 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
} }
formData.append('description', description); formData.append('description', description);
if (showcaseCreatedAt) {
formData.append('showcase_created_at', showcaseCreatedAt);
console.log('[DEBUG] Sending showcase_created_at:', showcaseCreatedAt);
} else {
console.log('[DEBUG] showcase_created_at is empty, NOT sending');
}
formData.append('items', JSON.stringify(items)); formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType); formData.append('price_adjustment_type', priceAdjustmentType);
formData.append('price_adjustment_value', priceAdjustmentValue); formData.append('price_adjustment_value', priceAdjustmentValue);
@@ -2398,6 +2456,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
// Сбрасываем поля формы // Сбрасываем поля формы
document.getElementById('tempKitDescription').value = ''; document.getElementById('tempKitDescription').value = '';
document.getElementById('showcaseCreatedAt').value = '';
document.getElementById('tempKitPhoto').value = ''; document.getElementById('tempKitPhoto').value = '';
document.getElementById('photoPreview').style.display = 'none'; document.getElementById('photoPreview').style.display = 'none';
document.getElementById('priceAdjustmentType').value = 'none'; document.getElementById('priceAdjustmentType').value = 'none';

View File

@@ -218,7 +218,15 @@
<label for="tempKitDescription" class="form-label">Описание (опционально)</label> <label for="tempKitDescription" class="form-label">Описание (опционально)</label>
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea> <textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
</div> </div>
<!-- Дата размещения на витрине -->
<div class="mb-3">
<label for="showcaseCreatedAt" class="form-label">Дата размещения на витрине</label>
<input type="datetime-local" class="form-control" id="showcaseCreatedAt"
placeholder="Выберите дату и время">
<small class="text-muted">Оставьте пустым для текущего времени</small>
</div>
<!-- Загрузка фото --> <!-- Загрузка фото -->
<div class="mb-3"> <div class="mb-3">
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label> <label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>

View File

@@ -83,6 +83,7 @@ def get_showcase_kits_for_pos():
'product_kit__price', 'product_kit__price',
'product_kit__sale_price', 'product_kit__sale_price',
'product_kit__base_price', 'product_kit__base_price',
'product_kit__showcase_created_at',
'showcase_id', 'showcase_id',
'showcase__name' 'showcase__name'
).annotate( ).annotate(
@@ -161,7 +162,9 @@ def get_showcase_kits_for_pos():
'total_count': item['total_count'], # Всего на витрине (включая в корзине) 'total_count': item['total_count'], # Всего на витрине (включая в корзине)
'showcase_item_ids': available_item_ids, # IDs только доступных 'showcase_item_ids': available_item_ids, # IDs только доступных
# Флаг неактуальной цены # Флаг неактуальной цены
'price_outdated': price_outdated 'price_outdated': price_outdated,
# Дата размещения на витрине
'showcase_created_at': item.get('product_kit__showcase_created_at')
}) })
return showcase_kits return showcase_kits
@@ -1052,7 +1055,8 @@ def get_product_kit_details(request, kit_id):
'final_price': str(kit.actual_price), 'final_price': str(kit.actual_price),
'showcase_id': showcase_id, 'showcase_id': showcase_id,
'items': items, 'items': items,
'photo_url': photo_url 'photo_url': photo_url,
'showcase_created_at': kit.showcase_created_at.isoformat() if kit.showcase_created_at else None
} }
}) })
except ProductKit.DoesNotExist: except ProductKit.DoesNotExist:
@@ -1087,7 +1091,8 @@ def create_temp_kit_to_showcase(request):
sale_price_str = request.POST.get('sale_price', '') sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo') photo_file = request.FILES.get('photo')
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
# Парсим items из JSON # Парсим items из JSON
items = json.loads(items_json) items = json.loads(items_json)
@@ -1100,7 +1105,24 @@ def create_temp_kit_to_showcase(request):
sale_price = None sale_price = None
except (ValueError, InvalidOperation): except (ValueError, InvalidOperation):
sale_price = None sale_price = None
# Showcase created at (опционально)
showcase_created_at = None
if showcase_created_at_str:
try:
from datetime import datetime
showcase_created_at = datetime.fromisoformat(showcase_created_at_str)
except ValueError:
try:
from datetime import datetime
showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M')
except ValueError:
pass # Неверный формат, оставляем как None
# Если не указана - устанавливаем текущее время для новых комплектов
if not showcase_created_at:
showcase_created_at = timezone.now()
# Валидация # Валидация
if not kit_name: if not kit_name:
return JsonResponse({ return JsonResponse({
@@ -1161,7 +1183,8 @@ def create_temp_kit_to_showcase(request):
price_adjustment_type=price_adjustment_type, price_adjustment_type=price_adjustment_type,
price_adjustment_value=price_adjustment_value, price_adjustment_value=price_adjustment_value,
sale_price=sale_price, sale_price=sale_price,
showcase=showcase showcase=showcase,
showcase_created_at=showcase_created_at
) )
# 2. Создаём KitItem для каждого товара из корзины # 2. Создаём KitItem для каждого товара из корзины
@@ -1284,6 +1307,9 @@ def update_product_kit(request, kit_id):
- photo: Новое фото (опционально) - photo: Новое фото (опционально)
- remove_photo: '1' для удаления фото - remove_photo: '1' для удаления фото
""" """
import logging
logger = logging.getLogger(__name__)
try: try:
kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id, is_temporary=True) kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id, is_temporary=True)
@@ -1296,7 +1322,8 @@ def update_product_kit(request, kit_id):
sale_price_str = request.POST.get('sale_price', '') sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo') photo_file = request.FILES.get('photo')
remove_photo = request.POST.get('remove_photo', '') == '1' remove_photo = request.POST.get('remove_photo', '') == '1'
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
items = json.loads(items_json) items = json.loads(items_json)
sale_price = None sale_price = None
@@ -1307,7 +1334,32 @@ def update_product_kit(request, kit_id):
sale_price = None sale_price = None
except (ValueError, InvalidOperation): except (ValueError, InvalidOperation):
sale_price = None sale_price = None
# Showcase created at (опционально)
showcase_created_at = None
if showcase_created_at_str:
logger.warning(f"[DEBUG] showcase_created_at_str received: '{showcase_created_at_str}'")
try:
from datetime import datetime
showcase_created_at = datetime.fromisoformat(showcase_created_at_str)
logger.warning(f"[DEBUG] Parsed fromisoformat: {showcase_created_at}")
except ValueError as e:
logger.warning(f"[DEBUG] fromisoformat failed: {e}, trying strptime")
try:
showcase_created_at = datetime.strptime(showcase_created_at_str, '%Y-%m-%dT%H:%M')
logger.warning(f"[DEBUG] Parsed strptime: {showcase_created_at}")
except ValueError as e2:
logger.warning(f"[DEBUG] strptime also failed: {e2}")
pass # Неверный формат, оставляем как есть
# Делаем datetime timezone-aware
if showcase_created_at and showcase_created_at.tzinfo is None:
from django.utils import timezone
showcase_created_at = timezone.make_aware(showcase_created_at)
logger.warning(f"[DEBUG] Made aware: {showcase_created_at}")
logger.warning(f"[DEBUG] Final showcase_created_at value: {showcase_created_at}")
# Валидация # Валидация
if not kit_name: if not kit_name:
return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400) return JsonResponse({'success': False, 'error': 'Необходимо указать название'}, status=400)
@@ -1381,6 +1433,11 @@ def update_product_kit(request, kit_id):
kit.price_adjustment_type = price_adjustment_type kit.price_adjustment_type = price_adjustment_type
kit.price_adjustment_value = price_adjustment_value kit.price_adjustment_value = price_adjustment_value
kit.sale_price = sale_price kit.sale_price = sale_price
if showcase_created_at is not None: # Обновляем только если передана
kit.showcase_created_at = showcase_created_at
logger.warning(f"[DEBUG] Saving kit.showcase_created_at = {kit.showcase_created_at}")
else:
logger.warning(f"[DEBUG] showcase_created_at is None, NOT updating field")
kit.save() kit.save()
# Обновляем состав # Обновляем состав

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.10 on 2026-01-23 22:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('products', '0002_bouquetname'),
]
operations = [
migrations.AddField(
model_name='productkit',
name='showcase_created_at',
field=models.DateTimeField(blank=True, help_text='Дата создания букета для витрины (редактируемая)', null=True, verbose_name='Дата размещения на витрине'),
),
]

View File

@@ -93,6 +93,14 @@ class ProductKit(BaseProductEntity):
help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа" help_text="Временные комплекты не показываются в каталоге и создаются для конкретного заказа"
) )
# Showcase creation date - editable date for when the bouquet was put on display
showcase_created_at = models.DateTimeField(
null=True,
blank=True,
verbose_name="Дата размещения на витрине",
help_text="Дата создания букета для витрины (редактируемая)"
)
order = models.ForeignKey( order = models.ForeignKey(
'orders.Order', 'orders.Order',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,

View File

@@ -158,49 +158,41 @@ class ImageProcessor:
@staticmethod @staticmethod
def _resize_image(img, size): def _resize_image(img, size):
""" """
Изменяет размер изображения с сохранением пропорций. Изменяет размер изображения с center-crop до точного квадратного размера.
НЕ увеличивает маленькие изображения (сохраняет качество). НЕ увеличивает маленькие изображения (сохраняет качество).
Создает адаптивный квадрат по размеру реального изображения. Создает квадратное изображение без белых полей.
Args: Args:
img: PIL Image object img: PIL Image object
size: Кортеж (width, height) - максимальный целевой размер size: Кортеж (width, height) - целевой размер (обычно квадратный)
Returns: Returns:
PIL Image object - квадратное изображение с минимальным белым фоном PIL Image object - квадратное изображение без белых полей
""" """
# Копируем изображение, чтобы не модифицировать оригинал
img_copy = img.copy() img_copy = img.copy()
target_width, target_height = size
# Вычисляем пропорции исходного изображения и целевого размера # Шаг 1: Center crop для получения квадрата
img_aspect = img_copy.width / img_copy.height # Определяем минимальную сторону (будет размер квадрата)
target_aspect = size[0] / size[1] min_side = min(img_copy.width, img_copy.height)
# Определяем, какой размер будет ограничивающим при масштабировании # Вычисляем координаты для обрезки из центра
if img_aspect > target_aspect: left = (img_copy.width - min_side) // 2
# Изображение шире - ограничиваемый размер это ширина top = (img_copy.height - min_side) // 2
new_width = min(img_copy.width, size[0]) right = left + min_side
new_height = int(new_width / img_aspect) bottom = top + min_side
# Обрезаем до квадрата
img_cropped = img_copy.crop((left, top, right, bottom))
# Шаг 2: Масштабируем до целевого размера (если исходный квадрат больше цели)
# Не увеличиваем маленькие изображения
if min_side > target_width:
img_resized = img_cropped.resize((target_width, target_height), Image.Resampling.LANCZOS)
else: else:
# Изображение выше - ограничиваемый размер это высота img_resized = img_cropped
new_height = min(img_copy.height, size[1])
new_width = int(new_height * img_aspect)
# Масштабируем только если необходимо (не увеличиваем маленькие изображения) return img_resized
if img_copy.width > new_width or img_copy.height > new_height:
img_copy = img_copy.resize((new_width, new_height), Image.Resampling.LANCZOS)
# Создаем адаптивный квадрат по размеру реального изображения (а не по конфигурации)
# Это позволяет избежать огромных белых полей для маленьких фото
square_size = max(img_copy.width, img_copy.height)
new_img = Image.new('RGB', (square_size, square_size), (255, 255, 255))
# Центрируем исходное изображение на белом фоне
offset_x = (square_size - img_copy.width) // 2
offset_y = (square_size - img_copy.height) // 2
new_img.paste(img_copy, (offset_x, offset_y))
return new_img
@staticmethod @staticmethod
def _make_square_image(img, max_size): def _make_square_image(img, max_size):