feat(employees): переработка формы редактирования, создание должности и мобильный выбор групп + валидации и UX-полировка

Основное
- Переписан шаблон `templates/employees/_employee_edit.html`:
  - Разделы: «Личные данные», «Контакты», «Должность» (аккордеон-блоки с единым отступом `.py-3` в заголовках).
  - Поля: first_name, last_name, patronymic, gender (коды 1/2, допускается `null`), birth_date.
  - Контакты: phone_number, email, telegram, whatsapp, wechat.
  - Валидация:
    - требуем минимум один контакт (любой из: email/phone_number/telegram/whatsapp/wechat);
    - e-mail валидируется HTML5 + regex-фолбэком; live-очистка ошибок.
  - Должность:
    - выпадающий список из `all_positions`;
    - доступ к редактированию/созданию скрывается/показывается по флагам контекста (см. ниже);
    - «Создать должность» (name/description) + выбор групп прав.
  - Выбор групп прав:
    - мобильный модальный пикер (поиск, чекбоксы, чипсы выбранных значений, скрытый input с csv id);
    - модал центрируется (`.modal-dialog-centered`), выносится в `document.body`, задран z-index (`.modal{z-index:1080}`, backdrop 1075), убран «просвет» (принудительный `background-color`).
  - Кнопки: индикаторы загрузки (label + spinner), аккуратная блокировка в процессе.

JS (внутри того же шаблона)
- Сборка payload для `PATCH`:
  - отправляем только изменённые поля;
  - для `gender`, `birth_date`, `position_id` при очистке отправляем `null`;
  - `gender` приводим к числу;
  - поддержка `skills_ids` (мультиселект): сравнение с `data-init`, отправка массива только при изменении;
  - `avatar`: чтение файла → dataURL (Base64ImageField), очистка через пустую строку.
- Валидация перед отправкой:
  - `ensureOneContact()` — хотя бы один контакт обязателен;
  - `validateEmail()` — валидный `email`, если поле непустое.
- Обработка ошибок API: подсветка конкретных полей (`.is-invalid` + `.invalid-feedback`), fallback-alert для общих ошибок.
- Успех: закрытие обёртки `#editFormWrap` (если есть collapse) + `location.reload()`.
- Блок «Создать должность»:
  - `POST` в `positions_create_url` с `name/description` и (если разрешено) `groups`;
  - затем `PATCH` сотрудника на `endpoint` с `position_id` созданной должности;
  - обновление селекта должностей.
- Исправления:
  - валидация e-mail перенесена внутрь IIFE (был reference error на `form`);
  - удалён двойной вызов `ensureOneContact()`; оставлена единая связка `ensureOneContact() || !validateEmail()`.

Контекст (ожидается из context_processors, без правок views_front)
- Передавать в шаблон (минимум):
  - `all_positions`: список объектов `{id, name}` (для выпадающего списка);
  - `all_groups`: список объектов `{id, name}` (для модального выбора);
  - `positions_create_url`: URL `POST /api/v1/positions/` (создание должности);
  - флаги доступа:
    - `can_edit_position` — право менять должность сотрудника (или глобальные staff/superuser);
    - `can_create_position` — право создавать должности;
    - `can_assign_position_groups` — право назначать групповые права должности.
  - Также в шаблоне допускаются `request.user.is_staff`/`is_superuser` (поддержаны как запасной сигнал в условиях показа).

API
- Используется уже имеющийся `PositionViewSet` (`/api/v1/positions/`):
  - создание должности с полем `groups` (список id) через `PositionSerializer`.
- Добавлен простой эндпоинт групп (если ещё не было):
  - `GET /api/v1/groups/` (list, опционально `?search=` по `name`);
  - сериализатор: `id`, `name`.
  - Примечание: в шаблоне сейчас используется предзагруженный `all_groups`; эндпоинт нужен для возможной ajax-подгрузки/фильтрации в будущем.

UI/стили
- Единый вертикальный ритм заголовков секций через `.py-3`.
- Модальный выбор групп по центру экрана; корректный stacking-context и непрозрачность.
- Мобильная дружелюбность: полноэкранный на sm-и ниже (`modal-fullscreen-sm-down`), поиск, чеклист, чипсы выбранных.

Как проверить
1) Откройте карточку сотрудника → «Редактировать».
2) Смените/очистите пол, дату рождения, должность — убедитесь, что очистка отправляет `null`, а изменение — нужное значение.
3) Удалите все контакты → форма подсветит ошибки и не отправится; добавьте валидный e-mail — ошибки исчезнут.
4) Создайте новую должность с группами → должность появится в селекте и применится сотруднику.
5) Проверить поведение прав:
   - без `can_edit_position`/staff/superuser — блок должности только для чтения;
   - без `can_assign_position_groups` — кнопка выбора групп скрыта.

Refs: DRF serializers (`EmployeeSerializer`, `PositionSerializer`, `Base64ImageField`), `PositionViewSet` (create + groups).
This commit is contained in:
admin i 2025-09-01 17:15:05 +03:00
parent da183c8fbd
commit 016ee1ef50
9 changed files with 1104 additions and 350 deletions

View File

@ -6,6 +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
from employees.models import (
Department,
DepartmentRole,
@ -17,9 +18,6 @@ from employees.models import (
from PIL import Image
from rest_framework import serializers
from employees.constants import ACTION_CHOICES
Employee = get_user_model()
@ -273,8 +271,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
"updated_at",
"last_login",
"date_joined",
"auth",
"auth",
)
read_only_fields = (
"is_active",
@ -283,7 +280,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
"updated_at",
"last_login",
"date_joined",
"auth",
"auth",
)
extra_kwargs = {
# чтобы точно не принимали/не отдавали пароль и коды
@ -497,3 +494,27 @@ class DepartmentRoleSerializer(serializers.ModelSerializer):
}
for p in obj.permissions.select_related("content_type").all()
]
class GroupSerializer(serializers.ModelSerializer):
permissions = serializers.PrimaryKeyRelatedField(
queryset=Permission.objects.all(), many=True, required=False
)
permissions_verbose = serializers.SerializerMethodField(read_only=True)
class Meta:
model = Group
fields = ["id", "name", "permissions", "permissions_verbose"]
def get_permissions_verbose(self, obj):
qs = obj.permissions.select_related("content_type").all()
return [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
"app": p.content_type.app_label,
"model": p.content_type.model,
}
for p in qs
]

View File

