Защита удаления заказов и улучшение интерфейса клиентов

Orders:
- Удаление разрешено только для черновиков (draft)
- Запрет удаления заказов с оплатой (amount_paid > 0)
- Кнопка "Удалить" скрыта для недопустимых заказов

Customers:
- Inline-редактирование полей клиента
- Улучшен дизайн карточки клиента
- Добавлена история заказов и кошелька

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-28 23:59:57 +03:00
parent 6c1b1c4aa2
commit 07829f867b
7 changed files with 684 additions and 438 deletions

View File

@@ -6,12 +6,15 @@
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<h1>Клиент: {{ customer.full_name }}</h1>
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0"><i class="bi bi-person-badge text-primary"></i> <span id="customer-title">{{ customer.full_name }}</span></h2>
<div>
<a href="{% url 'customers:customer-update' customer.pk %}" class="btn btn-primary">Редактировать</a>
<a href="{% url 'customers:customer-delete' customer.pk %}" class="btn btn-danger">Удалить</a>
<a href="{% url 'customers:customer-list' %}" class="btn btn-secondary">Назад к списку</a>
<a href="{% url 'customers:customer-delete' customer.pk %}" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Удалить
</a>
<a href="{% url 'customers:customer-list' %}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Назад к списку
</a>
</div>
</div>
</div>
@@ -20,67 +23,161 @@
<div class="row">
<!-- Customer Info -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Информация о клиенте</h5>
<div class="card mb-4 shadow-sm">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="bi bi-person-circle text-primary"></i> Информация о клиенте</h5>
</div>
<div class="card-body">
<table class="table table-borderless">
<tr>
<th>Имя:</th>
<td>{{ customer.full_name }}</td>
</tr>
<tr>
<th>Email:</th>
<td>{{ customer.email|default:"Не указано" }}</td>
</tr>
<tr>
<th>Телефон:</th>
<td>{{ customer.phone|default:"Не указано" }}</td>
</tr>
<tr>
<th>Сумма всех успешных заказов:</th>
<td><strong>{{ total_orders_sum|floatformat:2 }} руб.</strong></td>
</tr>
<tr>
<th>Сумма заказов за последний год:</th>
<td><strong>{{ last_year_orders_sum|floatformat:2 }} руб.</strong></td>
</tr>
<tr>
<th>Общий долг по активным заказам:</th>
<table class="table table-borderless mb-0" id="customer-info-table" data-customer-id="{{ customer.pk }}">
<colgroup>
<col style="width: 40%;">
<col style="width: 50%;">
<col style="width: 10%;">
</colgroup>
<tbody>
<!-- Основная информация -->
<tr data-field="name">
<td class="text-muted"><i class="bi bi-person-fill text-primary"></i> Имя:</td>
<td>
{% if total_debt > 0 %}
<span class="text-danger fw-bold">{{ total_debt|floatformat:2 }} руб.</span>
<small class="text-muted">(Кол-во заказов: {{ active_orders_count }})</small>
{% else %}
<span class="text-success">0.00 руб.</span>
{% endif %}
<span class="field-value fw-bold">{{ customer.name|default:"—" }}</span>
<input type="text" class="field-input form-control form-control-sm d-none" value="{{ customer.name|default:'' }}">
<div class="field-actions d-inline-block ms-2 d-none">
<button class="btn btn-sm btn-success save-btn py-0 px-1" title="Сохранить"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary cancel-btn py-0 px-1" title="Отменить"><i class="bi bi-x-lg"></i></button>
<span class="save-status ms-1"></span>
</div>
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<button class="btn btn-sm btn-outline-secondary copy-btn" title="Копировать" data-copy-value="{{ customer.name|default:'' }}">
<i class="bi bi-copy"></i>
</button>
<button class="btn btn-sm btn-outline-primary edit-btn" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
</div>
</td>
</tr>
<tr data-field="phone">
<td class="text-muted"><i class="bi bi-telephone-fill text-success"></i> Телефон:</td>
<td>
<span class="field-value">{{ customer.phone|default:"—" }}</span>
<input type="tel" class="field-input form-control form-control-sm d-none" value="{{ customer.phone|default:'' }}" placeholder="+375...">
<div class="field-actions d-inline-block ms-2 d-none">
<button class="btn btn-sm btn-success save-btn py-0 px-1" title="Сохранить"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary cancel-btn py-0 px-1" title="Отменить"><i class="bi bi-x-lg"></i></button>
<span class="save-status ms-1"></span>
</div>
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<button class="btn btn-sm btn-outline-secondary copy-btn" title="Копировать" data-copy-value="{{ customer.phone|default:'' }}">
<i class="bi bi-copy"></i>
</button>
<button class="btn btn-sm btn-outline-primary edit-btn" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
</div>
</td>
</tr>
<tr data-field="email">
<td class="text-muted"><i class="bi bi-envelope-fill text-info"></i> Email:</td>
<td>
<span class="field-value">{{ customer.email|default:"—" }}</span>
<input type="email" class="field-input form-control form-control-sm d-none" value="{{ customer.email|default:'' }}">
<div class="field-actions d-inline-block ms-2 d-none">
<button class="btn btn-sm btn-success save-btn py-0 px-1" title="Сохранить"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary cancel-btn py-0 px-1" title="Отменить"><i class="bi bi-x-lg"></i></button>
<span class="save-status ms-1"></span>
</div>
</td>
<td class="text-end">
<div class="d-flex gap-1 justify-content-end">
<button class="btn btn-sm btn-outline-secondary copy-btn" title="Копировать" data-copy-value="{{ customer.email|default:'' }}">
<i class="bi bi-copy"></i>
</button>
<button class="btn btn-sm btn-outline-primary edit-btn" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
</div>
</td>
</tr>
<tr data-field="notes">
<td class="text-muted"><i class="bi bi-card-text text-warning"></i> Заметки:</td>
<td>
<span class="field-value" style="white-space: pre-wrap;">{{ customer.notes|default:"—" }}</span>
<textarea class="field-input form-control form-control-sm d-none" rows="2">{{ customer.notes|default:'' }}</textarea>
<div class="field-actions d-inline-block ms-2 d-none">
<button class="btn btn-sm btn-success save-btn py-0 px-1" title="Сохранить"><i class="bi bi-check-lg"></i></button>
<button class="btn btn-sm btn-outline-secondary cancel-btn py-0 px-1" title="Отменить"><i class="bi bi-x-lg"></i></button>
<span class="save-status ms-1"></span>
</div>
</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-primary edit-btn" title="Редактировать">
<i class="bi bi-pencil"></i>
</button>
</td>
</tr>
<!-- Разделитель -->
<tr>
<td colspan="3"><hr class="my-2"></td>
</tr>
<!-- Финансовая информация -->
<tr>
<td class="text-muted"><i class="bi bi-cash-stack text-success"></i> Все успешные заказы:</td>
<td colspan="2">
<span class="badge bg-success">{{ total_orders_sum|floatformat:2 }} руб.</span>
</td>
</tr>
<tr>
<th>Заметки:</th>
<td>{{ customer.notes|default:"Нет" }}</td>
<td class="text-muted"><i class="bi bi-calendar-check text-info"></i> За последний год:</td>
<td colspan="2">
<span class="badge bg-info">{{ last_year_orders_sum|floatformat:2 }} руб.</span>
</td>
</tr>
<tr>
<th>Дата создания:</th>
<td>{{ customer.created_at|date:"d.m.Y H:i" }}</td>
<td class="text-muted"><i class="bi bi-exclamation-triangle-fill text-danger"></i> Общий долг:</td>
<td colspan="2">
{% if total_debt > 0 %}
<span class="badge bg-danger">{{ total_debt|floatformat:2 }} руб.</span>
<small class="text-muted ms-2">(Заказов: {{ active_orders_count }})</small>
{% else %}
<span class="badge bg-success">0.00 руб.</span>
{% endif %}
</td>
</tr>
<!-- Разделитель -->
<tr>
<td colspan="3"><hr class="my-2"></td>
</tr>
<!-- Системная информация -->
<tr>
<td class="text-muted small"><i class="bi bi-clock-history"></i> Дата создания:</td>
<td colspan="2" class="small">{{ customer.created_at|date:"d.m.Y H:i" }}</td>
</tr>
<tr>
<th>Дата обновления:</th>
<td>{{ customer.updated_at|date:"d.m.Y H:i" }}</td>
<td class="text-muted small"><i class="bi bi-arrow-clockwise"></i> Последнее изменение:</td>
<td colspan="2" class="small"><span id="updated-at">{{ customer.updated_at|date:"d.m.Y H:i" }}</span></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Каналы связи -->
<!-- Правая колонка: Каналы связи + История заказов + История кошелька -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Каналы связи</h5>
<!-- Каналы связи -->
<div class="card mb-4 shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-chat-dots text-success"></i> Каналы связи</h5>
<button class="btn btn-sm btn-success" data-bs-toggle="modal" data-bs-target="#addChannelModal">
<i class="bi bi-plus"></i> Добавить
<i class="bi bi-plus-circle"></i> Добавить
</button>
</div>
<div class="card-body">
@@ -112,12 +209,17 @@
{% if channel.is_primary %}<span class="badge bg-warning text-dark ms-1">основной</span>{% endif %}
{% if channel.notes %}<small class="text-muted d-block mt-1">{{ channel.notes }}</small>{% endif %}
</div>
<div class="d-flex gap-1">
<button class="btn btn-sm btn-outline-secondary copy-btn" title="Копировать" data-copy-value="{{ channel.value }}">
<i class="bi bi-copy"></i>
</button>
<form method="post" action="{% url 'customers:delete-contact-channel' channel.pk %}" class="d-inline">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-outline-danger" onclick="return confirm('Удалить канал?')">
<i class="bi bi-trash"></i>
</button>
</form>
</div>
</li>
{% endfor %}
</ul>
@@ -126,35 +228,202 @@
{% endif %}
</div>
</div>
<!-- История заказов -->
<div class="card mb-4 shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<button class="btn btn-link text-start text-decoration-none p-0 d-flex align-items-center flex-grow-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#ordersHistoryCollapse"
aria-expanded="false"
aria-controls="ordersHistoryCollapse"
style="border: none; background: none;">
<h5 class="mb-0 me-2"><i class="bi bi-cart-check text-primary"></i> История заказов</h5>
<span class="badge bg-primary">{{ orders_page.paginator.count }}</span>
</button>
<a href="{% url 'orders:order-create' %}?customer={{ customer.pk }}"
class="btn btn-sm btn-success ms-2">
<i class="bi bi-plus-circle"></i> Новый
</a>
</div>
<div class="collapse" id="ordersHistoryCollapse">
<div class="card-body p-0">
{% if orders_page %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th></th>
<th>Дата</th>
<th>Статус</th>
<th>Сумма</th>
<th>Остаток</th>
<th></th>
</tr>
</thead>
<tbody>
{% for order in orders_page %}
<tr {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}class="table-warning"{% endif %}>
<td><strong>#{{ order.order_number }}</strong></td>
<td><small>{{ order.created_at|date:"d.m.y" }}</small></td>
<td>
{% if order.status %}
{% if order.status.code == 'draft' %}
<span class="badge bg-secondary">Черновик</span>
{% elif order.status.code == 'pending' %}
<span class="badge bg-warning">Ожидает</span>
{% elif order.status.code == 'in_production' %}
<span class="badge bg-info">В пр-ве</span>
{% elif order.status.code == 'ready' %}
<span class="badge bg-primary">Готов</span>
{% elif order.status.code == 'delivered' %}
<span class="badge bg-success">Доставлен</span>
{% elif order.status.code == 'cancelled' %}
<span class="badge bg-danger">Отменён</span>
{% else %}
<span class="badge bg-secondary">{{ order.status.name }}</span>
{% endif %}
{% else %}
<span class="badge bg-secondary">-</span>
{% endif %}
</td>
<td><strong>{{ order.total_amount|floatformat:2 }}</strong></td>
<td>
{% if order.status and order.status.is_negative_end %}
{% if order.amount_paid > 0 %}
<span class="badge bg-warning text-dark">
<i class="bi bi-exclamation-triangle"></i> {{ order.amount_paid|floatformat:2 }}
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
{% elif order.amount_due > 0 %}
<span class="text-danger fw-bold">{{ order.amount_due|floatformat:2 }}</span>
{% else %}
<span class="text-success">0.00</span>
{% endif %}
</td>
<td class="text-end">
<a href="{% url 'orders:order-detail' order.order_number %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Алерт о необходимости возврата -->
{% if refund_amount > 0 %}
<div class="col-md-12">
<div class="alert alert-warning d-flex justify-content-between align-items-center mb-4" role="alert">
<div>
<h5 class="alert-heading mb-2">
<i class="bi bi-exclamation-triangle-fill"></i> Требуется возврат средств
</h5>
<p class="mb-0">
Клиент имеет отменённые заказы с внесённой оплатой.
Общая сумма к возврату: <strong>{{ refund_amount|floatformat:2 }} руб.</strong>
</p>
</div>
<div>
<span class="badge bg-warning text-dark" style="font-size: 1.2em;">
{{ refund_amount|floatformat:2 }} руб.
</span>
</div>
</div>
</div>
<!-- Пагинация -->
{% if orders_page.has_other_pages %}
<div class="p-3 bg-light border-top">
<nav aria-label="Навигация по заказам">
<ul class="pagination pagination-sm justify-content-center mb-0">
{% if orders_page.has_previous %}
<li class="page-item">
<a class="page-link" href="?page={{ orders_page.previous_page_number }}#ordersHistoryCollapse">«</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">
{{ orders_page.number }} / {{ orders_page.paginator.num_pages }}
</span>
</li>
{% if orders_page.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ orders_page.next_page_number }}#ordersHistoryCollapse">»</a>
</li>
{% endif %}
</ul>
</nav>
</div>
{% endif %}
{% else %}
<p class="text-muted mb-0 p-3">У клиента пока нет заказов.</p>
{% endif %}
</div>
</div>
</div>
<!-- История транзакций кошелька -->
<div class="card mb-4 shadow-sm">
<div class="card-header bg-light">
<button class="btn btn-link w-100 text-start text-decoration-none p-0 d-flex justify-content-between align-items-center"
type="button"
data-bs-toggle="collapse"
data-bs-target="#walletHistoryCollapse"
aria-expanded="false"
aria-controls="walletHistoryCollapse">
<div class="d-flex align-items-center">
<h5 class="mb-0 me-2"><i class="bi bi-clock-history text-info"></i> История кошелька</h5>
<span class="badge bg-primary">{{ wallet_transactions|length }}</span>
</div>
<i class="bi bi-chevron-down"></i>
</button>
</div>
<div class="collapse" id="walletHistoryCollapse">
<div class="card-body p-0">
{% if wallet_transactions %}
<div class="table-responsive">
<table class="table table-sm table-hover mb-0">
<thead class="table-light">
<tr>
<th>Дата</th>
<th>Тип</th>
<th>Сумма</th>
<th>Описание</th>
<th>Заказ</th>
</tr>
</thead>
<tbody>
{% for transaction in wallet_transactions %}
<tr>
<td><small>{{ transaction.created_at|date:"d.m.y H:i" }}</small></td>
<td>
{% if transaction.transaction_type == 'deposit' %}
<span class="badge bg-success">Пополн.</span>
{% elif transaction.transaction_type == 'spend' %}
<span class="badge bg-danger">Списан.</span>
{% else %}
<span class="badge bg-warning">Корр.</span>
{% endif %}
</td>
<td>
{% if transaction.transaction_type == 'deposit' or transaction.transaction_type == 'adjustment' and transaction.amount > 0 %}
<span class="text-success fw-bold">+{{ transaction.amount|floatformat:2 }}</span>
{% else %}
<span class="text-danger fw-bold">-{{ transaction.amount|floatformat:2 }}</span>
{% endif %}
</td>
<td><small>{{ transaction.description|default:"-"|truncatewords:5 }}</small></td>
<td>
{% if transaction.order %}
<a href="{% url 'orders:order-detail' transaction.order.order_number %}" class="btn btn-sm btn-outline-primary py-0">
#{{ transaction.order.order_number }}
</a>
{% else %}
-
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0 p-3">История транзакций пуста.</p>
{% endif %}
</div>
</div>
</div>
<!-- Операции с кошельком -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Операции с кошельком клиента</h5>
<div class="card mb-4 shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="bi bi-wallet2 text-warning"></i> Операции с кошельком клиента</h5>
<span>
{% if customer.wallet_balance > 0 %}
<span class="badge bg-success" style="font-size: 1.1em;">{{ customer.wallet_balance|floatformat:2 }} руб.</span>
@@ -229,264 +498,34 @@
</div>
</div>
<div class="alert alert-info mb-0 mt-3">
<i class="bi bi-info-circle"></i> Все операции логируются в истории ниже.
<i class="bi bi-info-circle"></i> Все операции логируются в истории выше.
</div>
</div>
</div>
</div>
<!-- Конец правой колонки -->
<!-- История транзакций кошелька -->
<!-- Алерт о необходимости возврата -->
{% if refund_amount > 0 %}
<div class="col-md-12">
<div class="card mb-4">
<div class="card-header">
<button class="btn btn-link w-100 text-start text-decoration-none p-0 d-flex justify-content-between align-items-center"
type="button"
data-bs-toggle="collapse"
data-bs-target="#walletHistoryCollapse"
aria-expanded="false"
aria-controls="walletHistoryCollapse">
<h5 class="mb-0">История кошелька (последние 20)</h5>
<div class="alert alert-warning shadow-sm d-flex justify-content-between align-items-center mb-4" role="alert">
<div>
<span class="badge bg-primary me-2">{{ wallet_transactions|length }}</span>
<i class="bi bi-chevron-down"></i>
<h5 class="alert-heading mb-2">
<i class="bi bi-exclamation-triangle-fill"></i> Требуется возврат средств
</h5>
<p class="mb-0">
Клиент имеет отменённые заказы с внесённой оплатой.
Общая сумма к возврату: <strong>{{ refund_amount|floatformat:2 }} руб.</strong>
</p>
</div>
</button>
</div>
<div class="collapse" id="walletHistoryCollapse">
<div class="card-body">
{% if wallet_transactions %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th>Дата</th>
<th>Тип</th>
<th>Сумма</th>
<th>Описание</th>
<th>Заказ</th>
<th>Создал</th>
</tr>
</thead>
<tbody>
{% for transaction in wallet_transactions %}
<tr>
<td><small>{{ transaction.created_at|date:"d.m.Y H:i" }}</small></td>
<td>
{% if transaction.transaction_type == 'deposit' %}
<span class="badge bg-success">Пополнение</span>
{% elif transaction.transaction_type == 'spend' %}
<span class="badge bg-danger">Списание</span>
{% else %}
<span class="badge bg-warning">Корректировка</span>
{% endif %}
</td>
<td>
{% if transaction.transaction_type == 'deposit' or transaction.transaction_type == 'adjustment' and transaction.amount > 0 %}
<span class="text-success fw-bold">+{{ transaction.amount|floatformat:2 }} руб.</span>
{% else %}
<span class="text-danger fw-bold">-{{ transaction.amount|floatformat:2 }} руб.</span>
{% endif %}
</td>
<td>{{ transaction.description|default:"-" }}</td>
<td>
{% if transaction.order %}
<a href="{% url 'orders:order-detail' transaction.order.order_number %}" class="btn btn-sm btn-outline-primary">
#{{ transaction.order.order_number }}
</a>
{% else %}
-
{% endif %}
</td>
<td><small>{{ transaction.created_by.username|default:"-" }}</small></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted mb-0">История транзакций пуста.</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- История заказов -->
<div class="col-md-12">
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<button class="btn btn-link text-start text-decoration-none p-0 d-flex align-items-center flex-grow-1"
type="button"
data-bs-toggle="collapse"
data-bs-target="#ordersHistoryCollapse"
aria-expanded="false"
aria-controls="ordersHistoryCollapse"
style="border: none; background: none;">
<h5 class="mb-0 me-3">История заказов</h5>
<span class="badge bg-primary">{{ orders_page.paginator.count }}</span>
<i class="bi bi-chevron-down ms-auto"></i>
</button>
<a href="{% url 'orders:order-create' %}?customer={{ customer.pk }}"
class="btn btn-sm btn-success ms-2">
<i class="bi bi-plus-circle"></i> Новый заказ
</a>
</div>
<div class="collapse" id="ordersHistoryCollapse">
<div class="card-body">
{% if orders_page %}
<div class="table-responsive">
<table class="table table-striped table-hover">
<thead>
<tr>
<th></th>
<th>Дата создания</th>
<th>Дата доставки</th>
<th>Статус</th>
<th>Оплата</th>
<th>Сумма</th>
<th>Оплачено</th>
<th>Остаток</th>
<th>Возврат</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{% for order in orders_page %}
<tr {% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}class="table-warning"{% endif %}>
<td><strong>#{{ order.order_number }}</strong></td>
<td><small>{{ order.created_at|date:"d.m.Y H:i" }}</small></td>
<td>
{% if order.delivery_date %}
<strong>{{ order.delivery_date|date:"d.m.Y" }}</strong>
{% if order.delivery_time %}
<br><small class="text-muted">{{ order.delivery_time }}</small>
{% endif %}
{% if order.is_delivery %}
<br><span class="badge bg-info">Доставка</span>
{% else %}
<br><span class="badge bg-secondary">Самовывоз</span>
{% endif %}
{% else %}
<span class="text-muted">Не указана</span>
{% endif %}
</td>
<td>
{% if order.status %}
{% if order.status.code == 'draft' %}
<span class="badge bg-secondary">Черновик</span>
{% elif order.status.code == 'pending' %}
<span class="badge bg-warning">Ожидает</span>
{% elif order.status.code == 'in_production' %}
<span class="badge bg-info">В производстве</span>
{% elif order.status.code == 'ready' %}
<span class="badge bg-primary">Готов</span>
{% elif order.status.code == 'delivered' %}
<span class="badge bg-success">Доставлен</span>
{% elif order.status.code == 'cancelled' %}
<span class="badge bg-danger">Отменён</span>
{% else %}
<span class="badge bg-secondary">{{ order.status.name }}</span>
{% endif %}
{% else %}
<span class="badge bg-secondary">Без статуса</span>
{% endif %}
</td>
<td>
{% if order.is_paid %}
<span class="badge bg-success">
<i class="bi bi-check-circle"></i> Оплачено
<div>
<span class="badge bg-warning text-dark" style="font-size: 1.3em;">
<i class="bi bi-exclamation-circle"></i> {{ refund_amount|floatformat:2 }} руб.
</span>
{% elif order.status and order.status.is_negative_end and order.amount_paid > 0 %}
<span class="badge bg-warning text-dark" style="border: 3px solid #dc3545 !important; box-shadow: 0 0 0 1px #dc3545;" title="Требуется возврат: {{ order.amount_paid|floatformat:2 }} руб.">
<i class="bi bi-exclamation-triangle"></i> Возврат <strong style="color: #dc3545;">({{ order.amount_paid|floatformat:2 }} руб.)</strong>
</span>
{% elif order.amount_paid > 0 %}
<span class="badge bg-warning">
<i class="bi bi-exclamation-circle"></i> Частично ({{ order.amount_paid|floatformat:2 }} руб.)
</span>
{% else %}
<span class="badge bg-danger">
<i class="bi bi-x-circle"></i> Не оплачено
</span>
{% endif %}
</td>
<td><strong>{{ order.total_amount|floatformat:2 }} руб.</strong></td>
<td>
{% if order.amount_paid > 0 %}
<span class="text-success">{{ order.amount_paid|floatformat:2 }} руб.</span>
{% else %}
<span class="text-muted">0.00 руб.</span>
{% endif %}
</td>
<td>
{% if order.status and order.status.is_negative_end %}
<span class="text-muted"></span>
{% elif order.amount_due > 0 %}
<span class="text-danger fw-bold">{{ order.amount_due|floatformat:2 }} руб.</span>
{% else %}
<span class="text-success">0.00 руб.</span>
{% endif %}
</td>
<td>
{% if order.status and order.status.is_negative_end and order.amount_paid > 0 %}
<span class="badge bg-warning text-dark" style="border: 3px solid #dc3545 !important; box-shadow: 0 0 0 1px #dc3545;" title="Требуется возврат">
<i class="bi bi-exclamation-triangle"></i> <strong style="color: #dc3545;">{{ order.amount_paid|floatformat:2 }} руб.</strong>
</span>
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
<a href="{% url 'orders:order-detail' order.order_number %}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i>
</a>
<a href="{% url 'orders:order-update' order.order_number %}" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Пагинация -->
{% if orders_page.has_other_pages %}
<nav aria-label="Навигация по заказам">
<ul class="pagination justify-content-center mt-3">
{% if orders_page.has_previous %}
<li class="page-item">
<a class="page-link" href="?page=1#ordersHistoryCollapse">Первая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ orders_page.previous_page_number }}#ordersHistoryCollapse">Предыдущая</a>
</li>
{% endif %}
<li class="page-item active">
<span class="page-link">
Страница {{ orders_page.number }} из {{ orders_page.paginator.num_pages }}
</span>
</li>
{% if orders_page.has_next %}
<li class="page-item">
<a class="page-link" href="?page={{ orders_page.next_page_number }}#ordersHistoryCollapse">Следующая</a>
</li>
<li class="page-item">
<a class="page-link" href="?page={{ orders_page.paginator.num_pages }}#ordersHistoryCollapse">Последняя</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<p class="text-muted mb-0">У клиента пока нет заказов.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
@@ -539,8 +578,8 @@
</div>
<script>
// Автооткрытие collapse при наличии якоря в URL
document.addEventListener('DOMContentLoaded', function() {
// Автооткрытие collapse при наличии якоря в URL
const hash = window.location.hash;
if (hash === '#ordersHistoryCollapse') {
const collapseElement = document.getElementById('ordersHistoryCollapse');
@@ -548,12 +587,177 @@ document.addEventListener('DOMContentLoaded', function() {
const bsCollapse = new bootstrap.Collapse(collapseElement, {
show: true
});
// Прокручиваем к элементу после открытия collapse (с учетом анимации)
collapseElement.addEventListener('shown.bs.collapse', function() {
collapseElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, { once: true });
}
}
// ========== INLINE EDITING ==========
const table = document.getElementById('customer-info-table');
if (!table) return;
const customerId = table.dataset.customerId;
const updateUrl = `/customers/${customerId}/api/update/`;
// Находим все редактируемые строки
const editableRows = table.querySelectorAll('tr[data-field]');
editableRows.forEach(row => {
const field = row.dataset.field;
const fieldValue = row.querySelector('.field-value');
const fieldInput = row.querySelector('.field-input');
const fieldActions = row.querySelector('.field-actions');
const editBtn = row.querySelector('.edit-btn');
const saveBtn = row.querySelector('.save-btn');
const cancelBtn = row.querySelector('.cancel-btn');
const saveStatus = row.querySelector('.save-status');
let originalValue = '';
// Клик на карандаш — начать редактирование
editBtn.addEventListener('click', function() {
originalValue = fieldInput.value;
fieldValue.classList.add('d-none');
fieldInput.classList.remove('d-none');
fieldActions.classList.remove('d-none');
editBtn.style.visibility = 'hidden'; // Скрываем карандаш но сохраняем место
fieldInput.focus();
if (fieldInput.tagName === 'INPUT') {
fieldInput.select();
}
});
// Клик на галочку или Enter — сохранить
saveBtn.addEventListener('click', saveField);
fieldInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter' && fieldInput.tagName !== 'TEXTAREA') {
e.preventDefault();
saveField();
}
if (e.key === 'Escape') {
cancelEdit();
}
});
// Клик на крестик — отменить
cancelBtn.addEventListener('click', cancelEdit);
function cancelEdit() {
fieldInput.value = originalValue;
fieldValue.classList.remove('d-none');
fieldInput.classList.add('d-none');
fieldActions.classList.add('d-none');
editBtn.style.visibility = 'visible';
saveStatus.innerHTML = '';
}
function saveField() {
const newValue = fieldInput.value.trim();
// Показываем индикатор загрузки
saveStatus.innerHTML = '<span class="spinner-border spinner-border-sm text-primary"></span>';
saveBtn.disabled = true;
cancelBtn.disabled = true;
fetch(updateUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': getCookie('csrftoken')
},
body: JSON.stringify({
field: field,
value: newValue
})
})
.then(response => response.json())
.then(data => {
saveBtn.disabled = false;
cancelBtn.disabled = false;
if (data.success) {
// Обновляем отображаемое значение
const displayValue = data.value || '—';
fieldValue.textContent = displayValue;
fieldInput.value = data.value || '';
originalValue = data.value || '';
// Обновляем заголовок страницы если изменилось имя
if (field === 'name') {
const titleSpan = document.getElementById('customer-title');
if (titleSpan) {
titleSpan.textContent = displayValue;
}
}
// Возвращаем в режим просмотра
fieldValue.classList.remove('d-none');
fieldInput.classList.add('d-none');
fieldActions.classList.add('d-none');
editBtn.style.visibility = 'visible';
// Показываем успех
saveStatus.innerHTML = '<i class="bi bi-check-circle-fill text-success"></i>';
setTimeout(() => {
saveStatus.innerHTML = '';
}, 1500);
} else {
// Показываем ошибку
saveStatus.innerHTML = `<span class="text-danger small"><i class="bi bi-exclamation-circle"></i> ${data.error}</span>`;
}
})
.catch(error => {
saveBtn.disabled = false;
cancelBtn.disabled = false;
saveStatus.innerHTML = '<span class="text-danger small"><i class="bi bi-exclamation-circle"></i> Ошибка сети</span>';
console.error('Error:', error);
});
}
});
// Функция получения CSRF токена
function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
// ========== COPY TO CLIPBOARD ==========
document.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', function() {
const value = this.dataset.copyValue;
if (!value) return;
navigator.clipboard.writeText(value).then(() => {
// Меняем иконку на галочку и стиль кнопки
const icon = this.querySelector('i');
const originalIconClass = icon.className;
const originalBtnClass = this.className;
// Меняем кнопку на зелёную с белой галочкой
this.className = 'btn btn-sm btn-success copy-btn';
icon.className = 'bi bi-check-lg';
// Возвращаем обратно через 1 сек
setTimeout(() => {
this.className = originalBtnClass;
icon.className = originalIconClass;
}, 1000);
}).catch(err => {
console.error('Ошибка копирования:', err);
});
});
});
});
</script>
{% endblock %}

