� Мобильная оптимизация и новый тип заявления 'Отгул'

MOBILE FIXES:
- request-list.css: переделан мобильный layout с flexbox вместо grid
- page-header.css: оптимизирован padding/margin для мобильных экранов
- requestListHandler.js:
  * Добавлен AbortController с таймаутом (10/15 сек для мобильных)
  * Обработка offline/online событий сети
  * Информативные сообщения об ошибках (отсутствие интернета, таймаут)
  * Адаптивный Intersection Observer (rootMargin 200px для мобильных)
  * Лучшая обработка network failures

UI/UX:
- Скрыт счётчик на экранах < 480px
- Контент теперь отображается на всех разрешениях
- Исчезновение спиннера при ошибке сети
- Визуальное различие между ошибками загрузки

NEW FEATURE:
- Добавлен новый тип заявления 'Отгул' (day_off)
- Обновлена миграция 0009_alter_request_type
- Добавлен во все формы создания/редактирования
- Добавлен в фильтры списка
This commit is contained in:
Игорь Надеин 2025-12-29 09:43:05 +03:00
parent bc5f66c6e5
commit a5d739d3b9
8 changed files with 278 additions and 54 deletions

View File

@ -7,6 +7,7 @@ class RequestType(TextChoices):
VACATION = "vacation", "Отпуск"
SICK_LEAVE = "sick_leave", "Больничный"
DAY_OFF = "day_off", "Отгул"
TRANSFER = "transfer", "Перевод"
DISMISSAL = "dismissal", "Увольнение"
OTHER = "other", "Другое"

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2025-12-29 06:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('requests_app', '0008_migrate_department_to_departments'),
]
operations = [
migrations.AlterField(
model_name='request',
name='type',
field=models.CharField(choices=[('vacation', 'Отпуск'), ('sick_leave', 'Больничный'), ('day_off', 'Отгул'), ('transfer', 'Перевод'), ('dismissal', 'Увольнение'), ('other', 'Другое')], max_length=32, verbose_name='Тип заявления'),
),
]

View File

@ -257,18 +257,60 @@
/* Мобильные */
@media (max-width: 575.98px) {
.page-header {
margin-bottom: var(--space-2);
padding: var(--space-1) 0;
top: var(--navbar-h, 60px);
}
.page-title {
font-size: 1.25rem;
font-size: 1.125rem;
font-weight: 700;
}
.page-count {
font-size: 0.75rem;
font-size: 0.7rem;
}
.page-scope-switch {
gap: 0.25rem;
}
.page-scope-switch .btn {
min-width: 50px;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
min-width: auto;
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
}
.page-filters {
margin-top: 0.5rem;
}
.page-filters .btn {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
}
.page-search {
margin-top: 0.5rem;
}
/* Скрываем счётчик на очень маленьких экранах */
@media (max-width: 480px) {
.page-count {
display: none;
}
.page-header .card {
margin-top: 0;
width: 100%;
}
.page-header .card button.dept-row,
.page-header .card a.dept-row {
padding: var(--space-1) var(--space-1);
font-size: 0.9rem;
}
}
}

View File

