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:
parent
a280f6e7f7
commit
3ab23973b5
@ -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-ветка для автокомплита
|
||||
|
||||
@ -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 для бесконечной прокрутки
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 %}
|
||||
|
||||
@ -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" %}'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user