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:
parent
07d5209f43
commit
409632e3f3
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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
|
||||
@ -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.
|
||||
|
||||
@ -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",))
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
@ -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">
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user