Optimize order statuses list page with compact card layout
- Changed from table to card-based design for better space efficiency - Reduced padding and margins to fit 15+ statuses on screen without scrolling - Minimized font sizes and icon sizes for compact display - Added proper styling for edit and delete buttons with hover effects - Improved visual hierarchy with color indicators and badges - Maintained all functionality while improving UX 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -4,152 +4,226 @@
|
|||||||
{% block title %}Статусы заказов{% endblock %}
|
{% block title %}Статусы заказов{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="container-fluid mt-4">
|
<style>
|
||||||
<div class="row mb-4">
|
.status-card {
|
||||||
<div class="col-md-6">
|
border: none;
|
||||||
<h1>Статусы заказов</h1>
|
border-left: 4px solid;
|
||||||
<p class="text-muted">Управление статусами для заказов вашего магазина</p>
|
padding: 10px 14px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card:hover {
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card-left {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-color {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .btn {
|
||||||
|
padding: 5px 10px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1.5px solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .btn i {
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .btn-outline-primary {
|
||||||
|
color: #0d6efd;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .btn-outline-primary:hover {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .btn-outline-danger {
|
||||||
|
color: #dc3545;
|
||||||
|
border-color: #dc3545;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .btn-outline-danger:hover {
|
||||||
|
background-color: #dc3545;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .btn-outline-secondary:disabled {
|
||||||
|
color: #6c757d;
|
||||||
|
border-color: #6c757d;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-section {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left p {
|
||||||
|
margin: 2px 0 0 0;
|
||||||
|
color: #6c757d;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header-section {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-card-left {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions {
|
||||||
|
width: 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-actions .btn {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="container mt-4" style="max-width: 900px;">
|
||||||
|
<div class="header-section">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Статусы</h1>
|
||||||
|
<p>{{ statuses|length }} статусов в системе</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 text-end">
|
<div>
|
||||||
<a href="{% url 'orders:status_create' %}" class="btn btn-primary">
|
<a href="{% url 'orders:status_create' %}" class="btn btn-primary" style="font-weight: 600;">
|
||||||
<i class="fas fa-plus"></i> Создать новый статус
|
<i class="fas fa-plus"></i> Создать
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
<div class="row">
|
{% for message in messages %}
|
||||||
<div class="col-md-12">
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert" style="margin-bottom: 16px;">
|
||||||
{% for message in messages %}
|
{{ message }}
|
||||||
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
{{ message }}
|
|
||||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="row">
|
<div>
|
||||||
<div class="col-md-12">
|
{% for status in statuses %}
|
||||||
<div class="card">
|
<div class="status-card" style="border-left-color: {{ status.color }};">
|
||||||
<div class="table-responsive">
|
<div class="status-card-left">
|
||||||
<table class="table table-hover mb-0">
|
<div class="status-color" style="background-color: {{ status.color }};"></div>
|
||||||
<thead class="table-light">
|
<div class="status-info">
|
||||||
<tr>
|
<p class="status-name" title="{{ status.name }}">{{ status.name }}</p>
|
||||||
<th style="width: 50px;">№</th>
|
<div class="status-meta">
|
||||||
<th style="width: 200px;">Название</th>
|
<span class="status-badge bg-secondary">{{ status.code }}</span>
|
||||||
<th style="width: 150px;">Код</th>
|
{% if status.is_system %}
|
||||||
<th style="width: 150px;">Тип</th>
|
<span class="status-badge bg-info">sys</span>
|
||||||
<th style="width: 100px;">Исход сделки</th>
|
{% endif %}
|
||||||
<th style="width: 80px;">Цвет</th>
|
{% if status.is_positive_end %}
|
||||||
<th style="width: 80px;">Заказов</th>
|
<span class="status-badge bg-success">✓</span>
|
||||||
<th style="width: 150px;">Действия</th>
|
{% elif status.is_negative_end %}
|
||||||
</tr>
|
<span class="status-badge bg-danger">✗</span>
|
||||||
</thead>
|
{% endif %}
|
||||||
<tbody>
|
{% if status.orders_count > 0 %}
|
||||||
{% for status in statuses %}
|
<span class="status-badge bg-light text-dark">{{ status.orders_count }} заказ{{ status.orders_count|pluralize:",а,ов" }}</span>
|
||||||
<tr>
|
{% endif %}
|
||||||
<td>
|
</div>
|
||||||
<span class="badge bg-secondary">{{ status.order }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<strong>{{ status.name }}</strong>
|
|
||||||
{% if status.description %}
|
|
||||||
<br>
|
|
||||||
<small class="text-muted">{{ status.description|truncatewords:10 }}</small>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<code>{{ status.code }}</code>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if status.is_system %}
|
|
||||||
<span class="badge bg-info">Системный</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-success">Пользовательский</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{% if status.is_positive_end %}
|
|
||||||
<span class="badge bg-success">✓ Успешный</span>
|
|
||||||
{% elif status.is_negative_end %}
|
|
||||||
<span class="badge bg-danger">✗ Отрицательный</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="badge bg-secondary">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div style="width: 40px; height: 30px; background-color: {{ status.color }}; border-radius: 4px; border: 1px solid #ccc;" title="{{ status.color }}"></div>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<span class="badge bg-light text-dark">{{ status.orders_count }}</span>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div class="d-flex gap-2">
|
|
||||||
<a href="{% url 'orders:status_edit' status.pk %}" class="btn btn-sm btn-outline-primary" title="Редактировать">
|
|
||||||
<i class="fas fa-edit"></i> Редактировать
|
|
||||||
</a>
|
|
||||||
{% if not status.is_system and status.orders_count == 0 %}
|
|
||||||
<a href="{% url 'orders:status_delete' status.pk %}" class="btn btn-sm btn-outline-danger" title="Удалить">
|
|
||||||
<i class="fas fa-trash"></i> Удалить
|
|
||||||
</a>
|
|
||||||
{% else %}
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" disabled title="{% if status.is_system %}Системный статус{% else %}В статусе есть заказы{% endif %}">
|
|
||||||
<i class="fas fa-trash"></i> Удалить
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% empty %}
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" class="text-center text-muted py-4">
|
|
||||||
<i class="fas fa-inbox"></i> Статусы не найдены
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="status-actions">
|
||||||
{% if is_paginated %}
|
<a href="{% url 'orders:status_edit' status.pk %}" class="btn btn-outline-primary" title="Редактировать">
|
||||||
<nav aria-label="Page navigation" class="mt-3">
|
<i class="fas fa-edit"></i> Редактировать
|
||||||
<ul class="pagination justify-content-center">
|
</a>
|
||||||
{% if page_obj.has_previous %}
|
{% if not status.is_system and status.orders_count == 0 %}
|
||||||
<li class="page-item">
|
<a href="{% url 'orders:status_delete' status.pk %}" class="btn btn-outline-danger" title="Удалить">
|
||||||
<a class="page-link" href="?page=1">Первая</a>
|
<i class="fas fa-trash"></i> Удалить
|
||||||
</li>
|
</a>
|
||||||
<li class="page-item">
|
{% else %}
|
||||||
<a class="page-link" href="?page={{ page_obj.previous_page_number }}">Предыдущая</a>
|
<button class="btn btn-outline-secondary" disabled title="{% if status.is_system %}Системный статус{% else %}В статусе есть заказы{% endif %}">
|
||||||
</li>
|
<i class="fas fa-trash"></i> Удалить
|
||||||
{% endif %}
|
</button>
|
||||||
|
{% endif %}
|
||||||
{% for num in page_obj.paginator.page_range %}
|
</div>
|
||||||
{% if page_obj.number == num %}
|
|
||||||
<li class="page-item active">
|
|
||||||
<span class="page-link">{{ num }}</span>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ num }}">{{ num }}</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
{% if page_obj.has_next %}
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ page_obj.next_page_number }}">Следующая</a>
|
|
||||||
</li>
|
|
||||||
<li class="page-item">
|
|
||||||
<a class="page-link" href="?page={{ page_obj.paginator.num_pages }}">Последняя</a>
|
|
||||||
</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% empty %}
|
||||||
|
<div style="text-align: center; padding: 40px 20px; color: #6c757d;">
|
||||||
|
<i class="fas fa-inbox" style="font-size: 32px; margin-bottom: 12px; opacity: 0.5;"></i>
|
||||||
|
<p style="font-size: 14px;">Статусы не найдены</p>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -764,7 +764,7 @@ ConfigurableKitOptionFormSetCreate = inlineformset_factory(
|
|||||||
form=ConfigurableKitOptionForm,
|
form=ConfigurableKitOptionForm,
|
||||||
formset=BaseConfigurableKitOptionFormSet,
|
formset=BaseConfigurableKitOptionFormSet,
|
||||||
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute
|
fields=['kit', 'is_default'], # Убрали 'attributes' - заполняется через ConfigurableKitOptionAttribute
|
||||||
extra=1, # Показать 1 пустую форму
|
extra=0, # Не требуем пустые формы (варианты скрыты в UI)
|
||||||
can_delete=True,
|
can_delete=True,
|
||||||
min_num=0,
|
min_num=0,
|
||||||
validate_min=False,
|
validate_min=False,
|
||||||
|
|||||||
@@ -189,80 +189,8 @@ input[name*="DELETE"] {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Варианты (комплекты) -->
|
<!-- Management form для option_formset (скрыт) -->
|
||||||
<div class="card border-0 shadow-sm mb-3">
|
{{ option_formset.management_form }}
|
||||||
<div class="card-header bg-white">
|
|
||||||
<h5 class="mb-0">Варианты (комплекты)</h5>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
{{ option_formset.management_form }}
|
|
||||||
|
|
||||||
{% if option_formset.non_form_errors %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
{{ option_formset.non_form_errors }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div id="optionFormsetContainer">
|
|
||||||
{% for form in option_formset %}
|
|
||||||
<div class="option-form border rounded p-3 mb-3" style="background: #f8f9fa;">
|
|
||||||
{{ form.id }}
|
|
||||||
{% if form.instance.pk %}
|
|
||||||
<input type="hidden" name="options-{{ forloop.counter0 }}-id" value="{{ form.instance.pk }}">
|
|
||||||
{% endif %}
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label small">{{ form.kit.label }}</label>
|
|
||||||
{{ form.kit }}
|
|
||||||
{% if form.kit.errors %}
|
|
||||||
<div class="text-danger small">{{ form.kit.errors.0 }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Динамически генерируемые поля для атрибутов варианта -->
|
|
||||||
{% for field in form %}
|
|
||||||
{% if "attribute_" in field.name %}
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small">{{ field.label }}</label>
|
|
||||||
{{ field }}
|
|
||||||
{% if field.errors %}
|
|
||||||
<div class="text-danger small">{{ field.errors.0 }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small d-block">{{ form.is_default.label }}</label>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
{{ form.is_default }}
|
|
||||||
<label class="form-check-label" for="{{ form.is_default.id_for_label }}">
|
|
||||||
<span class="default-switch-label">{% if form.instance.is_default %}Да{% else %}Нет{% endif %}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
{% if form.is_default.errors %}
|
|
||||||
<div class="text-danger small">{{ form.is_default.errors.0 }}</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<div class="col-md-2">
|
|
||||||
{% if option_formset.can_delete %}
|
|
||||||
<label class="form-label small d-block"> </label>
|
|
||||||
{{ form.DELETE }}
|
|
||||||
<label for="{{ form.DELETE.id_for_label }}" class="btn btn-sm btn-outline-danger d-block">
|
|
||||||
<i class="bi bi-trash"></i> Удалить
|
|
||||||
</label>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="addOptionBtn">
|
|
||||||
<i class="bi bi-plus-circle me-1"></i>Добавить вариант
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
@@ -296,169 +224,6 @@ input[name*="DELETE"] {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Добавление новых форм вариантов
|
|
||||||
document.getElementById('addOptionBtn').addEventListener('click', function() {
|
|
||||||
const container = document.getElementById('optionFormsetContainer');
|
|
||||||
const totalForms = document.querySelector('[name="options-TOTAL_FORMS"]');
|
|
||||||
const formIdx = parseInt(totalForms.value);
|
|
||||||
|
|
||||||
// Получаем первую существующую форму чтобы узнать какие атрибуты нужны
|
|
||||||
const firstForm = container.querySelector('.option-form');
|
|
||||||
let attributesHtml = '';
|
|
||||||
|
|
||||||
if (firstForm) {
|
|
||||||
// Ищем поля атрибутов в первой форме
|
|
||||||
const attributeFields = firstForm.querySelectorAll('select[data-attribute-name]');
|
|
||||||
attributeFields.forEach(field => {
|
|
||||||
const attrName = field.getAttribute('data-attribute-name');
|
|
||||||
const options = field.innerHTML;
|
|
||||||
const colWidth = attributeFields.length > 2 ? 'col-md-1.5' : 'col-md-2';
|
|
||||||
attributesHtml += `
|
|
||||||
<div class="${colWidth}">
|
|
||||||
<label class="form-label small">${attrName}</label>
|
|
||||||
<select name="options-${formIdx}-attribute_${attrName}"
|
|
||||||
id="id_options-${formIdx}-attribute_${attrName}"
|
|
||||||
class="form-select"
|
|
||||||
data-attribute-name="${attrName}">
|
|
||||||
<option value="">---------</option>
|
|
||||||
${options}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаём новую форму HTML
|
|
||||||
const newFormHtml = `
|
|
||||||
<div class="option-form border rounded p-3 mb-3" style="background: #f8f9fa;">
|
|
||||||
<div class="row g-2">
|
|
||||||
<div class="col-md-3">
|
|
||||||
<label class="form-label small">Комплект</label>
|
|
||||||
<select name="options-${formIdx}-kit" id="id_options-${formIdx}-kit" class="form-select">
|
|
||||||
<option value="">---------</option>
|
|
||||||
{% for kit in option_formset.empty_form.fields.kit.queryset %}
|
|
||||||
<option value="{{ kit.id }}">{{ kit.name }}{% if kit.sku %} ({{ kit.sku }}){% endif %}</option>
|
|
||||||
{% endfor %}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
${attributesHtml}
|
|
||||||
<div class="col-md-2">
|
|
||||||
<label class="form-label small d-block">По умолчанию</label>
|
|
||||||
<div class="form-check form-switch">
|
|
||||||
<input type="checkbox" name="options-${formIdx}-is_default"
|
|
||||||
id="id_options-${formIdx}-is_default"
|
|
||||||
class="form-check-input is-default-switch" role="switch">
|
|
||||||
<label class="form-check-label" for="id_options-${formIdx}-is_default">
|
|
||||||
<span class="default-switch-label">Нет</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-md-1.5">
|
|
||||||
<label class="form-label small d-block"> </label>
|
|
||||||
<input type="checkbox" name="options-${formIdx}-DELETE"
|
|
||||||
id="id_options-${formIdx}-DELETE"
|
|
||||||
style="display:none;">
|
|
||||||
<label for="id_options-${formIdx}-DELETE" class="btn btn-sm btn-outline-danger d-block">
|
|
||||||
<i class="bi bi-trash"></i> Удалить
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
container.insertAdjacentHTML('beforeend', newFormHtml);
|
|
||||||
totalForms.value = formIdx + 1;
|
|
||||||
|
|
||||||
// Переинициализируем логику switch после добавления новой формы
|
|
||||||
initDefaultSwitches();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Скрытие удаленных форм
|
|
||||||
document.addEventListener('change', function(e) {
|
|
||||||
if (e.target.type === 'checkbox' && e.target.name && e.target.name.includes('DELETE')) {
|
|
||||||
const form = e.target.closest('.option-form');
|
|
||||||
if (e.target.checked) {
|
|
||||||
form.style.opacity = '0.5';
|
|
||||||
form.style.textDecoration = 'line-through';
|
|
||||||
} else {
|
|
||||||
form.style.opacity = '1';
|
|
||||||
form.style.textDecoration = 'none';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Логика для switch "По умолчанию"
|
|
||||||
function initDefaultSwitches() {
|
|
||||||
const container = document.getElementById('optionFormsetContainer');
|
|
||||||
|
|
||||||
// Функция для обновления текста label
|
|
||||||
function updateSwitchLabel(switchInput) {
|
|
||||||
const label = switchInput.closest('.form-check').querySelector('.default-switch-label');
|
|
||||||
if (label) {
|
|
||||||
label.textContent = switchInput.checked ? 'Да' : 'Нет';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Функция для проверки и установки единственного варианта по умолчанию
|
|
||||||
function ensureSingleDefault() {
|
|
||||||
const visibleSwitches = Array.from(container.querySelectorAll('.is-default-switch')).filter(sw => {
|
|
||||||
const form = sw.closest('.option-form');
|
|
||||||
const deleteCheckbox = form.querySelector('input[name*="DELETE"]');
|
|
||||||
return !deleteCheckbox || !deleteCheckbox.checked;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Если только один вариант - включаем его автоматически
|
|
||||||
if (visibleSwitches.length === 1) {
|
|
||||||
visibleSwitches[0].checked = true;
|
|
||||||
visibleSwitches[0].disabled = true;
|
|
||||||
updateSwitchLabel(visibleSwitches[0]);
|
|
||||||
} else {
|
|
||||||
// Если вариантов несколько - убираем disabled
|
|
||||||
visibleSwitches.forEach(sw => {
|
|
||||||
sw.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Проверяем, есть ли хотя бы один включенный
|
|
||||||
const hasChecked = visibleSwitches.some(sw => sw.checked);
|
|
||||||
if (!hasChecked && visibleSwitches.length > 0) {
|
|
||||||
// Если ни один не включен, включаем первый
|
|
||||||
visibleSwitches[0].checked = true;
|
|
||||||
updateSwitchLabel(visibleSwitches[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Обработчик изменения switch
|
|
||||||
container.addEventListener('change', function(e) {
|
|
||||||
if (e.target.classList.contains('is-default-switch')) {
|
|
||||||
if (e.target.checked) {
|
|
||||||
// Выключаем все остальные
|
|
||||||
const allSwitches = container.querySelectorAll('.is-default-switch');
|
|
||||||
allSwitches.forEach(sw => {
|
|
||||||
if (sw !== e.target) {
|
|
||||||
sw.checked = false;
|
|
||||||
updateSwitchLabel(sw);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
updateSwitchLabel(e.target);
|
|
||||||
ensureSingleDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
// При изменении DELETE тоже проверяем
|
|
||||||
if (e.target.name && e.target.name.includes('DELETE')) {
|
|
||||||
ensureSingleDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Инициализация при загрузке
|
|
||||||
ensureSingleDefault();
|
|
||||||
container.querySelectorAll('.is-default-switch').forEach(updateSwitchLabel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Запускаем инициализацию
|
|
||||||
initDefaultSwitches();
|
|
||||||
|
|
||||||
// === Управление параметрами товара (карточный интерфейс) ===
|
// === Управление параметрами товара (карточный интерфейс) ===
|
||||||
|
|
||||||
// Функция для добавления нового поля значения параметра с выбором ProductKit
|
// Функция для добавления нового поля значения параметра с выбором ProductKit
|
||||||
|
|||||||
@@ -163,7 +163,28 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView):
|
|||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
if not option_formset.is_valid():
|
if not option_formset.is_valid():
|
||||||
messages.error(self.request, 'Пожалуйста, исправьте ошибки в вариантах.')
|
# Логирование ошибок formset
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Option formset errors: {option_formset.errors}")
|
||||||
|
logger.error(f"Option formset non-form errors: {option_formset.non_form_errors()}")
|
||||||
|
|
||||||
|
# Показываем детальные ошибки
|
||||||
|
error_msg = 'Ошибки в вариантах:\n'
|
||||||
|
for i, form_errors in enumerate(option_formset.errors):
|
||||||
|
if form_errors:
|
||||||
|
error_msg += f' Вариант {i+1}: {form_errors}\n'
|
||||||
|
if option_formset.non_form_errors():
|
||||||
|
error_msg += f' Общие ошибки: {option_formset.non_form_errors()}\n'
|
||||||
|
|
||||||
|
messages.error(self.request, error_msg)
|
||||||
|
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
|
||||||
|
|
||||||
|
# Валидация что каждый вариант имеет выбранный комплект
|
||||||
|
validation_errors = self._validate_variant_kits(option_formset)
|
||||||
|
if validation_errors:
|
||||||
|
for error in validation_errors:
|
||||||
|
messages.error(self.request, error)
|
||||||
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
|
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
|
||||||
|
|
||||||
if not attribute_formset.is_valid():
|
if not attribute_formset.is_valid():
|
||||||
@@ -297,6 +318,48 @@ class ConfigurableKitProductCreateView(LoginRequiredMixin, CreateView):
|
|||||||
|
|
||||||
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
|
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
|
||||||
|
|
||||||
|
def _validate_variant_kits(self, option_formset):
|
||||||
|
"""
|
||||||
|
Валидация что каждый вариант имеет выбранный комплект.
|
||||||
|
Возвращает список ошибок (пустой список если нет ошибок).
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for idx, option_form in enumerate(option_formset):
|
||||||
|
# Пропускаем удаленные или пустые формы
|
||||||
|
if not option_form.cleaned_data or self._should_delete_form(option_form, option_formset):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Получаем kit_id из POST данных (он там должен быть установлен JavaScript'ом)
|
||||||
|
kit_id = self.request.POST.get(f'options-{idx}-kit', '').strip()
|
||||||
|
|
||||||
|
if not kit_id:
|
||||||
|
# Пытаемся получить из cleaned_data
|
||||||
|
kit_id = option_form.cleaned_data.get('kit')
|
||||||
|
|
||||||
|
if not kit_id:
|
||||||
|
# Если у варианта есть выбранные атрибуты, но нет комплекта - это ошибка
|
||||||
|
has_attributes = any(
|
||||||
|
option_form.cleaned_data.get(k)
|
||||||
|
for k in option_form.cleaned_data.keys()
|
||||||
|
if k.startswith('attribute_')
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_attributes:
|
||||||
|
# Собираем названия выбранных атрибутов для сообщения об ошибке
|
||||||
|
selected_attrs = [
|
||||||
|
str(option_form.cleaned_data.get(k))
|
||||||
|
for k in option_form.cleaned_data.keys()
|
||||||
|
if k.startswith('attribute_') and option_form.cleaned_data.get(k)
|
||||||
|
]
|
||||||
|
errors.append(
|
||||||
|
f'Вариант {idx + 1} ({", ".join(selected_attrs)}): '
|
||||||
|
f'не выбран комплект. Пожалуйста, выберите значения атрибутов которые '
|
||||||
|
f'привязаны к одному комплекту.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _should_delete_form(form, formset):
|
def _should_delete_form(form, formset):
|
||||||
"""Проверить должна ли форма быть удалена"""
|
"""Проверить должна ли форма быть удалена"""
|
||||||
@@ -378,7 +441,28 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
return self.form_invalid(form)
|
return self.form_invalid(form)
|
||||||
|
|
||||||
if not option_formset.is_valid():
|
if not option_formset.is_valid():
|
||||||
messages.error(self.request, 'Пожалуйста, исправьте ошибки в вариантах.')
|
# Логирование ошибок formset
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
logger.error(f"Option formset errors: {option_formset.errors}")
|
||||||
|
logger.error(f"Option formset non-form errors: {option_formset.non_form_errors()}")
|
||||||
|
|
||||||
|
# Показываем детальные ошибки
|
||||||
|
error_msg = 'Ошибки в вариантах:\n'
|
||||||
|
for i, form_errors in enumerate(option_formset.errors):
|
||||||
|
if form_errors:
|
||||||
|
error_msg += f' Вариант {i+1}: {form_errors}\n'
|
||||||
|
if option_formset.non_form_errors():
|
||||||
|
error_msg += f' Общие ошибки: {option_formset.non_form_errors()}\n'
|
||||||
|
|
||||||
|
messages.error(self.request, error_msg)
|
||||||
|
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
|
||||||
|
|
||||||
|
# Валидация что каждый вариант имеет выбранный комплект
|
||||||
|
validation_errors = self._validate_variant_kits(option_formset)
|
||||||
|
if validation_errors:
|
||||||
|
for error in validation_errors:
|
||||||
|
messages.error(self.request, error)
|
||||||
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
|
return self.render_to_response(self.get_context_data(form=form, option_formset=option_formset, attribute_formset=attribute_formset))
|
||||||
|
|
||||||
if not attribute_formset.is_valid():
|
if not attribute_formset.is_valid():
|
||||||
@@ -511,6 +595,48 @@ class ConfigurableKitProductUpdateView(LoginRequiredMixin, UpdateView):
|
|||||||
|
|
||||||
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
|
ConfigurableKitProductAttribute.objects.create(**create_kwargs)
|
||||||
|
|
||||||
|
def _validate_variant_kits(self, option_formset):
|
||||||
|
"""
|
||||||
|
Валидация что каждый вариант имеет выбранный комплект.
|
||||||
|
Возвращает список ошибок (пустой список если нет ошибок).
|
||||||
|
"""
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for idx, option_form in enumerate(option_formset):
|
||||||
|
# Пропускаем удаленные или пустые формы
|
||||||
|
if not option_form.cleaned_data or self._should_delete_form(option_form, option_formset):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Получаем kit_id из POST данных (он там должен быть установлен JavaScript'ом)
|
||||||
|
kit_id = self.request.POST.get(f'options-{idx}-kit', '').strip()
|
||||||
|
|
||||||
|
if not kit_id:
|
||||||
|
# Пытаемся получить из cleaned_data
|
||||||
|
kit_id = option_form.cleaned_data.get('kit')
|
||||||
|
|
||||||
|
if not kit_id:
|
||||||
|
# Если у варианта есть выбранные атрибуты, но нет комплекта - это ошибка
|
||||||
|
has_attributes = any(
|
||||||
|
option_form.cleaned_data.get(k)
|
||||||
|
for k in option_form.cleaned_data.keys()
|
||||||
|
if k.startswith('attribute_')
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_attributes:
|
||||||
|
# Собираем названия выбранных атрибутов для сообщения об ошибке
|
||||||
|
selected_attrs = [
|
||||||
|
str(option_form.cleaned_data.get(k))
|
||||||
|
for k in option_form.cleaned_data.keys()
|
||||||
|
if k.startswith('attribute_') and option_form.cleaned_data.get(k)
|
||||||
|
]
|
||||||
|
errors.append(
|
||||||
|
f'Вариант {idx + 1} ({", ".join(selected_attrs)}): '
|
||||||
|
f'не выбран комплект. Пожалуйста, выберите значения атрибутов которые '
|
||||||
|
f'привязаны к одному комплекту.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return errors
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _should_delete_form(form, formset):
|
def _should_delete_form(form, formset):
|
||||||
"""Проверить должна ли форма быть удалена"""
|
"""Проверить должна ли форма быть удалена"""
|
||||||
|
|||||||
Reference in New Issue
Block a user