Полностью переработана страница деталей заявления

Исправлено:
- Добавлена правильная структура card-header с аватаром 48x48px
- Добавлены все необходимые детали: получатели, копия, согласующий
- Исправлены стили для badge и card-body
- Добавлена корректная форма комментария с emoji picker и auto-expand
- Подключены правильные JavaScript компоненты
- Исправлен формат дат (d.m.Y H:i)
- Добавлена передача can_process в контексте view

Добавлено:
- Иконки для каждого раздела (получатели, копия, согласующий)
- Ссылки на карточки сотрудников
- Правильные Badge с subtle цветами
- Форма комментария с message-field компонентом
This commit is contained in:
Игорь Надеин 2025-12-29 12:25:42 +03:00
parent 1349ebce35
commit be21b9ca4a
2 changed files with 220 additions and 42 deletions

View File

@ -278,7 +278,18 @@ def my_requests(request):
@login_required
def request_detail(request, pk):
req = get_object_or_404(DETAIL_REQUEST_QS, pk=pk, employee=request.user)
return render(request, "requests_app/request_detail.html", {"request_obj": req})
# Проверяем, может ли пользователь обрабатывать заявления
# (является получателем этого заявления или имеет права)
user_can_process = (
request.user in req.recipients.all() or
can_process_requests(request.user)
)
return render(request, "requests_app/request_detail.html", {
"request_obj": req,
"can_process": user_can_process
})
@login_required

View File

