From a5d739d3b9ecb95bedf87c36bc386cb032cb834d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Mon, 29 Dec 2025 09:43:05 +0300 Subject: [PATCH] =?UTF-8?q?=EF=BF=BD=20=D0=9C=D0=BE=D0=B1=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D0=B0=D1=8F=20=D0=BE=D0=BF=D1=82=D0=B8=D0=BC=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=B8=20=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D1=8B=D0=B9=20=D1=82=D0=B8=D0=BF=20=D0=B7=D0=B0=D1=8F=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20'=D0=9E=D1=82=D0=B3=D1=83?= =?UTF-8?q?=D0=BB'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 - Добавлен во все формы создания/редактирования - Добавлен в фильтры списка --- backend/requests_app/enums.py | 1 + .../migrations/0009_alter_request_type.py | 18 ++ backend/static/css/components/page-header.css | 52 ++++- .../static/css/components/request-list.css | 177 ++++++++++++++---- .../js/components/requestCrudHandler.js | 18 +- .../js/components/requestListHandler.js | 59 +++++- backend/templates/requests_app/_filters.html | 1 + .../requests_app/request_list_full.html | 6 +- 8 files changed, 278 insertions(+), 54 deletions(-) create mode 100644 backend/requests_app/migrations/0009_alter_request_type.py diff --git a/backend/requests_app/enums.py b/backend/requests_app/enums.py index be1ba10e..afd60865 100644 --- a/backend/requests_app/enums.py +++ b/backend/requests_app/enums.py @@ -7,6 +7,7 @@ class RequestType(TextChoices): VACATION = "vacation", "Отпуск" SICK_LEAVE = "sick_leave", "Больничный" + DAY_OFF = "day_off", "Отгул" TRANSFER = "transfer", "Перевод" DISMISSAL = "dismissal", "Увольнение" OTHER = "other", "Другое" diff --git a/backend/requests_app/migrations/0009_alter_request_type.py b/backend/requests_app/migrations/0009_alter_request_type.py new file mode 100644 index 00000000..1361513b --- /dev/null +++ b/backend/requests_app/migrations/0009_alter_request_type.py @@ -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='Тип заявления'), + ), + ] diff --git a/backend/static/css/components/page-header.css b/backend/static/css/components/page-header.css index a53b07db..bf375244 100644 --- a/backend/static/css/components/page-header.css +++ b/backend/static/css/components/page-header.css @@ -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; + } } } diff --git a/backend/static/css/components/request-list.css b/backend/static/css/components/request-list.css index b0091356..58de7fd9 100644 --- a/backend/static/css/components/request-list.css +++ b/backend/static/css/components/request-list.css @@ -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; + } } /* Компактный режим для маленьких экранов */ diff --git a/backend/static/js/components/requestCrudHandler.js b/backend/static/js/components/requestCrudHandler.js index d9776fa0..c666a2b5 100644 --- a/backend/static/js/components/requestCrudHandler.js +++ b/backend/static/js/components/requestCrudHandler.js @@ -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) { diff --git a/backend/static/js/components/requestListHandler.js b/backend/static/js/components/requestListHandler.js index c7c2d5f0..2821d497 100644 --- a/backend/static/js/components/requestListHandler.js +++ b/backend/static/js/components/requestListHandler.js @@ -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 = '
Потеряна связь с интернетом
'; + }); /** * Загружает заявления с 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 = '
Нет интернет-соединения. Проверьте подключение.
'; + 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 = '
Не удалось загрузить заявления
'; + + // Более информативное сообщение об ошибке + let errorMsg = 'Не удалось загрузить заявления'; + if (error.name === 'AbortError') { + errorMsg = 'Время ожидания истекло. Проверьте интернет-соединение.'; + } else if (!navigator.onLine) { + errorMsg = 'Нет интернет-соединения.'; + } + + listElement.innerHTML = ` +
+
${errorMsg}
+ Попробуйте обновить страницу +
+ `; 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 || 'Сотрудник'); // Кнопки действий diff --git a/backend/templates/requests_app/_filters.html b/backend/templates/requests_app/_filters.html index 2964fcdf..c55909ba 100644 --- a/backend/templates/requests_app/_filters.html +++ b/backend/templates/requests_app/_filters.html @@ -14,6 +14,7 @@ + diff --git a/backend/templates/requests_app/request_list_full.html b/backend/templates/requests_app/request_list_full.html index 7fb627eb..8ae08b29 100644 --- a/backend/templates/requests_app/request_list_full.html +++ b/backend/templates/requests_app/request_list_full.html @@ -48,7 +48,7 @@ - + {# Контейнер для данных заявлений #}
{# Список загружается через JavaScript из requestListHandler.js #} @@ -86,6 +86,7 @@ + @@ -94,7 +95,7 @@
-
@@ -174,6 +175,7 @@ +