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:
@@ -98,6 +98,37 @@ function formatMoney(v) {
|
||||
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');
|
||||
stock.className = 'product-stock';
|
||||
|
||||
// Для витринных комплектов показываем название витрины И количество (доступно/всего)
|
||||
// Для витринных комплектов показываем количество (доступно/всего) и дней на витрине
|
||||
if (item.type === 'showcase_kit') {
|
||||
const availableCount = item.available_count || 0;
|
||||
const totalCount = item.total_count || availableCount;
|
||||
@@ -922,7 +953,14 @@ function renderProducts() {
|
||||
let badgeText = totalCount > 1 ? `${availableCount}/${totalCount}` : `${availableCount}`;
|
||||
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.fontWeight = 'bold';
|
||||
} 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('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('priceAdjustmentValue').value = kit.price_adjustment_value;
|
||||
|
||||
@@ -2275,6 +2326,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
const kitName = document.getElementById('tempKitName').value.trim();
|
||||
const showcaseId = document.getElementById('showcaseSelect').value;
|
||||
const description = document.getElementById('tempKitDescription').value.trim();
|
||||
const showcaseCreatedAt = document.getElementById('showcaseCreatedAt').value;
|
||||
const photoFile = document.getElementById('tempKitPhoto').files[0];
|
||||
|
||||
// Валидация
|
||||
@@ -2329,6 +2381,12 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
formData.append('quantity', showcaseKitQuantity); // Количество экземпляров на витрину
|
||||
}
|
||||
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('price_adjustment_type', priceAdjustmentType);
|
||||
formData.append('price_adjustment_value', priceAdjustmentValue);
|
||||
@@ -2398,6 +2456,7 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
|
||||
|
||||
// Сбрасываем поля формы
|
||||
document.getElementById('tempKitDescription').value = '';
|
||||
document.getElementById('showcaseCreatedAt').value = '';
|
||||
document.getElementById('tempKitPhoto').value = '';
|
||||
document.getElementById('photoPreview').style.display = 'none';
|
||||
document.getElementById('priceAdjustmentType').value = 'none';
|
||||
|
||||
@@ -219,6 +219,14 @@
|
||||
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
|
||||
</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">
|
||||
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
|
||||
|
||||
@@ -83,6 +83,7 @@ def get_showcase_kits_for_pos():
|
||||
'product_kit__price',
|
||||
'product_kit__sale_price',
|
||||
'product_kit__base_price',
|
||||
'product_kit__showcase_created_at',
|
||||
'showcase_id',
|
||||
'showcase__name'
|
||||
).annotate(
|
||||
@@ -161,7 +162,9 @@ def get_showcase_kits_for_pos():
|
||||
'total_count': item['total_count'], # Всего на витрине (включая в корзине)
|
||||
'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
|
||||
@@ -1052,7 +1055,8 @@ def get_product_kit_details(request, kit_id):
|
||||
'final_price': str(kit.actual_price),
|
||||
'showcase_id': showcase_id,
|
||||
'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:
|
||||
@@ -1087,6 +1091,7 @@ def create_temp_kit_to_showcase(request):
|
||||
sale_price_str = request.POST.get('sale_price', '')
|
||||
photo_file = request.FILES.get('photo')
|
||||
showcase_kit_quantity = int(request.POST.get('quantity', 1)) # Количество букетов на витрину
|
||||
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
|
||||
|
||||
# Парсим items из JSON
|
||||
items = json.loads(items_json)
|
||||
@@ -1101,6 +1106,23 @@ def create_temp_kit_to_showcase(request):
|
||||
except (ValueError, InvalidOperation):
|
||||
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:
|
||||
return JsonResponse({
|
||||
@@ -1161,7 +1183,8 @@ def create_temp_kit_to_showcase(request):
|
||||
price_adjustment_type=price_adjustment_type,
|
||||
price_adjustment_value=price_adjustment_value,
|
||||
sale_price=sale_price,
|
||||
showcase=showcase
|
||||
showcase=showcase,
|
||||
showcase_created_at=showcase_created_at
|
||||
)
|
||||
|
||||
# 2. Создаём KitItem для каждого товара из корзины
|
||||
@@ -1284,6 +1307,9 @@ def update_product_kit(request, kit_id):
|
||||
- photo: Новое фото (опционально)
|
||||
- remove_photo: '1' для удаления фото
|
||||
"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
kit = ProductKit.objects.prefetch_related('kit_items__product', 'photos').get(id=kit_id, is_temporary=True)
|
||||
|
||||
@@ -1296,6 +1322,7 @@ def update_product_kit(request, kit_id):
|
||||
sale_price_str = request.POST.get('sale_price', '')
|
||||
photo_file = request.FILES.get('photo')
|
||||
remove_photo = request.POST.get('remove_photo', '') == '1'
|
||||
showcase_created_at_str = request.POST.get('showcase_created_at', '').strip()
|
||||
|
||||
items = json.loads(items_json)
|
||||
|
||||
@@ -1308,6 +1335,31 @@ def update_product_kit(request, kit_id):
|
||||
except (ValueError, InvalidOperation):
|
||||
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:
|
||||
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_value = price_adjustment_value
|
||||
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()
|
||||
|
||||
# Обновляем состав
|
||||
|
||||
@@ -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='Дата размещения на витрине'),
|
||||
),
|
||||
]
|
||||
@@ -93,6 +93,14 @@ class ProductKit(BaseProductEntity):
|
||||
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(
|
||||
'orders.Order',
|
||||
on_delete=models.SET_NULL,
|
||||
|
||||
@@ -158,49 +158,41 @@ class ImageProcessor:
|
||||
@staticmethod
|
||||
def _resize_image(img, size):
|
||||
"""
|
||||
Изменяет размер изображения с сохранением пропорций.
|
||||
Изменяет размер изображения с center-crop до точного квадратного размера.
|
||||
НЕ увеличивает маленькие изображения (сохраняет качество).
|
||||
Создает адаптивный квадрат по размеру реального изображения.
|
||||
Создает квадратное изображение без белых полей.
|
||||
|
||||
Args:
|
||||
img: PIL Image object
|
||||
size: Кортеж (width, height) - максимальный целевой размер
|
||||
size: Кортеж (width, height) - целевой размер (обычно квадратный)
|
||||
|
||||
Returns:
|
||||
PIL Image object - квадратное изображение с минимальным белым фоном
|
||||
PIL Image object - квадратное изображение без белых полей
|
||||
"""
|
||||
# Копируем изображение, чтобы не модифицировать оригинал
|
||||
img_copy = img.copy()
|
||||
target_width, target_height = size
|
||||
|
||||
# Вычисляем пропорции исходного изображения и целевого размера
|
||||
img_aspect = img_copy.width / img_copy.height
|
||||
target_aspect = size[0] / size[1]
|
||||
# Шаг 1: Center crop для получения квадрата
|
||||
# Определяем минимальную сторону (будет размер квадрата)
|
||||
min_side = min(img_copy.width, img_copy.height)
|
||||
|
||||
# Определяем, какой размер будет ограничивающим при масштабировании
|
||||
if img_aspect > target_aspect:
|
||||
# Изображение шире - ограничиваемый размер это ширина
|
||||
new_width = min(img_copy.width, size[0])
|
||||
new_height = int(new_width / img_aspect)
|
||||
# Вычисляем координаты для обрезки из центра
|
||||
left = (img_copy.width - min_side) // 2
|
||||
top = (img_copy.height - min_side) // 2
|
||||
right = left + min_side
|
||||
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:
|
||||
# Изображение выше - ограничиваемый размер это высота
|
||||
new_height = min(img_copy.height, size[1])
|
||||
new_width = int(new_height * img_aspect)
|
||||
img_resized = img_cropped
|
||||
|
||||
# Масштабируем только если необходимо (не увеличиваем маленькие изображения)
|
||||
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
|
||||
return img_resized
|
||||
|
||||
@staticmethod
|
||||
def _make_square_image(img, max_size):
|
||||
|
||||
Reference in New Issue
Block a user