feat: add employee dismissal UI and filter inactive employees

- Added dismissal form on employee profile page with permissions
- Shows warning when dismissing (LDAP deactivation notice)
- Inactive employees filtered by default in employee list (active=true)
- Team wheel now shows only active employees
- Auto-submit filter form on status change
- Added redirect to set active=true by default on first visit
This commit is contained in:
admin i 2025-12-17 09:13:51 +03:00
parent 1c9aa9284e
commit 9d47fc0c5c
7 changed files with 228 additions and 14 deletions

View File

@ -209,6 +209,15 @@ def employee_list(request):
Для HTML страницы - данные загружаются через JavaScript на клиенте.
"""
# Если параметр active отсутствует и это HTML-запрос,
# редиректим с active=true по умолчанию
if request.GET.get("format") != "json" and "active" not in request.GET:
params = request.GET.copy()
params["active"] = "true"
from django.http import QueryDict
query_string = params.urlencode()
return redirect(f"{request.path}?{query_string}")
api = get_api_client(request)
# JSON-ветка для автокомплита

View File

@ -26,7 +26,8 @@ export class EmployeesList {
search: urlParams.get('q') || '',
department: urlParams.get('department') || '',
position: urlParams.get('position') || '',
is_active: urlParams.get('is_active') || ''
// По умолчанию показываем только активных сотрудников
active: urlParams.get('active') !== null ? urlParams.get('active') : 'true'
};
// Intersection Observer для бесконечной прокрутки

View File

@ -67,14 +67,16 @@ export function initTeamWheel(options) {
// Источник данных
const src = document.getElementById(dataSourceId);
const members = Array.from(src ? src.children : []).map(n => ({
id: Number(n.getAttribute('data-id') || '0'),
name: n.getAttribute('data-name') || '',
role: n.getAttribute('data-role') || '',
email: n.getAttribute('data-email') || '',
avatar: n.getAttribute('data-avatar') || '',
active: n.getAttribute('data-active') === '1'
}));
const members = Array.from(src ? src.children : [])
.map(n => ({
id: Number(n.getAttribute('data-id') || '0'),
name: n.getAttribute('data-name') || '',
role: n.getAttribute('data-role') || '',
email: n.getAttribute('data-email') || '',
avatar: n.getAttribute('data-avatar') || '',
active: n.getAttribute('data-active') === '1'
}))
.filter(m => m.active); // Показываем только активных сотрудников
// Если никого нет — показываем руководителя
if (!members.length) {

View File

@ -25,10 +25,10 @@
<div class="col-12 col-md-6 col-lg-3">
<label class="form-label">Статус</label>
<select name="is_active" class="form-select">
<option value="">Все</option>
<option value="true" {% if request.GET.is_active == "true" %}selected{% endif %}>Активные</option>
<option value="false" {% if request.GET.is_active == "false" %}selected{% endif %}>Неактивные</option>
<select name="active" class="form-select" id="activeFilter">
<option value="">Все сотрудники</option>
<option value="true" {% if request.GET.active == "true" or not request.GET.active %}selected{% endif %}>Только активные</option>
<option value="false" {% if request.GET.active == "false" %}selected{% endif %}>Только неактивные</option>
</select>
</div>

View File

@ -79,5 +79,127 @@
}
});
}
// 5. Обработчик формы кадровых событий
const actionForm = document.getElementById('employeeActionForm');
const actionType = document.getElementById('actionType');
const actionDate = document.getElementById('actionDate');
const dismissalWarning = document.getElementById('dismissalWarning');
const actionSuccess = document.getElementById('actionSuccess');
const actionError = document.getElementById('actionError');
if (actionForm && actionType) {
// Установка текущей даты по умолчанию
if (actionDate) {
const now = new Date();
const offset = now.getTimezoneOffset() * 60000;
const localISOTime = new Date(now - offset).toISOString().slice(0, 16);
actionDate.value = localISOTime;
}
// Показ предупреждения при выборе увольнения
actionType.addEventListener('change', (e) => {
if (dismissalWarning) {
if (e.target.value === 'dismissed') {
dismissalWarning.classList.remove('d-none');
} else {
dismissalWarning.classList.add('d-none');
}
}
});
// Отправка формы
actionForm.addEventListener('submit', async (e) => {
e.preventDefault();
// Скрываем предыдущие сообщения
if (actionSuccess) actionSuccess.classList.add('d-none');
if (actionError) actionError.classList.add('d-none');
const formData = new FormData(actionForm);
const data = {
employee: parseInt(formData.get('employee')),
action: formData.get('action'),
date: formData.get('date'),
comment: formData.get('comment') || ''
};
const submitBtn = actionForm.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Сохранение...';
try {
const response = await fetch('/api/v1/employee-actions/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': formData.get('csrfmiddlewaretoken')
},
body: JSON.stringify(data)
});
if (response.ok) {
const result = await response.json();
// Показываем сообщение об успехе
if (actionSuccess) {
const actionNames = {
'hired': 'Принят на работу',
'dismissed': 'Уволен',
'transferred': 'Переведен',
'on_leave': 'В отпуске',
'returned_from_leave': 'Вернулся из отпуска',
'on_maternity': 'В декрете',
'returned_from_maternity': 'Вернулся из декрета',
'rehired': 'Восстановлен'
};
actionSuccess.textContent = `Событие "${actionNames[data.action]}" успешно создано.`;
actionSuccess.classList.remove('d-none');
}
// Сбрасываем форму
actionForm.reset();
if (actionDate) {
const now = new Date();
const offset = now.getTimezoneOffset() * 60000;
const localISOTime = new Date(now - offset).toISOString().slice(0, 16);
actionDate.value = localISOTime;
}
if (dismissalWarning) dismissalWarning.classList.add('d-none');
// Перезагружаем страницу через 2 секунды для отображения нового события
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
const error = await response.json();
let errorMsg = 'Ошибка при создании события.';
if (error.detail) {
errorMsg = error.detail;
} else if (error.error) {
errorMsg = error.error;
} else if (typeof error === 'object') {
errorMsg = Object.values(error).flat().join('. ');
}
if (actionError) {
actionError.textContent = errorMsg;
actionError.classList.remove('d-none');
}
}
} catch (error) {
console.error('Error creating employee action:', error);
if (actionError) {
actionError.textContent = 'Произошла ошибка при отправке данных.';
actionError.classList.remove('d-none');
}
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
}
});
</script>

View File

@ -238,7 +238,7 @@
{% if a.history and a.history|length %}
<details class="mt-2 small">
<summary class="text-secondary">История</summary>
<summary class="text-secondary">Подробнее</summary>
<div class="mt-2">
{% for h in a.history %}
{% with new_action=h.changes.action.new|default:a.action %}
@ -311,6 +311,75 @@
{% endwith %}
{% endif %}
{# === УПРАВЛЕНИЕ КАДРОВЫМИ СОБЫТИЯМИ === #}
{% if request.user.is_authenticated and not is_me %}
{% if perms.employees.add_employeeaction or request.user.is_staff or request.user.is_superuser %}
<div class="mt-3 text-start">
<div class="ios-acc">
<button class="ios-collapse-trigger" type="button"
data-bs-toggle="collapse" data-bs-target="#actionsManageCollapse"
aria-expanded="false" aria-controls="actionsManageCollapse">
<span class="title"><i class="bi-clipboard-plus me-1"></i>Управление событиями</span>
<i class="bi-chevron-down ios-collapse-caret"></i>
</button>
<div id="actionsManageCollapse" class="collapse">
<div class="ios-acc-body">
<form id="employeeActionForm" class="p-2">
{% csrf_token %}
<input type="hidden" name="employee" value="{{ emp.id }}">
<div class="mb-3">
<label for="actionType" class="form-label">Тип события</label>
<select class="form-select" id="actionType" name="action" required>
<option value="">Выберите событие</option>
<option value="hired">📋 Принят на работу</option>
<option value="dismissed">👋 Уволен</option>
<option value="transferred">🔄 Переведен</option>
<option value="on_leave">🏖️ В отпуске</option>
<option value="returned_from_leave">↩️ Вернулся из отпуска</option>
<option value="on_maternity">👶 В декрете</option>
<option value="returned_from_maternity">↩️ Вернулся из декрета</option>
<option value="rehired">🔄 Восстановлен</option>
</select>
</div>
<div class="mb-3">
<label for="actionDate" class="form-label">Дата события</label>
<input type="datetime-local" class="form-control" id="actionDate" name="date" required>
</div>
<div class="mb-3">
<label for="actionComment" class="form-label">Комментарий/причина</label>
<textarea class="form-control" id="actionComment" name="comment" rows="3" placeholder="Опционально"></textarea>
</div>
<div class="alert alert-warning d-none" id="dismissalWarning">
<i class="bi-exclamation-triangle-fill me-2"></i>
<strong>Внимание:</strong> Увольнение деактивирует сотрудника в системе и LDAP (если настроено).
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="bi-check-lg me-1"></i>Сохранить событие
</button>
<button type="button" class="btn btn-ghost" data-bs-toggle="collapse" data-bs-target="#actionsManageCollapse">
Отмена
</button>
</div>
<div class="mt-2">
<div class="alert alert-success d-none" id="actionSuccess"></div>
<div class="alert alert-danger d-none" id="actionError"></div>
</div>
</form>
</div>
</div>
</div>
</div>
{% endif %}
{% endif %}
{# ====== НАВЫКИ ====== #}
<div class="mt-3 text-start" id="skillsBlock"
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}

View File

@ -86,6 +86,17 @@
debounceMs: 300
});
// Обработка изменения фильтра активности
const activeFilter = document.getElementById('activeFilter');
if (activeFilter) {
activeFilter.addEventListener('change', (e) => {
const form = e.target.closest('form');
if (form) {
form.submit();
}
});
}
// Инициализация модала создания сотрудника
initCreateEmployeeModal({
submitUrl: '{% url "employees:employee_create_modal" %}'