feat(ui, departments): новая страница отдела — колесо команды (4 колонки), collapse «Настройки отдела», фиксы назначения ролей

— Переверстал backend/templates/employees/department_detail.html под раскладку как в employee_detail:
  • левая колонка (md-4): «Команда отдела» (круглое окно) + инфо блока;
  • правая колонка (md-8): collapse-блок «Настройки отдела» в стиле ios-card + «Публикации отдела».

— «Колесо команды»:
  • круглое окно с 4 колонками аватарок ВСЕХ участников отдела; каждый следующий ряд смещён на 0.5 размера аватарки;
  • аватарки круглые, без подписей; поддержка тёмной/светлой темы;
  • автоскролл с дуговым индикатором прогресса по правой полуокружности;
  • hover — пауза автоскролла, включается ручной скролл (колёсико/тач);
  • клик по аватарке — фокус: аватарка растягивается на весь круг; под кругом центрировано показываются имя/роль/email;
  • если в отделе нет сотрудников — в круге показывается руководитель;
  • a11y: aria-атрибуты для живого региона и описаний.

— Роли/кнопки:
  • кнопки Invite / Set role / Remove показываются только при наличии прав (is_superuser|is_staff|can_manage|can_assign_roles|head);
  • fixed: «Назначить роль» больше не падает с NoReverseMatch — action формируется из шаблона `/employees/0/departments/<dept>/set-role/` и подменяется на выбранный empId по клику;
  • формы модалок: мелкие правки, валидация, улучшения UX (ajax-поиск, активное состояние, блокировка submit, плавный скролл к collapse на мобильных).

— Настройки отдела:
  • оформлен collapse-блок «Настройки отдела» в стиле ios-card (как в редакторе профиля);
  • добавлена кнопка-шестерёнка в инфо-блоке для быстрого раскрытия;
  • include исправлен: `{% include "employees/_department_controls.html" with dept=dept edit_form=edit_form head_choices=head_choices %}`.

— Стили и скрипты:
  • локализованы в `{% block extra_js %}` (можно вынести в base позже);
  • CSS-переменные для размеров, цветов и дуги; учитывает базовый шаблон и смену темы.

Files:
  M backend/templates/employees/department_detail.html

BREAKING CHANGE: нет
This commit is contained in:
admin i 2025-09-05 18:38:10 +03:00
parent 07d5209f43
commit 409632e3f3
10 changed files with 1324 additions and 936 deletions

View File

@ -6,7 +6,7 @@ from io import BytesIO
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.core.files.base import ContentFile
from employees.constants import ACTION_CHOICES, DeptPerm
from employees.constants import ACTION_CHOICES
from employees.models import (
Department,
DepartmentRole,
@ -375,6 +375,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
class EmployeeBriefSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
avatar = Base64ImageField(read_only=True)
class Meta:
model = Employee
@ -386,6 +387,7 @@ class EmployeeBriefSerializer(serializers.ModelSerializer):
"email",
"phone_number",
"display_name",
"avatar",
)
def get_display_name(self, obj) -> str:

View File

@ -881,33 +881,49 @@ class DepartmentViewSet(viewsets.ModelViewSet):
@transaction.atomic
def remove_member(self, request, pk: int | None = None):
"""
Деактивирует (удаляет) сотрудника из отдела.
Роль НЕ изменяется.
Удаляет связь сотрудника с отделом (строку EmployeeDepartment), а не просто
помечает её неактивной.
Тело:
Тело запроса:
{ "employee_id": <int> }
Ограничения:
- Нельзя удалить руководителя отдела (вернёт 400).
Права:
ManagePerm (DeptPerm.MANAGE)
Ответ:
200 OK
{
"employee_id": <int>,
"removed": true
}
"""
dept = self.get_object()
payload = RemoveMemberInput(data=request.data)
payload.is_valid(raise_exception=True)
emp_id: int = payload.validated_data["employee_id"]
emp_id = payload.validated_data["employee_id"]
# Защита от удаления руководителя
if dept.head_id == emp_id:
return Response(
{"detail": "Нельзя удалить руководителя отдела."},
status=status.HTTP_400_BAD_REQUEST,
)
link = get_object_or_404(
EmployeeDepartment, employee_id=emp_id, department_id=dept.id
EmployeeDepartment,
employee_id=emp_id,
department_id=dept.id,
)
if link.is_active:
link.is_active = False
link.save(update_fields=["is_active"])
# Жёсткое удаление связи
link.delete()
return Response(
{
"employee_id": emp_id,
"is_active": False,
"role_id": (link.role_id if getattr(link, "role_id", None) else None),
},
{"employee_id": emp_id, "removed": True},
status=status.HTTP_200_OK,
)

View File

@ -1,82 +0,0 @@
# employees/api_client.py
import requests
from django.conf import settings
from django.http import HttpRequest
from django.urls import reverse
class APIError(Exception):
pass
def _json_safe(x):
try:
from phonenumber_field.phonenumber import PhoneNumber
except Exception:
PhoneNumber = None
if isinstance(x, dict):
return {k: _json_safe(v) for k, v in x.items()}
if isinstance(x, (list, tuple)):
return [_json_safe(v) for v in x]
if PhoneNumber and isinstance(x, PhoneNumber):
return x.as_e164 or x.raw_input or str(x)
return x
def _abs_url(
request: HttpRequest, url_name: str, url_kwargs: dict | None = None
) -> str:
rel = reverse(url_name, kwargs=url_kwargs or None)
return request.build_absolute_uri(rel)
def api_post(request: HttpRequest, url_name: str, payload: dict, **kwargs):
url = _abs_url(request, url_name)
payload = _json_safe(payload or {})
headers = {"Accept": "application/json"}
try:
resp = requests.post(url, json=payload, headers=headers, timeout=10, **kwargs)
except requests.RequestException as e:
raise APIError(f"api_network_error: {e}")
try:
data = resp.json()
except ValueError:
# ← Вот тут будем видеть «почему»
snippet = (resp.text or "")[:500]
raise APIError(f"api_bad_json status={resp.status_code} body={snippet!r}")
return resp.status_code, data
def _session_cookies(request: HttpRequest) -> dict:
sid_name = getattr(settings, "SESSION_COOKIE_NAME", "sessionid")
sid_value = request.COOKIES.get(sid_name)
return {sid_name: sid_value} if sid_value else {}
def api_get(
request: HttpRequest,
url_name: str,
params: dict | None = None,
url_kwargs: dict | None = None,
**req_kwargs,
):
url = _abs_url(request, url_name, url_kwargs)
headers = {"Accept": "application/json"}
cookies = _session_cookies(request)
try:
resp = requests.get(
url,
params=params or {},
headers=headers,
cookies=cookies,
timeout=10,
**req_kwargs,
)
except requests.RequestException as e:
raise APIError(f"api_network_error: {e}")
try:
data = resp.json()
except ValueError:
snippet = (resp.text or "")[:500]
raise APIError(f"api_bad_json status={resp.status_code} body={snippet!r}")
return resp.status_code, data

View File

@ -346,7 +346,7 @@ class Department(models.Model):
employee_id=old_head_id,
department_id=self.pk,
defaults={
"is_active": False,
"is_active": True,
"date_from": timezone.now().date(),
"date_to": timezone.now().date(),
},
@ -354,7 +354,7 @@ class Department(models.Model):
if not created:
EmployeeDepartment.objects.filter(
employee_id=old_head_id, department_id=self.pk
).update(is_active=False, date_to=timezone.now().date())
).update(is_active=True, date_to=timezone.now().date())
else:
# Смена A -> B: по текущей логике бывший остаётся сотрудником отдела (активным)
# Если хотите наоборот — поменяйте is_active=True на False и добавьте date_to.

View File

