Corp/backend/templates/requests_app/request_detail.html
Игорь Надеин 8159c6f472 feat: Replace Bootstrap buttons with custom button system
### Custom Button System Implementation

Created modern button system to replace Bootstrap buttons:

- ✅ Created core/_buttons-custom.scss (550+ lines)
- ✅ CSS Custom Properties for easy customization
- ✅ 8 color variants (primary, secondary, success, danger, warning, info, light, dark)
- ✅ Outline and Ghost variants
- ✅ 4 sizes (sm, md, lg, xl)
- ✅ Special variants (block, icon, circle, link)
- ✅ Loading state with spinner animation
- ✅ Button groups support
- ✅ Modern animations (hover, active, focus)

### Template Migration

Migrated 65 HTML templates using automated Python script:
- Replaced .btn → .button
- Replaced .btn-primary → .button--primary
- Replaced .btn-outline-* → .button--outline button--*
- Replaced size classes .btn-sm → .button--sm
- Total changes: 354 → 347 button usages

### Results

- app.css: 137 KB → 148 KB (+11 KB for custom buttons)
- Templates changed: 65 files
- Remaining .btn classes: 133 (mostly btn-close for modals)
- New .button classes: 347

### Backward Compatibility

Kept @extend rules in _buttons-custom.scss for gradual migration.
Can be removed after complete migration.

Next step: Remove Bootstrap button imports from custom-bootstrap.scss
2026-01-15 14:23:44 +03:00

520 lines
20 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% load static %}
{% block title %}Заявление #{{ request_obj.id }}{% endblock %}
{% block extra_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="button button--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="button button--success button--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="button button--danger button--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="button button--sm button--outline button--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="button button--link button--sm text-danger p-0 delete-comment-button"
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="button button--ghost btn-emoji message-icon-button"
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="button button--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="button button--secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="button button--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="button button--secondary" type="button" data-bs-dismiss="modal">Отмена</button>
<button class="button button--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 button = e.relatedTarget;
document.getElementById('approveId').value = button?.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 button = e.relatedTarget;
document.getElementById('rejectId').value = button?.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-button');
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 %}