� Мобильная оптимизация и новый тип заявления 'Отгул'
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:
parent
bc5f66c6e5
commit
a5d739d3b9
@ -7,6 +7,7 @@ class RequestType(TextChoices):
|
||||
|
||||
VACATION = "vacation", "Отпуск"
|
||||
SICK_LEAVE = "sick_leave", "Больничный"
|
||||
DAY_OFF = "day_off", "Отгул"
|
||||
TRANSFER = "transfer", "Перевод"
|
||||
DISMISSAL = "dismissal", "Увольнение"
|
||||
OTHER = "other", "Другое"
|
||||
|
||||
18
backend/requests_app/migrations/0009_alter_request_type.py
Normal file
18
backend/requests_app/migrations/0009_alter_request_type.py
Normal 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='Тип заявления'),
|
||||
),
|
||||
]
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Компактный режим для маленьких экранов */
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 || 'Сотрудник');
|
||||
|
||||
// Кнопки действий
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user