@ -1,7 +1,7 @@
{% extends "base.html" %}
{% load static %}
{% block title %}Заявление #{{ request_obj.id }}{% endblock %}
{% block title %}Заявление #{{ request.id }}{% endblock %}
{% block extra_css %}
<link rel="stylesheet" href="{% static 'css/components/card-list.css' %}">
@ -29,16 +29,20 @@
{# ===== Основная информация о заявлении ===== #}
<article class="card mb-3">
<header class="card-header d-flex align-items-start gap-3">
<div class="card-icon">
<i class="bi-clipboard-check"></i>
<header class="card-header">
<div class="card-icon" style="width:48px;height:48px;">
{% if request.employee.avatar %}
<img src="{{ request.employee.avatar }}" alt="Аватар">
{% else %}
<i class="bi-person"></i>
{% endif %}
</div>
<div class="card-meta flex-grow-1">
<div class="d-flex align-items-center gap-2 flex-wrap mb-2">
<span class="card-title">
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="{% url 'employees:employee_detail' requests.employee.pk %}" class="card-title text-decoration-none">
{{ request_obj.employee.full_name|default:request_obj.employee.display_name }}
</span>
</a>
{# статус — бейдж #}
{% if request_obj.status == 'pending' %}
@ -55,42 +59,116 @@
<span class="badge bg-light text-body border">Неизвестно</span>
{% endif %}
{# тип заявления #}
<span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">
{{ request_obj.get_type_display }}
</span>
</div>
<div class="card-subtitle">
<time datetime="{{ request_obj.created_at|date:'c' }}">{{ request_obj.created_at }}</time>
<div class="card-subtitle mt-1">
<time datetime="{{ request_obj.created_at|date:'c' }}">{{ request_obj.created_at|date:"d.m.Y H:i" }}</time>
{% if request_obj.date_from or request_obj.date_to %}
· Период: {{ request_obj.date_from|date:'d.m.Y' }}{% if request_obj.date_to %} — {{ request_obj.date_to|date:'d.m.Y' }}{% endif %}
{% endif %}
</div>
</div>
{# Отозвать — временно отключено (TODO: добавить в urls_front.py)
{% if request_obj.status == 'pending' and not request_obj.is_final %}
<a href="{% url 'requests_app:request_cancel' request_obj.id %}"
class="btn btn-ghost ms-auto">
<i class="bi-x-circle"></i> Отозвать
</a>
{# Кнопки действий #}
{% if can_process and request_obj.status == 'pending' %}
<div class="d-flex gap-2 align-items-start">
<button type="button" class="btn btn-success btn-sm"
data-bs-toggle="modal"
data-bs-target="#reqApproveModal"
data-id="{{ request_obj.id }}">
<i class="bi-check-circle me-1"></i>Одобрить
</button>
<button type="button" class="btn btn-danger btn-sm"
data-bs-toggle="modal"
data-bs-target="#reqRejectModal"
data-id="{{ request_obj.id }}">
<i class="bi-x-circle me-1"></i>Отклонить
</button>
</div>
{% endif %}
#}
</header>
<div class="card-body">
{# Тема заявления #}
{% if request_obj.title %}
<h6 class="mb-2">{{ request_obj.title }}</h6>
<div class="mb-3">
<span class="text-secondary small d-block mb-1">Тема:</span>
<div class="fw-semibold">{{ request_obj.title }}</div>
</div>
{% endif %}
{# Комментарий сотрудника #}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">Комментарий:</span>
<div>{{ request_obj.comment|default:"—"|linebreaksbr }}</div>
</div>
{# Получатели #}
{% if request_obj.recipients.exists %}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">
<i class="bi-person-check me-1"></i>Получатели:
</span>
<div class="d-flex flex-wrap gap-2">
{% for recipient in request_obj.recipients.all %}
<a href="{% url 'employees:employee_detail' recipient.pk %}"
class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle text-decoration-none">
{{ recipient.full_name|default:recipient.display_name }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{# Копия #}
{% if request_obj.cc_users.exists %}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">
<i class="bi-people me-1"></i>В копии:
</span>
<div class="d-flex flex-wrap gap-2">
{% for cc_user in request_obj.cc_users.all %}
<a href="{% url 'employees:employee_detail' cc_user.pk %}"
class="badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle text-decoration-none">
{{ cc_user.full_name|default:cc_user.display_name }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
{# Согласующий #}
{% if request_obj.approver %}
<div class="mb-3">
<span class="text-secondary small d-block mb-1">
<i class="bi-person-check-fill me-1"></i>Согласовал:
</span>
<a href="{% url 'employees:employee_detail' request_obj.approver.pk %}"
class="badge bg-success-subtle text-success-emphasis border border-success-subtle text-decoration-none">
{{ request_obj.approver.full_name|default:request_obj.approver.display_name }}
</a>
{% if request_obj.decided_at %}
<span class="text-secondary small ms-2">
{{ request_obj.decided_at|date:"d.m.Y H:i" }}
</span>
{% endif %}
</div>
{% endif %}
{# Вложение #}
{% if request_obj.attachment_url %}
<a class="btn btn-ghost d-inline-flex align-items-center gap-2"
href="{{ request_obj.attachment_url }}" target="_blank" rel="noopener">
<i class="bi-paperclip"></i>
<span>Вложение</span>
</a>
<div class="mb-2">
<a class="btn btn-ghost d-inline-flex align-items-center gap-2"
href="{{ request_obj.attachment_url }}" target="_blank" rel="noopener">
<i class="bi-paperclip"></i>
<span>Вложение</span>
</a>
</div>
{% endif %}
</div>
</article>
@ -98,7 +176,7 @@
{# ===== Комментарии ===== #}
<article class="card" id="comments">
<header class="card-header">
<div class="card-icon">
<div class="card-icon" style="width:44px;height:44px;">
<i class="bi-chat-dots"></i>
</div>
<div class="card-meta">
@ -114,14 +192,14 @@
<div class="card-list-item">
<div class="d-flex justify-content-between align-items-start mb-1">
<div>
<span class="fw-semibold">{{ c.author.full_name|default:c.author.display_name }}</span>
<strong>{{ c.author.full_name|default:c.author.display_name }}</strong>
<span class="text-secondary mx-1">·</span>
<time class="text-muted small">{{ c.created_at }}</time>
<time class="text-muted small">{{ c.created_at|date:"d.m.Y H:i" }}</time>
</div>
{# удалить — только автору комментария #}
{% if c.author.id == request.user.id %}
<form method="post" action="{% url 'requests:request_comment_delete' request_obj.id c.id %}">
<form method="post" action="{% url 'requests:request_comment_delete' request_obj.id c.id %}" class="d-inline">
{% csrf_token %}
<button class="btn btn-sm btn-ghost text-danger" type="submit" title="Удалить">
<i class="bi-trash"></i>
@ -137,8 +215,8 @@
<div class="text-secondary small text-center py-3">Комментариев пока нет.</div>
{% endif %}
{# Добавить комментарий — #}
<form class="mt-3 request-comment-form" method="post" action="{% url 'requests:request_comment_add' request_obj.id %}">
{# Форма добавления комментария #}
<form class="mt-3 request-comment-form" method="post" action="{% url 'requests:request_comment_add' request.id %}">
{% csrf_token %}
<div class="message-field message-field--compact">
<!-- Кнопка выбора эмодзи -->
@ -179,17 +257,71 @@
{% block extra_js %}
<script type="module">
// Обработка формы добавления комментария
import RequestCrudHandler from "{% static 'js/components/requestCrudHandler.js' %}";
import { initEmojiPicker } from "{% static 'js/components/emojiPicker.js' %}";
import { initTextareaAutoExpand } from "{% static 'js/components/textareaAutoExpand.js' %}";
// Инициализация обработчика CRUD
const crudHandler = new RequestCrudHandler();
// Обработка формы одобрения
const approveForm = document.getElementById('reqApproveForm');
const approveModal = document.getElementById('reqApproveModal');
if (approveModal && approveForm) {
approveModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const requestId = button.getAttribute('data-id');
document.getElementById('approveId').value = requestId;
});
approveForm.addEventListener('submit', async (e) => {
e.preventDefault();
const requestId = document.getElementById('approveId').value;
await crudHandler.handleApprove(requestId);
// Перезагрузка после закрытия модала
setTimeout(() => window.location.reload(), 500);
});
}
// Обработка формы отклонения
const rejectForm = document.getElementById('reqRejectForm');
const rejectModal = document.getElementById('reqRejectModal');
if (rejectModal && rejectForm) {
rejectModal.addEventListener('show.bs.modal', function (event) {
const button = event.relatedTarget;
const requestId = button.getAttribute('data-id');
document.getElementById('rejectId').value = requestId;
});
rejectForm.addEventListener('submit', async (e) => {
e.preventDefault();
const requestId = document.getElementById('rejectId').value;
await crudHandler.handleReject(requestId);
// Перезагрузка после закрытия модала
setTimeout(() => window.location.reload(), 500);
});
}
// Инициализация emoji picker для формы комментария
const commentForm = document.querySelector('.request-comment-form');
if (commentForm) {
const textarea = commentForm.querySelector('textarea[name="text"]');
const emojiButton = commentForm.querySelector('[data-bs-toggle="dropdown"]');
const emojiPicker = commentForm.querySelector('emoji-picker');
if (textarea && emojiButton && emojiPicker) {
initEmojiPicker(emojiButton, emojiPicker, textarea);
initTextareaAutoExpand(textarea);
}
// Обработка отправки формы
commentForm.addEventListener('submit', async (e) => {
e.preventDefault();
const textarea = commentForm.querySelector('textarea[name="text"]');
const text = textarea.value.trim();
if (!text) {
alert('Пожалуйста, введите текст комментария');
return;
}
@ -199,17 +331,16 @@
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
try {
const formData = new FormData(commentForm);
const response = await fetch(commentForm.action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': commentForm.querySelector('[name="csrfmiddlewaretoken"]').value
},
body: JSON.stringify({ text: text })
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `HTTP ${response.status}`);
}
// Перезагрузить страницу для отображения нового комментария
@ -234,11 +365,11 @@
}
try {
const formData = new FormData(form);
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRFToken': form.querySelector('[name="csrfmiddlewaretoken"]').value
}
body: formData
});
if (!response.ok) {
@ -257,3 +388,39 @@
</script>
{% endblock %}
{# Модальные окна для действий #}
<!-- Approve -->
<div class="modal fade" id="reqApproveModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="reqApproveForm">
{% csrf_token %}
<input type="hidden" name="id" id="approveId">
<div class="modal-header">
<h5 class="modal-title text-success">Одобрить заявление?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="btn btn-success" type="submit">Одобрить</button>
</div>
</form>
</div>
</div>
<!-- Reject -->
<div class="modal fade" id="reqRejectModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="reqRejectForm">
{% csrf_token %}
<input type="hidden" name="id" id="rejectId">
<div class="modal-header">
<h5 class="modal-title text-danger">Отклонить заявление?</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-footer">
<button class="btn btn-danger" type="submit">Отклонить</button>
</div>
</form>
</div>
</div>