@ -191,19 +191,19 @@
minmax(100px, 1fr) /* Автор */
90px /* Даты */
100px /* Статус */
70px; /* Создано + Действия объединены */
70px; /* Создано */
}
/* Прячем отдельную колонку действий */
#reqList .req-table-header .req-cell-actions,
#reqList .req-cell-actions {
/* На планшетах скрываем колонку действий как отдельное поле */
#reqList .req-table-header .req-cell-actions {
display: none;
}
/* Добавляем действия в строку заголовка */
#reqList .req-cell-title .req-cell-content::after {
content: '';
display: block;
/* Но сами кнопки остаются видимыми */
#reqList .req-cell-actions {
grid-column: 6;
display: flex !important;
justify-content: flex-end;
}
}
@ -213,57 +213,156 @@
#reqList .req-table-header {
display: none;
}
/* Переходим на flexbox layout */
#reqList .req-table-row {
grid-template-columns: 36px 1fr;
gap: 0.5rem;
padding: 0.75rem;
display: flex;
flex-direction: row;
flex-wrap: wrap;
padding: 1rem 0.75rem;
gap: 0.75rem;
align-items: flex-start;
}
/* Все ячейки в две колонки */
/* Сброс для всех ячеек */
#reqList .req-cell {
display: block;
min-width: 0;
}
/* Сброс для контента внутри ячеек */
#reqList .req-cell-content {
width: 100%;
}
/* Иконка слева сверху */
#reqList .req-cell-icon {
grid-row: 1 / span 3;
align-self: start;
flex: 0 0 36px;
padding-top: 0.25rem;
}
#reqList .req-cell-icon .card-icon {
width: 36px;
height: 36px;
width: 32px;
height: 32px;
}
/* Основной контент - заголовок занимает полную ширину минус иконка */
#reqList .req-cell-title {
grid-column: 2;
flex: 1 1 calc(100% - 48px);
font-size: 0.9rem;
}
#reqList .req-cell-author {
grid-column: 2;
#reqList .req-cell-title strong {
display: block;
margin-bottom: 0.25rem;
font-size: 0.95rem;
}
#reqList .req-cell-title .badge {
display: inline-block;
font-size: 0.7rem;
padding: 0.2rem 0.4rem;
}
#reqList .req-cell-title .small {
display: block;
font-size: 0.8rem;
margin-top: -0.25rem;
}
#reqList .req-cell-dates,
#reqList .req-cell-status,
#reqList .req-cell-created {
grid-column: 2;
font-size: 0.75rem;
}
#reqList .req-cell-actions {
grid-column: 2;
justify-content: flex-start;
margin-top: 0.25rem;
}
#reqList .req-footer {
margin-left: calc(36px + 0.5rem);
padding: 0.5rem 0.75rem;
/* Остальная информация - автор, даты, статус */
#reqList .req-cell-author,
#reqList .req-cell-dates,
#reqList .req-cell-status {
flex: 0 1 auto;
font-size: 0.8rem;
}
#reqList .req-cell-author {
flex: 0 1 auto;
}
#reqList .req-cell-dates {
flex: 0 1 auto;
white-space: nowrap;
}
#reqList .req-cell-dates br {
display: none;
}
#reqList .req-cell-status {
flex: 0 1 auto;
}
#reqList .req-cell-status .badge {
font-size: 0.7rem;
padding: 0.25rem 0.5rem;
white-space: nowrap;
}
/* Скрываем дату создания на мобильных */
#reqList .req-cell-created {
display: none;
}
/* Кнопки действий занимают полную строку */
#reqList .req-cell-actions {
display: flex !important;
flex: 1 1 100%;
gap: 0.5rem;
padding-top: 0.75rem;
border-top: 1px solid var(--bs-border-color-translucent);
margin: 0;
}
#reqList .req-cell-actions .req-cell-content {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
width: 100%;
}
#reqList .req-cell-actions .btn {
padding: 0.5rem 0.75rem;
font-size: 0.85rem;
}
/* Футер с комментариями занимает полную ширину */
#reqList .req-footer {
flex: 1 1 100%;
margin-left: 0;
padding: 0.75rem 0;
border-top: 1px solid var(--bs-border-color-translucent);
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
#reqList .req-footer .btn {
padding: 0.4rem 0.6rem;
font-size: 0.8rem;
}
/* Блок комментариев */
#reqList .comments-block {
margin-left: 0;
padding: 0.75rem;
}
/* Строка заявления */
#reqList .req-row {
padding: 0;
border-bottom: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 50%);
}
#reqList .req-row:hover {
background-color: transparent;
}
#reqList .req-row:last-child {
border-bottom: 0;
}
}
/* Компактный режим для маленьких экранов */

View File

