feat: динамическая загрузка витринных комплектов в POS

- Добавлен API endpoint GET /pos/api/showcase-kits/ для получения актуальных витринных букетов
- Изменена переменная SHOWCASE_KITS на изменяемую showcaseKits
- Добавлена функция refreshShowcaseKits() для обновления данных с сервера
- Кнопка ВИТРИНА теперь загружает свежие данные перед отображением
- После создания временного букета автоматически обновляется список и переключается вид на витрину
- Исправлена проблема с отображением только что созданных витринных букетов
This commit is contained in:
2025-11-16 23:08:34 +03:00
parent 740ed09113
commit 9dff9cc200
4 changed files with 350 additions and 62 deletions

View File

@@ -2,7 +2,7 @@
const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent); const CATEGORIES = JSON.parse(document.getElementById('categoriesData').textContent);
const ITEMS = JSON.parse(document.getElementById('itemsData').textContent); // Единый массив товаров и комплектов const ITEMS = JSON.parse(document.getElementById('itemsData').textContent); // Единый массив товаров и комплектов
const SHOWCASE_KITS = JSON.parse(document.getElementById('showcaseKitsData').textContent); // Витринные комплекты let showcaseKits = JSON.parse(document.getElementById('showcaseKitsData').textContent); // Витринные комплекты (изменяемый)
let currentCategoryId = null; let currentCategoryId = null;
let isShowcaseView = false; // Флаг режима просмотра витринных букетов let isShowcaseView = false; // Флаг режима просмотра витринных букетов
@@ -23,9 +23,10 @@ function renderCategories() {
showcaseCard.className = 'card category-card showcase-card' + (isShowcaseView ? ' active' : ''); showcaseCard.className = 'card category-card showcase-card' + (isShowcaseView ? ' active' : '');
showcaseCard.style.backgroundColor = '#fff3cd'; showcaseCard.style.backgroundColor = '#fff3cd';
showcaseCard.style.borderColor = '#ffc107'; showcaseCard.style.borderColor = '#ffc107';
showcaseCard.onclick = () => { showcaseCard.onclick = async () => {
isShowcaseView = true; isShowcaseView = true;
currentCategoryId = null; currentCategoryId = null;
await refreshShowcaseKits(); // Загружаем свежие данные
renderCategories(); renderCategories();
renderProducts(); renderProducts();
}; };
@@ -97,7 +98,7 @@ function renderProducts() {
// Если выбран режим витрины - показываем витринные комплекты // Если выбран режим витрины - показываем витринные комплекты
if (isShowcaseView) { if (isShowcaseView) {
filtered = SHOWCASE_KITS; filtered = showcaseKits; // Используем изменяемую переменную
} else { } else {
// Обычный режим - показываем товары и комплекты // Обычный режим - показываем товары и комплекты
filtered = currentCategoryId filtered = currentCategoryId
@@ -334,6 +335,22 @@ async function openCreateTempKitModal() {
modal.show(); modal.show();
} }
// Обновление списка витринных комплектов
async function refreshShowcaseKits() {
try {
const response = await fetch('/pos/api/showcase-kits/');
const data = await response.json();
if (data.success) {
showcaseKits = data.items;
} else {
console.error('Failed to refresh showcase kits:', data);
}
} catch (error) {
console.error('Error refreshing showcase kits:', error);
}
}
// Загрузка списка витрин // Загрузка списка витрин
async function loadShowcases() { async function loadShowcases() {
try { try {
@@ -344,12 +361,24 @@ async function loadShowcases() {
select.innerHTML = '<option value="">Выберите витрину...</option>'; select.innerHTML = '<option value="">Выберите витрину...</option>';
if (data.success && data.showcases.length > 0) { if (data.success && data.showcases.length > 0) {
let defaultShowcaseId = null;
data.showcases.forEach(showcase => { data.showcases.forEach(showcase => {
const option = document.createElement('option'); const option = document.createElement('option');
option.value = showcase.id; option.value = showcase.id;
option.textContent = `${showcase.name} (${showcase.warehouse_name})`; option.textContent = `${showcase.name} (${showcase.warehouse_name})`;
select.appendChild(option); select.appendChild(option);
// Запоминаем витрину склада по умолчанию
if (showcase.is_default_warehouse) {
defaultShowcaseId = showcase.id;
}
}); });
// Автовыбор витрины склада по умолчанию
if (defaultShowcaseId) {
select.value = defaultShowcaseId;
}
} else { } else {
select.innerHTML = '<option value="">Нет доступных витрин</option>'; select.innerHTML = '<option value="">Нет доступных витрин</option>';
} }
@@ -371,15 +400,15 @@ function renderTempKitItems() {
if (item.type !== 'product') return; if (item.type !== 'product') return;
const itemDiv = document.createElement('div'); const itemDiv = document.createElement('div');
itemDiv.className = 'd-flex justify-content-between align-items-center mb-2 pb-2 border-bottom'; itemDiv.className = 'd-flex justify-content-between align-items-center mb-1 pb-1 border-bottom';
itemDiv.innerHTML = ` itemDiv.innerHTML = `
<div> <div>
<strong>${item.name}</strong> <strong class="small">${item.name}</strong>
<br> <br>
<small class="text-muted">${item.qty} шт × ${formatMoney(item.price)} руб.</small> <small class="text-muted">${item.qty} шт × ${formatMoney(item.price)} руб.</small>
</div> </div>
<div class="text-end"> <div class="text-end">
<strong>${formatMoney(item.qty * item.price)} руб.</strong> <strong class="small">${formatMoney(item.qty * item.price)} руб.</strong>
</div> </div>
`; `;
container.appendChild(itemDiv); container.appendChild(itemDiv);
@@ -387,13 +416,120 @@ function renderTempKitItems() {
estimatedTotal += item.qty * item.price; estimatedTotal += item.qty * item.price;
}); });
document.getElementById('tempKitEstimatedPrice').textContent = formatMoney(estimatedTotal); // Обновляем все расчеты цен
updatePriceCalculations(estimatedTotal);
} }
// Расчет и обновление всех цен
function updatePriceCalculations(basePrice = null) {
// Если basePrice не передан, пересчитываем из корзины
if (basePrice === null) {
basePrice = 0;
cart.forEach((item, cartKey) => {
if (item.type === 'product') {
basePrice += item.qty * item.price;
}
});
}
// Базовая цена
document.getElementById('tempKitBasePrice').textContent = formatMoney(basePrice) + ' руб.';
// Корректировка
const adjustmentType = document.getElementById('priceAdjustmentType').value;
const adjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue').value) || 0;
let calculatedPrice = basePrice;
if (adjustmentType !== 'none' && adjustmentValue > 0) {
if (adjustmentType === 'increase_percent') {
calculatedPrice = basePrice + (basePrice * adjustmentValue / 100);
} else if (adjustmentType === 'increase_amount') {
calculatedPrice = basePrice + adjustmentValue;
} else if (adjustmentType === 'decrease_percent') {
calculatedPrice = Math.max(0, basePrice - (basePrice * adjustmentValue / 100));
} else if (adjustmentType === 'decrease_amount') {
calculatedPrice = Math.max(0, basePrice - adjustmentValue);
}
}
document.getElementById('tempKitCalculatedPrice').textContent = formatMoney(calculatedPrice) + ' руб.';
// Финальная цена (с учетом sale_price если задана)
const useSalePrice = document.getElementById('useSalePrice').checked;
const salePrice = parseFloat(document.getElementById('salePrice').value) || 0;
let finalPrice = calculatedPrice;
if (useSalePrice && salePrice > 0) {
finalPrice = salePrice;
}
document.getElementById('tempKitFinalPrice').textContent = formatMoney(finalPrice);
}
// Обработчики для полей цены
document.getElementById('priceAdjustmentType').addEventListener('change', function() {
const adjustmentBlock = document.getElementById('adjustmentValueBlock');
if (this.value === 'none') {
adjustmentBlock.style.display = 'none';
document.getElementById('priceAdjustmentValue').value = '0';
} else {
adjustmentBlock.style.display = 'block';
}
updatePriceCalculations();
});
document.getElementById('priceAdjustmentValue').addEventListener('input', function() {
updatePriceCalculations();
});
document.getElementById('useSalePrice').addEventListener('change', function() {
const salePriceBlock = document.getElementById('salePriceBlock');
if (this.checked) {
salePriceBlock.style.display = 'block';
} else {
salePriceBlock.style.display = 'none';
document.getElementById('salePrice').value = '';
}
updatePriceCalculations();
});
document.getElementById('salePrice').addEventListener('input', function() {
updatePriceCalculations();
});
// Обработчик загрузки фото
document.getElementById('tempKitPhoto').addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
if (!file.type.startsWith('image/')) {
alert('Пожалуйста, выберите файл изображения');
this.value = '';
return;
}
// Превью
const reader = new FileReader();
reader.onload = function(event) {
document.getElementById('photoPreviewImg').src = event.target.result;
document.getElementById('photoPreview').style.display = 'block';
};
reader.readAsDataURL(file);
}
});
// Удаление фото
document.getElementById('removePhoto').addEventListener('click', function() {
document.getElementById('tempKitPhoto').value = '';
document.getElementById('photoPreview').style.display = 'none';
document.getElementById('photoPreviewImg').src = '';
});
// Подтверждение создания временного комплекта // Подтверждение создания временного комплекта
document.getElementById('confirmCreateTempKit').onclick = async () => { 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 photoFile = document.getElementById('tempKitPhoto').files[0];
// Валидация // Валидация
if (!kitName) { if (!kitName) {
@@ -422,6 +558,27 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
return; return;
} }
// Получаем данные о ценах
const priceAdjustmentType = document.getElementById('priceAdjustmentType').value;
const priceAdjustmentValue = parseFloat(document.getElementById('priceAdjustmentValue').value) || 0;
const useSalePrice = document.getElementById('useSalePrice').checked;
const salePrice = useSalePrice ? (parseFloat(document.getElementById('salePrice').value) || 0) : 0;
// Формируем FormData для отправки с файлом
const formData = new FormData();
formData.append('kit_name', kitName);
formData.append('showcase_id', showcaseId);
formData.append('description', description);
formData.append('items', JSON.stringify(items));
formData.append('price_adjustment_type', priceAdjustmentType);
formData.append('price_adjustment_value', priceAdjustmentValue);
if (useSalePrice && salePrice > 0) {
formData.append('sale_price', salePrice);
}
if (photoFile) {
formData.append('photo', photoFile);
}
// Отправляем запрос на сервер // Отправляем запрос на сервер
const confirmBtn = document.getElementById('confirmCreateTempKit'); const confirmBtn = document.getElementById('confirmCreateTempKit');
confirmBtn.disabled = true; confirmBtn.disabled = true;
@@ -431,16 +588,10 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
const response = await fetch('/pos/api/create-temp-kit/', { const response = await fetch('/pos/api/create-temp-kit/', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken') 'X-CSRFToken': getCookie('csrftoken')
// Не указываем Content-Type - браузер сам установит multipart/form-data
}, },
body: JSON.stringify({ body: formData
kit_name: kitName,
showcase_id: parseInt(showcaseId),
items: items,
price_adjustment_type: 'none',
price_adjustment_value: 0
})
}); });
const data = await response.json(); const data = await response.json();
@@ -456,9 +607,27 @@ document.getElementById('confirmCreateTempKit').onclick = async () => {
// Очищаем корзину // Очищаем корзину
clearCart(); clearCart();
// Сбрасываем поля формы
document.getElementById('tempKitDescription').value = '';
document.getElementById('tempKitPhoto').value = '';
document.getElementById('photoPreview').style.display = 'none';
document.getElementById('priceAdjustmentType').value = 'none';
document.getElementById('priceAdjustmentValue').value = '0';
document.getElementById('adjustmentValueBlock').style.display = 'none';
document.getElementById('useSalePrice').checked = false;
document.getElementById('salePrice').value = '';
document.getElementById('salePriceBlock').style.display = 'none';
// Закрываем модальное окно // Закрываем модальное окно
const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal')); const modal = bootstrap.Modal.getInstance(document.getElementById('createTempKitModal'));
modal.hide(); modal.hide();
// Обновляем витринные комплекты и переключаемся на вид витрины
isShowcaseView = true;
currentCategoryId = null;
await refreshShowcaseKits();
renderCategories();
renderProducts();
} else { } else {
alert(`Ошибка: ${data.error}`); alert(`Ошибка: ${data.error}`);
} }