View File

@@ -1,31 +1,17 @@
{% extends "base.html" %}
{% block title %}
{% if is_creating %}Добавить нового клиента{% else %}Редактировать клиента{% endif %}
{% endblock %}
{% block title %}Добавить нового клиента{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<h1>
{% if is_creating %}
Добавить нового клиента
{% else %}
Редактировать клиента
{% endif %}
</h1>
<h1>Добавить нового клиента</h1>
<form method="post">
{% csrf_token %}
<div class="row">
<!-- Personal Information -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Личная информация</h5>
</div>
<div class="card-body">
<div class="mb-3">
{{ form.name.label_tag }}
@@ -51,39 +37,7 @@
<div class="text-danger">{{ form.email.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Preferences and Status -->
<div class="col-md-6">
<div class="card mb-4">
<div class="card-header">
<h5>Предпочтения и статус</h5>
</div>
<div class="card-body">
<div class="mb-3">
{{ form.loyalty_tier.label_tag }}
{{ form.loyalty_tier }}
{% if form.loyalty_tier.errors %}
<div class="text-danger">{{ form.loyalty_tier.errors }}</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Additional Information -->
<div class="card mb-4">
<div class="card-header">
<h5>Дополнительная информация</h5>
</div>
<div class="card-body">
<div class="mb-3">
{{ form.notes.label_tag }}
{{ form.notes }}
@@ -94,12 +48,9 @@
</div>
</div>
<!-- Form Actions -->
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
{% if is_creating %}Создать клиента{% else %}Сохранить изменения{% endif %}
</button>
<a href="{% if form.instance.pk %}{% url 'customers:customer-detail' form.instance.pk %}{% else %}{% url 'customers:customer-list' %}{% endif %}" class="btn btn-secondary">Отмена</a>
<button type="submit" class="btn btn-primary">Создать клиента</button>
<a href="{% url 'customers:customer-list' %}" class="btn btn-secondary">Отмена</a>
</div>
</form>
</div>

View File

@@ -78,9 +78,9 @@
<td class="text-end" onclick="event.stopPropagation();">
<a href="{% url 'customers:customer-detail' customer.pk %}"
class="btn btn-sm btn-outline-primary">👁</a>
<a href="{% url 'customers:customer-update' customer.pk %}"
class="btn btn-sm btn-outline-secondary"></a>
class="btn btn-sm btn-outline-primary" title="Просмотр">
<i class="bi bi-eye"></i>
</a>
</td>
</tr>
{% endfor %}

View File

@@ -9,7 +9,6 @@ urlpatterns = [
path('import/', views.customer_import, name='customer-import'),
path('export/', views.customer_export, name='customer-export'),
path('<int:pk>/', views.customer_detail, name='customer-detail'),
path('<int:pk>/edit/', views.customer_update, name='customer-update'),
path('<int:pk>/delete/', views.customer_delete, name='customer-delete'),
path('<int:pk>/wallet/deposit/', views.wallet_deposit, name='wallet-deposit'),
path('<int:pk>/wallet/withdraw/', views.wallet_withdraw, name='wallet-withdraw'),
@@ -21,4 +20,5 @@ urlpatterns = [
# AJAX API endpoints
path('api/search/', views.api_search_customers, name='api-search-customers'),
path('api/create/', views.api_create_customer, name='api-create-customer'),
path('<int:pk>/api/update/', views.api_update_customer, name='api-update-customer'),
]

View File

@@ -174,30 +174,6 @@ def customer_create(request):
return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': True})
def customer_update(request, pk):
"""Редактирование клиента"""
customer = get_object_or_404(Customer, pk=pk)
# Проверяем, не системный ли это клиент
if customer.is_system_customer:
messages.warning(request, 'Системный клиент не может быть изменен. Он создается автоматически и необходим для корректной работы системы.')
return redirect('customers:customer-detail', pk=pk)
if request.method == 'POST':
form = CustomerForm(request.POST, instance=customer)
if form.is_valid():
try:
form.save()
messages.success(request, f'Клиент {customer.full_name} успешно обновлён.')
return redirect('customers:customer-detail', pk=customer.pk)
except ValidationError as e:
messages.error(request, str(e))
else:
form = CustomerForm(instance=customer)
return render(request, 'customers/customer_form.html', {'form': form, 'is_creating': False})
def customer_delete(request, pk):
"""Удаление клиента"""
customer = get_object_or_404(Customer, pk=pk)
@@ -503,6 +479,101 @@ def api_search_customers(request):
})
@require_http_methods(["POST"])
def api_update_customer(request, pk):
"""
AJAX endpoint для обновления отдельного поля клиента (inline-редактирование).
Принимает POST JSON:
{
"field": "name",
"value": "Новое имя"
}
Возвращает JSON:
{
"success": true,
"value": "Новое имя"
}
При ошибке:
{
"success": false,
"error": "Текст ошибки"
}
"""
customer = get_object_or_404(Customer, pk=pk)
# Защита системного клиента
if customer.is_system_customer:
return JsonResponse({
'success': False,
'error': 'Системный клиент не может быть изменён'
}, status=403)
try:
data = json.loads(request.body)
field = data.get('field')
value = data.get('value', '').strip()
# Разрешённые поля для редактирования
allowed_fields = ['name', 'phone', 'email', 'notes']
if field not in allowed_fields:
return JsonResponse({
'success': False,
'error': f'Поле "{field}" недоступно для редактирования'
}, status=400)
# Валидация через форму
form_data = {field: value if value else None}
form = CustomerForm(form_data, instance=customer)
# Проверяем только нужное поле
if field in form.fields:
form.fields[field].required = False
field_value = form.fields[field].clean(value if value else None)
# Обновляем поле
setattr(customer, field, field_value)
customer.save(update_fields=[field, 'updated_at'])
# Возвращаем отформатированное значение
display_value = getattr(customer, field)
if display_value is None:
display_value = ''
elif field == 'phone' and display_value:
display_value = str(display_value)
else:
display_value = str(display_value)
return JsonResponse({
'success': True,
'value': display_value
})
return JsonResponse({
'success': False,
'error': 'Неизвестное поле'
}, status=400)
except json.JSONDecodeError:
return JsonResponse({
'success': False,
'error': 'Некорректный JSON'
}, status=400)
except ValidationError as e:
error_msg = e.message if hasattr(e, 'message') else str(e)
return JsonResponse({
'success': False,
'error': error_msg
}, status=400)
except Exception as e:
return JsonResponse({
'success': False,
'error': str(e)
}, status=400)
@require_http_methods(["POST"])
def api_create_customer(request):
"""

View File

@@ -26,9 +26,11 @@
<a href="{% url 'orders:order-update' order.order_number %}" class="btn btn-primary">
<i class="bi bi-pencil"></i> Редактировать
</a>
{% if order.status and order.status.code == 'draft' and order.amount_paid == 0 %}
<a href="{% url 'orders:order-delete' order.order_number %}" class="btn btn-danger">
<i class="bi bi-trash"></i> Удалить
</a>
{% endif %}
<a href="{% url 'orders:order-list' %}" class="btn btn-secondary">
<i class="bi bi-arrow-left"></i> К списку
</a>

View File

@@ -413,13 +413,31 @@ def order_update(request, order_number):
def order_delete(request, order_number):
"""Удаление заказа с подтверждением"""
"""Удаление заказа с подтверждением (только для черновиков без оплаты)"""
order = get_object_or_404(Order, order_number=order_number)
# Проверка: удалять можно только черновики
if not order.status or order.status.code != 'draft':
messages.error(
request,
f'Удаление невозможно. Удалять можно только заказы в статусе "Черновик". '
f'Текущий статус: {order.status.name if order.status else "не задан"}'
)
return redirect('orders:order-detail', order_number=order.order_number)
# Проверка: нельзя удалять заказы с оплатой
if order.amount_paid > 0:
messages.error(
request,
f'Удаление невозможно. Заказ имеет оплату ({order.amount_paid} руб.). '
f'Сначала оформите возврат платежа.'
)
return redirect('orders:order-detail', order_number=order.order_number)
if request.method == 'POST':
order_number = order.order_number
order_number_saved = order.order_number
order.delete()
messages.success(request, f'Заказ #{order_number} успешно удален.')
messages.success(request, f'Заказ #{order_number_saved} успешно удален.')
return redirect('orders:order-list')
context = {