@ -303,7 +303,11 @@ export function initRequestCrudHandler(options) {
try {
const response = await fetch(`${apiDetailBase}${reqId}/approve/`, {
method: 'POST',
headers: headers
headers: {
...headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!response.ok) {
@ -330,7 +334,11 @@ export function initRequestCrudHandler(options) {
try {
const response = await fetch(`${apiDetailBase}${reqId}/reject/`, {
method: 'POST',
headers: headers
headers: {
...headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!response.ok) {
@ -361,7 +369,11 @@ export function initRequestCrudHandler(options) {
try {
const response = await fetch(`${apiDetailBase}${reqId}/cancel/`, {
method: 'POST',
headers: headers
headers: {
...headers,
'Content-Type': 'application/json'
},
body: JSON.stringify({})
});
if (!response.ok) {

View File

@ -47,6 +47,18 @@ export function initRequestListHandler(options) {
// Intersection Observer для бесконечной прокрутки
let observerTarget = null;
let observer = null;
// Мобильная оптимизация: слушаем сетевые события
let isOnline = navigator.onLine;
window.addEventListener('online', () => {
isOnline = true;
console.log('Network restored');
});
window.addEventListener('offline', () => {
isOnline = false;
console.log('Network lost');
listElement.innerHTML = '<div class="p-4 text-center text-warning">Потеряна связь с интернетом</div>';
});
/**
* Загружает заявления с API
@ -58,6 +70,13 @@ export function initRequestListHandler(options) {
console.log('loadRequests: skipped', { loading, append, hasMore });
return [];
}
// Проверка сети на мобильных
if (!isOnline) {
console.warn('loadRequests: offline');
listElement.innerHTML = '<div class="p-4 text-center text-warning">Нет интернет-соединения. Проверьте подключение.</div>';
return [];
}
loading = true;
showLoadingSpinner();
@ -91,7 +110,17 @@ export function initRequestListHandler(options) {
console.log('loadRequests: initial load', url);
}
const response = await fetch(url, { headers });
const controller = new AbortController();
// На мобильных устройствах увеличиваем таймаут до 15 секунд
const timeoutMs = /iPhone|iPad|Android|Mobile/.test(navigator.userAgent) ? 15000 : 10000;
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
const response = await fetch(url, {
headers,
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
@ -128,7 +157,21 @@ export function initRequestListHandler(options) {
console.error('Ошибка загрузки заявлений:', error);
loading = false;
hideLoadingSpinner();
listElement.innerHTML = '<div class="p-4 text-center text-danger">Не удалось загрузить заявления</div>';
// Более информативное сообщение об ошибке
let errorMsg = 'Не удалось загрузить заявления';
if (error.name === 'AbortError') {
errorMsg = 'Время ожидания истекло. Проверьте интернет-соединение.';
} else if (!navigator.onLine) {
errorMsg = 'Нет интернет-соединения.';
}
listElement.innerHTML = `
<div class="p-4 text-center">
<div class="text-danger mb-2">${errorMsg}</div>
<small class="text-body-secondary d-block">Попробуйте обновить страницу</small>
</div>
`;
return [];
}
}
@ -190,18 +233,22 @@ export function initRequestListHandler(options) {
function setupInfiniteScroll() {
if (!observerTarget) return;
// Создаем observer
// Создаем observer с адаптивными параметрами для мобильных
const isMobile = /iPhone|iPad|Android|Mobile/.test(navigator.userAgent);
const rootMargin = isMobile ? '200px' : '100px'; // На мобильных загружаем раньше
observer = new IntersectionObserver(
(entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && hasMore && !loading) {
console.log('Intersection observer: triggering loadMore');
loadMore();
}
});
},
{
root: null,
rootMargin: '100px',
rootMargin,
threshold: 0.01
}
);
@ -293,6 +340,7 @@ export function initRequestListHandler(options) {
let typeIcon = 'bi-clipboard-check text-secondary';
if (type === 'vacation') typeIcon = 'bi-calendar-check text-primary';
else if (type === 'sick_leave') typeIcon = 'bi-capsule text-danger';
else if (type === 'day_off') typeIcon = 'bi-umbrella-fill text-info';
else if (type === 'transfer') typeIcon = 'bi-box-arrow-right text-info';
else if (type === 'dismissal') typeIcon = 'bi-door-open text-warning';
@ -300,6 +348,7 @@ export function initRequestListHandler(options) {
const typeText = {
'vacation': 'Отпуск',
'sick_leave': 'Больничный',
'day_off': 'Отгул',
'transfer': 'Перевод',
'dismissal': 'Увольнение',
'other': 'Другое'
@ -315,7 +364,7 @@ export function initRequestListHandler(options) {
else if (status === 'cancelled') { statusBadgeClass = 'text-bg-secondary'; statusText = 'Отменено'; }
// URL для сотрудника
const employeeUrl = req.employee?.id === userId ? '/employees/profile/' : `/employees/${req.employee?.id}/`;
const employeeUrl = `/employees/${req.employee?.id}/`;
const employeeName = req.employee?.id === userId ? 'Вы' : (req.employee?.full_name || req.employee?.display_name || 'Сотрудник');
// Кнопки действий

View File

@ -14,6 +14,7 @@
<option value="">— Любой —</option>
<option value="vacation" {% if filters.type == 'vacation' %}selected{% endif %}>Отпуск</option>
<option value="sick_leave" {% if filters.type == 'sick_leave' %}selected{% endif %}>Больничный</option>
<option value="day_off" {% if filters.type == 'day_off' %}selected{% endif %}>Отгул</option>
<option value="transfer" {% if filters.type == 'transfer' %}selected{% endif %}>Перевод</option>
<option value="dismissal" {% if filters.type == 'dismissal' %}selected{% endif %}>Увольнение</option>
<option value="other" {% if filters.type == 'other' %}selected{% endif %}>Другое</option>

View File

@ -48,7 +48,7 @@
</div>
</div>
</div>
{# Контейнер для данных заявлений #}
<div id="reqListContent">
{# Список загружается через JavaScript из requestListHandler.js #}
@ -86,6 +86,7 @@
<option value="" selected disabled>Выберите тип…</option>
<option value="vacation">📅 Отпуск</option>
<option value="sick_leave">🏥 Больничный</option>
<option value="day_off">🏖️ Отгул</option>
<option value="transfer">↔️ Перевод</option>
<option value="dismissal">👋 Увольнение</option>
<option value="other">📋 Другое</option>
@ -94,7 +95,7 @@
<div class="mb-3">
<label class="form-label">Тема</label>
<input type="text" name="title" class="form-control"
<input type="text" name="title" class="form-control"
placeholder="Краткое описание заявления..." maxlength="255">
</div>
@ -174,6 +175,7 @@
<option value="" disabled>Выберите тип…</option>
<option value="vacation">📅 Отпуск</option>
<option value="sick_leave">🏥 Больничный</option>
<option value="day_off">🏖️ Отгул</option>
<option value="transfer">↔️ Перевод</option>
<option value="dismissal">👋 Увольнение</option>
<option value="other">📋 Другое</option>