@ -31,8 +31,9 @@ from ..permissions import (ASSIGN_ROLE_PERM, MANAGE_PERM,
from .serializers import (DepartmentRoleSerializer, DepartmentSerializer,
EmailSerializer, EmailVerifySerializer,
EmployeeActionSerializer, EmployeeListSerializer,
EmployeeSerializer, PositionSerializer,
RegisterSerializer, SkillSerializer)
EmployeeSerializer, GroupSerializer,
PositionSerializer, RegisterSerializer,
SkillSerializer)
def _to_bool(val: str | None) -> bool | None:
@ -544,17 +545,22 @@ class EmployeeViewSet(viewsets.ModelViewSet):
}
def get_permissions(self):
if self.action in {"add_skill", "remove_skill"}:
# create: только staff/superuser (перепроверяете и в методе create)
if self.action == "create":
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
elif self.action in {"create", "update", "partial_update", "destroy"}:
if self.action in {"update", "partial_update"}:
return [
IsAuthenticated(),
(IsSelfOrStaff | AdminOrActionOrModelPerms)(),
]
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
else:
return [IsAuthenticated()]
# update/partial_update/destroy: владелец ИЛИ админ/модельные пермишены
if self.action in {
"update",
"partial_update",
"destroy",
"add_skill",
"remove_skill",
}:
return [IsAuthenticated(), (IsSelfOrStaff | AdminOrActionOrModelPerms)()]
# list / retrieve / me -> только аутентификация
return [IsAuthenticated()]
def get_queryset(self):
last_action_code_sq = Subquery(
@ -712,11 +718,14 @@ class EmployeeViewSet(viewsets.ModelViewSet):
"skills",
Prefetch(
"departments_links",
queryset=EmployeeDepartment.objects.filter(is_active=True)
.select_related("department", "role"),
queryset=EmployeeDepartment.objects.filter(
is_active=True
).select_related("department", "role"),
to_attr="dept_links",
),
Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")),
Prefetch(
"actions", queryset=EmployeeAction.objects.order_by("-date")
),
)
.get(pk=request.user.pk)
)
@ -735,12 +744,14 @@ class EmployeeViewSet(viewsets.ModelViewSet):
new_wechat = vd.get("wechat", instance.wechat)
if not (new_whatsapp or new_telegram or new_wechat):
return Response(
{"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."},
{
"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."
},
status=status.HTTP_400_BAD_REQUEST,
)
ser.save()
return Response(ser.data, status=status.HTTP_200_OK)
return Response(ser.data, status=status.HTTP_200_OK)
@action(detail=True, methods=["post"])
def add_skill(self, request, pk=None):
@ -1240,3 +1251,113 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet):
def perform_update(self, serializer):
obj = serializer.save()
self._apply_effects(obj)
class GroupViewSet(viewsets.ModelViewSet):
"""
/api/v1/groups/
GET list/retrieve — аутентифицированные
POST/PUT/PATCH/DELETE — staff/superuser ИЛИ пользователи с model perms
Экшены:
GET /{id}/permissions
POST /{id}/set-permissions
POST /{id}/add-permissions
POST /{id}/remove-permissions
"""
queryset = Group.objects.all()
serializer_class = GroupSerializer
pagination_class = None
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
search_fields = ["name"]
ordering_fields = ["name", "id"]
ordering = ["name"]
permission_classes = [IsAuthenticated, AdminOrActionOrModelPerms]
required_perms_by_action = {
"set_permissions": "employees.assign_group_permissions",
"add_permissions": "employees.assign_group_permissions",
"remove_permissions": "employees.assign_group_permissions",
}
# ----- helpers -----
def _validate_permissions_payload(self, request):
ids = request.data.get("permissions")
if not isinstance(ids, list):
return None, Response(
{"detail": "Поле 'permissions' должно быть списком id"}, status=400
)
qs = Permission.objects.filter(id__in=ids)
if qs.count() != len(set(ids)):
return None, Response(
{"detail": "Некоторые permissions не найдены"}, status=400
)
return qs, None
# ----- actions -----
@action(detail=True, methods=["get"])
def permissions(self, request, pk=None):
grp = self.get_object()
perms = (
Permission.objects.filter(group=grp)
.select_related("content_type")
.distinct()
)
data = [
{
"id": p.id,
"codename": f"{p.content_type.app_label}.{p.codename}",
"name": p.name,
"app": p.content_type.app_label,
"model": p.content_type.model,
}
for p in perms
]
return Response({"count": len(data), "results": data}, status=200)
@action(detail=True, methods=["post"])
def set_permissions(self, request, pk=None):
grp = self.get_object()
qs, err = self._validate_permissions_payload(request)
if err:
return err
grp.permissions.set(qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=200,
)
@action(detail=True, methods=["post"])
def add_permissions(self, request, pk=None):
grp = self.get_object()
qs, err = self._validate_permissions_payload(request)
if err:
return err
grp.permissions.add(*qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=200,
)
@action(detail=True, methods=["post"])
def remove_permissions(self, request, pk=None):
grp = self.get_object()
qs, err = self._validate_permissions_payload(request)
if err:
return err
grp.permissions.remove(*qs)
return Response(
{
"ok": True,
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=200,
)

View File

@ -6,7 +6,7 @@ from .employees.views import (DepartmentRoleViewSet, DepartmentViewSet,
EmployeeActionViewSet, EmployeeViewSet,
PositionViewSet, RegisterAPIView,
ResendEmailAPIView, SkillViewSet,
VerifyEmailAPIView)
VerifyEmailAPIView, GroupViewSet)
from .feed.views import CommentViewSet, PostViewSet
app_name = "v1"
@ -21,6 +21,7 @@ router.register(r"department-roles", DepartmentRoleViewSet, basename="department
router.register(r"skills", SkillViewSet, basename="skills")
router.register(r"posts", PostViewSet, basename="posts")
router.register(r"comments", CommentViewSet, basename="comments")
router.register(r"groups", GroupViewSet, basename="groups")
urlpatterns = [
path("auth/register/", RegisterAPIView.as_view(), name="register"),

View File

@ -1,7 +1,7 @@
# backend\employees\forms_front.py
from django import forms
from .models import Department
from .models import Department, Employee
class DepartmentEditForm(forms.Form):
@ -28,3 +28,31 @@ class DepartmentForm(forms.ModelForm):
class Meta:
model = Department
fields = ["name", "description", "head"]
class EmployeeForm(forms.ModelForm):
class Meta:
model = Employee
fields = (
"first_name",
"last_name",
"patronymic",
"telegram",
"whatsapp",
"wechat",
)
widgets = {
"first_name": forms.TextInput(attrs={"autocomplete": "given-name"}),
"last_name": forms.TextInput(attrs={"autocomplete": "family-name"}),
"whatsapp": forms.TextInput(attrs={"inputmode": "tel"}),
"telegram": forms.TextInput(),
"wechat": forms.TextInput(),
}
labels = {
"first_name": "Имя",
"last_name": "Фамилия",
"patronymic": "Отчество",
"telegram": "Telegram",
"whatsapp": "WhatsApp",
"wechat": "WeChat",
}

View File

@ -5,32 +5,44 @@ from . import views_front
app_name = "employees"
urlpatterns = [
# --- Профиль и сотрудники ---
path("list/", views_front.employee_list, name="employees_list"),
path("me/", views_front.employee_me, name="profile"),
path("me/edit/", views_front.employee_edit_me, name="employee_edit_profile"),
path("<int:pk>/", views_front.employee_detail, name="employee_detail"),
path("me/", views_front.employee_profile, name="profile"),
path("me/edit/", views_front.employee_edit_me, name="employee_edit_me"),
path("<int:pk>/", views_front.employee_profile, name="employee_detail"),
path("<int:pk>/edit/", views_front.employee_edit, name="employee_edit"),
path("create/", views_front.employee_create, name="employee_create"),
# --- Отделы ---
path(
"departments/add/",
views_front.DepartmentCreateView.as_view(),
name="department_add",
),
path("departments/<int:dept_id>/members/<int:employee_id>/role/", views_front.edit_department_role, name="edit_department_role",),
path(
"departments/<int:dept_id>/members/<int:employee_id>/role/",
views_front.edit_department_role,
name="edit_department_role",
),
path("departments/", views_front.department_list, name="department_list"),
path("departments/<int:pk>/", views_front.department_detail, name="department_detail"),
path("departments/<int:pk>/edit/", views_front.department_edit, name="department_edit",),
path("departments/<int:pk>/set-head/", views_front.department_set_head, name="department_set_head",),
path("departments/<int:pk>/set-member-role/", views_front.department_set_member_role, name="department_set_member_role",),
path(
"departments/<int:pk>/", views_front.department_detail, name="department_detail"
),
path(
"departments/<int:pk>/edit/",
views_front.department_edit,
name="department_edit",
),
path(
"departments/<int:pk>/set-head/",
views_front.department_set_head,
name="department_set_head",
),
path(
"departments/<int:pk>/set-member-role/",
views_front.department_set_member_role,
name="department_set_member_role",
),
# --- Навыки ---
path("<int:pk>/skill/add/", views_front.skill_add, name="skill_add"),
path("<int:pk>/skill/remove/", views_front.skill_remove, name="skill_remove"),
]

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import json
import base64
import mimetypes
from typing import Any
@ -152,175 +153,120 @@ def employee_list(request):
return render(request, "employees/employees_list.html", context)
# ---------- DETAIL ----------
# ---------- DETAIL / ME ----------
@require_api_auth
def employee_detail(request, pk: int):
def employee_profile(request, pk="me"):
"""
/employees/me/ и /employees/<int:pk>/
Если pk == текущему пользователю — редиректим на /employees/me/
"""
# --- редирект "свой id" -> /me ---
try:
int_pk = int(pk)
except (TypeError, ValueError):
int_pk = None
if (
int_pk is not None
and request.user.is_authenticated
and request.user.id == int_pk
):
url = reverse("employees:profile")
qs = request.META.get("QUERY_STRING")
if qs: # сохраняем ?tab=skills и т.п.
url = f"{url}?{qs}"
return redirect(url) # 302 достаточно, чтобы не кэшировать навсегда
# --- обычная логика detail/me ---
api = get_api_client(request)
resp = api.get(f"v1/employees/{pk}/")
is_me = str(pk) in {"me", "self"}
endpoint = "v1/employees/me/" if is_me else f"v1/employees/{pk}/"
resp = api.get(endpoint)
if not resp.ok:
messages.error(request, f"Сотрудник не найден ({resp.status})")
return redirect("employees:employees_list")
emp = resp.json or {}
can_edit = bool(
request.user and (request.user.is_staff or request.user.id == emp.get("id"))
)
posts = _fetch_employee_posts(api, pk)
emp_id = emp.get("id") or (request.user.id if is_me else pk)
posts = _fetch_employee_posts(api, emp_id)
sresp = api.get("v1/skills/", params={"ordering": "name"})
all_skills = sresp.json if sresp.ok else []
return render(
request,
"employees/employee_detail.html",
{"emp": emp, "can_edit": can_edit, "all_skills": all_skills, "posts": posts},
sdata = sresp.json if sresp.ok else []
all_skills = sdata.get("results", sdata) if isinstance(sdata, dict) else sdata
gresp = None
for path in ("v1/auth/groups/", "v1/groups/"):
r = api.get(path, params={"ordering": "name"})
if r.ok:
gresp = r
break
gdata = gresp.json if gresp and gresp.ok else []
all_groups = gdata.get("results", gdata) if isinstance(gdata, dict) else gdata
presp = api.get("v1/positions/", params={"ordering": "name"})
pdata = presp.json if presp.ok else []
all_positions = pdata.get("results", pdata) if isinstance(pdata, dict) else pdata
can_edit = (
True
if is_me
else bool(
request.user and (request.user.is_staff or request.user.id == emp.get("id"))
)
)
ctx = {
"emp": emp,
"can_edit": can_edit,
"all_skills": all_skills,
"all_positions": all_positions,
"all_groups": all_groups,
"posts": posts,
}
if is_me:
ctx["is_me"] = True
# ---------- ME ----------
@require_api_auth
def employee_me(request):
api = get_api_client(request)
resp = api.get("v1/employees/me/")
if not resp.ok:
messages.error(request, f"Ошибка API ({resp.status})")
return redirect("employees:employees_list")
emp = resp.json or {}
# новости сотрудника
posts = _fetch_employee_posts(api, emp.get("id"))
# все навыки для даталиста (разворачиваем results)
sresp = api.get("v1/skills/", params={"ordering": "name"})
data = sresp.json if sresp.ok else []
all_skills = data.get("results", data) if isinstance(data, dict) else data
return render(
request,
"employees/employee_detail.html",
{
"emp": emp,
"can_edit": True,
"is_me": True,
"all_skills": all_skills,
"posts": posts,
},
)
return render(request, "employees/employee_detail.html", ctx)
# ---------- EDIT (self or staff) ----------
@require_api_auth
@require_http_methods(["GET", "POST"])
@require_http_methods(["PATCH", "POST"])
def employee_edit(request, pk: int):
"""
Редактирование пользователя по id.
Разрешено staff/superuser или самому себе (проверку делает API).
"""
api = get_api_client(request)
if request.method == "GET":
resp = api.get(f"v1/employees/{pk}/")
if not resp.ok:
messages.error(request, f"Сотрудник не найден ({resp.status})")
return redirect("employees:employees_list")
emp = resp.json or {}
return render(request, "employees/employee_edit.html", {"emp": emp})
# POST → PATCH
data = {
"last_name": request.POST.get("last_name", "").strip(),
"first_name": request.POST.get("first_name", "").strip(),
"patronymic": request.POST.get("patronymic", "").strip(),
"gender": request.POST.get("gender") or None,
"birth_date": request.POST.get("birth_date") or None,
"position": request.POST.get("position") or None,
"telegram": request.POST.get("telegram", "").strip(),
"whatsapp": request.POST.get("whatsapp", "").strip(),
"wechat": request.POST.get("wechat", "").strip(),
}
# skills (множественный select): skills=1&skills=2 …
skills = request.POST.getlist("skills")
if skills:
data["skills"] = skills
# avatar: либо новый файл, либо снять (clear_avatar=on)
if request.FILES.get("avatar"):
data["avatar"] = _file_to_data_uri(request.FILES["avatar"])
elif request.POST.get("clear_avatar") == "on":
data["avatar"] = "" # Base64ImageField очистит
# удаляем пустые строки, чтобы не перетирать nullables
data = _cleanup_payload(data, keep_empty={"avatar"})
resp = api.patch(f"v1/employees/{pk}/", json=data)
if not resp.ok:
messages.error(request, f"Не удалось сохранить: {resp.status} — {resp.text}")
# Подставим обратно форму с введёнными данными
current = api.get(f"v1/employees/{pk}/").json or {}
current.update(
{k: request.POST.get(k) for k in ["last_name", "first_name", "patronymic"]}
)
return render(request, "employees/employee_edit.html", {"emp": current})
messages.success(request, "Профиль обновлён.")
return redirect("employees:employee_detail", pk=pk)
try:
payload = json.loads(request.body or b"{}")
except Exception:
payload = {}
resp = api.patch(f"v1/employees/{pk}/", json=payload)
try:
data = resp.json
except Exception:
data = {"detail": resp.text}
return JsonResponse(data or {}, status=resp.status)
# ---------- EDIT ME ----------
@require_api_auth
@require_http_methods(["GET", "POST"])
@require_http_methods(["PATCH", "POST"])
def employee_edit_me(request):
"""
Редактирование собственного профиля через /api/v1/employees/me/
"""
api = get_api_client(request)
if request.method == "GET":
resp = api.get("v1/employees/me/")
if not resp.ok:
messages.error(request, f"Ошибка API ({resp.status})")
return redirect("employees:employees_list")
emp = resp.json or {}
return render(
request, "employees/employee_edit.html", {"emp": emp, "is_me": True}
)
data = {
"last_name": request.POST.get("last_name", "").strip(),
"first_name": request.POST.get("first_name", "").strip(),
"patronymic": request.POST.get("patronymic", "").strip(),
"gender": request.POST.get("gender") or None,
"birth_date": request.POST.get("birth_date") or None,
"position": request.POST.get("position") or None,
"telegram": request.POST.get("telegram", "").strip(),
"whatsapp": request.POST.get("whatsapp", "").strip(),
"wechat": request.POST.get("wechat", "").strip(),
}
skills = request.POST.getlist("skills")
if skills:
data["skills"] = skills
if request.FILES.get("avatar"):
data["avatar"] = _file_to_data_uri(request.FILES["avatar"])
elif request.POST.get("clear_avatar") == "on":
data["avatar"] = ""
data = _cleanup_payload(data, keep_empty={"avatar"})
resp = api.patch("v1/employees/me/", json=data)
if not resp.ok:
messages.error(request, f"Не удалось сохранить: {resp.status} — {resp.text}")
current = api.get("v1/employees/me/").json or {}
current.update(
{k: request.POST.get(k) for k in ["last_name", "first_name", "patronymic"]}
)
return render(
request, "employees/employee_edit.html", {"emp": current, "is_me": True}
)
messages.success(request, "Профиль обновлён.")
return redirect("employees:employee_me")
# принимаем JSON из браузера
try:
payload = json.loads(request.body or b"{}")
except Exception:
payload = {}
# отправляем в DRF
resp = api.patch("v1/employees/me/", json=payload)
try:
data = resp.json
except Exception:
data = {"detail": resp.text}
return JsonResponse(data or {}, status=resp.status)
# ---------- CREATE (staff) ----------

View File

@ -0,0 +1,692 @@
{# templates/employees/_employee_edit.html #}
<style>
/* модал всегда выше бэкдропа/локальных стэков */
.modal { z-index: 1080; }
.modal-backdrop { z-index: 1075; }
/* не даём «просвечивать» через бэкдроп */
.modal .modal-content { background-color: var(--bs-body-bg); }
</style>
<form id="apiEditForm" class="row g-3" data-endpoint="{{ endpoint }}">
{% csrf_token %}
{# === ЛИЧНЫЕ ДАННЫЕ === #}
<div class="col-12">
<div class="ios-acc mb-3">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-person-badge"></i>
<div class="ios-section-title mb-0">Личные данные</div>
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
<div class="col-sm-6">
<label class="form-label" for="f_first_name">Имя</label>
<input id="f_first_name" class="form-control" name="first_name"
value="{{ emp.first_name|default:'' }}" autocomplete="given-name"
data-init="{{ emp.first_name|default:'' }}">
</div>
<div class="col-sm-6">
<label class="form-label" for="f_last_name">Фамилия</label>
<input id="f_last_name" class="form-control" name="last_name"
value="{{ emp.last_name|default:'' }}" autocomplete="family-name"
data-init="{{ emp.last_name|default:'' }}">
</div>
<div class="col-sm-6">
<label class="form-label" for="f_patronymic">Отчество</label>
<input id="f_patronymic" class="form-control" name="patronymic"
value="{{ emp.patronymic|default:'' }}"
data-init="{{ emp.patronymic|default:'' }}">
</div>
<div class="col-sm-3">
<label class="form-label" for="f_gender">Пол</label>
<select id="f_gender" class="form-select" name="gender"
data-init="{{ emp.gender|default_if_none:'' }}">
<option value="" {% if emp.gender is None %}selected{% endif %}>— Не указано —</option>
<option value="1" {% if emp.gender == 1 %}selected{% endif %}>Мужской</option>
<option value="2" {% if emp.gender == 2 %}selected{% endif %}>Женский</option>
</select>
</div>
<div class="col-sm-3">
<label class="form-label" for="f_birth">Дата рождения</label>
<input id="f_birth" class="form-control" type="date" name="birth_date"
value="{{ emp.birth_date|default:'' }}"
data-init="{{ emp.birth_date|default:'' }}">
</div>
</div>
</div>
</div>
</div>
{# === КОНТАКТЫ === #}
<div class="col-12">
<div class="ios-acc mb-3">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-person-lines-fill"></i>
<div class="ios-section-title mb-0">Контакты</div>
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
<div class="col-sm-6">
<label class="form-label" for="f_phone">Телефон</label>
<input id="f_phone" class="form-control" name="phone_number"
value="{{ emp.phone_number|default:'' }}" inputmode="tel"
data-init="{{ emp.phone_number|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
<div class="col-sm-6">
<label class="form-label" for="f_email">Email</label>
<input id="f_email" class="form-control" type="email" name="email"
value="{{ emp.email|default:'' }}" autocomplete="email"
data-init="{{ emp.email|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
<div class="col-sm-4">
<label class="form-label" for="f_telegram">Telegram</label>
<input id="f_telegram" class="form-control" name="telegram"
value="{{ emp.telegram|default:'' }}" inputmode="text"
data-init="{{ emp.telegram|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
<div class="col-sm-4">
<label class="form-label" for="f_whatsapp">WhatsApp</label>
<input id="f_whatsapp" class="form-control" name="whatsapp"
value="{{ emp.whatsapp|default:'' }}" inputmode="tel"
data-init="{{ emp.whatsapp|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
<div class="col-sm-4">
<label class="form-label" for="f_wechat">WeChat</label>
<input id="f_wechat" class="form-control" name="wechat"
value="{{ emp.wechat|default:'' }}" inputmode="text"
data-init="{{ emp.wechat|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
</div>
</div>
</div>
</div>
{# === ДОЛЖНОСТЬ (как раньше) === #}
<div class="col-12">
{% if can_edit_position or request.user.is_staff or request.user.is_superuser %}
<div class="ios-acc mb-2" id="positionBlock"
data-employee-url="{{ endpoint }}"
data-create-url="{{ positions_create_url }}"
data-can-assign="{% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %}1{% else %}0{% endif %}">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-briefcase"></i>
<div class="ios-section-title mb-0">Должность</div>
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
<div class="col-md-6">
<label class="form-label" for="f_position">Текущая должность</label>
{% if all_positions %}
<select id="f_position" class="form-select" name="position_id"
data-init="{{ emp.position.id|default:'' }}">
<option value="">— Не указано —</option>
{% for p in all_positions %}
{% if p.id and p.name %}
<option value="{{ p.id }}"
{% if emp.position and emp.position.id == p.id %}selected{% endif %}>
{{ p.name }}
</option>
{% endif %}
{% endfor %}
</select>
{% else %}
<input id="f_position" class="form-control" name="position_id"
value="{{ emp.position.id|default:'' }}" placeholder="ID должности"
data-init="{{ emp.position.id|default:'' }}">
<div class="form-text">Передайте в контекст <code>all_positions</code> для выпадающего списка.</div>
{% endif %}
</div>
{% if can_create_position or request.user.is_staff or request.user.is_superuser %}
<div class="col-md-6">
<label class="form-label d-block">Новая должность</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="collapse" data-bs-target="#newPositionPane">
Создать должность
</button>
</div>
<div class="col-12">
<div id="newPositionPane" class="collapse mt-2">
<div class="row g-2">
<div class="col-sm-6">
<label class="form-label" for="pos_name">Название</label>
<input id="pos_name" class="form-control" placeholder="Напр. Руководитель отдела">
</div>
<div class="col-sm-6">
<label class="form-label" for="pos_desc">Описание</label>
<input id="pos_desc" class="form-control" placeholder="Опционально">
</div>
{% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %}
<div class="col-12">
{# --- Группы прав (мобильно-дружелюбный выбор) --- #}
{% if all_groups %}
<div class="col-12">
{# выбранные группы в виде чипсов #}
<div id="pos_groups_chips" class="chips mb-2"></div>
{# хранение выбранных id (csv), чтобы JS легко забрал #}
<input type="hidden" id="pos_groups_hidden" value="">
</div>
{# Модальное окно выбора групп #}
<div class="modal fade" id="groupsPickerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Группы прав</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<input type="search" class="form-control mb-3" id="groupsSearch" placeholder="Поиск по группам">
<div id="groupsList" class="list-group">
{% for g in all_groups %}
{% if g.id and g.name %}
<label class="list-group-item d-flex align-items-center gap-2">
<input class="form-check-input me-2 group-check" type="checkbox" value="{{ g.id }}">
<span class="flex-grow-1">{{ g.name }}</span>
<span class="text-secondary small">#{{ g.id }}</span>
</label>
{% endif %}
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" id="groupsClear" class="btn btn-ghost">Сбросить</button>
<button type="button" id="groupsDone" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>
</div>
</div>
</div>
{% else %}
{# Фолбэк: если в контексте нет all_groups — оставить поле CSV #}
<div class="col-12">
<label class="form-label" for="pos_groups_csv">Группы прав (id через запятую)</label>
<input id="pos_groups_csv" class="form-control" placeholder="Напр. 1,2,3">
</div>
{% endif %}
</div>
{% endif %}
<div class="col-12 d-flex gap-2 mt-1">
<button type="button"
class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#groupsPickerModal">
Выбрать группы полномочий
</button>
<button type="button" class="btn btn-primary btn-sm" id="posCreateBtn">
<span class="lbl">Создать и назначить</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-bs-toggle="collapse" data-bs-target="#newPositionPane">Отмена</button>
</div>
<div class="col-12">
<div class="small mt-1 d-none" id="posCreateMsg"></div>
</div>
</div>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="ios-acc mb-2">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-briefcase"></i>
<div class="ios-section-title mb-0">Должность</div>
</div>
<div class="ios-acc-body">
<input class="form-control" value="{% firstof emp.position.name emp.position_label '—' %}" disabled>
</div>
</div>
{% endif %}
</div>
{# === КНОПКИ === #}
<div class="col-12 d-flex gap-2 mt-2">
<button type="submit" class="btn btn-primary">
<span class="btn-label"><i class="bi-check2 me-1"></i>Сохранить</span>
<span class="btn-spinner d-none spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn-ghost" data-bs-toggle="collapse" data-bs-target="#editFormWrap">Отмена</button>
</div>
</form>
<script>
(function(){
const form = document.getElementById('apiEditForm');
if (!form) return;
const endpoint = form.dataset.endpoint || '';
if (!endpoint) { console.error('Нет endpoint для формы редактирования'); return; }
const CONTACT_FIELDS = ['email','phone_number','telegram','whatsapp','wechat'];
const btn = form.querySelector('[type="submit"]');
const btnLabel = btn?.querySelector('.btn-label');
const btnSpin = btn?.querySelector('.btn-spinner');
function setSubmitting(on){
if (!btn) return;
if (on){ btn.setAttribute('disabled',''); btnSpin?.classList.remove('d-none'); btnLabel?.classList.add('visually-hidden'); }
else { btn.removeAttribute('disabled'); btnSpin?.classList.add('d-none'); btnLabel?.classList.remove('visually-hidden'); }
}
function clearErrors(){
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
form.querySelectorAll('.invalid-feedback').forEach(el => el.textContent = '');
}
function fieldError(name, message){
const el = form.querySelector(`[name="${CSS.escape(name)}"]`);
if (!el) return;
el.classList.add('is-invalid');
const container = el.closest('[class^="col-"]') || el.parentElement;
let fb = container?.querySelector('.invalid-feedback');
if (!fb) { fb = document.createElement('div'); fb.className = 'invalid-feedback'; el.after(fb); }
fb.textContent = message || 'Некорректное значение';
}
function ensureOneContact(){
const hasAny = CONTACT_FIELDS.some(n => (form.elements[n]?.value || '').trim().length > 0);
if (hasAny) return true;
CONTACT_FIELDS.forEach(n => fieldError(n, 'Укажите хотя бы один контакт'));
form.elements[CONTACT_FIELDS[0]]?.focus();
return false;
}
// Прочитать файл в Base64
function readFileAsDataURL(file){
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsDataURL(file);
});
}
// собрать skills_ids (массив) и сравнить с init
function collectSkillsPayload(){
const sel = form.querySelector('select[name="skills_ids"]');
if (!sel) return null;
const selected = Array.from(sel.selectedOptions).map(o => o.value).filter(Boolean);
const initCsv = sel.dataset.init || '';
const initArr = initCsv ? initCsv.split(',').map(s => s.trim()).filter(Boolean) : [];
// сравнение (без учёта порядка)
const norm = a => a.slice().sort().join(',');
if (norm(selected) === norm(initArr)) return null;
return selected;
}
// собрать общие поля (кроме skills_ids и avatar)
function buildScalarPayload(){
const payload = {};
const fields = form.querySelectorAll(
'input[name]:not([type="file"]):not([name="clear_avatar"]):not(:disabled), select[name]:not([name="skills_ids"]):not(:disabled)'
);
fields.forEach(el => {
const name = el.name;
const raw = (el.value ?? '').toString().trim();
const init = (el.dataset.init ?? el.defaultValue ?? '').toString();
// Ничего не менялось
if (raw === init) return;
// Пустое значение: для некоторых полей нужно слать null
const shouldNull = (raw === '' && ['gender','birth_date','position_id'].includes(name));
if (shouldNull) {
// меняем непустое init -> null (очистка)
if (init !== '') payload[name] = null;
return;
}
// Типизация gender в число
if (name === 'gender' && raw !== '') {
payload[name] = Number(raw);
return;
}
// Остальные — как есть (пустые строки допустимы для текстовых полей)
payload[name] = raw;
});
return payload;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearErrors();
if (!ensureOneContact()) return;
setSubmitting(true);
try{
const payload = buildScalarPayload();
// skills_ids (массив)
const skills = collectSkillsPayload();
if (skills) payload.skills_ids = skills;
// avatar (Base64) / clear
const avatarFile = document.getElementById('f_avatar')?.files?.[0] || null;
const clearAvatar = form.querySelector('input[name="clear_avatar"]')?.checked;
if (avatarFile){
try{
const dataUrl = await readFileAsDataURL(avatarFile);
payload.avatar = dataUrl; // Base64ImageField ожидает data URL
}catch(_){}
} else if (clearAvatar){
payload.avatar = ""; // очистка
}
// если нечего отправлять — просто обновим UI
if (Object.keys(payload).length === 0){
setSubmitting(false);
if (window.bootstrap) {
try { new bootstrap.Collapse(document.getElementById('editFormWrap'), {toggle:false}).hide(); } catch(_){}
}
location.reload();
return;
}
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
const resp = await fetch(endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(payload),
credentials: 'same-origin'
});
if (!resp.ok){
let body = null;
try { body = await resp.json(); } catch {}
if (body && typeof body === 'object'){
let shown = false;
for (const [k, v] of Object.entries(body)){
if (k === 'detail') continue;
const msg = Array.isArray(v) ? v.join(' ') : String(v);
fieldError(k, msg); shown = true;
}
if (body.detail) alert(body.detail);
else if (!shown) alert('Не удалось сохранить изменения (ошибка валидации).');
} else {
const t = await resp.text();
alert('Не удалось сохранить изменения.\n' + (t || `HTTP ${resp.status}`));
}
setSubmitting(false);
return;
}
// успех
try { if (window.bootstrap) new bootstrap.Collapse(document.getElementById('editFormWrap'), {toggle:false}).hide(); } catch(_){}
location.reload();
}catch(err){
console.error(err);
alert('Не удалось сохранить изменения. Попробуйте ещё раз.');
setSubmitting(false);
}
});
})();
</script>
<script>
(function(){
const form = document.getElementById('apiEditForm');
const block = document.getElementById('positionBlock');
if (!form || !block) return;
const select = document.getElementById('f_position');
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
const employeeUrl = block.dataset.employeeUrl || '';
const createUrl = block.dataset.createUrl || '';
const canAssign = block.dataset.canAssign === '1';
function busy(btn, on){
const lbl = btn.querySelector('.lbl');
const sp = btn.querySelector('.spinner-border');
if (on){ btn.setAttribute('disabled',''); sp?.classList.remove('d-none'); lbl?.classList.add('visually-hidden'); }
else { btn.removeAttribute('disabled'); sp?.classList.add('d-none'); lbl?.classList.remove('visually-hidden'); }
}
function msg(el, text, ok){
if (!el) return;
el.textContent = text || '';
el.classList.toggle('d-none', !text);
el.classList.toggle('text-success', !!ok);
el.classList.toggle('text-danger', !ok);
}
document.getElementById('posCreateBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('posCreateBtn');
const m = document.getElementById('posCreateMsg');
msg(m, '');
if (!createUrl || !employeeUrl){
msg(m, 'Не настроены URL создания/обновления.', false);
return;
}
const name = (document.getElementById('pos_name')?.value || '').trim();
const description = (document.getElementById('pos_desc')?.value || '').trim();
if (!name){ msg(m, 'Укажите название должности.', false); return; }
// собрать группы (для сериализатора PositionSerializer.groups)
let groupIds = [];
if (canAssign){
const msel = document.getElementById('pos_groups'); // десктопный мультиселект (если есть)
const hid = document.getElementById('pos_groups_hidden'); // мобильный модал
const csv = document.getElementById('pos_groups_csv'); // фолбэк
if (msel) groupIds = Array.from(msel.selectedOptions).map(o => o.value).filter(Boolean);
else if (hid) groupIds = (hid.value || '').split(',').map(s => s.trim()).filter(Boolean);
else if (csv) groupIds = (csv.value || '').split(',').map(s => s.trim()).filter(Boolean);
// привести к числам (DRF примет и строки, но так чище)
groupIds = groupIds.map(id => Number(id)).filter(n => !Number.isNaN(n));
}
busy(btn, true);
try{
// 1) POST /positions/ — передаём groups сразу в create (согласно PositionSerializer)
const createPayload = { name, description };
if (canAssign && groupIds.length) createPayload.groups = groupIds;
const cResp = await fetch(createUrl, {
method:'POST', credentials:'same-origin',
headers:{ 'Content-Type':'application/json', 'X-Requested-With':'XMLHttpRequest', 'X-CSRFToken': csrftoken },
body: JSON.stringify(createPayload)
});
const cData = await (async()=>{ try{return await cResp.json()}catch{return null} })();
if (!cResp.ok){
msg(m, (cData?.detail || JSON.stringify(cData) || `HTTP ${cResp.status}`), false);
busy(btn,false); return;
}
const newId = cData?.id;
if (!newId){ msg(m, 'API не вернул id новой должности.', false); busy(btn,false); return; }
// 2) PATCH сотрудника -> position_id (согласно EmployeeSerializer)
const eResp = await fetch(employeeUrl, {
method:'PATCH', credentials:'same-origin',
headers:{ 'Content-Type':'application/json', 'X-Requested-With':'XMLHttpRequest', 'X-CSRFToken': csrftoken },
body: JSON.stringify({ position_id: newId })
});
if (!eResp.ok){
const eData = await (async()=>{ try{return await eResp.json()}catch{return null} })();
msg(m, 'Создано, но не назначено: ' + (eData?.detail || JSON.stringify(eData) || `HTTP ${eResp.status}`), false);
busy(btn,false); return;
}
// 3) обновить селект и закрыть панель
if (select){
let opt = Array.from(select.options).find(o => o.value == String(newId));
if (!opt){
opt = document.createElement('option');
opt.value = String(newId);
opt.textContent = cData?.name || ('Должность #'+newId);
select.appendChild(opt);
}
select.value = String(newId);
select.dataset.init = String(newId);
}
msg(m, 'Должность создана и назначена.', true);
try{ if (window.bootstrap) new bootstrap.Collapse(document.getElementById('newPositionPane'), {toggle:false}).hide(); }catch(_){}
}catch(ex){
console.error(ex);
msg(m, 'Неожиданная ошибка при создании/назначении.', false);
}finally{
busy(btn, false);
}
});
})();
</script>
<script>
(function(){
const chipsBox = document.getElementById('pos_groups_chips');
const hiddenInp = document.getElementById('pos_groups_hidden');
const listBox = document.getElementById('groupsList');
const searchInp = document.getElementById('groupsSearch');
const clearBtn = document.getElementById('groupsClear');
const doneBtn = document.getElementById('groupsDone');
if (!listBox || !hiddenInp) return;
// Текущее множество выбранных id
const sel = new Set((hiddenInp.value || '').split(',').map(s => s.trim()).filter(Boolean));
// Рендер чипсов
function renderChips(){
if (!chipsBox) return;
chipsBox.innerHTML = '';
if (sel.size === 0){ return; }
// соберём карту id->название из списка (чтобы показывать имена)
const nameById = {};
listBox.querySelectorAll('.group-check').forEach(ch => {
nameById[ch.value] = ch.closest('.list-group-item').querySelector('span.flex-grow-1')?.textContent || ('#'+ch.value);
});
for (const id of sel){
const chip = document.createElement('span');
chip.className = 'chip';
chip.dataset.id = id;
chip.innerHTML = `${nameById[id] || ('#'+id)} <button type="button" class="btn btn-sm p-0 ms-1" aria-label="Удалить" style="line-height:1;">×</button>`;
chipsBox.appendChild(chip);
}
}
// Синхронизация чекбоксов ↔ выбранные
function syncChecksFromSel(){
listBox.querySelectorAll('.group-check').forEach(ch => {
ch.checked = sel.has(ch.value);
});
}
function syncSelFromChecks(){
sel.clear();
listBox.querySelectorAll('.group-check:checked').forEach(ch => sel.add(ch.value));
}
function saveHidden(){
hiddenInp.value = Array.from(sel).join(',');
}
// Интерактив: клик по чекбоксу
listBox.addEventListener('change', (e) => {
const ch = e.target.closest('.group-check');
if (!ch) return;
if (ch.checked) sel.add(ch.value); else sel.delete(ch.value);
});
// Фильтр
searchInp?.addEventListener('input', () => {
const q = (searchInp.value || '').toLowerCase().trim();
listBox.querySelectorAll('.list-group-item').forEach(row => {
const name = row.querySelector('.flex-grow-1')?.textContent.toLowerCase() || '';
const id = row.querySelector('.group-check')?.value || '';
row.classList.toggle('d-none', !(name.includes(q) || id.includes(q)));
});
});
// Сброс
clearBtn?.addEventListener('click', () => {
sel.clear();
syncChecksFromSel();
renderChips();
saveHidden();
});
// Готово
doneBtn?.addEventListener('click', () => {
// на случай если что-то поменяли напрямую в чекбоксах
syncSelFromChecks();
renderChips();
saveHidden();
});
// Удаление из чипса
chipsBox?.addEventListener('click', (e) => {
const btn = e.target.closest('button');
const chip = btn?.closest('.chip');
if (!chip) return;
sel.delete(chip.dataset.id);
renderChips();
syncChecksFromSel();
saveHidden();
});
// Инициализация при первом открытии модала (если уже есть значения)
renderChips();
syncChecksFromSel();
// === ДОПОЛНЕНИЕ К ВАШЕЙ КНОПКЕ "Создать и назначить" ===
// в вашем коде уже есть место, где собираются groupIds:
// if (msel) ... else if (csv) ...
// добавим чтение из скрытого поля:
const posCreateBtn = document.getElementById('posCreateBtn');
if (posCreateBtn){
posCreateBtn.addEventListener('click', () => {
// прокинем выбранные id в data-атрибут кнопки, чтобы ваш обработчик прочитал
if (hiddenInp) {
posCreateBtn.dataset.groupsSelected = hiddenInp.value; // "1,2,3"
}
}, {capture:true});
}
// А внутри вашего обработчика posCreateBtn используйте ещё один источник:
// const msel = document.getElementById('pos_groups');
// const csv = document.getElementById('pos_groups_csv');
// const hid = document.getElementById('pos_groups_hidden');
// if (msel) groupIds = Array.from(msel.selectedOptions).map(o => o.value).filter(Boolean);
// else if (hid) groupIds = (hid.value || '').split(',').map(s => s.trim()).filter(Boolean);
// else if (csv) groupIds = (csv.value || '').split(',').map(s => s.trim()).filter(Boolean);
//
// и перед отправкой:
// groupIds = groupIds.map(id => Number(id)).filter(n => !Number.isNaN(n));
})();
</script>
<script>
(function(){
const m = document.getElementById('groupsPickerModal');
if (m && m.parentNode !== document.body) {
document.body.appendChild(m);
}
})();
</script>

View File

@ -202,7 +202,7 @@
/* ====== SKILLS UI ====== */
/* плюс как «пилюля» */
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;gap:.35rem;cursor:pointer;border-style:dashed}
#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}
@ -267,9 +267,18 @@
{{ emp.last_name }} {{ emp.first_name }}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
</div>
{% if can_edit %}
<a class="btn-icon" href="{% url 'employees:employee_edit' emp.id %}" title="Редактировать профиль" aria-label="Редактировать профиль">
<button class="btn-icon"
type="button"
data-bs-toggle="collapse"
data-bs-target="#editFormWrap"
aria-expanded="false"
aria-controls="editFormWrap"
title="Редактировать профиль" aria-label="Редактировать профиль">
<i class="bi-pencil-square"></i>
</a>
</button>
<noscript>
<a class="btn btn-link p-0" href="{% url 'employees:employee_edit' emp.id %}">Редактировать</a>
</noscript>
{% endif %}
</div>
@ -478,83 +487,105 @@
{% endwith %}
{% endif %}
{# ====== НАВЫКИ ====== #}
<div class="mt-3 text-start" id="skillsBlock"
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
data-editable="1"
{% endif %}>
<div class="ios-section-title mb-2"><i class="bi bi-stars me-1"></i>Навыки</div>
<div class="mt-3 text-start" id="skillsBlock"
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
data-editable="1"
{% endif %}>
<div class="ios-section-title mb-2"><i class="bi bi-stars me-1"></i>Навыки</div>
<div class="chips align-items-center">
{% with skills=emp.skills %}
{% if skills %}
{% for s in skills %}
{% firstof s.name s as sk_name %}
{% if sk_name %}
<span class="chip chip-skill"
data-skill-id="{{ s.id|default:'' }}"
data-skill-name="{{ sk_name }}">
<span class="lbl">{{ sk_name }}</span>
<div class="chips align-items-center">
{% with skills=emp.skills %}
{% if skills %}
{% for s in skills %}
{% firstof s.name s as sk_name %}
{% if sk_name %}
<span class="chip chip-skill"
data-skill-id="{{ s.id|default:'' }}"
data-skill-name="{{ sk_name }}">
<span class="lbl">{{ sk_name }}</span>
{# кнопка «минус» только если есть право #}
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
<button type="button" class="chip-del-btn" title="Убрать навык" aria-label="Убрать навык">
<i class="bi bi-dash-lg"></i>
</button>
{% endif %}
</span>
{% endif %}
{% endfor %}
{% else %}
<span class="skills-empty"></span>
{% endif %}
{% endwith %}
{# плюс только если есть право #}
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
<button type="button" class="chip chip-add"
data-bs-toggle="collapse" data-bs-target="#skillsForm"
aria-expanded="false" aria-controls="skillsForm" title="Добавить навык">
<i class="bi bi-plus-lg"></i><span class="d-none d-sm-inline"></span>
</button>
{% endif %}
</div>
{# кнопка «минус» только если есть право #}
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
<button type="button" class="chip-del-btn" title="Убрать навык" aria-label="Убрать навык">
<i class="bi bi-dash-lg"></i>
</button>
<div id="skillsForm" class="collapse mt-2">
<form id="skillAddForm" class="skill-form"
action="{% url 'employees:skill_add' emp.id %}" method="post">
{% csrf_token %}
<div class="row g-2">
<div class="col-12">
<input class="form-control" name="skill_name" list="skillsAll"
placeholder="Выберите или введите новый навык" required>
<datalist id="skillsAll">
{% for sk in all_skills %}
{% if sk.name %}<option value="{{ sk.name }}"></option>{% endif %}
{% endfor %}
</datalist>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>Добавить
</button>
<button type="button" class="btn btn-ghost btn-sm" data-bs-toggle="collapse" data-bs-target="#skillsForm">
Отмена
</button>
</div>
</div>
</form>
</div>
{% endif %}
</span>
{% endif %}
{% endfor %}
{% else %}
<span class="skills-empty"></span>
{% endif %}
{% endwith %}
{# плюс только если есть право #}
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
<button type="button" class="chip chip-add"
data-bs-toggle="collapse" data-bs-target="#skillsForm"
aria-expanded="false" aria-controls="skillsForm" title="Добавить навык">
<i class="bi bi-plus-lg"></i><span class="d-none d-sm-inline"></span>
</button>
{% endif %}
</div>
{% if perms.employees.manage_employee_skills or request.user.is_staff or request.user.is_superuser or is_me %}
<div id="skillsForm" class="collapse mt-2">
<form id="skillAddForm" class="skill-form"
action="{% url 'employees:skill_add' emp.id %}" method="post">
{% csrf_token %}
<div class="row g-2">
<div class="col-12">
<input class="form-control" name="skill_name" list="skillsAll"
placeholder="Выберите или введите новый навык" required>
<datalist id="skillsAll">
{% for sk in all_skills %}
{% if sk.name %}<option value="{{ sk.name }}"></option>{% endif %}
{% endfor %}
</datalist>
</div>
<div class="col-12 d-flex gap-2">
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-plus-lg me-1"></i>
</button>
<button type="button" class="btn btn-ghost btn-sm" data-bs-toggle="collapse" data-bs-target="#skillsForm">
Отмена
</button>
</div>
</div>
</form>
</div>
{% endif %}
</div>
</div>
</div>
</article>
</div>
<!-- RIGHT -->
<div class="col-md-8">
{% if can_edit %}
<div id="editFormWrap" class="collapse">
<article class="ios-card">
<div class="p-4">
<div class="d-flex align-items-center gap-2">
<i class="bi-pencil-square"></i>
<div class="ios-section-title mb-0">Редактирование профиля</div>
<div class="ms-auto">
<button type="button" class="btn btn-sm btn-ghost"
data-bs-toggle="collapse" data-bs-target="#editFormWrap">Скрыть</button>
</div>
</div>
<div class="mt-3">
{% if is_me %}
{% url 'employees:employee_edit_me' as edit_endpoint %}
{% else %}
{% url 'employees:employee_edit' emp.id as edit_endpoint %}
{% endif %}
{% include "employees/_employee_edit.html" with emp=emp endpoint=edit_endpoint %}
</div>
</div>
</article>
</div>
{% endif %}
<article class="ios-card" id="activity">
<div class="p-4">
<div class="ios-section-title mb-3"><i class="bi-activity me-1"></i>Активность</div>
@ -562,9 +593,6 @@
<div class="activity-feed">
{% include "feed/_feed_cards.html" with items=posts only %}
</div>
{% else %}
<div class="text-secondary small">Публикаций пока нет.</div>
{% endif %}
</div>
</article>
@ -572,6 +600,8 @@
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', () => {
@ -685,7 +715,5 @@ document.addEventListener('DOMContentLoaded', () => {
{% endblock %}
{% endblock %}

View File

@ -1,95 +0,0 @@
{% extends "base.html" %}
{% load static django_bootstrap5 %}
{% block title %}Редактирование профиля{% endblock %}
{% block content %}
<div class="container">
<div class="row justify-content-center">
<div class="col-lg-8 col-xl-7">
<h3 class="mb-3">Редактирование профиля</h3>
<form method="post" enctype="multipart/form-data" class="card">
<div class="card-body">
{% csrf_token %}
<div class="row g-3">
<div class="col-md-4">
<label class="form-label">Фамилия</label>
<input name="last_name" class="form-control" value="{{ emp.last_name }}">
</div>
<div class="col-md-4">
<label class="form-label">Имя</label>
<input name="first_name" class="form-control" value="{{ emp.first_name }}">
</div>
<div class="col-md-4">
<label class="form-label">Отчество</label>
<input name="patronymic" class="form-control" value="{{ emp.patronymic }}">
</div>
<div class="col-md-4">
<label class="form-label">Пол</label>
<select name="gender" class="form-select">
<option value="" {% if not emp.gender %}selected{% endif %}>—</option>
<option value="1" {% if emp.gender == 1 %}selected{% endif %}>Мужской</option>
<option value="2" {% if emp.gender == 2 %}selected{% endif %}>Женский</option>
</select>
</div>
<div class="col-md-4">
<label class="form-label">Дата рождения</label>
<input type="date" name="birth_date" class="form-control" value="{{ emp.birth_date }}">
</div>
<div class="col-md-4">
<label class="form-label">Должность (id)</label>
<input name="position" class="form-control" value="{{ emp.position|default:'' }}">
</div>
<div class="col-md-4">
<label class="form-label">Telegram</label>
<input name="telegram" class="form-control" value="{{ emp.telegram }}">
</div>
<div class="col-md-4">
<label class="form-label">WhatsApp</label>
<input name="whatsapp" class="form-control" value="{{ emp.whatsapp }}">
</div>
<div class="col-md-4">
<label class="form-label">WeChat</label>
<input name="wechat" class="form-control" value="{{ emp.wechat }}">
</div>
<div class="col-md-12">
<label class="form-label">Навыки </label>
<select name="skills" class="form-select" multiple>
{% comment %}
Тут можно подставить реальные варианты, если передать их из вьюхи.
Пока оставим пустой селект — API всё равно примет список ids.
{% endcomment %}
</select>
</div>
<div class="col-md-6">
<label class="form-label">Аватар</label>
<input type="file" name="avatar" class="form-control">
{% if emp.avatar %}
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox" name="clear_avatar" id="clearAvatar">
<label class="form-check-label" for="clearAvatar">Удалить текущий</label>
</div>
{% endif %}
</div>
</div>
</div>
<div class="card-footer d-flex justify-content-end gap-2">
<a href="{% if emp.id %}{% url 'employees:employee_detail' emp.id %}{% else %}{% url 'employees:employees_list' %}{% endif %}"
class="btn btn-outline-secondary">Отмена</a>
<button class="btn btn-primary" type="submit">Сохранить</button>
</div>
</form>
</div>
</div>
</div>
{% endblock %}