@ -106,15 +106,6 @@ def _api_ok_or_error(resp) -> Tuple[bool, Dict[str, Any], int]:
return resp.ok, data, getattr(resp, "status", 500)
def _has_role(link):
r = link.get("role")
if r is None:
return False
if isinstance(r, dict):
return bool(r.get("id") or r.get("name"))
s = str(r).strip()
return s not in {"", "None", "null", "0"}
# =========================
# Сотрудники
@ -627,7 +618,9 @@ def set_member_role(request, dept_id: int, emp_id: int):
role_id = None
# заодно не даём менять самому себе (UI тоже скрывает)
if str(emp_id) == str(getattr(request.user, "id", None)):
if (str(emp_id) == str(getattr(request.user, "id", None))) and not (
request.user.is_staff or request.user.is_superuser
):
messages.error(request, "Нельзя изменять собственную роль в отделе.")
return redirect(
request.POST.get("next") or reverse("employees:profile", args=("me",))

View File

@ -15,7 +15,6 @@
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
crossorigin="anonymous">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/fullcalendar@6.1.8/index.global.min.css" rel="stylesheet">
<!-- Базовые стили приложения (+ feed) -->
<style>
@ -66,7 +65,7 @@
.app-layout.with-sidebar.with-rightbar{
display:grid; grid-template-columns: var(--sidebar-w) 1fr var(--rightbar-w); align-items:start;
}
.app-layout.with-sidebar:not(.with-rightbar)){
.app-layout.with-sidebar:not(.with-rightbar){
display:grid; grid-template-columns: var(--sidebar-w) 1fr; align-items:start;
}
}
@ -127,7 +126,7 @@
}
.feed-ava img{ width:100%; height:100%; object-fit:cover; display:block; }
.feed-meta{ line-height:1.2; }
.feed-author{ font-weight:700; color: var(--bs-body-color); }
.feed-author{ font-weight:700; color: var(--bs-body-color); }
.feed-sub{ font-size:.82rem; color: var(--bs-secondary-color); }
.feed-pin{
@ -204,6 +203,242 @@
/* чтобы кнопка «Подробнее» не была перекрыта stretched-link */
.readmore-toggle{ position: relative; z-index: 2; }
</style>
<style>
/* Base card / layout */
.ios-card{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
border-radius: var(--feed-radius);
background: var(--bs-body-bg);
box-shadow: var(--feed-shadow);
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
overflow:hidden;
}
.ios-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
.ios-header{
position: sticky; top: var(--navbar-h, 0px);
z-index: 5; padding:.75rem 0;
backdrop-filter: saturate(180%) blur(12px);
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
border-bottom: none !important;
}
.ios-title{ font-weight: 800; letter-spacing: -.01em; }
.row.g-ios{ align-items: flex-start; }
.ios-ava, .ios-ava--ph{
display: grid; place-items: center; background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color); font-size: 3rem; line-height: 1;
width: 100%; height: 100%; border-radius: 28px;
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ava-wrap{ position: relative; width:132px; height:132px; margin:0 auto 12px; display:block; }
.btn-icon{
width:36px; height:36px; padding:0; border-radius:9999px; display:grid; place-items:center;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.btn-icon:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.ios-name{ font-weight:800; font-size:1.25rem; }
.ios-role{ font-size:.9rem; color: var(--bs-secondary-color); }
.chips{ display:flex; flex-wrap:wrap; gap:.5rem; }
.chip{
padding:.35rem .65rem; border-radius:9999px; font-size:.85rem; line-height:1;
background: var(--bs-secondary-bg); color: var(--bs-body-color);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ios-collapse-trigger{
width:100%; display:flex; align-items:center; justify-content:space-between; gap:.75rem;
padding:.6rem .9rem; cursor:pointer; border-radius:14px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.ios-collapse-caret{ transition: transform .25s ease; }
.ios-collapse-trigger[aria-expanded="true"] .ios-collapse-caret{ transform: rotate(180deg); }
.ios-list{
border-radius:14px; overflow:hidden; background: var(--bs-body-bg);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell{
display:flex; align-items:center; gap:.75rem; padding:.85rem 1rem;
border-bottom:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell:last-child{ border-bottom:0; }
.ios-cell i{ width:1.25rem; text-align:center; opacity:.9; }
.ios-cell a{ text-decoration:none; color:inherit; }
.ios-section-title{ font-weight:700; font-size:1rem; margin-bottom:.5rem; }
.rounded-ios{ border-radius:14px !important; }
/* Departments list */
.ios-cell.dept-item{ justify-content: space-between; }
.ios-cell.dept-head{ border-left: 0; background: transparent; }
.dept-line{ display:flex; align-items:center; gap:.5rem; }
.dept-name{ font-weight:700; }
.dept-role{ color: var(--bs-secondary-color); }
.badge-head{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px;
background: color-mix(in srgb, var(--bs-warning) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-warning) 60%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-warning) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1;
white-space: nowrap;
}
.badge-head .bi{ font-size:.95rem; line-height:1; }
/* NEW: blue pill like .badge-head, for the gear edit button */
.badge-gear{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px; cursor:pointer;
background: color-mix(in srgb, var(--bs-primary) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-primary) 65%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-primary) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1; white-space: nowrap;
}
.badge-gear .bi{ font-size:.95rem; line-height:1; }
.badge-gear:hover{ background: color-mix(in srgb, var(--bs-primary) 20%, var(--bs-body-bg)); }
@media (max-width: 420px){
.badge-head .lbl, .badge-gear .lbl{ display:none; }
}
.ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-collapse-trigger + .collapse > .ios-list,
.ios-collapse-trigger + .collapse > .chips { margin-top: .5rem; }
.collapsing { overflow: hidden; transition: height .35s ease; }
.ios-acc{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
border-radius:14px; background: var(--bs-body-bg); overflow: hidden;
}
.ios-acc + .ios-acc{ margin-top:.75rem; }
.ios-acc .ios-collapse-trigger{ border:0; border-radius:0; background: transparent; width:100%; padding:.6rem .9rem; }
.ios-acc .ios-collapse-trigger:focus{ box-shadow:none; }
.ios-acc .collapse{ margin:0 !important; }
.ios-acc .ios-list{ border:0; border-radius:0; }
.ios-acc .chips{ margin:0; }
.ios-acc .ios-acc-body{
padding:.5rem .25rem .75rem;
border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
/* Activity block adjustments */
#activity .p-4{ padding:0 !important; }
#activity .ios-section-title{ padding:1rem 1rem .5rem !important; margin:0 !important; }
#activity .activity-feed{ margin:0 !important; }
#activity .activity-feed .feed-card{
width:100% !important; max-width:none !important; border:0 !important; box-shadow:none !important;
border-radius:0 !important; margin:0 !important;
}
#activity .activity-feed{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .feed-stream{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .row > *{ flex:0 0 100% !important; max-width:100% !important; }
#activity .feed-actions{ border-top: 0 !important; }
#activity .collapse .bg-body-tertiary.border{ border: 0 !important; }
/* Skills UI */
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;cursor:pointer;border-style:dashed}
#skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease}
#skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem}
.chip.chip-skill{position:relative;padding:.35rem .65rem}
.chip.chip-skill .lbl{transition:opacity .15s ease}
.chip.chip-skill .chip-del-btn{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:transparent;border:0;padding:0;margin:0;cursor:pointer;
color:var(--bs-danger);font-size:1rem;line-height:1;
visibility:hidden;opacity:0;transition:opacity .15s ease;z-index:1
}
.chip.chip-skill .chip-del-btn .bi{pointer-events:none}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .lbl{opacity:0}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .chip-del-btn,
#skillsBlock[data-editable="1"] .chip.chip-skill:focus-within .chip-del-btn{visibility:visible;opacity:1}
.skills-empty{color:var(--bs-secondary-color);font-size:.875rem;margin-right:.25rem}
/* Dept inline editor */
.dept-edit-btn{
width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:999px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
}
.dept-edit-btn:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.dept-editor-wrap{ flex: 0 0 100%; width:100%; margin-top:.5rem; }
.dept-editor form{ display:flex; align-items:center; gap:.5rem; }
.dept-role-select{ min-width: 180px; }
/* полностью прячем роль, когда открыт редактор */
.dept-item.is-editing .dept-role{ display:none !important; }
/* не даём блоку с кнопками сжиматься/уезжать */
.dept-item > .d-flex{
flex: 0 1 auto;
flex-wrap: wrap;
justify-content: flex-end;
gap: .35rem .5rem;
max-width: 100%;
}
/* 2) Левую часть реально можно ужать с многоточием */
.dept-line{ min-width: 0; }
/* 3) Сами пилюли не растягиваются */
.badge-head,
.badge-gear{ flex: 0 0 auto; }
/* 4) Строка отдела допускает перенос элементов (на всякий) */
.ios-cell{ flex-wrap: wrap; }
/* 5) При открытом редакторе полностью прячем роль */
.dept-item.is-editing .dept-role{ display:none !important; }
/* 6) На узких — прячем подписи внутри пилюль, чтобы всё поместилось */
@media (max-width: 576px){
.badge-head .lbl,
.badge-gear .lbl{ display:none; }
}
/* (аккуратная связка кнопки-разделителя и панели списка) — у тебя была потеряна скобка */
.ios-acc .ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-acc .ios-acc-body { border-top: 0; }
/* Группа иконок у заголовка */
.header-icon-group .btn-icon{ width:36px; height:36px; }
/* Варианты окраски, дружат со светлой/тёмной темой */
.btn-icon--primary{
color: var(--bs-primary);
border-color: color-mix(in srgb, var(--bs-primary) 30%, var(--bs-border-color));
}
.btn-icon--primary:hover{
background: color-mix(in srgb, var(--bs-primary) 16%, var(--bs-body-bg));
}
.btn-icon--success{
color: var(--bs-success);
border-color: color-mix(in srgb, var(--bs-success) 30%, var(--bs-border-color));
}
.btn-icon--success:hover{
background: color-mix(in srgb, var(--bs-success) 16%, var(--bs-body-bg));
}
.btn-icon--danger{
color: var(--bs-danger);
border-color: color-mix(in srgb, var(--bs-danger) 30%, var(--bs-border-color));
}
.btn-icon--danger:hover{
background: color-mix(in srgb, var(--bs-danger) 16%, var(--bs-body-bg));
}
/* Акцентированный фокус для клавиатуры */
.btn-icon:focus-visible{
outline: 2px solid color-mix(in srgb, currentColor 60%, transparent);
outline-offset: 2px;
}
</style>
{% block extra_css %}{% endblock %}
</head>
@ -241,8 +476,6 @@
});
</script>
<script>
// Глобальный обработчик лайков (использует стили/классы базового шаблона)
(function(){
@ -850,7 +1083,6 @@
})();
</script>
{% block extra_js %}{% endblock %}
</body>
</html>

View File

@ -1,78 +1,66 @@
{# backend/templates/employees/_department_controls.html #}
{# Контекст:#}
{# dept, head_choices, roles, dept_perm_choices,#}
{# can_manage, can_assign_roles, can_change_head#}
{# Контекст: dept, head_choices, roles, dept_perm_choices, can_manage, can_assign_roles, can_change_head #}
{# Модалки поверх любых локальных dropdown/оверлеев #}
<style>
/* Модалки поверх всего */
.modal-backdrop.show { z-index: 3000 !important; }
.modal.show { z-index: 3010 !important; }
.modal-dialog { pointer-events: auto !important; }
/* Портальный дропдаун автокомплита ролей ниже модалки */
.role-portal { z-index: 1800 !important; }
</style>
{% if can_manage %}
<section class="feed-card mb-4">
<div class="feed-hd">
<i class="bi-gear"></i>
<div class="feed-meta">
<div class="feed-author">Управление отделом</div>
<div class="feed-sub">Название и описание</div>
</div>
<div class="ios-acc mb-3">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-pencil-square"></i>
<div class="ios-section-title mb-0">Управление отделом</div>
</div>
<div class="feed-body">
<div class="ios-acc-body">
<form method="post" action="{% url 'employees:department_edit' dept.id %}">
{% csrf_token %}
<!-- увеличенный отступ от заголовка до полей -->
<div class="vstack gap-3 mt-3">
<div>
<div class="row g-3 px-2">
<div class="col-12">
<label class="form-label mb-1" for="dept_name">Название</label>
<input id="dept_name"
name="name"
class="form-control form-control-sm"
value="{{ dept.name|default_if_none:'' }}"
required>
<input id="dept_name" name="name" class="form-control"
value="{{ dept.name|default_if_none:'' }}" required>
</div>
<div>
<div class="col-12">
<label class="form-label mb-1" for="dept_desc">Описание</label>
<textarea id="dept_desc"
name="description"
class="form-control form-control-sm"
rows="3"
data-autogrow
data-max-height="240"
<textarea id="dept_desc" name="description" class="form-control"
rows="3" data-autogrow data-max-height="240"
style="overflow:hidden;">{{ dept.description|default_if_none:'' }}</textarea>
</div>
<div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi-check2 me-1"></i>Сохранить
</button>
</div>
</div>
</form>
</div>
</section>
</div>
{% endif %}
{% if can_change_head %}
<section class="feed-card mb-4">
<div class="feed-hd">
<div class="ios-acc mb-3">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-person-check"></i>
<div class="feed-meta">
<div class="feed-author">Руководитель отдела</div>
<div class="feed-sub">Назначение / снятие</div>
</div>
<div class="ios-section-title mb-0">Руководитель отдела</div>
</div>
<div class="feed-body">
<div class="ios-acc-body">
<form id="deptHeadForm" method="post" action="{% url 'employees:department_set_head' dept.id %}">
{% csrf_token %}
<div class="vstack gap-3 mt-3">
<div>
<div class="row g-3 px-2">
<div class="col-12">
<label class="form-label mb-1" for="dept_head_text">Руководитель</label>
<div class="position-relative"
data-head-picker
data-choices='[
@ -80,31 +68,18 @@
{"id":"{{ m.id }}","name":"{{ m.name|escapejs }}","email":"{{ m.email|default_if_none:''|escapejs }}"}{% if not forloop.last %},{% endif %}
{% endfor %}
]'>
<!-- по умолчанию — имя текущего руководителя -->
<input id="dept_head_text"
type="text"
class="form-control form-control-sm"
placeholder="ФИО или email"
autocomplete="off"
<input id="dept_head_text" type="text" class="form-control"
placeholder="ФИО или email" autocomplete="off"
value="{% if dept.head %}{{ dept.head.name|default_if_none:'' }}{% endif %}">
<!-- скрытое поле с id текущего руководителя (если есть) -->
<input id="dept_head_id"
type="hidden"
name="head_id"
<input id="dept_head_id" type="hidden" name="head_id"
value="{% if dept.head %}{{ dept.head.id }}{% endif %}">
<!-- Выпадающее меню -->
<div class="dropdown-menu w-100 shadow" style="max-height:240px; overflow:auto;"></div>
<div class="invalid-feedback">Выберите сотрудника из списка, введите точный email или очистите поле.</div>
</div>
<div class="form-text">Очистите поле и сохраните, чтобы снять руководителя.</div>
</div>
<div>
<div class="col-12">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi-check2 me-1"></i>Сохранить
</button>
@ -112,137 +87,121 @@
</div>
</form>
</div>
</section>
</div>
{% endif %}
{% if can_assign_roles %}
<section class="feed-card mb-4">
<div class="feed-hd">
<div class="ios-acc mb-3">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-list-check"></i>
<div class="feed-meta">
<div class="feed-author">Роли отдела</div>
<div class="feed-sub">Редактирование и создание</div>
</div>
<div class="ios-section-title mb-0">Роли отдела</div>
</div>
<div class="feed-body">
<div class="vstack gap-4 mt-3">
<div class="ios-acc-body">
<div class="row g-3 px-2">
<!-- Редактировать существующую роль -->
<form id="roleEditForm" method="post" action="{% url 'employees:edit_department_role' dept.id %}">
{% csrf_token %}
<input type="hidden" name="op" value="update">
<input type="hidden" name="role_id" id="edit_role_id" value="">
<!-- контейнер с множеством input[name=permission_ids] -->
<div id="edit_role_perms_hidden" class="d-none"></div>
<div class="col-12">
<form id="roleEditForm" method="post" action="{% url 'employees:edit_department_role' dept.id %}">
{% csrf_token %}
<input type="hidden" name="op" value="update">
<input type="hidden" name="role_id" id="edit_role_id" value="">
<div id="edit_role_perms_hidden" class="d-none"></div>
<div class="vstack gap-3">
<div>
<label class="form-label mb-1" for="role_name_text">Роль</label>
<div class="row g-3">
<div class="col-12">
<label class="form-label mb-1" for="role_name_text">Роль</label>
<div class="position-relative"
data-role-picker
data-choices='[
{% for r in roles %}
{"id":"{{ r.id }}",
"name":"{{ r.name|escapejs }}",
"perms":"{% if r.permissions %}{% for p in r.permissions %}{% if not forloop.first %},{% endif %}{{ p }}{% endfor %}{% endif %}"}
{% if not forloop.last %},{% endif %}
{% endfor %}
]'>
<input id="role_name_text" name="name" type="text" class="form-control"
placeholder="Начните вводить название роли" autocomplete="off">
<input type="hidden" id="role_id_hidden">
<div class="dropdown-menu w-100 shadow" style="max-height:240px; overflow:auto;"></div>
<div class="invalid-feedback">Выберите роль из списка или введите новое название для создания.</div>
</div>
</div>
<div class="position-relative"
data-role-picker
data-choices='[
{% for r in roles %}
{"id":"{{ r.id }}",
"name":"{{ r.name|escapejs }}",
"perms":"{% if r.permissions %}{% for p in r.permissions %}{% if not forloop.first %},{% endif %}{{ p }}{% endfor %}{% endif %}"}
{% if not forloop.last %},{% endif %}
{% endfor %}
]'>
<div class="col-12">
<button type="button"
class="btn btn-outline-secondary btn-sm me-2"
data-bs-toggle="modal"
data-bs-target="#rolePermsEditModal"
{% if not dept_perm_choices %}disabled{% endif %}>
Выбрать права
</button>
<div id="edit_role_perms_chips" class="chips mt-2"></div>
</div>
<!-- видимое поле: и поиск, и редактирование названия -->
<input id="role_name_text"
name="name"
type="text"
class="form-control form-control-sm"
placeholder="Начните вводить название роли"
autocomplete="off">
<!-- скрытое поле с id выбранной роли -->
<input type="hidden" id="role_id_hidden">
<!-- Выпадающее меню -->
<div class="dropdown-menu w-100 shadow" style="max-height:240px; overflow:auto;"></div>
<div class="invalid-feedback">Выберите роль из списка или введите новое название для создания.</div>
<div class="col-12 d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-primary btn-sm" id="roleSaveBtn" disabled>
<i class="bi-check2 me-1"></i>Сохранить
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="roleDeleteBtn" disabled>
<i class="bi-trash me-1"></i>Удалить
</button>
<button type="button"
id="toggleNewRole"
class="btn btn-outline-secondary btn-sm"
data-toggle-target="#roleCreateForm">
<i class="bi-plus-lg me-1"></i>Новая роль
</button>
</div>
</div>
</form>
</div>
<div>
<button type="button"
class="btn btn-outline-secondary btn-sm me-2"
data-bs-toggle="modal"
data-bs-target="#rolePermsEditModal"
{% if not dept_perm_choices %}disabled{% endif %}>
Выбрать права
</button>
<div id="edit_role_perms_chips" class="d-flex flex-wrap gap-1 mt-2"></div>
</div>
<div class="d-flex flex-wrap gap-2">
<button type="submit" class="btn btn-primary btn-sm" id="roleSaveBtn" disabled>
<i class="bi-check2 me-1"></i>Сохранить
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="roleDeleteBtn" disabled>
<i class="bi-trash me-1"></i>Удалить
</button>
<button type="button"
id="toggleNewRole"
class="btn btn-outline-secondary btn-sm"
data-toggle-target="#roleCreateForm">
<i class="bi-plus-lg me-1"></i>Новая роль
</button>
</div>
</div>
</form>
<hr class="my-0">
{# ——— УБРАНО <hr class="my-0"> ——— #}
<!-- Форма создания роли (скрыта по умолчанию) -->
<form id="roleCreateForm" class="d-none" method="post" action="{% url 'employees:edit_department_role' dept.id %}">
{% csrf_token %}
<input type="hidden" name="op" value="create">
<!-- контейнер с множеством input[name=permission_ids] -->
<div id="new_role_perms_hidden" class="d-none"></div>
<div class="col-12">
<form id="roleCreateForm" class="d-none" method="post" action="{% url 'employees:edit_department_role' dept.id %}">
{% csrf_token %}
<input type="hidden" name="op" value="create">
<div id="new_role_perms_hidden" class="d-none"></div>
<div class="vstack gap-3">
<div>
<label class="form-label mb-1" for="new_role_name">Название новой роли</label>
<input id="new_role_name" name="name" class="form-control form-control-sm" placeholder="Напр. Руководитель смены" required>
<div class="row g-3">
<div class="col-12">
<label class="form-label mb-1" for="new_role_name">Название новой роли</label>
<input id="new_role_name" name="name" class="form-control"
placeholder="Напр. Руководитель смены" required>
</div>
<div class="col-12">
<button type="button"
class="btn btn-outline-secondary btn-sm me-2"
data-bs-toggle="modal"
data-bs-target="#rolePermsCreateModal"
{% if not dept_perm_choices %}disabled{% endif %}>
Выбрать права
</button>
<div id="new_role_perms_chips" class="chips mt-2"></div>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-success btn-sm">
<i class="bi-plus-lg me-1"></i>Создать
</button>
<button type="button" id="cancelNewRole" class="btn btn-ghost btn-sm">
<i class="bi-x-lg me-1"></i>Отмена
</button>
</div>
</div>
<div>
<button type="button"
class="btn btn-outline-secondary btn-sm me-2"
data-bs-toggle="modal"
data-bs-target="#rolePermsCreateModal"
{% if not dept_perm_choices %}disabled{% endif %}>
Выбрать права
</button>
<div id="new_role_perms_chips" class="d-flex flex-wrap gap-1 mt-2"></div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-success btn-sm">
<i class="bi-plus-lg me-1"></i>Создать
</button>
<button type="button" id="cancelNewRole" class="btn btn-ghost btn-sm">
<i class="bi-x-lg me-1"></i>Отмена
</button>
</div>
</div>
</form>
</form>
</div>
</div>
</div>
</section>
</div>
{% endif %}
{% if not can_manage and not can_assign_roles and not can_change_head %}
<div class="alert alert-light border small mb-3">У вас нет прав для управления этим отделом.</div>
{% endif %}
@ -310,16 +269,16 @@
</div>
</div>
{# ===== Форма удаления роли (постит на edit_department_role/) ===== #}
{# ===== Форма удаления роли ===== #}
<form id="roleDeleteForm" method="post" action="{% url 'employees:edit_department_role' dept.id %}" class="d-none">
{% csrf_token %}
<input type="hidden" name="op" value="delete">
<input type="hidden" name="role_id" id="delete_role_id">
</form>
{# ===== JS: автогроу / пикеры / роли ===== #}
<script>
(function() {
// Защита от повторной инициализации
if (window.__autogrowBound) return;
window.__autogrowBound = true;
@ -330,25 +289,18 @@
el.style.height = newH + 'px';
el.style.overflowY = (el.scrollHeight > max) ? 'auto' : 'hidden';
}
function bind(el) {
if (el.dataset.autogrowBound === '1') return;
el.dataset.autogrowBound = '1';
el.addEventListener('input', () => autoGrow(el));
// Инициализация текущим значением (в т.ч. с сервером предзаполненным текстом)
autoGrow(el);
}
// Привязка ко всем textarea с атрибутом data-autogrow
document.querySelectorAll('textarea[data-autogrow]').forEach(bind);
// Если у вас есть динамическая подгрузка/перерисовка — можно дергать это повторно:
// window.refreshAutogrow = () => document.querySelectorAll('textarea[data-autogrow]').forEach(bind);
})();
</script>
<script>
(function(){
// защитимся от повторной инициализации
if (window.__headPickerWithEmailBound) return;
window.__headPickerWithEmailBound = true;
@ -359,13 +311,9 @@
const input = root.querySelector('input[type="text"]');
const hidden = root.querySelector('input[type="hidden"][name="head_id"]');
const menu = root.querySelector('.dropdown-menu');
const choices = (JSON.parse(root.dataset.choices || '[]') || []).map(c => ({
id: c.id, name: c.name || '', email: c.email || ''
}));
const choices = (JSON.parse(root.dataset.choices || '[]') || []).map(c => ({ id: c.id, name: c.name || '', email: c.email || '' }));
function findById(id){
return choices.find(c => String(c.id) === String(id));
}
function findById(id){ return choices.find(c => String(c.id) === String(id)); }
function findByExact(text){
const t = text.trim();
const byName = choices.find(c => c.name === t);
@ -374,7 +322,6 @@
return byEmail || null;
}
// Инициализация значением по умолчанию
if (hidden.value && !input.value){
const item = findById(hidden.value);
if (item) input.value = item.name || item.email || '';
@ -402,31 +349,18 @@
function filter(q){
const s = norm(q);
const res = s
? choices.filter(c => norm(c.name).includes(s) || norm(c.email).includes(s)).slice(0, 8)
: choices.slice(0, 8);
const res = s ? choices.filter(c => norm(c.name).includes(s) || norm(c.email).includes(s)).slice(0, 8) : choices.slice(0, 8);
listItems(res);
}
function hideMenu(){ menu.classList.remove('show'); }
input.addEventListener('focus', () => filter(input.value));
input.addEventListener('input', () => {
hidden.value = ''; // ручной ввод — сбрасываем выбранный id
input.classList.remove('is-invalid');
filter(input.value);
});
input.addEventListener('input', () => { hidden.value = ''; input.classList.remove('is-invalid'); filter(input.value); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter'){
const first = menu.querySelector('.dropdown-item:not(.disabled)');
if (menu.classList.contains('show') && first){
e.preventDefault();
first.click();
}
} else if (e.key === 'Escape'){
hideMenu();
}
if (menu.classList.contains('show') && first){ e.preventDefault(); first.click(); }
} else if (e.key === 'Escape'){ hideMenu(); }
});
menu.addEventListener('click', (e) => {
@ -437,27 +371,15 @@
input.classList.remove('is-invalid');
hideMenu();
});
document.addEventListener('click', (e) => { if (!root.contains(e.target)) hideMenu(); });
document.addEventListener('click', (e) => {
if (!root.contains(e.target)) hideMenu();
});
// Валидация при отправке:
// - пустое поле → head_id пуст (снятие руководителя)
// - непустое поле и пустой id → пробуем точное соответствие по имени/почте; если нет — ошибка
const form = root.closest('form');
form?.addEventListener('submit', (e) => {
const val = input.value.trim();
if (val === ''){
hidden.value = '';
return;
}
if (val === ''){ hidden.value = ''; return; }
if (!hidden.value){
const m = findByExact(val);
if (m){
hidden.value = m.id;
return;
}
if (m){ hidden.value = m.id; return; }
e.preventDefault();
input.classList.add('is-invalid');
filter(val);
@ -469,23 +391,17 @@
document.querySelectorAll('[data-head-picker]').forEach(initPicker);
})();
</script>
<script>
/**
* UI для блока "Роли отдела":
* - автокомплит по ролям в одном поле
* - синхронизация прав в hidden input'ы (permission_ids[])
* - кнопка "Новая роль" открывает/закрывает форму создания
*/
(function() {
// ===== helpers
const esc = s => String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
const norm = s => String(s||'').toLowerCase();
function makeChip(text, value) {
const span = document.createElement('span');
span.className = 'badge rounded-pill bg-light text-dark border';
span.textContent = text;
span.className = 'chip';
span.dataset.value = value;
span.textContent = text;
return span;
}
function buildDict(listSelector) {
@ -500,15 +416,10 @@
}
function setChips(container, ids, dict) {
container.innerHTML = '';
ids.forEach(id => {
const p = dict.get(id);
container.appendChild(makeChip(p ? (p.name || id) : id, id));
});
ids.forEach(id => container.appendChild(makeChip((dict.get(id)?.name || id), id)));
}
function setChecks(listSelector, ids) {
document.querySelectorAll(listSelector + ' input[type=checkbox]').forEach(cb => {
cb.checked = ids.includes(cb.value);
});
document.querySelectorAll(listSelector + ' input[type=checkbox]').forEach(cb => { cb.checked = ids.includes(cb.value); });
}
function syncHidden(containerEl, ids) {
containerEl.innerHTML = '';
@ -520,32 +431,23 @@
containerEl.appendChild(inp);
});
}
function parseIds(csv) {
if (!csv) return [];
return csv.split(',').map(x => x.trim()).filter(Boolean);
}
// ===== элементы редактирования
const editForm = document.getElementById('roleEditForm');
const roleNameInp = document.getElementById('role_name_text');
const roleIdHidden = document.getElementById('role_id_hidden'); // внутреннее
const roleIdInput = document.getElementById('edit_role_id'); // уходит на сервер
const roleIdHidden = document.getElementById('role_id_hidden');
const roleIdInput = document.getElementById('edit_role_id');
const roleSaveBtn = document.getElementById('roleSaveBtn');
const roleDeleteBtn = document.getElementById('roleDeleteBtn');
const editChips = document.getElementById('edit_role_perms_chips');
const editHidden = document.getElementById('edit_role_perms_hidden');
const deleteForm = document.getElementById('roleDeleteForm');
const deleteRoleId = document.getElementById('delete_role_id');
const editDict = () => buildDict('#permListEdit');
// ===== элементы создания
const createForm = document.getElementById('roleCreateForm');
const newRoleName = document.getElementById('new_role_name');
const newChips = document.getElementById('new_role_perms_chips');
const newHidden = document.getElementById('new_role_perms_hidden');
const createDict = () => buildDict('#permListCreate');
// ===== делегирование на кнопку "Новая роль" (надежно, даже если скрипт выполнится раньше)
document.addEventListener('click', function(e) {
const btn = e.target.closest('#toggleNewRole');
if (!btn) return;
@ -553,14 +455,12 @@
const frm = document.querySelector(targetSel);
if (!frm) return;
const willShow = frm.classList.contains('d-none'); // true => откроем
const willShow = frm.classList.contains('d-none');
if (willShow) {
frm.classList.remove('d-none');
// Подставим текст из поля роли, если это новая (невыбранная) роль
if (roleNameInp?.value && !roleIdHidden?.value) newRoleName.value = roleNameInp.value.trim();
newRoleName?.focus();
} else {
// Скрываем и чистим
frm.classList.add('d-none');
frm.reset?.();
newChips.innerHTML = '';
@ -569,35 +469,28 @@
}
});
// ===== автокомплит по ролям
// ——— автокомплит по ролям c порталом в body (не клипается родителями)
// Ролевой автокомплит с меню в body
(function initRolePicker(){
const root = document.querySelector('[data-role-picker]');
const roleNameInp = document.getElementById('role_name_text');
if (!root || !roleNameInp) return;
const roleInp = document.getElementById('role_name_text');
if (!root || !roleInp) return;
const choices = JSON.parse(root.dataset.choices || '[]');
// создаём слой-меню в body
const menuLayer = document.createElement('div');
menuLayer.className = 'dropdown-menu shadow';
// визуальные параметры, чтобы было как раньше
menuLayer.style.position = 'fixed';
menuLayer.style.zIndex = '2000';
menuLayer.style.maxHeight = '240px';
menuLayer.style.overflowY = 'auto';
document.body.appendChild(menuLayer);
const esc = s => String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
const norm = s => String(s||'').toLowerCase();
function placeMenu() {
const r = roleNameInp.getBoundingClientRect();
const r = roleInp.getBoundingClientRect();
menuLayer.style.left = r.left + 'px';
menuLayer.style.top = (r.bottom) + 'px';
menuLayer.style.width = r.width + 'px';
}
function listItems(arr){
if (!arr.length){
menuLayer.innerHTML = '<div class="dropdown-item disabled">Ничего не найдено</div>';
@ -616,30 +509,19 @@
window.addEventListener('scroll', placeMenu, true);
window.addEventListener('resize', placeMenu);
}
function hideMenu(){
menuLayer.classList.remove('show');
window.removeEventListener('scroll', placeMenu, true);
window.removeEventListener('resize', placeMenu);
}
function filter(q){
const s = norm(q);
const res = s ? choices.filter(c => norm(c.name).includes(s)).slice(0, 10) : choices.slice(0, 10);
listItems(res);
}
// ссылки на элементы/функции из твоего скрипта выше
const roleIdHidden = document.getElementById('role_id_hidden');
const roleIdInput = document.getElementById('edit_role_id');
const roleSaveBtn = document.getElementById('roleSaveBtn');
const roleDeleteBtn = document.getElementById('roleDeleteBtn');
const editChips = document.getElementById('edit_role_perms_chips');
const editHidden = document.getElementById('edit_role_perms_hidden');
const editDict = () => buildDict('#permListEdit');
roleNameInp.addEventListener('focus', () => filter(roleNameInp.value));
roleNameInp.addEventListener('input', () => {
roleInp.addEventListener('focus', () => filter(roleInp.value));
roleInp.addEventListener('input', () => {
roleIdHidden.value = '';
roleIdInput.value = '';
roleSaveBtn.disabled = true;
@ -647,22 +529,15 @@
setChips(editChips, [], editDict());
syncHidden(editHidden, []);
document.querySelectorAll('#permListEdit input[type=checkbox]').forEach(cb => cb.checked = false);
filter(roleNameInp.value);
filter(roleInp.value);
});
roleNameInp.addEventListener('keydown', (e) => {
roleInp.addEventListener('keydown', (e) => {
if (e.key === 'Enter'){
const first = menuLayer.querySelector('.dropdown-item:not(.disabled)');
if (menuLayer.classList.contains('show') && first){
e.preventDefault();
first.click();
}
} else if (e.key === 'Escape'){
hideMenu();
}
if (menuLayer.classList.contains('show') && first){ e.preventDefault(); first.click(); }
} else if (e.key === 'Escape'){ hideMenu(); }
});
// выбор из списка
menuLayer.addEventListener('click', (e) => {
const btn = e.target.closest('.dropdown-item');
if (!btn || btn.classList.contains('disabled')) return;
@ -671,9 +546,9 @@
const name = btn.dataset.name || '';
const permsCsv = btn.dataset.perms || '';
roleNameInp.value = name;
roleIdHidden.value = id;
roleIdInput.value = id;
roleInp.value = name;
roleIdHidden.value = id;
roleIdInput.value = id;
roleSaveBtn.disabled = !id;
roleDeleteBtn.disabled = !id;
@ -685,44 +560,43 @@
hideMenu();
});
// клик вне поля/меню — закрыть
document.addEventListener('click', (e) => {
if (!roleNameInp.contains(e.target) && !menuLayer.contains(e.target)) hideMenu();
if (!roleInp.contains(e.target) && !menuLayer.contains(e.target)) hideMenu();
});
// На открытие любой модалки — гарантированно спрятать слой автокомплита
document.addEventListener('show.bs.modal', hideMenu);
})();
// ===== «Права» — применить из модалки (редактирование)
document.getElementById('applyPermsEdit')?.addEventListener('click', function() {
const ids = Array.from(document.querySelectorAll('#permListEdit input[type=checkbox]:checked')).map(cb => cb.value);
setChips(editChips, ids, editDict());
syncHidden(editHidden, ids);
});
// ===== удаление роли
roleDeleteBtn?.addEventListener('click', function() {
if (!roleIdInput.value) return;
if (confirm('Удалить выбранную роль?')) {
deleteRoleId.value = roleIdInput.value;
deleteForm.submit();
document.getElementById('delete_role_id').value = roleIdInput.value;
document.getElementById('roleDeleteForm').submit();
}
});
// ===== «Права» — применить из модалки (создание)
document.getElementById('applyPermsCreate')?.addEventListener('click', function() {
const ids = Array.from(document.querySelectorAll('#permListCreate input[type=checkbox]:checked')).map(cb => cb.value);
setChips(newChips, ids, createDict());
syncHidden(newHidden, ids);
});
// ===== страховка — синхронизируем права перед сабмитом формы создания
createForm?.addEventListener('submit', function() {
const ids = Array.from(document.querySelectorAll('#permListCreate input[type=checkbox]:checked')).map(cb => cb.value);
syncHidden(newHidden, ids);
});
})();
</script>
<script>
// Делегированный обработчик для кнопки "Отмена"
// Кнопка "Отмена" — скрыть форму создания роли и сбросить выбор
document.addEventListener('click', function(e) {
const btn = e.target.closest('#cancelNewRole');
if (!btn) return;
@ -731,18 +605,23 @@
const frm = document.getElementById('roleCreateForm');
if (!frm) return;
// Скрыть и очистить форму создания
frm.classList.add('d-none');
frm.reset?.();
// Почистить чипсы и hidden-инпуты прав
const newChips = document.getElementById('new_role_perms_chips');
const newHidden = document.getElementById('new_role_perms_hidden');
if (newChips) newChips.innerHTML = '';
if (newHidden) newHidden.innerHTML = '';
// Снять чекбоксы прав в модалке создания
document.querySelectorAll('#permListCreate input[type=checkbox]')
.forEach(cb => cb.checked = false);
});
</script>
<script>
// Всегда держим модалки прямыми детьми <body>, чтобы избежать локальных стеков/перекрытий
(function () {
document.querySelectorAll('.modal').forEach(m => {
if (m.parentElement !== document.body) document.body.appendChild(m);
});
})();
</script>

File diff suppressed because it is too large Load Diff

View File

@ -7,208 +7,7 @@
{% endblock %}
{% block extra_css %}
<style>
/* Base card / layout */
.ios-card{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
border-radius: var(--feed-radius);
background: var(--bs-body-bg);
box-shadow: var(--feed-shadow);
transition: box-shadow .18s ease, transform .18s ease, border-color .18s ease;
overflow:hidden;
}
.ios-card:hover{ box-shadow: var(--feed-shadow-hover); transform: translateY(-1px); }
.ios-header{
position: sticky; top: var(--navbar-h, 0px);
z-index: 5; padding:.75rem 0;
backdrop-filter: saturate(180%) blur(12px);
background: color-mix(in srgb, var(--bs-body-bg) 85%, transparent);
border-bottom: none !important;
}
.ios-title{ font-weight: 800; letter-spacing: -.01em; }
.row.g-ios{ align-items: flex-start; }
.ios-ava, .ios-ava--ph{
display: grid; place-items: center; background: var(--bs-tertiary-bg);
color: var(--bs-secondary-color); font-size: 3rem; line-height: 1;
width: 100%; height: 100%; border-radius: 28px;
border: 1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ava-wrap{ position: relative; width:132px; height:132px; margin:0 auto 12px; display:block; }
.btn-icon{
width:36px; height:36px; padding:0; border-radius:9999px; display:grid; place-items:center;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.btn-icon:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.ios-name{ font-weight:800; font-size:1.25rem; }
.ios-role{ font-size:.9rem; color: var(--bs-secondary-color); }
.chips{ display:flex; flex-wrap:wrap; gap:.5rem; }
.chip{
padding:.35rem .65rem; border-radius:9999px; font-size:.85rem; line-height:1;
background: var(--bs-secondary-bg); color: var(--bs-body-color);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
.ios-collapse-trigger{
width:100%; display:flex; align-items:center; justify-content:space-between; gap:.75rem;
padding:.6rem .9rem; cursor:pointer; border-radius:14px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent); color: var(--bs-body-color);
}
.ios-collapse-caret{ transition: transform .25s ease; }
.ios-collapse-trigger[aria-expanded="true"] .ios-collapse-caret{ transform: rotate(180deg); }
.ios-list{
border-radius:14px; overflow:hidden; background: var(--bs-body-bg);
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell{
display:flex; align-items:center; gap:.75rem; padding:.85rem 1rem;
border-bottom:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
}
.ios-cell:last-child{ border-bottom:0; }
.ios-cell i{ width:1.25rem; text-align:center; opacity:.9; }
.ios-cell a{ text-decoration:none; color:inherit; }
.ios-section-title{ font-weight:700; font-size:1rem; margin-bottom:.5rem; }
.rounded-ios{ border-radius:14px !important; }
/* Departments list */
.ios-cell.dept-item{ justify-content: space-between; }
.ios-cell.dept-head{ border-left: 0; background: transparent; }
.dept-line{ display:flex; align-items:center; gap:.5rem; }
.dept-name{ font-weight:700; }
.dept-role{ color: var(--bs-secondary-color); }
.badge-head{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px;
background: color-mix(in srgb, var(--bs-warning) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-warning) 60%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-warning) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1;
white-space: nowrap;
}
.badge-head .bi{ font-size:.95rem; line-height:1; }
/* NEW: blue pill like .badge-head, for the gear edit button */
.badge-gear{
display:inline-flex; align-items:center; gap:.35rem;
padding:.2rem .55rem; border-radius:999px; cursor:pointer;
background: color-mix(in srgb, var(--bs-primary) 15%, var(--bs-body-bg));
color: color-mix(in srgb, var(--bs-primary) 65%, black 0%);
border:1px solid color-mix(in srgb, var(--bs-primary) 35%, transparent);
font-weight:700; font-size:.8rem; line-height:1; white-space: nowrap;
}
.badge-gear .bi{ font-size:.95rem; line-height:1; }
.badge-gear:hover{ background: color-mix(in srgb, var(--bs-primary) 20%, var(--bs-body-bg)); }
@media (max-width: 420px){
.badge-head .lbl, .badge-gear .lbl{ display:none; }
}
.ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-collapse-trigger + .collapse > .ios-list,
.ios-collapse-trigger + .collapse > .chips { margin-top: .5rem; }
.collapsing { overflow: hidden; transition: height .35s ease; }
.ios-acc{
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
border-radius:14px; background: var(--bs-body-bg); overflow: hidden;
}
.ios-acc + .ios-acc{ margin-top:.75rem; }
.ios-acc .ios-collapse-trigger{ border:0; border-radius:0; background: transparent; width:100%; padding:.6rem .9rem; }
.ios-acc .ios-collapse-trigger:focus{ box-shadow:none; }
.ios-acc .collapse{ margin:0 !important; }
.ios-acc .ios-list{ border:0; border-radius:0; }
.ios-acc .chips{ margin:0; }
.ios-acc .ios-acc-body{
padding:.5rem .25rem .75rem;
border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
}
/* Activity block adjustments */
#activity .p-4{ padding:0 !important; }
#activity .ios-section-title{ padding:1rem 1rem .5rem !important; margin:0 !important; }
#activity .activity-feed{ margin:0 !important; }
#activity .activity-feed .feed-card{
width:100% !important; max-width:none !important; border:0 !important; box-shadow:none !important;
border-radius:0 !important; margin:0 !important;
}
#activity .activity-feed{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .feed-stream{ display:flex; flex-direction:column; gap:1rem; }
#activity .activity-feed .row > *{ flex:0 0 100% !important; max-width:100% !important; }
#activity .feed-actions{ border-top: 0 !important; }
#activity .collapse .bg-body-tertiary.border{ border: 0 !important; }
/* Skills UI */
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;cursor:pointer;border-style:dashed}
#skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease}
#skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem}
.chip.chip-skill{position:relative;padding:.35rem .65rem}
.chip.chip-skill .lbl{transition:opacity .15s ease}
.chip.chip-skill .chip-del-btn{
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
background:transparent;border:0;padding:0;margin:0;cursor:pointer;
color:var(--bs-danger);font-size:1rem;line-height:1;
visibility:hidden;opacity:0;transition:opacity .15s ease;z-index:1
}
.chip.chip-skill .chip-del-btn .bi{pointer-events:none}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .lbl{opacity:0}
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .chip-del-btn,
#skillsBlock[data-editable="1"] .chip.chip-skill:focus-within .chip-del-btn{visibility:visible;opacity:1}
.skills-empty{color:var(--bs-secondary-color);font-size:.875rem;margin-right:.25rem}
/* Dept inline editor */
.dept-edit-btn{
width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:999px;
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
}
.dept-edit-btn:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
.dept-editor-wrap{ flex: 0 0 100%; width:100%; margin-top:.5rem; }
.dept-editor form{ display:flex; align-items:center; gap:.5rem; }
.dept-role-select{ min-width: 180px; }
/* полностью прячем роль, когда открыт редактор */
.dept-item.is-editing .dept-role{ display:none !important; }
/* не даём блоку с кнопками сжиматься/уезжать */
.dept-item > .d-flex{
flex: 0 1 auto; /* раньше было 0 0 auto — не сжимался */
flex-wrap: wrap; /* можно переносить на новую строку */
justify-content: flex-end;
gap: .35rem .5rem; /* компактнее на переносах */
max-width: 100%;
}
/* 2) Левую часть реально можно ужать с многоточием */
.dept-line{ min-width: 0; }
/* 3) Сами пилюли не растягиваются */
.badge-head,
.badge-gear{ flex: 0 0 auto; }
/* 4) Строка отдела допускает перенос элементов (на всякий) */
.ios-cell{ flex-wrap: wrap; }
/* 5) При открытом редакторе полностью прячем роль */
.dept-item.is-editing .dept-role{ display:none !important; }
/* 6) На узких — прячем подписи внутри пилюль, чтобы всё поместилось */
@media (max-width: 576px){
.badge-head .lbl,
.badge-gear .lbl{ display:none; }
}
/* (аккуратная связка кнопки-разделителя и панели списка) — у тебя была потеряна скобка */
.ios-acc .ios-collapse-trigger + .collapse { margin-top: 0 !important; }
.ios-acc .ios-acc-body { border-top: 0; }
</style>
{% endblock %}
@ -222,7 +21,7 @@
<div class="ms-auto"></div>
</div>
<div class="row g-4 g-lg-5 g-ios">
<div class="row g-4 align-items-start">
<!-- LEFT -->
<div class="col-md-4">
<article class="ios-card">

View File

@ -150,37 +150,96 @@ def test_add_member_requires_manage_and_does_not_assign_role(api_client: APIClie
@pytest.mark.django_db
def test_remove_member_requires_manage_and_does_not_touch_role(api_client: APIClient):
def test_all_three_actions_require_auth_and_permissions(api_client: APIClient):
"""
remove_member:
- 403 при отсутствии manage_department (даже если есть assign_department_role)
- 200 с manage_department
- не трогает role (оставляет как есть)
Сетка проверок прав для трёх эндпоинтов отдела:
- без авторизации все три эндпоинта возвращают 401
- пользователь с manage_department МОЖЕТ add/remove (200), но НЕ МОЖЕТ set_member_role (403)
- пользователь с assign_department_role МОЖЕТ set_member_role (200), но НЕ МОЖЕТ add/remove (403)
ВАЖНО: remove_member теперь физически удаляет связь (hard delete), поэтому
перед проверкой assign_department_role мы восстанавливаем членство менеджером.
"""
d = Department.objects.create(name="Dept")
target = make_user("target2@example.com")
some_role = make_role(d, "any")
EmployeeDepartment.objects.create(
employee=target, department=d, is_active=True, role=some_role
worker = make_user("worker3@example.com")
EmployeeDepartment.objects.create(employee=worker, department=d, is_active=True)
role = make_role(d, "WorkerX")
# --- 401 без авторизации ---
for url, payload in [
(url_add_member(d.pk), {"employee_id": worker.id}),
(url_remove_member(d.pk), {"employee_id": worker.id}),
(url_set_member_role(d.pk), {"employee_id": worker.id, "role_id": role.id}),
]:
resp = api_client.post(url, payload, format="json")
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
# --- Менеджер отдела ---
manager = make_user("manager4@example.com")
grant_manage_in_dept(manager, d)
api_client.force_authenticate(manager)
# может add/remove
assert (
api_client.post(
url_add_member(d.pk), {"employee_id": worker.id}, format="json"
).status_code
== 200
)
assert (
api_client.post(
url_remove_member(d.pk), {"employee_id": worker.id}, format="json"
).status_code
== 200
)
url = url_remove_member(d.pk)
# после hard delete связи — менеджер восстанавливает членство,
# чтобы далее "назначающий роли" мог менять роль
assert (
api_client.post(
url_add_member(d.pk), {"employee_id": worker.id}, format="json"
).status_code
== 200
)
assigner = make_user("assigner2@example.com")
api_client.force_authenticate(assigner)
# но менеджер не может set_member_role
assert (
api_client.post(
url_set_member_role(d.pk),
{"employee_id": worker.id, "role_id": role.id},
format="json",
).status_code
== 403
)
# --- Назначающий роли ---
assigner = make_user("assigner5@example.com")
grant_assign_in_dept(assigner, d)
resp = api_client.post(url, {"employee_id": target.id}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
api_client.force_authenticate(assigner)
manager = make_user("manager2@example.com")
api_client.force_authenticate(manager)
grant_manage_in_dept(manager, d)
resp = api_client.post(url, {"employee_id": target.id}, format="json")
assert resp.status_code == status.HTTP_200_OK
# может set_member_role
assert (
api_client.post(
url_set_member_role(d.pk),
{"employee_id": worker.id, "role_id": role.id},
format="json",
).status_code
== 200
)
link = EmployeeDepartment.objects.get(employee=target, department=d)
assert link.is_active is False
assert link.role_id == some_role.id # роль не менялась
# но не может add/remove
assert (
api_client.post(
url_add_member(d.pk), {"employee_id": worker.id}, format="json"
).status_code
== 403
)
assert (
api_client.post(
url_remove_member(d.pk), {"employee_id": worker.id}, format="json"
).status_code
== 403
)
# =========================
@ -279,17 +338,20 @@ def test_set_member_role_does_not_create_membership_and_does_not_toggle_active(
@pytest.mark.django_db
def test_all_three_actions_require_auth_and_permissions(api_client: APIClient):
"""
Ещё одна сетка проверок:
Сетка проверок прав для трёх эндпоинтов отдела:
- без авторизации все три эндпоинта возвращают 401
- пользователь с manage_department не может set_member_role (403)
- пользователь с assign_department_role не может add/remove (403)
- пользователь с manage_department МОЖЕТ add/remove (200), но НЕ МОЖЕТ set_member_role (403)
- пользователь с assign_department_role МОЖЕТ set_member_role (200), но НЕ МОЖЕТ add/remove (403)
ВАЖНО: remove_member теперь физически удаляет связь (hard delete), поэтому
перед проверкой assign_department_role мы восстанавливаем членство менеджером.
"""
d = Department.objects.create(name="Dept")
worker = make_user("worker3@example.com")
EmployeeDepartment.objects.create(employee=worker, department=d, is_active=True)
role = make_role(d, "WorkerX")
# 401 без авторизации
# --- 401 без авторизации ---
for url, payload in [
(url_add_member(d.pk), {"employee_id": worker.id}),
(url_remove_member(d.pk), {"employee_id": worker.id}),
@ -298,10 +360,11 @@ def test_all_three_actions_require_auth_and_permissions(api_client: APIClient):
resp = api_client.post(url, payload, format="json")
assert resp.status_code == status.HTTP_401_UNAUTHORIZED
# Менеджер отдела
# --- Менеджер отдела ---
manager = make_user("manager4@example.com")
grant_manage_in_dept(manager, d)
api_client.force_authenticate(manager)
# может add/remove
assert (
api_client.post(
@ -315,7 +378,17 @@ def test_all_three_actions_require_auth_and_permissions(api_client: APIClient):
).status_code
== 200
)
# но не может set_member_role
# после hard delete связи — менеджер восстанавливает членство,
# чтобы далее "назначающий роли" мог менять роль
assert (
api_client.post(
url_add_member(d.pk), {"employee_id": worker.id}, format="json"
).status_code
== 200
)
# но менеджер не может set_member_role
assert (
api_client.post(
url_set_member_role(d.pk),
@ -325,10 +398,11 @@ def test_all_three_actions_require_auth_and_permissions(api_client: APIClient):
== 403
)
# Назначающий роли
# --- Назначающий роли ---
assigner = make_user("assigner5@example.com")
grant_assign_in_dept(assigner, d)
api_client.force_authenticate(assigner)
# может set_member_role
assert (
api_client.post(
@ -338,6 +412,7 @@ def test_all_three_actions_require_auth_and_permissions(api_client: APIClient):
).status_code
== 200
)
# но не может add/remove
assert (
api_client.post(
@ -376,3 +451,45 @@ def test_add_member_ignores_role_parameter(api_client: APIClient):
link = EmployeeDepartment.objects.get(employee=target, department=d)
assert link.is_active is True
assert link.role_id is None, "add_member не должен трогать роль"
@pytest.mark.django_db
def test_remove_member_requires_manage_and_does_not_touch_role(api_client: APIClient):
"""
remove_member (hard delete):
- 403 при отсутствии manage_department (даже если есть assign_department_role)
- 200 с manage_department
- удаляет связь EmployeeDepartment; объект роли остаётся без изменений
"""
d = Department.objects.create(name="Dept")
target = make_user("target2@example.com")
some_role = make_role(d, "any")
# создаём членство с ролью
EmployeeDepartment.objects.create(
employee=target, department=d, is_active=True, role=some_role
)
url = url_remove_member(d.pk)
# нет manage → 403 (даже если есть право назначать роли)
assigner = make_user("assigner2@example.com")
grant_assign_in_dept(assigner, d)
api_client.force_authenticate(assigner)
resp = api_client.post(url, {"employee_id": target.id}, format="json")
assert resp.status_code == status.HTTP_403_FORBIDDEN
# есть manage → 200 и связь удаляется физически
manager = make_user("manager2@example.com")
grant_manage_in_dept(manager, d)
api_client.force_authenticate(manager)
resp = api_client.post(url, {"employee_id": target.id}, format="json")
assert resp.status_code == status.HTTP_200_OK
# связь удалена
assert not EmployeeDepartment.objects.filter(
employee=target, department=d
).exists()
# роль как объект не тронута
role_from_db = type(some_role).objects.get(pk=some_role.pk)
assert role_from_db.name == "any"