feat: Add Product Kit creation and editing functionality with new views and templates.

This commit is contained in:
2026-01-25 00:09:45 +03:00
parent bf399996b8
commit 41e6c33683
3 changed files with 1393 additions and 1232 deletions

View File

@@ -76,7 +76,8 @@
</div> </div>
<!-- Загрузка с устройства --> <!-- Загрузка с устройства -->
<input type="file" name="photos" accept="image/*" multiple class="form-control form-control-sm" id="id_photos"> <input type="file" name="photos" accept="image/*" multiple class="form-control form-control-sm"
id="id_photos">
<div id="photoPreviewContainer" class="mt-2" style="display: none;"> <div id="photoPreviewContainer" class="mt-2" style="display: none;">
<div id="photoPreview" class="row g-1"></div> <div id="photoPreview" class="row g-1"></div>
</div> </div>
@@ -97,7 +98,8 @@
<div class="card-body p-3"> <div class="card-body p-3">
<p class="small text-muted mb-3"> <p class="small text-muted mb-3">
Сгенерируйте привлекательное название для вашего букета автоматически Сгенерируйте привлекательное название для вашего букета автоматически
<br><span class="badge bg-secondary mt-1">В базе: <span id="bouquetNamesCount">{{ bouquet_names_count }}</span> названий</span> <br><span class="badge bg-secondary mt-1">В базе: <span id="bouquetNamesCount">{{
bouquet_names_count }}</span> названий</span>
</p> </p>
<div class="d-flex gap-2 mb-4"> <div class="d-flex gap-2 mb-4">
<button type="button" class="btn btn-outline-primary btn-sm" id="populateNamesBtn"> <button type="button" class="btn btn-outline-primary btn-sm" id="populateNamesBtn">
@@ -111,27 +113,36 @@
<!-- Предложения названий --> <!-- Предложения названий -->
<div class="name-suggestions"> <div class="name-suggestions">
<!-- Строка 1 --> <!-- Строка 1 -->
<div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row" data-name-id=""> <div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row"
data-name-id="">
<span class="text-muted small name-text">-</span> <span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;"> <div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="button" class="btn btn-success btn-xs btn-take-name">Взять</button> <button type="button"
<button type="button" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button> class="btn btn-success btn-xs btn-take-name">Взять</button>
<button type="button"
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
</div> </div>
</div> </div>
<!-- Строка 2 --> <!-- Строка 2 -->
<div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row" data-name-id=""> <div class="d-flex justify-content-between align-items-center py-2 border-bottom name-row"
data-name-id="">
<span class="text-muted small name-text">-</span> <span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;"> <div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="button" class="btn btn-success btn-xs btn-take-name">Взять</button> <button type="button"
<button type="button" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button> class="btn btn-success btn-xs btn-take-name">Взять</button>
<button type="button"
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
</div> </div>
</div> </div>
<!-- Строка 3 --> <!-- Строка 3 -->
<div class="d-flex justify-content-between align-items-center py-2 name-row" data-name-id=""> <div class="d-flex justify-content-between align-items-center py-2 name-row"
data-name-id="">
<span class="text-muted small name-text">-</span> <span class="text-muted small name-text">-</span>
<div class="d-flex gap-1 name-buttons" style="display: none;"> <div class="d-flex gap-1 name-buttons" style="display: none;">
<button type="button" class="btn btn-success btn-xs btn-take-name">Взять</button> <button type="button"
<button type="button" class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button> class="btn btn-success btn-xs btn-take-name">Взять</button>
<button type="button"
class="btn btn-outline-danger btn-xs btn-delete-name">Удалить</button>
</div> </div>
</div> </div>
</div> </div>
@@ -154,8 +165,10 @@
<!-- Базовая цена (отображение) --> <!-- Базовая цена (отображение) -->
<div class="mb-3 p-2 rounded" style="background: #f8f9fa; border-left: 3px solid #6c757d;"> <div class="mb-3 p-2 rounded" style="background: #f8f9fa; border-left: 3px solid #6c757d;">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен компонентов:</span> <span class="text-muted small"><i class="bi bi-calculator"></i> Сумма цен
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00 руб.</span> компонентов:</span>
<span id="basePriceDisplay" class="fw-semibold" style="font-size: 1.1rem;">0.00
руб.</span>
</div> </div>
</div> </div>
@@ -169,13 +182,15 @@
<div class="row g-2"> <div class="row g-2">
<div class="col-6"> <div class="col-6">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="number" id="id_increase_percent" class="form-control" placeholder="%" step="0.01" min="0"> <input type="number" id="id_increase_percent" class="form-control"
placeholder="%" step="0.01" min="0">
<span class="input-group-text">%</span> <span class="input-group-text">%</span>
</div> </div>
</div> </div>
<div class="col-6"> <div class="col-6">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="number" id="id_increase_amount" class="form-control" placeholder="руб" step="0.01" min="0"> <input type="number" id="id_increase_amount" class="form-control"
placeholder="руб" step="0.01" min="0">
<span class="input-group-text">руб</span> <span class="input-group-text">руб</span>
</div> </div>
</div> </div>
@@ -191,13 +206,15 @@
<div class="row g-2"> <div class="row g-2">
<div class="col-6"> <div class="col-6">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="number" id="id_decrease_percent" class="form-control" placeholder="%" step="0.01" min="0"> <input type="number" id="id_decrease_percent" class="form-control"
placeholder="%" step="0.01" min="0">
<span class="input-group-text">%</span> <span class="input-group-text">%</span>
</div> </div>
</div> </div>
<div class="col-6"> <div class="col-6">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<input type="number" id="id_decrease_amount" class="form-control" placeholder="руб" step="0.01" min="0"> <input type="number" id="id_decrease_amount" class="form-control"
placeholder="руб" step="0.01" min="0">
<span class="input-group-text">руб</span> <span class="input-group-text">руб</span>
</div> </div>
</div> </div>
@@ -210,8 +227,10 @@
<!-- Итоговая цена --> <!-- Итоговая цена -->
<div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;"> <div class="p-2 rounded" style="background: #e7f5e7; border-left: 3px solid #198754;">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая цена:</strong></span> <span class="text-dark small"><i class="bi bi-check-circle me-1"></i><strong>Итоговая
<span id="finalPriceDisplay" class="fw-bold" style="font-size: 1.3rem; color: #198754;">0.00 руб.</span> цена:</strong></span>
<span id="finalPriceDisplay" class="fw-bold"
style="font-size: 1.3rem; color: #198754;">0.00 руб.</span>
</div> </div>
</div> </div>
@@ -227,7 +246,8 @@
<h6 class="mb-2 text-muted"><i class="bi bi-tag-discount"></i> Цена со скидкой</h6> <h6 class="mb-2 text-muted"><i class="bi bi-tag-discount"></i> Цена со скидкой</h6>
<label class="form-label small mb-1">{{ form.sale_price.label }}</label> <label class="form-label small mb-1">{{ form.sale_price.label }}</label>
{{ form.sale_price }} {{ form.sale_price }}
<small class="form-text text-muted">Если указана, будет использоваться вместо расчетной цены</small> <small class="form-text text-muted">Если указана, будет использоваться вместо расчетной
цены</small>
{% if form.sale_price.errors %} {% if form.sale_price.errors %}
<div class="text-danger small mt-1">{{ form.sale_price.errors }}</div> <div class="text-danger small mt-1">{{ form.sale_price.errors }}</div>
{% endif %} {% endif %}
@@ -296,7 +316,8 @@
</div> </div>
<!-- Sticky Footer --> <!-- Sticky Footer -->
<div class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm"> <div
class="sticky-bottom bg-white border-top mt-4 p-3 d-flex justify-content-between align-items-center shadow-sm">
<a href="{% url 'products:products-list' %}" class="btn btn-outline-secondary"> <a href="{% url 'products:products-list' %}" class="btn btn-outline-secondary">
Отмена Отмена
</a> </a>
@@ -308,14 +329,14 @@
</div> </div>
<style> <style>
/* Breadcrumbs */ /* Breadcrumbs */
.breadcrumb-sm { .breadcrumb-sm {
font-size: 0.875rem; font-size: 0.875rem;
padding: 0.5rem 0; padding: 0.5rem 0;
} }
/* Крупное поле названия */ /* Крупное поле названия */
#id_name { #id_name {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 500; font-weight: 500;
border: 3px solid #dee2e6; border: 3px solid #dee2e6;
@@ -323,222 +344,229 @@
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
background: #fff; background: #fff;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
} }
#id_name:focus { #id_name:focus {
border-color: #198754; border-color: #198754;
box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.15); box-shadow: 0 0 0 0.25rem rgba(25, 135, 84, 0.15);
outline: 0; outline: 0;
} }
/* Описание */ /* Описание */
#id_description { #id_description {
font-size: 0.95rem; font-size: 0.95rem;
min-height: 80px; min-height: 80px;
} }
/* Компактные чекбоксы */ /* Компактные чекбоксы */
.compact-checkboxes { .compact-checkboxes {
max-height: 200px; max-height: 200px;
overflow-y: auto; overflow-y: auto;
font-size: 0.875rem; font-size: 0.875rem;
} }
.compact-checkboxes ul { .compact-checkboxes ul {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: 0;
} }
.compact-checkboxes li { .compact-checkboxes li {
padding: 0.25rem 0; padding: 0.25rem 0;
} }
.compact-checkboxes label { .compact-checkboxes label {
font-weight: normal; font-weight: normal;
margin-bottom: 0; margin-bottom: 0;
} }
/* Компонент комплекта */ /* Компонент комплекта */
.kititem-form { .kititem-form {
transition: all 0.2s; transition: all 0.2s;
} }
.kititem-form:hover { .kititem-form:hover {
box-shadow: 0 0.125rem 0.25rem rgba(0,0,0,0.075) !important; box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important;
} }
.kititem-form .card-body { .kititem-form .card-body {
background: #fafbfc; background: #fafbfc;
} }
.kititem-form input[type="checkbox"][name$="-DELETE"] { .kititem-form input[type="checkbox"][name$="-DELETE"] {
display: none; display: none;
} }
/* Sticky footer */ /* Sticky footer */
.sticky-bottom { .sticky-bottom {
position: sticky; position: sticky;
bottom: 0; bottom: 0;
z-index: 1020; z-index: 1020;
} }
/* Карточки */ /* Карточки */
.card.border-0 { .card.border-0 {
border-radius: 0.5rem; border-radius: 0.5rem;
} }
/* Лейблы */ /* Лейблы */
.form-label.small { .form-label.small {
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 500; font-weight: 500;
color: #6c757d; color: #6c757d;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
/* Фото превью */ /* Фото превью */
#photoPreview .col-4, #photoPreview .col-4,
#photoPreview .col-md-3, #photoPreview .col-md-3,
#photoPreview .col-lg-2 { #photoPreview .col-lg-2 {
padding: 0.25rem; padding: 0.25rem;
} }
#photoPreview .card { #photoPreview .card {
border-radius: 0.375rem; border-radius: 0.375rem;
} }
#photoPreview img { #photoPreview img {
height: 100px; height: 100px;
object-fit: cover; object-fit: cover;
} }
/* Alert компактный */ /* Alert компактный */
.alert-sm { .alert-sm {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
margin-bottom: 0; margin-bottom: 0;
} }
/* Анимация */ /* Анимация */
@keyframes slideIn { @keyframes slideIn {
from { from {
opacity: 0; opacity: 0;
transform: translateY(-10px); transform: translateY(-10px);
} }
to { to {
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }
.kititem-form.new-item { .kititem-form.new-item {
animation: slideIn 0.3s ease-out; animation: slideIn 0.3s ease-out;
} }
/* Разделитель ИЛИ */ /* Разделитель ИЛИ */
.kit-item-separator { .kit-item-separator {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
min-height: 40px; min-height: 40px;
} }
.kit-item-separator .separator-text { .kit-item-separator .separator-text {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
color: #adb5bd; color: #adb5bd;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 1px; letter-spacing: 1px;
} }
.kit-item-separator .separator-help { .kit-item-separator .separator-help {
font-size: 0.85rem; font-size: 0.85rem;
color: #6c757d; color: #6c757d;
cursor: help; cursor: help;
} }
.kit-item-separator .separator-help:hover { .kit-item-separator .separator-help:hover {
color: #0d6efd; color: #0d6efd;
} }
/* Стили для генератора названий */ /* Стили для генератора названий */
.cursor-pointer { .cursor-pointer {
cursor: pointer; cursor: pointer;
} }
.card-header[data-bs-toggle="collapse"]:hover { .card-header[data-bs-toggle="collapse"]:hover {
background-color: #f8f9fa; background-color: #f8f9fa;
} }
.card-header[data-bs-toggle="collapse"] .bi-chevron-down { .card-header[data-bs-toggle="collapse"] .bi-chevron-down {
transition: transform 0.2s ease; transition: transform 0.2s ease;
} }
.collapse.show .card-header[data-bs-toggle="collapse"] .bi-chevron-down { .collapse.show .card-header[data-bs-toggle="collapse"] .bi-chevron-down {
transform: rotate(180deg); transform: rotate(180deg);
} }
/* Кнопки очень маленького размера */ /* Кнопки очень маленького размера */
.btn-xs { .btn-xs {
padding: 0.125rem 0.25rem; padding: 0.125rem 0.25rem;
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1; line-height: 1;
border-radius: 0.2rem; border-radius: 0.2rem;
} }
.btn-xs:hover { .btn-xs:hover {
transform: translateY(-1px); transform: translateY(-1px);
} }
/* Стили для списка предложений */ /* Стили для списка предложений */
.name-suggestions { .name-suggestions {
background-color: #f8f9fa; background-color: #f8f9fa;
border-radius: 0.375rem; border-radius: 0.375rem;
overflow: hidden; overflow: hidden;
} }
.name-suggestions .text-muted { .name-suggestions .text-muted {
font-size: 0.875rem; font-size: 0.875rem;
} }
.name-suggestions .border-bottom { .name-suggestions .border-bottom {
border-color: #e9ecef !important; border-color: #e9ecef !important;
} }
/* Стили для полей корректировки цены */ /* Стили для полей корректировки цены */
#id_increase_percent:disabled, #id_increase_percent:disabled,
#id_increase_amount:disabled, #id_increase_amount:disabled,
#id_decrease_percent:disabled, #id_decrease_percent:disabled,
#id_decrease_amount:disabled { #id_decrease_amount:disabled {
background-color: #e9ecef; background-color: #e9ecef;
color: #6c757d; color: #6c757d;
cursor: not-allowed; cursor: not-allowed;
opacity: 0.6; opacity: 0.6;
} }
#id_increase_percent.is-invalid, #id_increase_percent.is-invalid,
#id_increase_amount.is-invalid, #id_increase_amount.is-invalid,
#id_decrease_percent.is-invalid, #id_decrease_percent.is-invalid,
#id_decrease_amount.is-invalid { #id_decrease_amount.is-invalid {
border-color: #dc3545; border-color: #dc3545;
box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25); box-shadow: 0 0 0 0.2rem rgba(220, 53, 69, 0.25);
} }
/* Адаптивность */ /* Адаптивность */
@media (max-width: 991px) { @media (max-width: 991px) {
.col-lg-8, .col-lg-4 {
.col-lg-8,
.col-lg-4 {
max-width: 100%; max-width: 100%;
} }
} }
</style> </style>
<!-- Select2 инициализация --> <!-- Select2 инициализация -->
{% include 'products/includes/select2-product-init.html' %} {% include 'products/includes/select2-product-init.html' %}
{{ selected_products|default:"{}"|json_script:"selected-products-data" }}
{{ selected_variants|default:"{}"|json_script:"selected-variants-data" }}
{{ selected_sales_units|default:"{}"|json_script:"selected-sales-units-data" }}
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function () {
// ========== УПРАВЛЕНИЕ ЦЕНОЙ КОМПЛЕКТА ========== // ========== УПРАВЛЕНИЕ ЦЕНОЙ КОМПЛЕКТА ==========
const increasePercentInput = document.getElementById('id_increase_percent'); const increasePercentInput = document.getElementById('id_increase_percent');
const increaseAmountInput = document.getElementById('id_increase_amount'); const increaseAmountInput = document.getElementById('id_increase_amount');
@@ -550,6 +578,7 @@ document.addEventListener('DOMContentLoaded', function() {
const finalPriceDisplay = document.getElementById('finalPriceDisplay'); const finalPriceDisplay = document.getElementById('finalPriceDisplay');
let basePrice = 0; let basePrice = 0;
let activeUpdates = 0; // Счетчик активных обновлений
// Кэш цен товаров для быстрого доступа // Кэш цен товаров для быстрого доступа
const priceCache = {}; const priceCache = {};
@@ -743,6 +772,19 @@ document.addEventListener('DOMContentLoaded', function() {
// Функция для обновления списка единиц продажи при выборе товара // Функция для обновления списка единиц продажи при выборе товара
async function updateSalesUnitsOptions(salesUnitSelect, productValue) { async function updateSalesUnitsOptions(salesUnitSelect, productValue) {
activeUpdates++; // Начинаем обновление
try {
// Сохраняем текущее значение перед очисткой (важно для редактирования и копирования)
let targetValue = salesUnitSelect.value;
// Если значения нет, проверяем preloaded данные (фаллбэк для инициализации)
if (!targetValue) {
const fieldName = salesUnitSelect.name;
if (selectedSalesUnits && selectedSalesUnits[fieldName]) {
targetValue = selectedSalesUnits[fieldName].id;
}
}
// Очищаем текущие опции // Очищаем текущие опции
salesUnitSelect.innerHTML = '<option value="">---------</option>'; salesUnitSelect.innerHTML = '<option value="">---------</option>';
salesUnitSelect.disabled = true; salesUnitSelect.disabled = true;
@@ -761,7 +803,6 @@ document.addEventListener('DOMContentLoaded', function() {
} }
if (isNaN(productId) || productId <= 0) { if (isNaN(productId) || productId <= 0) {
console.warn('updateSalesUnitsOptions: invalid product id', productValue);
return; return;
} }
@@ -783,17 +824,29 @@ document.addEventListener('DOMContentLoaded', function() {
salesUnitSelect.appendChild(option); salesUnitSelect.appendChild(option);
}); });
salesUnitSelect.disabled = false; salesUnitSelect.disabled = false;
// Обновляем Select2
// Восстанавливаем значение
if (targetValue) {
$(salesUnitSelect).val(targetValue).trigger('change');
} else {
// Обновляем Select2 без значения
$(salesUnitSelect).trigger('change'); $(salesUnitSelect).trigger('change');
} }
} }
}
} catch (error) { } catch (error) {
console.error('Error fetching sales units:', error); console.error('Error fetching sales units:', error);
} }
} finally {
activeUpdates--; // Завершаем обновление
if (activeUpdates === 0) {
calculateFinalPrice();
}
}
} }
// Обновляем data-product-id и загружаем цену при выборе товара // Обновляем data-product-id и загружаем цену при выборе товара
$('[name$="-product"]').on('select2:select', async function() { $('[name$="-product"]').on('select2:select', async function () {
const form = $(this).closest('.kititem-form'); const form = $(this).closest('.kititem-form');
if (this.value) { if (this.value) {
// Извлекаем числовой ID из "product_123" // Извлекаем числовой ID из "product_123"
@@ -809,9 +862,9 @@ document.addEventListener('DOMContentLoaded', function() {
if (salesUnitSelect) { if (salesUnitSelect) {
await updateSalesUnitsOptions(salesUnitSelect, this.value); await updateSalesUnitsOptions(salesUnitSelect, this.value);
} }
calculateFinalPrice();
} }
}).on('select2:unselect', function() { calculateFinalPrice();
}).on('select2:unselect', function () {
const form = $(this).closest('.kititem-form'); const form = $(this).closest('.kititem-form');
// Очищаем список единиц продажи // Очищаем список единиц продажи
const salesUnitSelect = form.find('[name$="-sales_unit"]')[0]; const salesUnitSelect = form.find('[name$="-sales_unit"]')[0];
@@ -885,6 +938,11 @@ document.addEventListener('DOMContentLoaded', function() {
// Функция для расчета финальной цены // Функция для расчета финальной цены
async function calculateFinalPrice() { async function calculateFinalPrice() {
// Если идут обновления - не считаем, ждем их завершения
if (activeUpdates > 0) {
return;
}
// Получаем базовую цену (сумма всех компонентов) // Получаем базовую цену (сумма всех компонентов)
let newBasePrice = 0; let newBasePrice = 0;
const formsContainer = document.getElementById('kititem-forms'); const formsContainer = document.getElementById('kititem-forms');
@@ -1060,8 +1118,8 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}); });
// Инициальный расчет (асинхронно) // Инициальный расчет не нужен, так как он выполняется по событиям изменения полей
calculateFinalPrice(); // и после завершения загрузки единиц продажи
// ========== SELECT2 ИНИЦИАЛИЗАЦИЯ ========== // ========== SELECT2 ИНИЦИАЛИЗАЦИЯ ==========
function initSelect2(element, type, preloadedData) { function initSelect2(element, type, preloadedData) {
@@ -1072,23 +1130,23 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
const selectedProducts = {{ selected_products|default:"{}"|safe }}; const selectedProducts = JSON.parse(document.getElementById('selected-products-data').textContent || '{}');
const selectedVariants = {{ selected_variants|default:"{}"|safe }}; const selectedVariants = JSON.parse(document.getElementById('selected-variants-data').textContent || '{}');
const selectedSalesUnits = {{ selected_sales_units|default:"{}"|safe }}; const selectedSalesUnits = JSON.parse(document.getElementById('selected-sales-units-data').textContent || '{}');
$('[name$="-product"]').each(function() { $('[name$="-product"]').each(function () {
const fieldName = $(this).attr('name'); const fieldName = $(this).attr('name');
const preloadedData = selectedProducts[fieldName] || null; const preloadedData = selectedProducts[fieldName] || null;
initSelect2(this, 'product', preloadedData); initSelect2(this, 'product', preloadedData);
// Обработчик уже добавлен выше (строки 673-701) // Обработчик уже добавлен выше (строки 673-701)
}); });
$('[name$="-variant_group"]').each(function() { $('[name$="-variant_group"]').each(function () {
const fieldName = $(this).attr('name'); const fieldName = $(this).attr('name');
const preloadedData = selectedVariants[fieldName] || null; const preloadedData = selectedVariants[fieldName] || null;
initSelect2(this, 'variant', preloadedData); initSelect2(this, 'variant', preloadedData);
// При выборе варианта очищаем единицу продажи // При выборе варианта очищаем единицу продажи
$(this).on('select2:select', function() { $(this).on('select2:select', function () {
const form = $(this).closest('.kititem-form'); const form = $(this).closest('.kititem-form');
const salesUnitSelect = form.find('[name$="-sales_unit"]')[0]; const salesUnitSelect = form.find('[name$="-sales_unit"]')[0];
if (salesUnitSelect) { if (salesUnitSelect) {
@@ -1099,7 +1157,7 @@ document.addEventListener('DOMContentLoaded', function() {
}).on('select2:unselect', calculateFinalPrice); }).on('select2:unselect', calculateFinalPrice);
}); });
$('[name$="-sales_unit"]').each(function() { $('[name$="-sales_unit"]').each(function () {
const fieldName = $(this).attr('name'); const fieldName = $(this).attr('name');
const preloadedData = selectedSalesUnits[fieldName] || null; const preloadedData = selectedSalesUnits[fieldName] || null;
initSelect2(this, 'sales_unit', preloadedData); initSelect2(this, 'sales_unit', preloadedData);
@@ -1169,7 +1227,7 @@ document.addEventListener('DOMContentLoaded', function() {
if (quantityInput) { if (quantityInput) {
quantityInput.addEventListener('change', calculateFinalPrice); quantityInput.addEventListener('change', calculateFinalPrice);
// Выделяем весь текст при фокусе на поле количества // Выделяем весь текст при фокусе на поле количества
quantityInput.addEventListener('focus', function() { quantityInput.addEventListener('focus', function () {
this.select(); this.select();
}); });
} }
@@ -1238,7 +1296,7 @@ document.addEventListener('DOMContentLoaded', function() {
initSelect2(salesUnitSelect, 'sales_unit'); initSelect2(salesUnitSelect, 'sales_unit');
// Добавляем обработчики для новой формы (как в основном коде) // Добавляем обработчики для новой формы (как в основном коде)
$(productSelect).on('select2:select', async function() { $(productSelect).on('select2:select', async function () {
const form = $(this).closest('.kititem-form'); const form = $(this).closest('.kititem-form');
if (this.value) { if (this.value) {
let numericId = this.value; let numericId = this.value;
@@ -1252,7 +1310,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
} }
calculateFinalPrice(); calculateFinalPrice();
}).on('select2:unselect', function() { }).on('select2:unselect', function () {
if (salesUnitSelect) { if (salesUnitSelect) {
salesUnitSelect.innerHTML = '<option value="">---------</option>'; salesUnitSelect.innerHTML = '<option value="">---------</option>';
$(salesUnitSelect).trigger('change'); $(salesUnitSelect).trigger('change');
@@ -1260,7 +1318,7 @@ document.addEventListener('DOMContentLoaded', function() {
calculateFinalPrice(); calculateFinalPrice();
}); });
$(variantSelect).on('select2:select', function() { $(variantSelect).on('select2:select', function () {
if (salesUnitSelect) { if (salesUnitSelect) {
salesUnitSelect.innerHTML = '<option value="">---------</option>'; salesUnitSelect.innerHTML = '<option value="">---------</option>';
$(salesUnitSelect).trigger('change'); $(salesUnitSelect).trigger('change');
@@ -1290,7 +1348,7 @@ document.addEventListener('DOMContentLoaded', function() {
let selectedFiles = []; let selectedFiles = [];
if (photoInput) { if (photoInput) {
photoInput.addEventListener('change', function(e) { photoInput.addEventListener('change', function (e) {
selectedFiles = Array.from(e.target.files); selectedFiles = Array.from(e.target.files);
if (selectedFiles.length > 0) { if (selectedFiles.length > 0) {
@@ -1299,7 +1357,7 @@ document.addEventListener('DOMContentLoaded', function() {
selectedFiles.forEach((file, index) => { selectedFiles.forEach((file, index) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = function(event) { reader.onload = function (event) {
const col = document.createElement('div'); const col = document.createElement('div');
col.className = 'col-4 col-md-3 col-lg-2'; col.className = 'col-4 col-md-3 col-lg-2';
col.innerHTML = ` col.innerHTML = `
@@ -1321,7 +1379,7 @@ document.addEventListener('DOMContentLoaded', function() {
}); });
} }
window.removePhoto = function(index) { window.removePhoto = function (index) {
selectedFiles.splice(index, 1); selectedFiles.splice(index, 1);
const dataTransfer = new DataTransfer(); const dataTransfer = new DataTransfer();
selectedFiles.forEach(file => dataTransfer.items.add(file)); selectedFiles.forEach(file => dataTransfer.items.add(file));
@@ -1413,7 +1471,7 @@ document.addEventListener('DOMContentLoaded', function() {
// Обработчик для кнопки "Пополнить базу названиям<D18F><D0BC>" // Обработчик для кнопки "Пополнить базу названиям<D18F><D0BC>"
const populateNamesBtn = document.getElementById('populateNamesBtn'); const populateNamesBtn = document.getElementById('populateNamesBtn');
if (populateNamesBtn) { if (populateNamesBtn) {
populateNamesBtn.addEventListener('click', async function() { populateNamesBtn.addEventListener('click', async function () {
const originalHTML = populateNamesBtn.innerHTML; const originalHTML = populateNamesBtn.innerHTML;
populateNamesBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Пополнение...'; populateNamesBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Пополнение...';
populateNamesBtn.disabled = true; populateNamesBtn.disabled = true;
@@ -1425,7 +1483,7 @@ document.addEventListener('DOMContentLoaded', function() {
'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value, 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value,
'Content-Type': 'application/x-www-form-urlencoded' 'Content-Type': 'application/x-www-form-urlencoded'
}, },
body: new URLSearchParams({'count': 100}) body: new URLSearchParams({ 'count': 100 })
}); });
const data = await response.json(); const data = await response.json();
@@ -1512,7 +1570,7 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.btn-take-name').forEach(button => { document.querySelectorAll('.btn-take-name').forEach(button => {
// Проверяем, был ли уже добавлен обработчик // Проверяем, был ли уже добавлен обработчик
if (!button.dataset.handlerAttached) { if (!button.dataset.handlerAttached) {
button.addEventListener('click', async function() { button.addEventListener('click', async function () {
const row = this.closest('.name-row'); const row = this.closest('.name-row');
const nameText = row.querySelector('.name-text').textContent; const nameText = row.querySelector('.name-text').textContent;
const nameId = row.getAttribute('data-name-id'); const nameId = row.getAttribute('data-name-id');
@@ -1546,7 +1604,7 @@ document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.btn-delete-name').forEach(button => { document.querySelectorAll('.btn-delete-name').forEach(button => {
// Проверяем, был ли уже добавлен обработчик // Проверяем, был ли уже добавлен обработчик
if (!button.dataset.handlerAttached) { if (!button.dataset.handlerAttached) {
button.addEventListener('click', async function() { button.addEventListener('click', async function () {
const row = this.closest('.name-row'); const row = this.closest('.name-row');
const nameId = row.getAttribute('data-name-id'); const nameId = row.getAttribute('data-name-id');
@@ -1632,7 +1690,7 @@ document.addEventListener('DOMContentLoaded', function() {
} }
// Инициализация обработчиков кнопок // Инициализация обработчиков кнопок
document.addEventListener('click', function(e) { document.addEventListener('click', function (e) {
if (e.target.classList.contains('btn-take-name') || e.target.classList.contains('btn-delete-name')) { if (e.target.classList.contains('btn-take-name') || e.target.classList.contains('btn-delete-name')) {
attachNameRowHandlers(); attachNameRowHandlers();
} }
@@ -1641,7 +1699,7 @@ document.addEventListener('DOMContentLoaded', function() {
// ========== ВАЛИДАЦИЯ ПЕРЕД ОТПРАВКОЙ ========== // ========== ВАЛИДАЦИЯ ПЕРЕД ОТПРАВКОЙ ==========
const kitForm = document.querySelector('form[method="post"]'); const kitForm = document.querySelector('form[method="post"]');
if (kitForm) { if (kitForm) {
kitForm.addEventListener('submit', function(e) { kitForm.addEventListener('submit', function (e) {
const formsContainer = document.getElementById('kititem-forms'); const formsContainer = document.getElementById('kititem-forms');
if (formsContainer) { if (formsContainer) {
const allForms = formsContainer.querySelectorAll('.kititem-form'); const allForms = formsContainer.querySelectorAll('.kititem-form');
@@ -1671,6 +1729,6 @@ document.addEventListener('DOMContentLoaded', function() {
allSelects.forEach(select => select.disabled = false); allSelects.forEach(select => select.disabled = false);
}); });
} }
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -506,6 +506,9 @@
<a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary"> <a href="{% url 'products:productkit-detail' object.pk %}" class="btn btn-outline-secondary">
Отмена Отмена
</a> </a>
<a href="{% url 'products:productkit-create' %}?copy_from={{ object.pk }}" class="btn btn-warning text-white mx-2">
<i class="bi bi-files me-1"></i>Копировать комплект
</a>
<button type="submit" class="btn btn-primary px-4"> <button type="submit" class="btn btn-primary px-4">
<i class="bi bi-check-circle me-1"></i>Сохранить изменения <i class="bi bi-check-circle me-1"></i>Сохранить изменения
</button> </button>

View File

@@ -9,7 +9,7 @@ from django.shortcuts import redirect
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from user_roles.mixins import ManagerOwnerRequiredMixin from user_roles.mixins import ManagerOwnerRequiredMixin
from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName from ..models import ProductKit, ProductCategory, ProductTag, ProductKitPhoto, Product, ProductVariantGroup, BouquetName, ProductSalesUnit
from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate from ..forms import ProductKitForm, KitItemFormSetCreate, KitItemFormSetUpdate
from .utils import handle_photos from .utils import handle_photos
@@ -97,6 +97,28 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
form_class = ProductKitForm form_class = ProductKitForm
template_name = 'products/productkit_create.html' template_name = 'products/productkit_create.html'
def get_initial(self):
initial = super().get_initial()
copy_id = self.request.GET.get('copy_from')
if copy_id:
try:
kit = ProductKit.objects.get(pk=copy_id)
initial.update({
'name': f"{kit.name} (Копия)",
'description': kit.description,
'short_description': kit.short_description,
'categories': list(kit.categories.values_list('pk', flat=True)),
'tags': list(kit.tags.values_list('pk', flat=True)),
'sale_price': kit.sale_price,
'price_adjustment_type': kit.price_adjustment_type,
'price_adjustment_value': kit.price_adjustment_value,
'external_category': kit.external_category,
'status': 'active', # Default to active for new kits
})
except ProductKit.DoesNotExist:
pass
return initial
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
""" """
Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов. Обрабатываем POST данные и очищаем ID товаров/комплектов от префиксов.
@@ -132,7 +154,6 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem') context['kititem_formset'] = KitItemFormSetCreate(self.request.POST, prefix='kititem')
# При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2 # При ошибке валидации: извлекаем выбранные товары для предзагрузки в Select2
from ..models import Product, ProductVariantGroup, ProductSalesUnit
selected_products = {} selected_products = {}
selected_variants = {} selected_variants = {}
selected_sales_units = {} selected_sales_units = {}
@@ -194,9 +215,88 @@ class ProductKitCreateView(LoginRequiredMixin, ManagerOwnerRequiredMixin, Create
context['selected_products'] = selected_products context['selected_products'] = selected_products
context['selected_variants'] = selected_variants context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units context['selected_sales_units'] = selected_sales_units
else:
# COPY KIT LOGIC
copy_id = self.request.GET.get('copy_from')
initial_items = []
selected_products = {}
selected_variants = {}
selected_sales_units = {}
if copy_id:
try:
source_kit = ProductKit.objects.get(pk=copy_id)
for item in source_kit.kit_items.all():
item_data = {
'quantity': item.quantity,
# Delete flag is false by default
}
form_prefix = f"kititem-{len(initial_items)}"
if item.product:
item_data['product'] = item.product
# Select2 prefill
product = item.product
text = product.name
if product.sku:
text += f" ({product.sku})"
actual_price = product.sale_price if product.sale_price else product.price
selected_products[f"{form_prefix}-product"] = {
'id': product.id,
'text': text,
'price': str(product.price) if product.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.sales_unit:
item_data['sales_unit'] = item.sales_unit
# Select2 prefill
sales_unit = item.sales_unit
text = f"{sales_unit.name} ({sales_unit.product.name})"
actual_price = sales_unit.sale_price if sales_unit.sale_price else sales_unit.price
selected_sales_units[f"{form_prefix}-sales_unit"] = {
'id': sales_unit.id,
'text': text,
'price': str(sales_unit.price) if sales_unit.price else None,
'actual_price': str(actual_price) if actual_price else '0'
}
if item.variant_group:
item_data['variant_group'] = item.variant_group
# Select2 prefill
variant_group = ProductVariantGroup.objects.prefetch_related(
'items__product'
).get(id=item.variant_group.id)
variant_price = variant_group.price or 0
count = variant_group.items.count()
selected_variants[f"{form_prefix}-variant_group"] = {
'id': variant_group.id,
'text': f"{variant_group.name} ({count} вариантов)",
'price': str(variant_price),
'actual_price': str(variant_price),
'type': 'variant',
'count': count
}
initial_items.append(item_data)
except ProductKit.DoesNotExist:
pass
if initial_items:
context['kititem_formset'] = KitItemFormSetCreate(
prefix='kititem',
initial=initial_items
)
context['kititem_formset'].extra = len(initial_items)
else: else:
context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem') context['kititem_formset'] = KitItemFormSetCreate(prefix='kititem')
# Pass Select2 data to context
context['selected_products'] = selected_products
context['selected_variants'] = selected_variants
context['selected_sales_units'] = selected_sales_units
# Количество названий букетов в базе # Количество названий букетов в базе
context['bouquet_names_count'] = BouquetName.objects.count() context['bouquet_names_count'] = BouquetName.objects.count()