View File

@@ -107,7 +107,7 @@
<!-- Modal: Создание временного комплекта на витрину --> <!-- Modal: Создание временного комплекта на витрину -->
<div class="modal fade" id="createTempKitModal" tabindex="-1" aria-labelledby="createTempKitModalLabel" aria-hidden="true"> <div class="modal fade" id="createTempKitModal" tabindex="-1" aria-labelledby="createTempKitModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-xl">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title" id="createTempKitModalLabel"> <h5 class="modal-title" id="createTempKitModalLabel">
@@ -116,32 +116,110 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<!-- Название комплекта --> <div class="row">
<div class="mb-3"> <!-- Левая колонка: основные поля -->
<label for="tempKitName" class="form-label">Название комплекта *</label> <div class="col-md-6">
<input type="text" class="form-control" id="tempKitName" placeholder="Введите название" required> <!-- Название комплекта -->
</div> <div class="mb-3">
<label for="tempKitName" class="form-label">Название комплекта *</label>
<!-- Выбор витрины --> <input type="text" class="form-control" id="tempKitName" placeholder="Введите название" required>
<div class="mb-3"> </div>
<label for="showcaseSelect" class="form-label">Витрина *</label>
<select class="form-select" id="showcaseSelect" required> <!-- Выбор витрины -->
<option value="">Загрузка...</option> <div class="mb-3">
</select> <label for="showcaseSelect" class="form-label">Витрина *</label>
</div> <select class="form-select" id="showcaseSelect" required>
<option value="">Загрузка...</option>
<!-- Список товаров в корзине --> </select>
<div class="mb-3"> </div>
<label class="form-label">Товары в комплекте</label>
<div class="border rounded p-3" id="tempKitItemsList" style="max-height: 300px; overflow-y: auto;"> <!-- Описание -->
<!-- Динамически заполняется через JS --> <div class="mb-3">
<label for="tempKitDescription" class="form-label">Описание (опционально)</label>
<textarea class="form-control" id="tempKitDescription" rows="3" placeholder="Краткое описание комплекта"></textarea>
</div>
<!-- Загрузка фото -->
<div class="mb-3">
<label for="tempKitPhoto" class="form-label">Фото комплекта (опционально)</label>
<input type="file" class="form-control" id="tempKitPhoto" accept="image/*">
<div id="photoPreview" class="mt-2" style="display: none;">
<img id="photoPreviewImg" src="" alt="Preview" class="img-thumbnail" style="max-width: 200px; max-height: 200px;">
<button type="button" class="btn btn-sm btn-danger ms-2" id="removePhoto">
<i class="bi bi-x"></i> Удалить
</button>
</div>
</div>
</div>
<!-- Правая колонка: цены и состав -->
<div class="col-md-6">
<!-- Список товаров в корзине -->
<div class="mb-3">
<label class="form-label">Товары в комплекте</label>
<div class="border rounded p-2" id="tempKitItemsList" style="max-height: 200px; overflow-y: auto; background: #f8f9fa;">
<!-- Динамически заполняется через JS -->
</div>
</div>
<!-- Блок ценообразования -->
<div class="card">
<div class="card-header bg-light">
<strong>Ценообразование</strong>
</div>
<div class="card-body">
<!-- Базовая цена -->
<div class="mb-2">
<small class="text-muted">Базовая цена (сумма компонентов):</small>
<div class="fw-bold" id="tempKitBasePrice">0.00 руб.</div>
</div>
<!-- Корректировка цены -->
<div class="mb-2">
<label for="priceAdjustmentType" class="form-label small">Корректировка цены</label>
<select class="form-select form-select-sm" id="priceAdjustmentType">
<option value="none">Без изменения</option>
<option value="increase_percent">Увеличить на %</option>
<option value="increase_amount">Увеличить на сумму</option>
<option value="decrease_percent">Уменьшить на %</option>
<option value="decrease_amount">Уменьшить на сумму</option>
</select>
</div>
<div class="mb-2" id="adjustmentValueBlock" style="display: none;">
<label for="priceAdjustmentValue" class="form-label small">Значение</label>
<input type="number" class="form-control form-control-sm" id="priceAdjustmentValue"
min="0" step="0.01" value="0" placeholder="0.00">
</div>
<!-- Расчётная цена с корректировкой -->
<div class="mb-2 pb-2 border-bottom">
<small class="text-muted">Расчётная цена:</small>
<div class="fw-bold text-primary" id="tempKitCalculatedPrice">0.00 руб.</div>
</div>
<!-- Ручная финальная цена (sale_price) -->
<div class="mb-0">
<div class="form-check form-switch mb-2">
<input class="form-check-input" type="checkbox" id="useSalePrice">
<label class="form-check-label small" for="useSalePrice">
Установить свою цену (приоритет)
</label>
</div>
<div id="salePriceBlock" style="display: none;">
<input type="number" class="form-control form-control-sm" id="salePrice"
min="0" step="0.01" placeholder="Введите цену">
</div>
</div>
<!-- Итоговая цена продажи -->
<div class="alert alert-success mt-3 mb-0">
<strong>Итоговая цена продажи:</strong><br>
<span class="fs-4" id="tempKitFinalPrice">0.00</span> руб.
</div>
</div>
</div>
</div> </div>
</div>
<!-- Итоговая цена (расчётная) -->
<div class="alert alert-info mb-0">
<strong>Расчётная цена:</strong> <span id="tempKitEstimatedPrice">0.00</span> руб.
<small class="d-block text-muted">Цена будет пересчитана автоматически после создания</small>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -8,5 +8,6 @@ urlpatterns = [
path('', views.pos_terminal, name='terminal'), path('', views.pos_terminal, name='terminal'),
path('api/showcase-items/', views.showcase_items_api, name='showcase-items-api'), path('api/showcase-items/', views.showcase_items_api, name='showcase-items-api'),
path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'), path('api/get-showcases/', views.get_showcases_api, name='get-showcases-api'),
path('api/showcase-kits/', views.get_showcase_kits_api, name='showcase-kits-api'),
path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'), path('api/create-temp-kit/', views.create_temp_kit_to_showcase, name='create-temp-kit-api'),
] ]

