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:
parent
da183c8fbd
commit
016ee1ef50
@ -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
|
||||
]
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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",
|
||||
}
|
||||
|
||||
@ -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"),
|
||||
|
||||
|
||||
|
||||
]
|
||||
|
||||
@ -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) ----------
|
||||
|
||||
692
backend/templates/employees/_employee_edit.html
Normal file
692
backend/templates/employees/_employee_edit.html
Normal 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>
|
||||
@ -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 %}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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 %}
|
||||
Loading…
x
Reference in New Issue
Block a user