- Замена всех CDN зависимостей на локальные файлы: * Bootstrap 5.3.3 (CSS + JS) * Bootstrap Icons 1.11.3 (CSS + шрифты woff/woff2) * FullCalendar 6.1.8 (JS + локали) * Cropper.js 1.6.1 * emoji-picker-element 1.21.3 - Web Push Notifications: * VAPID ключи настроены в settings.py * Service Worker (sw.js) в корне backend/ * API endpoints: /api/v1/notifications/push/ * Auto-request permission при загрузке страницы * Simplified subscribe logic (Phase 1 refactor) * resetEverything() для восстановления после ошибок - Документация: * backend/docs/guides/WEB_PUSH_QUICK_TEST.md * backend/docs/guides/FIX_NO_PUSH_SUBSCRIPTIONS.md * backend/docs/troubleshooting/WEB_PUSH_OFFLINE_ISSUE.md - Обновлено 10 шаблонов для использования local vendor - Устранены проблемы с CORS и integrity checks - Все иконки Bootstrap Icons теперь отображаются корректно
523 lines
20 KiB
HTML
523 lines
20 KiB
HTML
{% extends "base.html" %}
|
||
{% load static %}
|
||
|
||
{% block title %}Заявление #{{ request_obj.id }}{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<link rel="stylesheet" href="{% static 'css/components/request-list.css' %}">
|
||
<link rel="stylesheet" href="{% static 'css/components/request-detail.css' %}">
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
|
||
<div class="container-fluid py-3">
|
||
{# ===== Хлебные крошки ===== #}
|
||
<nav aria-label="breadcrumb" class="mb-3">
|
||
<ol class="breadcrumb mb-0">
|
||
<li class="breadcrumb-item">
|
||
<a href="{% url 'requests:request_list' %}" class="text-decoration-none">
|
||
<i class="bi-clipboard-check me-1"></i>Заявления
|
||
</a>
|
||
</li>
|
||
<li class="breadcrumb-item active" aria-current="page">
|
||
#{{ request_obj.id }}
|
||
</li>
|
||
</ol>
|
||
</nav>
|
||
|
||
{% if error %}
|
||
{# ===== Ошибка ===== #}
|
||
<div class="card">
|
||
<div class="card-body text-center py-5">
|
||
<i class="bi bi-exclamation-triangle text-warning" style="font-size: 3rem;"></i>
|
||
<h5 class="mt-3">{{ error }}</h5>
|
||
<p class="text-body-secondary">Код ошибки: {{ error_code }}</p>
|
||
<a href="{% url 'requests:request_list' %}" class="btn btn-primary mt-3">
|
||
<i class="bi-arrow-left me-2"></i>Вернуться к списку
|
||
</a>
|
||
</div>
|
||
</div>
|
||
{% else %}
|
||
|
||
<div class="row g-3">
|
||
{# ===== Основная карточка заявления ===== #}
|
||
<div class="col-12 col-lg-8">
|
||
<article class="card request-detail-card" id="reqList">
|
||
{# Шапка как в списке #}
|
||
<div class="req-detail-header">
|
||
<div class="req-detail-row">
|
||
{# Иконка типа #}
|
||
<div class="req-detail-icon">
|
||
{% if request_obj.type == 'vacation' %}
|
||
<div class="card-icon"><i class="bi-calendar-check text-primary"></i></div>
|
||
{% elif request_obj.type == 'sick_leave' %}
|
||
<div class="card-icon"><i class="bi-capsule text-danger"></i></div>
|
||
{% elif request_obj.type == 'day_off' %}
|
||
<div class="card-icon"><i class="bi-umbrella-fill text-info"></i></div>
|
||
{% elif request_obj.type == 'transfer' %}
|
||
<div class="card-icon"><i class="bi-box-arrow-right text-info"></i></div>
|
||
{% elif request_obj.type == 'dismissal' %}
|
||
<div class="card-icon"><i class="bi-door-open text-warning"></i></div>
|
||
{% else %}
|
||
<div class="card-icon"><i class="bi-clipboard-check text-secondary"></i></div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{# Заголовок и мета #}
|
||
<div class="req-detail-content">
|
||
<div class="d-flex align-items-center flex-wrap gap-2 mb-1">
|
||
<h1 class="h5 mb-0">{{ request_obj.title|default:"Без заголовка" }}</h1>
|
||
|
||
{# Бейдж типа #}
|
||
<span class="badge text-bg-light">{{ request_obj.get_type_display }}</span>
|
||
|
||
{# Бейдж статуса #}
|
||
{% if request_obj.status == 'draft' %}
|
||
<span class="badge text-bg-info">Черновик</span>
|
||
{% elif request_obj.status == 'pending' %}
|
||
<span class="badge text-bg-warning">На рассмотрении</span>
|
||
{% elif request_obj.status == 'approved' %}
|
||
<span class="badge text-bg-success">Одобрено</span>
|
||
{% elif request_obj.status == 'rejected' %}
|
||
<span class="badge text-bg-danger">Отклонено</span>
|
||
{% elif request_obj.status == 'cancelled' %}
|
||
<span class="badge text-bg-secondary">Отменено</span>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div class="text-body-secondary small">
|
||
Создано: {{ request_obj.created_at }}
|
||
{% if request_obj.date_from or request_obj.date_to %}
|
||
<span class="mx-1">·</span>
|
||
Период: {{ request_obj.date_from|default:"—" }}{% if request_obj.date_to %} — {{ request_obj.date_to }}{% endif %}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{# Кнопки действий #}
|
||
{% if can_process and request_obj.status == 'pending' %}
|
||
<div class="req-detail-actions">
|
||
<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-hand-thumbs-up 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-hand-thumbs-down me-1"></i>Отклонить
|
||
</button>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{# Тело карточки #}
|
||
<div class="card-body">
|
||
{# Автор заявления #}
|
||
<div class="req-info-block">
|
||
<div class="req-info-label">
|
||
<i class="bi-person me-1"></i>Автор
|
||
</div>
|
||
<div class="req-info-value">
|
||
{% if request_obj.employee.id %}
|
||
<a href="{% url 'employees:employee_detail' request_obj.employee.id %}" class="text-decoration-none">
|
||
{{ request_obj.employee.full_name|default:request_obj.employee.display_name }}
|
||
</a>
|
||
{% else %}
|
||
{{ request_obj.employee.full_name|default:request_obj.employee.display_name }}
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
{# Комментарий автора #}
|
||
{% if request_obj.comment %}
|
||
<div class="req-info-block">
|
||
<div class="req-info-label">
|
||
<i class="bi-chat-left-text me-1"></i>Описание
|
||
</div>
|
||
<div class="req-info-value req-comment-text">
|
||
{{ request_obj.comment|linebreaksbr }}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# Получатели #}
|
||
{% if request_obj.recipients %}
|
||
<div class="req-info-block">
|
||
<div class="req-info-label">
|
||
<i class="bi-people me-1"></i>Получатели
|
||
</div>
|
||
<div class="req-info-value">
|
||
<div class="d-flex flex-wrap gap-1">
|
||
{% for recipient in request_obj.recipients %}
|
||
{% if recipient.id %}
|
||
<a href="{% url 'employees:employee_detail' recipient.id %}"
|
||
class="badge text-bg-primary text-decoration-none">
|
||
{{ recipient.full_name|default:recipient.display_name }}
|
||
</a>
|
||
{% else %}
|
||
<span class="badge text-bg-primary">
|
||
{{ recipient.full_name|default:recipient.display_name }}
|
||
</span>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# В копии #}
|
||
{% if request_obj.cc_users %}
|
||
<div class="req-info-block">
|
||
<div class="req-info-label">
|
||
<i class="bi-people-fill me-1"></i>В копии
|
||
</div>
|
||
<div class="req-info-value">
|
||
<div class="d-flex flex-wrap gap-1">
|
||
{% for cc in request_obj.cc_users %}
|
||
{% if cc.id %}
|
||
<a href="{% url 'employees:employee_detail' cc.id %}"
|
||
class="badge text-bg-secondary text-decoration-none">
|
||
{{ cc.full_name|default:cc.display_name }}
|
||
</a>
|
||
{% else %}
|
||
<span class="badge text-bg-secondary">
|
||
{{ cc.full_name|default:cc.display_name }}
|
||
</span>
|
||
{% endif %}
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# Кто обработал #}
|
||
{% if request_obj.approver %}
|
||
<div class="req-info-block">
|
||
<div class="req-info-label">
|
||
<i class="bi-person-check me-1"></i>
|
||
{% if request_obj.status == 'approved' %}Одобрил{% elif request_obj.status == 'rejected' %}Отклонил{% else %}Обработал{% endif %}
|
||
</div>
|
||
<div class="req-info-value">
|
||
{% if request_obj.approver.id %}
|
||
<a href="{% url 'employees:employee_detail' request_obj.approver.id %}"
|
||
class="badge {% if request_obj.status == 'approved' %}text-bg-success{% elif request_obj.status == 'rejected' %}text-bg-danger{% else %}text-bg-secondary{% endif %} text-decoration-none">
|
||
{{ request_obj.approver.full_name|default:request_obj.approver.display_name }}
|
||
</a>
|
||
{% else %}
|
||
<span class="badge {% if request_obj.status == 'approved' %}text-bg-success{% elif request_obj.status == 'rejected' %}text-bg-danger{% else %}text-bg-secondary{% endif %}">
|
||
{{ request_obj.approver.full_name|default:request_obj.approver.display_name }}
|
||
</span>
|
||
{% endif %}
|
||
{% if request_obj.decided_at %}
|
||
<span class="text-body-secondary small ms-2">{{ request_obj.decided_at }}</span>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{# Вложение #}
|
||
{% if request_obj.attachment_url %}
|
||
<div class="req-info-block">
|
||
<div class="req-info-label">
|
||
<i class="bi-paperclip me-1"></i>Вложение
|
||
</div>
|
||
<div class="req-info-value">
|
||
<a href="{{ request_obj.attachment_url }}" target="_blank" rel="noopener"
|
||
class="btn btn-sm btn-outline-secondary">
|
||
<i class="bi-download me-1"></i>Скачать
|
||
</a>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
{# ===== Боковая панель с комментариями ===== #}
|
||
<div class="col-12 col-lg-4">
|
||
<article class="card" id="comments-section">
|
||
<div class="card-header d-flex align-items-center gap-2">
|
||
<i class="bi-chat-dots"></i>
|
||
<span class="fw-semibold">Комментарии</span>
|
||
<span class="badge text-bg-secondary ms-auto">{{ comments|length }}</span>
|
||
</div>
|
||
|
||
<div class="card-body p-0">
|
||
{% if comments %}
|
||
<div class="list-group list-group-flush">
|
||
{% for c in comments %}
|
||
<div class="list-group-item">
|
||
<div class="d-flex justify-content-between align-items-start mb-1">
|
||
<div>
|
||
<strong class="small">{{ c.author.full_name|default:c.author.display_name }}</strong>
|
||
<span class="text-body-secondary small ms-1">{{ c.created_at }}</span>
|
||
</div>
|
||
|
||
{# Удаление — только автору #}
|
||
{% if c.author.id == request.user.id %}
|
||
<button class="btn btn-link btn-sm text-danger p-0 delete-comment-btn"
|
||
type="button" title="Удалить"
|
||
data-request-id="{{ request_obj.id }}"
|
||
data-comment-id="{{ c.id }}">
|
||
<i class="bi-trash"></i>
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
<div class="small">{{ c.text|linebreaksbr }}</div>
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
{% else %}
|
||
<div class="text-body-secondary small text-center py-4">
|
||
Комментариев пока нет
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
{# Форма добавления комментария #}
|
||
<div class="card-footer">
|
||
<form method="post" action="{% url 'requests:request_comment_add' request_obj.id %}"
|
||
class="request-comment-form">
|
||
{% csrf_token %}
|
||
<div class="message-field message-field--compact">
|
||
{# Кнопка эмодзи #}
|
||
<div class="dropdown message-emoji">
|
||
<button type="button" class="btn btn-ghost btn-emoji message-icon-btn"
|
||
data-bs-toggle="dropdown" aria-expanded="false" title="Вставить смайлик">
|
||
<i class="bi-emoji-smile"></i>
|
||
</button>
|
||
<div class="dropdown-menu dropdown-menu-start message-emoji-menu">
|
||
<emoji-picker data-emoji-picker class="chat-emoji-picker"></emoji-picker>
|
||
</div>
|
||
</div>
|
||
|
||
{# Поле ввода #}
|
||
<textarea name="text" class="form-control message-input" rows="1"
|
||
placeholder="Добавить комментарий..." required></textarea>
|
||
|
||
{# Кнопка отправки #}
|
||
<button type="submit" class="btn btn-primary message-send" title="Отправить">
|
||
<i class="bi-send"></i>
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
</div>
|
||
|
||
{% endif %}
|
||
</div>
|
||
|
||
{# ===== Модальные окна ===== #}
|
||
|
||
<!-- 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 border-0">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<i class="bi-hand-thumbs-up text-success fs-4"></i>
|
||
<h5 class="modal-title mb-0">Одобрить заявление?</h5>
|
||
</div>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body pt-0">
|
||
<p class="text-body-secondary mb-0">
|
||
Заявление #{{ request_obj.id }} будет одобрено. Автор получит уведомление.
|
||
</p>
|
||
</div>
|
||
<div class="modal-footer border-0">
|
||
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
|
||
<button class="btn btn-success" type="submit">
|
||
<i class="bi-check-lg me-1"></i>Одобрить
|
||
</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 border-0">
|
||
<div class="d-flex align-items-center gap-2">
|
||
<i class="bi-hand-thumbs-down text-danger fs-4"></i>
|
||
<h5 class="modal-title mb-0">Отклонить заявление?</h5>
|
||
</div>
|
||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||
</div>
|
||
<div class="modal-body pt-0">
|
||
<p class="text-body-secondary mb-0">
|
||
Заявление #{{ request_obj.id }} будет отклонено. Автор получит уведомление.
|
||
</p>
|
||
</div>
|
||
<div class="modal-footer border-0">
|
||
<button class="btn btn-secondary" type="button" data-bs-dismiss="modal">Отмена</button>
|
||
<button class="btn btn-danger" type="submit">
|
||
<i class="bi-x-lg me-1"></i>Отклонить
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script type="module" src="{% static 'vendor/emoji-picker/index.js' %}"></script>
|
||
<script>
|
||
(function() {
|
||
const requestId = {{ request_obj.id|default:0 }};
|
||
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
|
||
|
||
// ===== Обработка Approve =====
|
||
const approveForm = document.getElementById('reqApproveForm');
|
||
const approveModal = document.getElementById('reqApproveModal');
|
||
|
||
if (approveModal && approveForm) {
|
||
approveModal.addEventListener('show.bs.modal', function(e) {
|
||
const btn = e.relatedTarget;
|
||
document.getElementById('approveId').value = btn?.dataset.id || requestId;
|
||
});
|
||
|
||
approveForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const id = document.getElementById('approveId').value;
|
||
|
||
try {
|
||
const res = await fetch(`/api/v1/requests/${id}/approve/`, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json' },
|
||
});
|
||
if (!res.ok) throw new Error('Ошибка');
|
||
window.location.reload();
|
||
} catch (err) {
|
||
alert('Ошибка при одобрении: ' + err.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== Обработка Reject =====
|
||
const rejectForm = document.getElementById('reqRejectForm');
|
||
const rejectModal = document.getElementById('reqRejectModal');
|
||
|
||
if (rejectModal && rejectForm) {
|
||
rejectModal.addEventListener('show.bs.modal', function(e) {
|
||
const btn = e.relatedTarget;
|
||
document.getElementById('rejectId').value = btn?.dataset.id || requestId;
|
||
});
|
||
|
||
rejectForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const id = document.getElementById('rejectId').value;
|
||
|
||
try {
|
||
const res = await fetch(`/api/v1/requests/${id}/reject/`, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRFToken': csrfToken, 'Content-Type': 'application/json' },
|
||
});
|
||
if (!res.ok) throw new Error('Ошибка');
|
||
window.location.reload();
|
||
} catch (err) {
|
||
alert('Ошибка при отклонении: ' + err.message);
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== Обработка комментариев =====
|
||
const commentForm = document.querySelector('.request-comment-form');
|
||
if (commentForm) {
|
||
const textarea = commentForm.querySelector('textarea[name="text"]');
|
||
const emojiPicker = commentForm.querySelector('emoji-picker');
|
||
const submitBtn = commentForm.querySelector('button[type="submit"]');
|
||
|
||
// Emoji picker
|
||
if (textarea && emojiPicker) {
|
||
emojiPicker.addEventListener('emoji-click', (e) => {
|
||
textarea.value += e.detail.unicode;
|
||
textarea.focus();
|
||
});
|
||
}
|
||
|
||
// AJAX отправка комментария
|
||
commentForm.addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
|
||
const text = textarea.value.trim();
|
||
if (!text) return;
|
||
|
||
submitBtn.disabled = true;
|
||
const originalHTML = submitBtn.innerHTML;
|
||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span>';
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('text', text);
|
||
|
||
const res = await fetch(commentForm.action, {
|
||
method: 'POST',
|
||
headers: { 'X-CSRFToken': csrfToken },
|
||
body: formData
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||
}
|
||
|
||
// Успех — очищаем форму и перезагружаем
|
||
textarea.value = '';
|
||
window.location.reload();
|
||
|
||
} catch (err) {
|
||
alert('Ошибка: ' + err.message);
|
||
submitBtn.disabled = false;
|
||
submitBtn.innerHTML = originalHTML;
|
||
}
|
||
});
|
||
}
|
||
|
||
// ===== Delete comments =====
|
||
document.addEventListener('click', async (e) => {
|
||
const deleteBtn = e.target.closest('.delete-comment-btn');
|
||
if (!deleteBtn) return;
|
||
|
||
if (!confirm('Удалить комментарий?')) return;
|
||
|
||
const requestId = deleteBtn.dataset.requestId;
|
||
const commentId = deleteBtn.dataset.commentId;
|
||
|
||
try {
|
||
const res = await fetch(
|
||
`/requests/comments/${requestId}/delete/${commentId}/`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'X-CSRFToken': csrfToken }
|
||
}
|
||
);
|
||
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}));
|
||
throw new Error(err.detail || `HTTP ${res.status}`);
|
||
}
|
||
|
||
window.location.reload();
|
||
|
||
} catch (err) {
|
||
alert('Ошибка: ' + err.message);
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|