View File

@@ -5,7 +5,7 @@ from django.http import JsonResponse
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.db import transaction from django.db import transaction
from django.utils import timezone from django.utils import timezone
from decimal import Decimal from decimal import Decimal, InvalidOperation
import json import json
from products.models import Product, ProductCategory, ProductKit, KitItem from products.models import Product, ProductCategory, ProductKit, KitItem
@@ -184,7 +184,8 @@ def get_showcases_api(request):
'id': s.id, 'id': s.id,
'name': s.name, 'name': s.name,
'warehouse_name': s.warehouse.name, 'warehouse_name': s.warehouse.name,
'warehouse_id': s.warehouse.id 'warehouse_id': s.warehouse.id,
'is_default_warehouse': s.warehouse.is_default # Для автовыбора
} for s in showcases] } for s in showcases]
return JsonResponse({ return JsonResponse({
@@ -193,6 +194,21 @@ def get_showcases_api(request):
}) })
@login_required
@require_http_methods(["GET"])
def get_showcase_kits_api(request):
"""
API endpoint для получения актуального списка витринных комплектов.
Используется для динамического обновления после создания нового букета.
"""
showcase_kits_data = get_showcase_kits_for_pos()
return JsonResponse({
'success': True,
'items': showcase_kits_data
})
@login_required @login_required
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def create_temp_kit_to_showcase(request): def create_temp_kit_to_showcase(request):
@@ -200,26 +216,39 @@ def create_temp_kit_to_showcase(request):
API endpoint для создания временного комплекта из корзины POS API endpoint для создания временного комплекта из корзины POS
и резервирования его на витрину. и резервирования его на витрину.
Ожидаемый payload: Ожидаемый payload (multipart/form-data):
{ - kit_name: Название комплекта
"kit_name": "Название комплекта", - showcase_id: ID витрины
"showcase_id": 1, - items: JSON список [{product_id, quantity}, ...]
"items": [ - description: Описание (опционально)
{"product_id": 1, "quantity": 2.0}, - price_adjustment_type: Тип корректировки (опционально)
{"product_id": 3, "quantity": 1.0} - price_adjustment_value: Значение корректировки (опционально)
], - sale_price: Ручная финальная цена (опционально)
"price_adjustment_type": "none", // optional - photo: Файл изображения (опционально)
"price_adjustment_value": 0 // optional
}
""" """
try: try:
data = json.loads(request.body) # Получаем данные из FormData
kit_name = request.POST.get('kit_name', '').strip()
showcase_id = request.POST.get('showcase_id')
description = request.POST.get('description', '').strip()
items_json = request.POST.get('items', '[]')
price_adjustment_type = request.POST.get('price_adjustment_type', 'none')
price_adjustment_value = Decimal(str(request.POST.get('price_adjustment_value', 0)))
sale_price_str = request.POST.get('sale_price', '')
photo_file = request.FILES.get('photo')
kit_name = data.get('kit_name', '').strip() # Парсим items из JSON
showcase_id = data.get('showcase_id') items = json.loads(items_json)
items = data.get('items', [])
price_adjustment_type = data.get('price_adjustment_type', 'none') # Sale price (опционально)
price_adjustment_value = Decimal(str(data.get('price_adjustment_value', 0))) sale_price = None
if sale_price_str:
try:
sale_price = Decimal(str(sale_price_str))
if sale_price <= 0:
sale_price = None
except (ValueError, InvalidOperation):
sale_price = None
# Валидация # Валидация
if not kit_name: if not kit_name:
@@ -275,10 +304,12 @@ def create_temp_kit_to_showcase(request):
# 1. Создаём ProductKit (is_temporary=True) # 1. Создаём ProductKit (is_temporary=True)
kit = ProductKit.objects.create( kit = ProductKit.objects.create(
name=kit_name, name=kit_name,
description=description,
is_temporary=True, is_temporary=True,
status='active', status='active',
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
) )
# 2. Создаём KitItem для каждого товара из корзины # 2. Создаём KitItem для каждого товара из корзины
@@ -292,7 +323,16 @@ def create_temp_kit_to_showcase(request):
# 3. Пересчитываем цену комплекта # 3. Пересчитываем цену комплекта
kit.recalculate_base_price() kit.recalculate_base_price()
# 4. Резервируем комплект на витрину # 4. Загружаем фото, если есть
if photo_file:
from products.models import ProductKitPhoto
ProductKitPhoto.objects.create(
kit=kit,
image=photo_file,
order=0
)
# 5. Резервируем комплект на витрину
result = ShowcaseManager.reserve_kit_to_showcase( result = ShowcaseManager.reserve_kit_to_showcase(
product_kit=kit, product_kit=kit,
showcase=showcase, showcase=showcase,