employees: доработки detail/list шаблонов и управление отделом
- employee_list.html:
• убраны лишние внешние отступы у карточек сотрудников
• нормализованы внутренние паддинги (feed-hd, feed-actions, feed-body)
• добавлены стили для более компактного списка (#empList)
- department_detail.html:
• добавлены блоки управления отделом для staff и руководителя:
- редактирование названия и описания (POST /departments/<id>/edit/)
- назначение/снятие руководителя (POST /departments/<id>/set-head/)
• секция «Команда отдела» теперь показывает только сотрудников с назначенными ролями
• реализована форма изменения роли участника (POST /departments/<id>/set_member_role/)
доступно только staff и руководителю, кроме самого себя
• исправлены условия в шаблоне (убран синтаксис с круглыми скобками)
• корректный вывод ФИО и fallback «Сотрудник #id», если данных нет
• ссылки на сотрудников и отделы нормализованы через {% url %}
• сообщения «пока нет сотрудников» и «пока нет публикаций» для пустых состояний
- views_front.py:
• department_detail теперь подтягивает:
- dept (детали отдела)
- roles (роли отдела)
- links (активные employee-departments)
- posts (публикации отдела)
• department_edit: редирект исправлен на employees:department_detail
• department_set_head и set_member_role проксируют данные в API и возвращают флеш-сообщения
- базовый шаблон:
• уточнено, что стили можно выносить в base.html
• сохранён общий вид feed-card, но для списка сотрудников применяется упрощённый layout
Итог: появилась полноценная страница отдела —
редактируем основные поля, управляем руководителем,
назначаем роли сотрудникам. Список сотрудников в отделе
отображается компактно и с корректными отступами.
This commit is contained in:
parent
0674bd49e6
commit
1228fb8d87
@ -38,11 +38,17 @@ urlpatterns = [
|
||||
name="department_set_head",
|
||||
),
|
||||
path(
|
||||
"departments/<int:pk>/set-member-role/",
|
||||
views_front.department_set_member_role,
|
||||
name="department_set_member_role",
|
||||
"<int:emp_id>/departments/<int:dept_id>/set-role/",
|
||||
views_front.set_member_role,
|
||||
name="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"),
|
||||
|
||||
# --- Должности ---
|
||||
path("positions/create/", views_front.position_create_front, name="position_create"),
|
||||
path("positions/<int:pos_id>/update/", views_front.position_update_front, name="position_update"),
|
||||
path("<int:emp_id>/set-position/", views_front.employee_set_position_front, name="employee_set_position"),
|
||||
path("me/set-position/", views_front.employee_set_position_me_front, name="employee_set_position_me"),
|
||||
]
|
||||
|
||||
@ -2,23 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import base64
|
||||
import json
|
||||
import mimetypes
|
||||
from typing import Any
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from api.client import get_api_client
|
||||
from api.decorators import require_api_auth
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin
|
||||
from django.http import HttpRequest, JsonResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
from django.views.decorators.http import require_http_methods, require_POST
|
||||
from django.views.generic import CreateView, DeleteView, ListView, UpdateView
|
||||
from django.views.decorators.http import require_POST
|
||||
from django.http import JsonResponse
|
||||
|
||||
from .forms import (
|
||||
DepartmentEditForm,
|
||||
@ -30,7 +30,6 @@ from .forms import (
|
||||
from .forms_front import DepartmentEditForm, SetHeadForm, SetMemberRoleForm
|
||||
from .models import Department, EmployeeDepartment, Skill
|
||||
|
||||
|
||||
# =========================
|
||||
# Утилиты
|
||||
# =========================
|
||||
@ -103,6 +102,43 @@ def _fetch_employee_posts(api, employee_id: int):
|
||||
return _extract_results(data)
|
||||
|
||||
|
||||
def _json_body(request: HttpRequest) -> Dict[str, Any]:
|
||||
"""Безопасно парсим JSON; если не JSON — возвращаем пустой dict."""
|
||||
try:
|
||||
if request.body:
|
||||
return json.loads(request.body.decode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _error(message: str, status: int = 400) -> JsonResponse:
|
||||
return JsonResponse({"detail": message}, status=status)
|
||||
|
||||
|
||||
def _ok(data: Dict[str, Any], status: int = 200) -> JsonResponse:
|
||||
return JsonResponse(data, status=status)
|
||||
|
||||
|
||||
def _api_ok_or_error(resp) -> Tuple[bool, Dict[str, Any], int]:
|
||||
"""Унифицированная распаковка ответа API."""
|
||||
try:
|
||||
data = resp.json or {}
|
||||
except Exception:
|
||||
data = {}
|
||||
return resp.ok, data, getattr(resp, "status", 500)
|
||||
|
||||
|
||||
def _has_role(link):
|
||||
r = link.get("role")
|
||||
if r is None:
|
||||
return False
|
||||
if isinstance(r, dict):
|
||||
return bool(r.get("id") or r.get("name"))
|
||||
s = str(r).strip()
|
||||
return s not in {"", "None", "null", "0"}
|
||||
|
||||
|
||||
# =========================
|
||||
# Сотрудники
|
||||
# =========================
|
||||
@ -213,17 +249,64 @@ def employee_profile(request, pk="me"):
|
||||
request.user and (request.user.is_staff or request.user.id == emp.get("id"))
|
||||
)
|
||||
)
|
||||
emp_depts = []
|
||||
for d in emp.get("departments") or []:
|
||||
did = d.get("id")
|
||||
if not did:
|
||||
emp_depts.append(d)
|
||||
continue
|
||||
|
||||
# узнаём head отдела
|
||||
dresp = api.get(f"v1/departments/{did}/")
|
||||
head_id = None
|
||||
if dresp.ok:
|
||||
dj = dresp.json or {}
|
||||
head = dj.get("head") or {}
|
||||
head_id = head.get("id") or dj.get("head_id")
|
||||
|
||||
# подгружаем роли отдела
|
||||
rresp = api.get(
|
||||
"v1/department-roles/", params={"department": did, "ordering": "name"}
|
||||
)
|
||||
roles = []
|
||||
if rresp.ok:
|
||||
rj = rresp.json
|
||||
roles = (rj.get("results", rj) if isinstance(rj, dict) else rj) or []
|
||||
|
||||
can_edit_role = (
|
||||
bool(head_id)
|
||||
and str(head_id) == str(getattr(request.user, "id", None))
|
||||
and not d.get("is_head") # нельзя править главу
|
||||
and str(emp_id) != str(getattr(request.user, "id", None)) # не себе
|
||||
)
|
||||
|
||||
d2 = dict(d)
|
||||
d2.update(
|
||||
{
|
||||
"roles_choices": roles, # [{id, name, ...}]
|
||||
"can_edit_role": can_edit_role, # bool
|
||||
}
|
||||
)
|
||||
emp_depts.append(d2)
|
||||
|
||||
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,
|
||||
"emp_depts": emp_depts,
|
||||
"can_edit": can_edit,
|
||||
"all_skills": all_skills,
|
||||
"all_positions": all_positions,
|
||||
"all_groups": all_groups,
|
||||
"posts": posts,
|
||||
"is_me": True if is_me else False,
|
||||
}
|
||||
if is_me:
|
||||
ctx["is_me"] = True
|
||||
|
||||
return render(request, "employees/employee_detail.html", ctx)
|
||||
|
||||
|
||||
@ -381,13 +464,6 @@ def department_list(request):
|
||||
# ---------- DETAIL ----------
|
||||
@require_api_auth
|
||||
def department_detail(request, pk: int):
|
||||
"""
|
||||
Детальная страница отдела.
|
||||
Тянет:
|
||||
- /api/v1/departments/{id}/
|
||||
- /api/v1/department-roles/?department={id}
|
||||
- /api/v1/employee-departments/?department={id}&is_active=true
|
||||
"""
|
||||
api = get_api_client(request)
|
||||
|
||||
dep = api.get(f"v1/departments/{pk}/")
|
||||
@ -396,60 +472,77 @@ def department_detail(request, pk: int):
|
||||
request,
|
||||
"Отдел не найден" if dep.status == 404 else f"Ошибка API ({dep.status})",
|
||||
)
|
||||
return redirect("employees:dept_list")
|
||||
return redirect("employees:department_list")
|
||||
dept = dep.json
|
||||
|
||||
roles_resp = api.get(
|
||||
"v1/department-roles/", params={"department": pk, "page_size": 1000}
|
||||
)
|
||||
roles = (
|
||||
roles_resp.json.get("results", [])
|
||||
if roles_resp.ok and isinstance(roles_resp.json, dict)
|
||||
else roles_resp.json or []
|
||||
)
|
||||
roles_resp = api.get("v1/department-roles/", params={"department": pk, "page_size": 1000})
|
||||
roles = (roles_resp.json.get("results", [])
|
||||
if roles_resp.ok and isinstance(roles_resp.json, dict)
|
||||
else roles_resp.json or [])
|
||||
|
||||
links_resp = api.get(
|
||||
"v1/employee-departments/",
|
||||
params={"department": pk, "is_active": True, "page_size": 1000},
|
||||
)
|
||||
links = (
|
||||
links_resp.json.get("results", [])
|
||||
if links_resp.ok and isinstance(links_resp.json, dict)
|
||||
else links_resp.json or []
|
||||
)
|
||||
links = (links_resp.json.get("results", [])
|
||||
if links_resp.ok and isinstance(links_resp.json, dict)
|
||||
else links_resp.json or [])
|
||||
|
||||
# 👉 нормализуем участников для селекта руководителя
|
||||
members = []
|
||||
for ln in links:
|
||||
e = ln.get("employee")
|
||||
# API может вернуть dict или id; поддержим оба варианта
|
||||
if isinstance(e, dict):
|
||||
emp_id = e.get("id")
|
||||
last_name = e.get("last_name") or ""
|
||||
first_name = e.get("first_name") or ""
|
||||
patronymic = e.get("patronymic") or ""
|
||||
full_name = " ".join([s for s in (last_name, first_name, patronymic) if s])
|
||||
members.append({
|
||||
"id": emp_id,
|
||||
"name": full_name or f"Сотрудник #{emp_id}",
|
||||
})
|
||||
elif e is not None:
|
||||
# fallback, если пришёл только id
|
||||
emp_id = e
|
||||
members.append({
|
||||
"id": emp_id,
|
||||
"name": f"Сотрудник #{emp_id}",
|
||||
})
|
||||
|
||||
# Уберём дубликаты, если вдруг
|
||||
seen = set()
|
||||
head_choices = []
|
||||
for m in members:
|
||||
mid = m.get("id")
|
||||
if mid and mid not in seen:
|
||||
head_choices.append(m)
|
||||
seen.add(mid)
|
||||
|
||||
context = {
|
||||
"dept": dept,
|
||||
"roles": roles,
|
||||
"links": links, # элементы вида {"employee": <id>, "role": <id>|null, ...}
|
||||
"edit_form": DepartmentEditForm(
|
||||
initial={
|
||||
"name": dept.get("name", ""),
|
||||
"description": dept.get("description", ""),
|
||||
}
|
||||
),
|
||||
"links": links,
|
||||
"head_choices": head_choices, # 👈 добавили
|
||||
"edit_form": DepartmentEditForm(initial={
|
||||
"name": dept.get("name", ""),
|
||||
"description": dept.get("description", ""),
|
||||
}),
|
||||
"set_head_form": SetHeadForm(),
|
||||
"set_member_role_form": SetMemberRoleForm(),
|
||||
}
|
||||
|
||||
page = request.GET.get("page") or 1
|
||||
r = api.get(
|
||||
"v1/feed/posts/",
|
||||
params={"department": pk, "page": page, "ordering": "-created_at"},
|
||||
)
|
||||
r = api.get("v1/feed/posts/", params={"department": pk, "page": page, "ordering": "-created_at"})
|
||||
posts = []
|
||||
if r.ok:
|
||||
data = r.json
|
||||
posts = data.get("results", data) if isinstance(data, dict) else data
|
||||
|
||||
context.update(
|
||||
{
|
||||
"posts": posts,
|
||||
"page": page, # если пагинация нужна в шаблоне
|
||||
}
|
||||
)
|
||||
context.update({"posts": posts, "page": page})
|
||||
return render(request, "employees/department_detail.html", context)
|
||||
|
||||
|
||||
# ---------- EDIT BASIC FIELDS ----------
|
||||
@require_api_auth
|
||||
@require_http_methods(["POST"])
|
||||
@ -462,7 +555,7 @@ def department_edit(request, pk: int):
|
||||
form = DepartmentEditForm(request.POST)
|
||||
if not form.is_valid():
|
||||
messages.error(request, "Проверьте форму")
|
||||
return redirect("employees:dept_detail", pk=pk)
|
||||
return redirect("employees:department_detail", pk=pk)
|
||||
|
||||
payload = {
|
||||
"name": form.cleaned_data["name"],
|
||||
@ -476,7 +569,7 @@ def department_edit(request, pk: int):
|
||||
)
|
||||
else:
|
||||
messages.success(request, "Отдел обновлён")
|
||||
return redirect("employees:dept_detail", pk=pk)
|
||||
return redirect("employees:department_detail", pk=pk)
|
||||
|
||||
|
||||
# ---------- SET HEAD ----------
|
||||
@ -491,7 +584,7 @@ def department_set_head(request, pk: int):
|
||||
form = SetHeadForm(request.POST)
|
||||
if not form.is_valid():
|
||||
messages.error(request, "Проверьте форму")
|
||||
return redirect("employees:dept_detail", pk=pk)
|
||||
return redirect("employees:department_detail", pk=pk)
|
||||
|
||||
head_id = form.cleaned_data.get("head_id", None)
|
||||
payload = {"head_id": head_id if head_id is not None else None} # None → снять
|
||||
@ -505,37 +598,57 @@ def department_set_head(request, pk: int):
|
||||
messages.success(
|
||||
request, "Руководитель обновлён" if head_id else "Руководитель снят"
|
||||
)
|
||||
return redirect("employees:dept_detail", pk=pk)
|
||||
return redirect("employees:department_detail", pk=pk)
|
||||
|
||||
|
||||
# ---------- SET MEMBER ROLE ----------
|
||||
@require_api_auth
|
||||
@require_http_methods(["POST"])
|
||||
def department_set_member_role(request, pk: int):
|
||||
def set_member_role(request, dept_id: int, emp_id: int):
|
||||
"""
|
||||
Назначение/снятие роли участнику отдела через POST /api/v1/departments/{id}/set_member_role/.
|
||||
Права проверяет API (assign_department_role).
|
||||
Серверный proxy: меняем роль сотрудника в отделе.
|
||||
Тело: role_id (пусто => снять роль), next (опционально для редиректа).
|
||||
"""
|
||||
api = get_api_client(request)
|
||||
form = SetMemberRoleForm(request.POST)
|
||||
if not form.is_valid():
|
||||
messages.error(request, "Проверьте форму")
|
||||
return redirect("employees:dept_detail", pk=pk)
|
||||
|
||||
payload = {
|
||||
"employee_id": form.cleaned_data["employee_id"],
|
||||
"role_id": form.cleaned_data.get("role_id", None),
|
||||
"is_active": form.cleaned_data.get("is_active", None),
|
||||
}
|
||||
resp = api.post(f"v1/departments/{pk}/set_member_role/", json=payload)
|
||||
if not resp.ok:
|
||||
messages.error(
|
||||
request,
|
||||
f"Не удалось обновить роль ({resp.status}) — {resp.json or resp.text}",
|
||||
role_id_raw = request.POST.get("role_id", "")
|
||||
role_id = None
|
||||
if str(role_id_raw).strip() not in {"", "None", "null"}:
|
||||
try:
|
||||
role_id = int(role_id_raw)
|
||||
except (TypeError, ValueError):
|
||||
role_id = None
|
||||
|
||||
# заодно не даём менять самому себе (UI тоже скрывает)
|
||||
if str(emp_id) == str(getattr(request.user, "id", None)):
|
||||
messages.error(request, "Нельзя изменять собственную роль в отделе.")
|
||||
return redirect(
|
||||
request.POST.get("next") or reverse("employees:profile", args=("me",))
|
||||
)
|
||||
|
||||
# пробрасываем в API — он сам проверит, что вы глава отдела
|
||||
resp = api.post(
|
||||
f"v1/departments/{dept_id}/set_member_role/",
|
||||
json={
|
||||
"employee_id": emp_id,
|
||||
"role_id": role_id,
|
||||
},
|
||||
)
|
||||
|
||||
if resp.ok:
|
||||
messages.success(request, "Роль обновлена.")
|
||||
else:
|
||||
messages.success(request, "Роль участника обновлена")
|
||||
return redirect("employees:dept_detail", pk=pk)
|
||||
try:
|
||||
data = resp.json
|
||||
except Exception:
|
||||
data = {}
|
||||
err = data.get("detail") or data.get("error") or f"HTTP {resp.status}"
|
||||
messages.error(request, f"Не удалось обновить роль: {err}")
|
||||
|
||||
return redirect(
|
||||
request.POST.get("next")
|
||||
or reverse("employees:employee_profile", args=(emp_id,))
|
||||
)
|
||||
|
||||
|
||||
class StaffOnlyMixin(UserPassesTestMixin):
|
||||
@ -686,3 +799,143 @@ def skill_remove(request, pk: int):
|
||||
# NB: у DRF-экшена underscore: remove_skill
|
||||
resp = api.post(f"v1/employees/{pk}/remove_skill/", data=payload)
|
||||
return JsonResponse(resp.json or {"detail": resp.text}, status=resp.status or 500)
|
||||
|
||||
|
||||
# =========================
|
||||
# Должности
|
||||
# =========================
|
||||
|
||||
|
||||
# ---------- CREATE POSITION ----------
|
||||
@csrf_protect
|
||||
@login_required
|
||||
@require_POST
|
||||
def position_create_front(request: HttpRequest) -> JsonResponse:
|
||||
"""
|
||||
Создать должность (прокси к POST /api/v1/positions/).
|
||||
Ожидает JSON или form-data с полями: name, description?, groups? (list[int]).
|
||||
"""
|
||||
payload = _json_body(request) or request.POST.dict()
|
||||
name = (payload.get("name") or "").strip()
|
||||
description = (payload.get("description") or "").strip()
|
||||
|
||||
# groups может прийти как список, как csv или отсутствовать
|
||||
groups = payload.get("groups")
|
||||
if isinstance(groups, str):
|
||||
groups = [int(x) for x in groups.split(",") if x.strip().isdigit()]
|
||||
elif isinstance(groups, list):
|
||||
groups = [int(x) for x in groups if str(x).isdigit()]
|
||||
else:
|
||||
groups = []
|
||||
|
||||
if not name:
|
||||
return _error("Укажите название должности", 400)
|
||||
|
||||
api = get_api_client(request)
|
||||
api_payload = {"name": name}
|
||||
if description:
|
||||
api_payload["description"] = description
|
||||
if groups:
|
||||
api_payload["groups"] = groups
|
||||
|
||||
resp = api.post("v1/positions/", json=api_payload)
|
||||
ok, data, status = _api_ok_or_error(resp)
|
||||
if not ok:
|
||||
return JsonResponse(
|
||||
data or {"detail": "Не удалось создать должность"}, status=status
|
||||
)
|
||||
return _ok(data, status=201)
|
||||
|
||||
|
||||
# ---------- UPDATE POSITION ----------
|
||||
@csrf_protect
|
||||
@login_required
|
||||
@require_http_methods(["PATCH", "POST"])
|
||||
def position_update_front(request: HttpRequest, pos_id: int) -> JsonResponse:
|
||||
"""
|
||||
Обновить должность (прокси к PATCH /api/v1/positions/{id}/).
|
||||
Приходит JSON с любыми изменяемыми полями (name, description, groups).
|
||||
Разрешаем метод PATCH и POST (для совместимости) — на POST тоже шлём PATCH в API.
|
||||
"""
|
||||
payload = _json_body(request) or request.POST.dict()
|
||||
|
||||
# Если groups пришли строкой — распарсим
|
||||
groups = payload.get("groups", None)
|
||||
if isinstance(groups, str):
|
||||
payload["groups"] = [int(x) for x in groups.split(",") if x.strip().isdigit()]
|
||||
|
||||
api = get_api_client(request)
|
||||
resp = api.patch(f"v1/positions/{pos_id}/", json=payload)
|
||||
ok, data, status = _api_ok_or_error(resp)
|
||||
if not ok:
|
||||
return JsonResponse(
|
||||
data or {"detail": "Не удалось обновить должность"}, status=status
|
||||
)
|
||||
return _ok(data)
|
||||
|
||||
|
||||
# ---------- ASSIGN POSITION TO EMPLOYEE ----------
|
||||
@csrf_protect
|
||||
@login_required
|
||||
@require_POST
|
||||
def employee_set_position_front(request: HttpRequest, emp_id: int) -> JsonResponse:
|
||||
"""
|
||||
Назначить сотруднику position_id (прокси к PATCH /api/v1/employees/{id}/).
|
||||
Ожидает JSON/form-data: position_id (int или пусто для снятия).
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"detail": "Bad JSON"}, status=400)
|
||||
|
||||
raw = payload.get("position_id", None)
|
||||
|
||||
try:
|
||||
position_id = int(str(raw).strip()) if raw is not None else None
|
||||
except (ValueError, TypeError):
|
||||
return JsonResponse({"detail": "position_id must be an integer"}, status=400)
|
||||
|
||||
if not position_id:
|
||||
return JsonResponse({"detail": "position_id is required"}, status=400)
|
||||
|
||||
api = get_api_client(request)
|
||||
resp = api.patch(f"v1/employees/{emp_id}/", json={"position_id": position_id})
|
||||
ok, data, status = _api_ok_or_error(resp)
|
||||
if not ok:
|
||||
return JsonResponse(
|
||||
data or {"detail": "Не удалось назначить должность"}, status=status
|
||||
)
|
||||
return _ok(data)
|
||||
|
||||
|
||||
# Удобная обёртка под «/me/»
|
||||
@csrf_protect
|
||||
@login_required
|
||||
@require_POST
|
||||
def employee_set_position_me_front(request: HttpRequest) -> JsonResponse:
|
||||
"""
|
||||
Назначить должность текущему пользователю (прокси к PATCH /api/v1/employees/me/).
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8") or "{}")
|
||||
except json.JSONDecodeError:
|
||||
return JsonResponse({"detail": "Bad JSON"}, status=400)
|
||||
|
||||
raw = payload.get("position_id", None)
|
||||
|
||||
try:
|
||||
position_id = int(str(raw).strip()) if raw is not None else None
|
||||
except (ValueError, TypeError):
|
||||
return JsonResponse({"detail": "position_id must be an integer"}, status=400)
|
||||
|
||||
if not position_id:
|
||||
return JsonResponse({"detail": "position_id is required"}, status=400)
|
||||
|
||||
api = get_api_client(request)
|
||||
resp = api.patch("v1/employees/me/", json={"position_id": position_id})
|
||||
ok, data, status = _api_ok_or_error(resp)
|
||||
if not ok:
|
||||
return JsonResponse(
|
||||
data or {"detail": "Не удалось назначить должность"}, status=status
|
||||
)
|
||||
return _ok(data)
|
||||
|
||||
@ -7,7 +7,8 @@
|
||||
.modal .modal-content { background-color: var(--bs-body-bg); }
|
||||
</style>
|
||||
|
||||
<form id="apiEditForm" class="row g-3" data-endpoint="{{ endpoint }}">
|
||||
<form id="apiEditForm" class="row g-3" data-endpoint="{{ endpoint }}"
|
||||
action="{% if is_me %}{% url 'employees:employee_set_position_me' %}{% else %}{% url 'employees:employee_set_position' emp.id %}{% endif %}">
|
||||
{% csrf_token %}
|
||||
|
||||
{# === ЛИЧНЫЕ ДАННЫЕ === #}
|
||||
@ -114,8 +115,8 @@
|
||||
<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-create-url="{% url 'employees:position_create' %}"
|
||||
data-assign-url="{% if is_me %}{% url 'employees:employee_set_position_me' %}{% else %}{% url 'employees:employee_set_position' emp.id %}{% endif %}"
|
||||
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>
|
||||
@ -270,186 +271,186 @@
|
||||
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const form = document.getElementById('apiEditForm');
|
||||
if (!form) return;
|
||||
(function(){
|
||||
const form = document.getElementById('apiEditForm');
|
||||
if (!form) return;
|
||||
|
||||
const endpoint = form.dataset.endpoint || '';
|
||||
if (!endpoint) { console.error('Нет endpoint для формы редактирования'); return; }
|
||||
const endpoint = form.dataset.endpoint || '';
|
||||
if (!endpoint) { console.error('Нет endpoint для формы редактирования'); return; }
|
||||
|
||||
const CONTACT_FIELDS = ['email','phone_number','telegram','whatsapp','wechat'];
|
||||
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');
|
||||
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 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;
|
||||
}
|
||||
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);
|
||||
});
|
||||
}
|
||||
// Прочитать файл в 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 (массив) и сравнить с 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)'
|
||||
);
|
||||
// собрать общие поля (кроме 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();
|
||||
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;
|
||||
// Ничего не менялось
|
||||
if (raw === init) return;
|
||||
|
||||
// Пустое значение: для некоторых полей нужно слать null
|
||||
const shouldNull = (raw === '' && ['gender','birth_date','position_id'].includes(name));
|
||||
// Пустое значение: для некоторых полей нужно слать 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(_){}
|
||||
if (shouldNull) {
|
||||
// меняем непустое init -> null (очистка)
|
||||
if (init !== '') payload[name] = null;
|
||||
return;
|
||||
}
|
||||
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'
|
||||
// Типизация gender в число
|
||||
if (name === 'gender' && raw !== '') {
|
||||
payload[name] = Number(raw);
|
||||
return;
|
||||
}
|
||||
|
||||
// Остальные — как есть (пустые строки допустимы для текстовых полей)
|
||||
payload[name] = raw;
|
||||
});
|
||||
|
||||
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);
|
||||
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(){
|
||||
@ -457,12 +458,11 @@
|
||||
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';
|
||||
const select = document.getElementById('f_position');
|
||||
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
|
||||
const createUrl = block.dataset.createUrl || '{% url "employees:position_create" %}';
|
||||
const assignUrl = block.dataset.assignUrl || '';
|
||||
const canAssign = block.dataset.canAssign === '1';
|
||||
|
||||
function busy(btn, on){
|
||||
const lbl = btn.querySelector('.lbl');
|
||||
@ -483,62 +483,77 @@
|
||||
const m = document.getElementById('posCreateMsg');
|
||||
msg(m, '');
|
||||
|
||||
if (!createUrl || !employeeUrl){
|
||||
msg(m, 'Не настроены URL создания/обновления.', false);
|
||||
// ВАЛИДАЦИЯ URL'ов
|
||||
if (!createUrl || !assignUrl){
|
||||
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'); // фолбэк
|
||||
const msel = document.getElementById('pos_groups'); // если есть select[multiple]
|
||||
const hid = document.getElementById('pos_groups_hidden'); // из модального выбора
|
||||
const csv = document.getElementById('pos_groups_csv'); // fallback
|
||||
|
||||
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)
|
||||
// 1) Создаём должность
|
||||
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 },
|
||||
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;
|
||||
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;
|
||||
if (!newId){
|
||||
msg(m, 'API не вернул id новой должности.', false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) обновить селект и закрыть панель
|
||||
// 2) Назначаем сотруднику
|
||||
const eResp = await fetch(assignUrl, {
|
||||
method:'POST',
|
||||
credentials:'same-origin',
|
||||
headers:{
|
||||
'Content-Type':'application/json',
|
||||
'X-Requested-With':'XMLHttpRequest',
|
||||
'X-CSRFToken': csrftoken
|
||||
},
|
||||
body: JSON.stringify({ position_id: newId })
|
||||
});
|
||||
const eData = await (async()=>{ try{return await eResp.json()}catch{return null} })();
|
||||
if (!eResp.ok){
|
||||
msg(m, 'Создано, но не назначено: ' + (eData?.detail || JSON.stringify(eData) || `HTTP ${eResp.status}`), false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Обновляем селект и закрываем панель
|
||||
if (select){
|
||||
let opt = Array.from(select.options).find(o => o.value == String(newId));
|
||||
if (!opt){
|
||||
@ -561,6 +576,7 @@
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
const chipsBox = document.getElementById('pos_groups_chips');
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
{# templates/employees/department_detail.html #}
|
||||
{% extends 'base.html' %}
|
||||
{% load static django_bootstrap5 %}
|
||||
|
||||
@ -9,29 +10,13 @@
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-lg-9 col-xl-8">
|
||||
|
||||
{# — Заголовок — #}
|
||||
{# ===== Заголовок ===== #}
|
||||
<div class="feed-header d-flex align-items-center gap-2 flex-wrap">
|
||||
<i class="bi-diagram-3 fs-2 text-primary"></i>
|
||||
<h2 class="title mb-0">{{ dept.name }}</h2>
|
||||
|
||||
{# staff или сам руководитель (без скобок) #}
|
||||
{% if user.is_staff %}
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
{% url 'feed:post_create' as post_create_url %}
|
||||
{% if post_create_url %}
|
||||
<a href="{{ post_create_url }}?department={{ dept_id }}&next={{ request.get_full_path|urlencode }}"
|
||||
class="btn btn-primary">
|
||||
<i class="bi-plus-circle me-1"></i> Создать публикацию
|
||||
</a>
|
||||
{% endif %}
|
||||
{% url 'employees:invite_to_department' dept_id as invite_url %}
|
||||
{% if invite_url %}
|
||||
<a href="{{ invite_url }}" class="btn btn-success">
|
||||
<i class="bi-person-plus me-1"></i> Пригласить
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% elif dept.head and dept.head.id == user.id %}
|
||||
{# Кнопки только для staff или текущего руководителя #}
|
||||
{% if user.is_staff or dept.head and dept.head.id == user.id %}
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
{% url 'feed:post_create' as post_create_url %}
|
||||
{% if post_create_url %}
|
||||
@ -50,7 +35,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Инфоблок — описание / руководитель #}
|
||||
{# ===== Инфо-блок ===== #}
|
||||
{% if dept.description or dept.head %}
|
||||
<div class="feed-new mb-3">
|
||||
{% if dept.description %}
|
||||
@ -72,7 +57,76 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Команда отдела (берём то, что пришло в links) #}
|
||||
{# ===== Управление отделом (для staff/главы) ===== #}
|
||||
{% if user.is_staff or dept.head and dept.head.id == user.id %}
|
||||
<div class="feed-card mb-4">
|
||||
<div class="feed-hd">
|
||||
<i class="bi-gear"></i>
|
||||
<div class="feed-meta">
|
||||
<div class="feed-author">Управление отделом</div>
|
||||
<div class="feed-sub">Название, описание и руководитель</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feed-body">
|
||||
<div class="row g-3">
|
||||
|
||||
{# Редактирование name/description -> /employees/departments/<id>/edit/ #}
|
||||
<div class="col-12 col-lg-6">
|
||||
<form method="post" action="{% url 'employees:department_edit' dept.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="mb-2">
|
||||
<label class="form-label">Название</label>
|
||||
<input name="name" class="form-control" value="{{ dept.name|default_if_none:'' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Описание</label>
|
||||
<textarea name="description" class="form-control" rows="3">{{ dept.description|default_if_none:'' }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary btn-sm">
|
||||
<i class="bi-check2 me-1"></i>Сохранить
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{# Назначение/снятие руководителя -> /employees/departments/<id>/set-head/ #}
|
||||
<div class="col-12 col-lg-6">
|
||||
<form method="post" action="{% url 'employees:department_set_head' dept.id %}">
|
||||
{% csrf_token %}
|
||||
<label class="form-label">Руководитель</label>
|
||||
<select class="form-select" name="head_id">
|
||||
<option value="">— Снять руководителя —</option>
|
||||
{# Берём участников из links; поддерживаем и dict, и id #}
|
||||
{% for link in links %}
|
||||
{% with emp=link.employee %}
|
||||
{% if emp %}
|
||||
{% with eid=emp.id|default:emp %}
|
||||
<option value="{{ eid }}"
|
||||
{% if dept.head and dept.head.id == eid %}selected{% endif %}>
|
||||
{% if emp.last_name %}
|
||||
{{ emp.last_name }}{% if emp.first_name %} {{ emp.first_name }}{% endif %}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
|
||||
{% else %}
|
||||
Сотрудник #{{ eid }}
|
||||
{% endif %}
|
||||
</option>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="mt-2">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi-person-check me-1"></i>Обновить руководителя
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# ===== Команда отдела (только сотрудники с назначённой ролью) ===== #}
|
||||
<div class="feed-new mb-4">
|
||||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||||
<h6 class="mb-0 text-secondary">Команда отдела</h6>
|
||||
@ -85,36 +139,74 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# показываем только те связи, где есть role #}
|
||||
{% if links %}
|
||||
<div class="d-flex gap-3 flex-nowrap overflow-auto pb-1">
|
||||
{% for link in links %}
|
||||
{% with emp=link.employee %}
|
||||
{% url 'employees:employee_detail' emp.id as emp_url %}
|
||||
<a {% if emp_url %}href="{{ emp_url }}"{% endif %}
|
||||
class="text-decoration-none d-inline-flex align-items-center gap-2">
|
||||
<div class="feed-ava" style="width:44px;height:44px;">
|
||||
{% if emp.avatar %}
|
||||
<img src="{{ emp.avatar }}" alt="">
|
||||
{% else %}
|
||||
<i class="bi-person"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small text-truncate" style="max-width:140px;">
|
||||
<div class="fw-semibold text-reset">
|
||||
{{ emp.last_name }}{% if emp.first_name %} {{ emp.first_name }}{% endif %}
|
||||
</div>
|
||||
{% if link.role %}<div class="text-secondary">{{ link.role }}</div>{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-secondary small">Пока нет сотрудников.</div>
|
||||
{% with any_has_role=0 %}
|
||||
{% for li in links %}{% if li.role %}{% with any_has_role=1 %}{% endwith %}{% endif %}{% endfor %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
<div class="d-flex flex-column gap-2">
|
||||
{% for link in links %}
|
||||
{% if link.role %}
|
||||
{% with emp=link.employee cur_role_id=link.role.id|default:link.role %}
|
||||
<div class="d-flex align-items-center justify-content-between p-2 border rounded">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="feed-ava" style="width:44px;height:44px;">
|
||||
{% if emp.avatar %}<img src="{{ emp.avatar }}" alt="">{% else %}<i class="bi-person"></i>{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<div class="fw-semibold">
|
||||
{% with eid=emp.id|default:emp %}
|
||||
<a href="{% url 'employees:employee_detail' eid %}"
|
||||
class="text-decoration-none text-reset">
|
||||
{% if emp.last_name %}
|
||||
{{ emp.last_name }}{% if emp.first_name %} {{ emp.first_name }}{% endif %}{% if emp.patronymic %} {{ emp.patronymic }}{% endif %}
|
||||
{% else %}
|
||||
Сотрудник #{{ eid }}
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="text-secondary small">
|
||||
{% if link.role.name %}{{ link.role.name }}{% else %}Роль #{{ cur_role_id }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Право менять роль: staff или глава; и не себе #}
|
||||
{% if user.is_staff or dept.head and dept.head.id == user.id %}
|
||||
{% if emp.id != user.id %}
|
||||
{% with eid=emp.id|default:emp %}
|
||||
<form method="post"
|
||||
action="{% url 'employees:set_member_role' dept.id eid %}"
|
||||
class="d-flex align-items-center gap-2 mb-0">
|
||||
{% csrf_token %}
|
||||
<select name="role_id" class="form-select form-select-sm">
|
||||
<option value="">— Без роли —</option>
|
||||
{% for r in roles %}
|
||||
<option value="{{ r.id }}"
|
||||
{% if cur_role_id == r.id %}selected{% endif %}>
|
||||
{{ r.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">OK</button>
|
||||
</form>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
{% empty %}
|
||||
<div class="text-secondary small">Пока нет сотрудников с назначёнными ролями.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# — Лента отдела — #}
|
||||
{# ===== Лента отдела ===== #}
|
||||
<div class="feed-stream">
|
||||
{% if posts %}
|
||||
{% include "feed/_feed_cards.html" with items=posts only %}
|
||||
@ -123,7 +215,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# — Простейшая пагинация без Page-объекта — #}
|
||||
{# Простейшая пагинация без Page-объекта #}
|
||||
{% if page|default:0 %}
|
||||
<div class="mt-4">
|
||||
{% if page|add:"-1" %}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
{# templates/employees/employee_detail.html #}
|
||||
{% extends "base.html" %}
|
||||
{% load static django_bootstrap5 %}
|
||||
|
||||
@ -7,6 +8,7 @@
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
/* Base card / layout */
|
||||
.ios-card{
|
||||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);
|
||||
border-radius: var(--feed-radius);
|
||||
@ -25,7 +27,6 @@
|
||||
border-bottom: none !important;
|
||||
}
|
||||
.ios-title{ font-weight: 800; letter-spacing: -.01em; }
|
||||
|
||||
.row.g-ios{ align-items: flex-start; }
|
||||
|
||||
.ios-ava, .ios-ava--ph{
|
||||
@ -77,15 +78,14 @@
|
||||
.ios-section-title{ font-weight:700; font-size:1rem; margin-bottom:.5rem; }
|
||||
.rounded-ios{ border-radius:14px !important; }
|
||||
|
||||
/* Отделы — аккуратный список без иконок-аватаров */
|
||||
/* Departments list */
|
||||
.ios-cell.dept-item{ justify-content: space-between; }
|
||||
/* dept-head больше не выделяется фоном/линией */
|
||||
.ios-cell.dept-head{ border-left: 0; background: transparent; }
|
||||
|
||||
.dept-line{ display:flex; align-items:center; gap:.5rem; }
|
||||
.dept-name{ font-weight:700; }
|
||||
.dept-role{ color: var(--bs-secondary-color); }
|
||||
/* было: круглая иконка; станет компактная пилюля с подписью */
|
||||
|
||||
.badge-head{
|
||||
display:inline-flex; align-items:center; gap:.35rem;
|
||||
padding:.2rem .55rem; border-radius:999px;
|
||||
@ -97,120 +97,63 @@
|
||||
}
|
||||
.badge-head .bi{ font-size:.95rem; line-height:1; }
|
||||
|
||||
/* На очень узких — только иконка (если будет подпись) */
|
||||
/* NEW: blue pill like .badge-head, for the gear edit button */
|
||||
.badge-gear{
|
||||
display:inline-flex; align-items:center; gap:.35rem;
|
||||
padding:.2rem .55rem; border-radius:999px; cursor:pointer;
|
||||
background: color-mix(in srgb, var(--bs-primary) 15%, var(--bs-body-bg));
|
||||
color: color-mix(in srgb, var(--bs-primary) 65%, black 0%);
|
||||
border:1px solid color-mix(in srgb, var(--bs-primary) 35%, transparent);
|
||||
font-weight:700; font-size:.8rem; line-height:1; white-space: nowrap;
|
||||
}
|
||||
.badge-gear .bi{ font-size:.95rem; line-height:1; }
|
||||
.badge-gear:hover{ background: color-mix(in srgb, var(--bs-primary) 20%, var(--bs-body-bg)); }
|
||||
|
||||
@media (max-width: 420px){
|
||||
.badge-head .lbl{ display:none; }
|
||||
.badge-head .lbl, .badge-gear .lbl{ display:none; }
|
||||
}
|
||||
/* collapse не должен иметь внешних отступов — они «торчат», когда блок схлопывается */
|
||||
|
||||
.ios-collapse-trigger + .collapse { margin-top: 0 !important; }
|
||||
|
||||
/* отступ делаем у внутреннего контента */
|
||||
.ios-collapse-trigger + .collapse > .ios-list,
|
||||
.ios-collapse-trigger + .collapse > .chips {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
.ios-collapse-trigger + .collapse > .chips { margin-top: .5rem; }
|
||||
|
||||
/* на всякий случай — во время анимации скрываем всё, чтобы края/бордеры не «просвечивали» */
|
||||
.collapsing { overflow: hidden; }
|
||||
.collapsing { overflow: hidden; transition: height .35s ease; }
|
||||
|
||||
/* не трогаем скорость Bootstrap; если где-то переопределялась — вернём дефолт */
|
||||
.collapsing {
|
||||
transition: height .35s ease; /* стандарт Bootstrap 5 */
|
||||
}
|
||||
|
||||
|
||||
/* Единый блок для кнопки+панели */
|
||||
.ios-acc{
|
||||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||||
border-radius:14px;
|
||||
background: var(--bs-body-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
.ios-acc + .ios-acc{ margin-top:.75rem; } /* расстояние между такими блоками */
|
||||
|
||||
/* Кнопка внутри общего блока — без собственного бордера/радиуса */
|
||||
.ios-acc .ios-collapse-trigger{
|
||||
border:0;
|
||||
border-radius:0;
|
||||
background: transparent;
|
||||
width:100%;
|
||||
padding:.6rem .9rem; /* как у тебя было */
|
||||
border-radius:14px; background: var(--bs-body-bg); overflow: hidden;
|
||||
}
|
||||
.ios-acc + .ios-acc{ margin-top:.75rem; }
|
||||
.ios-acc .ios-collapse-trigger{ border:0; border-radius:0; background: transparent; width:100%; padding:.6rem .9rem; }
|
||||
.ios-acc .ios-collapse-trigger:focus{ box-shadow:none; }
|
||||
|
||||
/* Панель — без внешних отступов, высота анимируется стандартом Bootstrap */
|
||||
.ios-acc .collapse{ margin:0 !important; }
|
||||
.collapsing{ overflow:hidden; transition: height .35s ease; }
|
||||
|
||||
|
||||
/* Внутренние списки/чипсы — убираем собственный бордер/радиус, они уже внутри общего блока */
|
||||
.ios-acc .ios-list{ border:0; border-radius:0; }
|
||||
.ios-acc .chips{ margin:0; }
|
||||
|
||||
.ios-acc .ios-acc-body{
|
||||
padding:.5rem .25rem .75rem;
|
||||
border-top:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%);
|
||||
}
|
||||
</style>
|
||||
<style>
|
||||
/* Убираем внутренний паддинг у «Активности» и возвращаем его только заголовку */
|
||||
|
||||
/* Activity block adjustments */
|
||||
#activity .p-4{ padding:0 !important; }
|
||||
#activity .ios-section-title{ padding:1rem 1rem .5rem !important; margin:0 !important; }
|
||||
|
||||
/* Растягиваем ленту на всю ширину карточки активности */
|
||||
#activity .activity-feed{ margin:0 !important; }
|
||||
|
||||
/* Делаем посты «монолитом»: без рамок, без тени, без скруглений.
|
||||
Линия между постами — это бордеры .feed-card; отключаем их. */
|
||||
#activity .activity-feed .feed-card{
|
||||
width:100% !important;
|
||||
max-width:none !important;
|
||||
border:0 !important; /* ← убирает «черточку» между постами */
|
||||
box-shadow:none !important; /* без теней внутри монолитного блока */
|
||||
border-radius:0 !important;
|
||||
margin:0 !important; /* расстояния контролируем контейнером */
|
||||
width:100% !important; max-width:none !important; border:0 !important; box-shadow:none !important;
|
||||
border-radius:0 !important; margin:0 !important;
|
||||
}
|
||||
|
||||
/* Контейнер — в столбик с равномерным зазором между постами */
|
||||
#activity .activity-feed{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:1rem; /* мягкий просвет без линий */
|
||||
}
|
||||
|
||||
/* На случай, если внутри используешь .feed-stream/.row */
|
||||
#activity .activity-feed{ display:flex; flex-direction:column; gap:1rem; }
|
||||
#activity .activity-feed .feed-stream{ display:flex; flex-direction:column; gap:1rem; }
|
||||
#activity .activity-feed .row > *{ flex:0 0 100% !important; max-width:100% !important; }
|
||||
|
||||
|
||||
/* убираем линию между постом и блоком комментариев */
|
||||
#activity .feed-actions{ border-top: 0 !important; }
|
||||
|
||||
/* опционально: убрать рамку у превью последнего комментария */
|
||||
#activity .collapse .bg-body-tertiary.border{ border: 0 !important; }
|
||||
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* базовые карточки/разметка — как у тебя */
|
||||
.ios-card{border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 10%);border-radius:var(--feed-radius);background:var(--bs-body-bg);box-shadow:var(--feed-shadow);transition:box-shadow .18s ease, transform .18s ease, border-color .18s ease;overflow:hidden}
|
||||
.ios-card:hover{box-shadow:var(--feed-shadow-hover);transform:translateY(-1px)}
|
||||
.ios-header{position:sticky;top:var(--navbar-h,0px);z-index:5;padding:.75rem 0;backdrop-filter:saturate(180%) blur(12px);background:color-mix(in srgb, var(--bs-body-bg) 85%, transparent);border-bottom:none!important}
|
||||
.ios-title{font-weight:800;letter-spacing:-.01em}
|
||||
.chips{display:flex;flex-wrap:wrap;gap:.5rem}
|
||||
.chip{padding:.35rem .65rem;border-radius:9999px;font-size:.85rem;line-height:1;background:var(--bs-secondary-bg);color:var(--bs-body-color);border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 20%)}
|
||||
|
||||
/* ====== SKILLS UI ====== */
|
||||
/* плюс как «пилюля» */
|
||||
/* Skills UI */
|
||||
#skillsBlock[data-editable="1"] .chip.chip-add{display:inline-flex;align-items:center;cursor:pointer;border-style:dashed}
|
||||
#skillsBlock[data-editable="1"] .chip.chip-add.pulse{transform:scale(.97);transition:transform .15s ease}
|
||||
#skillsBlock[data-editable="1"] .chip.chip-add .bi{font-size:.95rem}
|
||||
|
||||
/* чип навыка */
|
||||
.chip.chip-skill{position:relative;padding:.35rem .65rem}
|
||||
.chip.chip-skill .lbl{transition:opacity .15s ease}
|
||||
|
||||
/* кнопка-«минус» по центру (поверх подписи), без кружка */
|
||||
.chip.chip-skill .chip-del-btn{
|
||||
position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
|
||||
background:transparent;border:0;padding:0;margin:0;cursor:pointer;
|
||||
@ -218,19 +161,57 @@
|
||||
visibility:hidden;opacity:0;transition:opacity .15s ease;z-index:1
|
||||
}
|
||||
.chip.chip-skill .chip-del-btn .bi{pointer-events:none}
|
||||
|
||||
/* показываем минус и прячем подпись ТОЛЬКО если можно редактировать */
|
||||
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .lbl{opacity:0}
|
||||
#skillsBlock[data-editable="1"] .chip.chip-skill:hover .chip-del-btn,
|
||||
#skillsBlock[data-editable="1"] .chip.chip-skill:focus-within .chip-del-btn{visibility:visible;opacity:1}
|
||||
|
||||
/* подсказка-пусто */
|
||||
.skills-empty{color:var(--bs-secondary-color);font-size:.875rem;margin-right:.25rem}
|
||||
|
||||
/* Dept inline editor */
|
||||
.dept-edit-btn{
|
||||
width:32px;height:32px;padding:0;display:grid;place-items:center;border-radius:999px;
|
||||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 30%);
|
||||
background: color-mix(in srgb, var(--bs-body-bg) 94%, transparent);
|
||||
}
|
||||
.dept-edit-btn:hover{ background: color-mix(in srgb, var(--bs-body-bg) 88%, transparent); }
|
||||
.dept-editor-wrap{ flex: 0 0 100%; width:100%; margin-top:.5rem; }
|
||||
.dept-editor form{ display:flex; align-items:center; gap:.5rem; }
|
||||
.dept-role-select{ min-width: 180px; }
|
||||
/* полностью прячем роль, когда открыт редактор */
|
||||
.dept-item.is-editing .dept-role{ display:none !important; }
|
||||
|
||||
/* не даём блоку с кнопками сжиматься/уезжать */
|
||||
.dept-item > .d-flex{
|
||||
flex: 0 1 auto; /* раньше было 0 0 auto — не сжимался */
|
||||
flex-wrap: wrap; /* можно переносить на новую строку */
|
||||
justify-content: flex-end;
|
||||
gap: .35rem .5rem; /* компактнее на переносах */
|
||||
max-width: 100%;
|
||||
}
|
||||
/* 2) Левую часть реально можно ужать с многоточием */
|
||||
.dept-line{ min-width: 0; }
|
||||
|
||||
/* 3) Сами пилюли не растягиваются */
|
||||
.badge-head,
|
||||
.badge-gear{ flex: 0 0 auto; }
|
||||
|
||||
/* 4) Строка отдела допускает перенос элементов (на всякий) */
|
||||
.ios-cell{ flex-wrap: wrap; }
|
||||
|
||||
/* 5) При открытом редакторе полностью прячем роль */
|
||||
.dept-item.is-editing .dept-role{ display:none !important; }
|
||||
|
||||
/* 6) На узких — прячем подписи внутри пилюль, чтобы всё поместилось */
|
||||
@media (max-width: 576px){
|
||||
.badge-head .lbl,
|
||||
.badge-gear .lbl{ display:none; }
|
||||
}
|
||||
/* (аккуратная связка кнопки-разделителя и панели списка) — у тебя была потеряна скобка */
|
||||
.ios-acc .ios-collapse-trigger + .collapse { margin-top: 0 !important; }
|
||||
.ios-acc .ios-acc-body { border-top: 0; }
|
||||
</style>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="container ios-page">
|
||||
<div class="ios-header feed-header d-flex align-items-center gap-2 flex-wrap">
|
||||
@ -251,8 +232,8 @@
|
||||
<div class="ava-wrap mb-3">
|
||||
{% if emp.avatar %}
|
||||
<img src="{{ emp.avatar }}"
|
||||
alt="Аватар {{ emp.last_name }} {{ emp.first_name }}"
|
||||
class="ios-ava" style="object-fit:cover;">
|
||||
alt="Аватар {{ emp.last_name }} {{ emp.first_name }}"
|
||||
class="ios-ava" style="object-fit:cover;">
|
||||
{% else %}
|
||||
<div class="ios-ava--ph">
|
||||
{% firstof emp.first_name emp.last_name "?" as initial %}
|
||||
@ -305,33 +286,34 @@
|
||||
<i class="bi-chevron-down ios-collapse-caret"></i>
|
||||
</button>
|
||||
|
||||
<div id="contactsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% if emp.email %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-envelope"></i>
|
||||
<a href="mailto:{{ emp.email }}">{{ emp.email }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.phone_number %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-telephone"></i>
|
||||
<a href="tel:{{ emp.phone_number }}">{{ emp.phone_number }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.telegram %}<div class="ios-cell"><i class="bi-telegram"></i><span>Telegram: {{ emp.telegram }}</span></div>{% endif %}
|
||||
{% if emp.whatsapp %}<div class="ios-cell"><i class="bi-whatsapp"></i><span>WhatsApp: {{ emp.whatsapp }}</span></div>{% endif %}
|
||||
{% if emp.wechat %}<div class="ios-cell"><i class="bi-wechat"></i><span>WeChat: {{ emp.wechat }}</span></div>{% endif %}
|
||||
{% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %}
|
||||
<div class="ios-cell"><span class="text-secondary">Контакты не указаны</span></div>
|
||||
{% endif %}
|
||||
<div id="contactsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% if emp.email %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-envelope"></i>
|
||||
<a href="mailto:{{ emp.email }}">{{ emp.email }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.phone_number %}
|
||||
<div class="ios-cell">
|
||||
<i class="bi-telephone"></i>
|
||||
<a href="tel:{{ emp.phone_number }}">{{ emp.phone_number }}</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if emp.telegram %}<div class="ios-cell"><i class="bi-telegram"></i><span>Telegram: {{ emp.telegram }}</span></div>{% endif %}
|
||||
{% if emp.whatsapp %}<div class="ios-cell"><i class="bi-whatsapp"></i><span>WhatsApp: {{ emp.whatsapp }}</span></div>{% endif %}
|
||||
{% if emp.wechat %}<div class="ios-cell"><i class="bi-wechat"></i><span>WeChat: {{ emp.wechat }}</span></div>{% endif %}
|
||||
{% if not emp.email and not emp.phone_number and not emp.telegram and not emp.whatsapp and not emp.wechat %}
|
||||
<div class="ios-cell"><span class="text-secondary">Контакты не указаны</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === ОТДЕЛЫ (без картинок; роль через тире; руководитель — визуально выделен) === #}
|
||||
{# === ОТДЕЛЫ: роль скрыта; редактор по кнопке-карандашу (только для начальников) === #}
|
||||
<div class="mt-3 text-start">
|
||||
<div class="ios-acc">
|
||||
<button class="ios-collapse-trigger"
|
||||
@ -344,37 +326,79 @@
|
||||
<i class="bi-chevron-down ios-collapse-caret"></i>
|
||||
</button>
|
||||
|
||||
<div id="departmentsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% if emp.departments and emp.departments|length %}
|
||||
{% for d in emp.departments %}
|
||||
{% if d.name %}
|
||||
<div class="ios-cell dept-item">
|
||||
<div class="dept-line">
|
||||
{% if d.id %}
|
||||
<a class="text-decoration-none dept-name" href="{% url 'employees:department_detail' d.id %}">{{ d.name }}</a>
|
||||
{% else %}
|
||||
<span class="dept-name">{{ d.name }}</span>
|
||||
<div id="departmentsCollapse" class="collapse">
|
||||
<div class="ios-acc-body">
|
||||
<div class="ios-list">
|
||||
{% if emp_depts and emp_depts|length %}
|
||||
{% for d in emp_depts %}
|
||||
{% if d.name %}
|
||||
<div class="ios-cell dept-item"
|
||||
data-dept-id="{{ d.id|default:'' }}"
|
||||
data-role-id="{{ d.role_id|default:'' }}"
|
||||
data-is-head="{{ d.is_head|yesno:'1,0' }}">
|
||||
<div class="dept-line">
|
||||
{% if d.id %}
|
||||
<a class="text-decoration-none dept-name" href="{% url 'employees:department_detail' d.id %}">{{ d.name }}</a>
|
||||
{% else %}
|
||||
<span class="dept-name">{{ d.name }}</span>
|
||||
{% endif %}
|
||||
<span class="dept-role">
|
||||
{% if d.role_name %}— {{ d.role_name }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if d.is_head %}
|
||||
<span class="badge-head" data-bs-toggle="tooltip" data-bs-title="Руководитель отдела">
|
||||
<i class="bi-award-fill" aria-hidden="true"></i><span class="lbl">Руководитель</span>
|
||||
</span>
|
||||
{% endif %}
|
||||
|
||||
{% if d.can_edit_role %}
|
||||
<button class="badge-gear" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#roleEditor-{{ d.id }}"
|
||||
aria-expanded="false" aria-controls="roleEditor-{{ d.id }}"
|
||||
title="Изменить роль сотрудника в этом отделе" aria-label="Изменить роль">
|
||||
<i class="bi-gear-fill"></i><span class="lbl">Роль</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if d.can_edit_role %}
|
||||
<div id="roleEditor-{{ d.id }}" class="collapse dept-editor-wrap">
|
||||
<div class="dept-editor">
|
||||
<form method="post"
|
||||
action="{% url 'employees:set_member_role' emp_id=emp.id dept_id=d.id %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="next" value="{{ request.get_full_path }}">
|
||||
<select name="role_id" class="form-select form-select-sm dept-role-select">
|
||||
<option value="" {% if not d.role_id %}selected{% endif %}>Без роли</option>
|
||||
{% for r in d.roles_choices %}
|
||||
{% if r.name %}
|
||||
<option value="{{ r.id }}" {% if d.role_id == r.id %}selected{% endif %}>{{ r.name }}</option>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="submit" class="btn btn-sm btn-primary dept-role-save" disabled>OK</button>
|
||||
<button type="button" class="btn btn-sm btn-ghost"
|
||||
data-bs-toggle="collapse" data-bs-target="#roleEditor-{{ d.id }}">Отмена</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if d.role_name %}<span class="dept-role">— {{ d.role_name }}</span>{% endif %}
|
||||
</div>
|
||||
{% if d.is_head %}
|
||||
<span class="badge-head" data-bs-toggle="tooltip" data-bs-title="Руководитель отдела">
|
||||
<i class="bi-award-fill" aria-hidden="true"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="ios-cell"><span class="text-secondary">Отделы не указаны</span></div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="ios-cell"><span class="text-secondary">Отделы не указаны</span></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# === КАДРОВЫЕ СОБЫТИЯ (для аутентифицированных) === #}
|
||||
{% if request.user.is_authenticated %}
|
||||
{% with acts_src=employee_actions|default:emp.actions %}
|
||||
@ -409,7 +433,6 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# История этого события: без "создано/изменено", только название события и новые значения #}
|
||||
{% if a.history and a.history|length %}
|
||||
<details class="mt-2 small">
|
||||
<summary class="text-secondary">История</summary>
|
||||
@ -419,7 +442,6 @@
|
||||
<div class="py-2 border-top">
|
||||
<div class="d-flex align-items-baseline gap-2">
|
||||
<span class="fw-semibold">
|
||||
{# Переводим код события в человекочитаемый #}
|
||||
{% if new_action == 'hired' %}Принят
|
||||
{% elif new_action == 'dismissed' %}Уволен
|
||||
{% elif new_action == 'on_leave' %}В отпуске
|
||||
@ -437,7 +459,6 @@
|
||||
|
||||
{% if h.changes %}
|
||||
<div class="ms-3 mt-1">
|
||||
{# Показываем только НОВЫЕ значения (без зачёркнутых старых) #}
|
||||
{% for field, diff in h.changes.items %}
|
||||
{% if field != 'action' %}
|
||||
<div>
|
||||
@ -486,11 +507,12 @@
|
||||
{% endwith %}
|
||||
{% 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 %}>
|
||||
{% 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">
|
||||
@ -503,8 +525,6 @@
|
||||
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>
|
||||
@ -518,7 +538,6 @@
|
||||
{% 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"
|
||||
@ -536,7 +555,7 @@
|
||||
<div class="row g-2">
|
||||
<div class="col-12">
|
||||
<input class="form-control" name="skill_name" list="skillsAll"
|
||||
placeholder="Выберите или введите новый навык" required>
|
||||
placeholder="Выберите или введите новый навык" required>
|
||||
<datalist id="skillsAll">
|
||||
{% for sk in all_skills %}
|
||||
{% if sk.name %}<option value="{{ sk.name }}"></option>{% endif %}
|
||||
@ -556,6 +575,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
@ -563,29 +583,30 @@
|
||||
<!-- RIGHT -->
|
||||
<div class="col-md-8">
|
||||
{% if can_edit %}
|
||||
<div id="editFormWrap" class="collapse mb-4">
|
||||
<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 id="editFormWrap" class="collapse mb-4">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
@ -599,12 +620,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
/* SKILLS: add/remove via AJAX form endpoints */
|
||||
const block = document.getElementById('skillsBlock');
|
||||
if (!block || block.dataset.editable !== '1') return;
|
||||
|
||||
@ -712,45 +733,64 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/* Smooth-scroll to profile edit on mobile */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const wrap = document.getElementById('editFormWrap');
|
||||
if (!wrap) return;
|
||||
|
||||
// определяем, мобильный ли экран
|
||||
const isMobile = () => window.matchMedia('(max-width: 767.98px)').matches;
|
||||
|
||||
// вычисляем отступ под фикс/стикки заголовок
|
||||
function getTopOffset(){
|
||||
const sticky = document.querySelector('.ios-header'); // твой page header
|
||||
return (sticky?.offsetHeight || 0) + 8; // + маленький зазор
|
||||
const sticky = document.querySelector('.ios-header');
|
||||
return (sticky?.offsetHeight || 0) + 8;
|
||||
}
|
||||
|
||||
function smoothScrollTo(el){
|
||||
const top = el.getBoundingClientRect().top + window.pageYOffset - getTopOffset();
|
||||
// если поддерживается smooth
|
||||
try {
|
||||
window.scrollTo({ top, behavior: 'smooth' });
|
||||
} catch {
|
||||
window.scrollTo(0, top);
|
||||
}
|
||||
try { window.scrollTo({ top, behavior: 'smooth' }); }
|
||||
catch { window.scrollTo(0, top); }
|
||||
}
|
||||
|
||||
// когда коллапс раскрылся — скроллим к нему (только на мобилках)
|
||||
wrap.addEventListener('shown.bs.collapse', () => {
|
||||
if (isMobile()) smoothScrollTo(wrap);
|
||||
});
|
||||
|
||||
// если блок уже открыт при загрузке (например, по якорю) — тоже скроллим
|
||||
if (wrap.classList.contains('show') && isMobile()) {
|
||||
// небольшая задержка, чтобы страница «устаканилась»
|
||||
setTimeout(() => smoothScrollTo(wrap), 50);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/* Enable OK button in dept role editor only when value changed + init tooltips */
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.dept-editor form').forEach(form => {
|
||||
const select = form.querySelector('select[name="role_id"]');
|
||||
const okBtn = form.querySelector('.dept-role-save');
|
||||
const initial = (select?.value ?? '');
|
||||
const toggle = () => { if(okBtn) okBtn.disabled = (select.value === initial); };
|
||||
if(select && okBtn){ toggle(); select.addEventListener('change', toggle); }
|
||||
});
|
||||
document.querySelectorAll('.dept-editor-wrap.collapse').forEach(col => {
|
||||
const row = col.closest('.dept-item');
|
||||
if (!row) return;
|
||||
|
||||
// прячем роль ещё ДО анимации
|
||||
col.addEventListener('show.bs.collapse', () => row.classList.add('is-editing'));
|
||||
// возвращаем роль после закрытия
|
||||
col.addEventListener('hidden.bs.collapse', () => row.classList.remove('is-editing'));
|
||||
|
||||
// если по каким-то причинам коллапс уже открыт при загрузке
|
||||
if (col.classList.contains('show')) row.classList.add('is-editing');
|
||||
});
|
||||
|
||||
if (window.bootstrap){
|
||||
document.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
|
||||
new bootstrap.Tooltip(el);
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
|
||||
@ -5,116 +5,108 @@
|
||||
{% block title %}Сотрудники{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.ios-search{ position:relative; width:100%; max-width:420px; }
|
||||
.ios-search-input{
|
||||
width:100%; height:40px; padding:8px 38px;
|
||||
border-radius:14px; border:1px solid var(--bs-border-color);
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #000 4%);
|
||||
font-size:15px; outline:none;
|
||||
transition: box-shadow .2s, border-color .2s, background .2s;
|
||||
}
|
||||
[data-bs-theme="dark"] .ios-search-input{
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
|
||||
}
|
||||
.ios-search-input:focus{
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), .15);
|
||||
}
|
||||
.ios-search > .bi-search{
|
||||
position:absolute; left:10px; top:50%; transform:translateY(-50%);
|
||||
opacity:.55; pointer-events:none; font-size:.95rem;
|
||||
}
|
||||
.ios-search-clear{
|
||||
position:absolute; right:4px; top:50%; transform:translateY(-50%);
|
||||
border:0; background:transparent; padding:6px; display:none;
|
||||
}
|
||||
.ios-search-input:not(:placeholder-shown) ~ .ios-search-clear{ display:block; }
|
||||
.feed-header { gap:.6rem; flex-wrap:wrap; }
|
||||
</style>
|
||||
<style>
|
||||
/* Поиск */
|
||||
.ios-search{ position:relative; width:100%; max-width:420px; }
|
||||
.ios-search-input{
|
||||
width:100%; height:40px; padding:8px 38px;
|
||||
border-radius:14px; border:1px solid var(--bs-border-color);
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #000 4%);
|
||||
font-size:15px; outline:none;
|
||||
transition: box-shadow .2s, border-color .2s, background .2s;
|
||||
}
|
||||
[data-bs-theme="dark"] .ios-search-input{
|
||||
background: color-mix(in srgb, var(--bs-body-bg), #fff 6%);
|
||||
}
|
||||
.ios-search-input:focus{
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--bs-primary-rgb), .15);
|
||||
}
|
||||
.ios-search > .bi-search{
|
||||
position:absolute; left:10px; top:50%; transform:translateY(-50%);
|
||||
opacity:.55; pointer-events:none; font-size:.95rem;
|
||||
}
|
||||
.ios-search-clear{
|
||||
position:absolute; right:4px; top:50%; transform:translateY(-50%);
|
||||
border:0; background:transparent; padding:6px; display:none;
|
||||
}
|
||||
.ios-search-input:not(:placeholder-shown) ~ .ios-search-clear{ display:block; }
|
||||
.feed-header { gap:.6rem; flex-wrap:wrap; }
|
||||
|
||||
<style>
|
||||
:root{
|
||||
--emp-avatar: 56px;
|
||||
--emp-gap: 14px;
|
||||
}
|
||||
/* Токены списка сотрудников */
|
||||
#empList{
|
||||
--emp-row-py: .6rem; /* вертикальные отступы строки */
|
||||
--emp-row-px: .75rem; /* горизонтальные отступы строки */
|
||||
--emp-gap: 14px; /* расстояние внутри строки */
|
||||
--emp-avatar: 56px; /* размер аватара */
|
||||
}
|
||||
|
||||
/* общий блок-«карточка» со скроллом */
|
||||
#empList{
|
||||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 12%);
|
||||
border-radius: var(--feed-radius, 16px);
|
||||
background: var(--bs-body-bg);
|
||||
overflow:hidden;
|
||||
padding:0;
|
||||
max-height: calc(100vh - var(--navbar-h, 0px) - 160px);
|
||||
overflow-y:auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* Контейнер списка (скролл в колонке) */
|
||||
#empList{
|
||||
border:1px solid color-mix(in srgb, var(--bs-border-color), transparent 12%);
|
||||
border-radius: var(--feed-radius, 16px);
|
||||
background: var(--bs-body-bg);
|
||||
padding:0;
|
||||
max-height: calc(100dvh - var(--navbar-h, 0px) - 160px);
|
||||
overflow:auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
/* строка сотрудника */
|
||||
#empList .emp-row{
|
||||
padding:.6rem .75rem !important;
|
||||
border-bottom:1px solid var(--bs-border-color);
|
||||
}
|
||||
#empList .emp-row:last-child{ border-bottom:0; }
|
||||
/* Важно: убираем межстрочный gap от .feed-stream */
|
||||
#empList.feed-stream{ display:block; gap:0 !important; }
|
||||
|
||||
/* раскладка: [аватар] [контент] [действия] */
|
||||
#empList .emp-row .feed-hd{
|
||||
display:grid;
|
||||
grid-template-columns: var(--emp-avatar) 1fr auto;
|
||||
align-items:center;
|
||||
gap: var(--emp-gap);
|
||||
margin:0; padding:0;
|
||||
}
|
||||
/* Строка сотрудника */
|
||||
#empList .emp-row{
|
||||
padding: var(--emp-row-py) var(--emp-row-px) !important;
|
||||
margin:0 !important;
|
||||
border-bottom:1px solid var(--bs-border-color);
|
||||
}
|
||||
#empList .emp-row:last-child{ border-bottom:0; }
|
||||
|
||||
/* аватар: круг, центрирование, одинаковое поведение img и иконки */
|
||||
#empList .emp-row .feed-ava{
|
||||
width: var(--emp-avatar);
|
||||
height: var(--emp-avatar); /* вместо aspect-ratio */
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: color-mix(in srgb, var(--bs-secondary-bg), transparent 30%);
|
||||
/* На случай агрессивных глобальных правил */
|
||||
flex: none; /* grid не тронет размер ячейки */
|
||||
}
|
||||
/* Шапка строки: [аватар] [контент] [действия] */
|
||||
#empList .emp-row .feed-hd{
|
||||
display:grid;
|
||||
grid-template-columns: var(--emp-avatar) 1fr auto;
|
||||
align-items:center;
|
||||
gap: var(--emp-gap);
|
||||
margin:0; padding:0 !important; /* без внутренних паддингов — ими управляет .emp-row */
|
||||
}
|
||||
|
||||
#empList .emp-row .feed-ava img{
|
||||
display: block; /* убираем baseline-глюк */
|
||||
width: 100%;
|
||||
height: 100%; /* принудительно квадратная вписка */
|
||||
object-fit: cover; /* не тянем пропорции */
|
||||
object-position: center;
|
||||
}
|
||||
/* Аватар */
|
||||
#empList .emp-row .feed-ava{
|
||||
width: var(--emp-avatar);
|
||||
height: var(--emp-avatar);
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
background: color-mix(in srgb, var(--bs-secondary-bg), transparent 30%);
|
||||
flex:none;
|
||||
}
|
||||
#empList .emp-row .feed-ava img{
|
||||
display:block; width:100%; height:100%;
|
||||
object-fit:cover; object-position:center;
|
||||
}
|
||||
#empList .emp-row .feed-ava i{ font-size:1.5rem; line-height:1; opacity:.6; }
|
||||
|
||||
#empList .emp-row .feed-ava i{
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
opacity: .6;
|
||||
}
|
||||
|
||||
/* контент */
|
||||
#empList .emp-row .feed-meta{
|
||||
margin:0; padding:0; min-width:0;
|
||||
}
|
||||
.feed-author{ font-weight:700; line-height:1.25; }
|
||||
.emp-pos{ color:var(--bs-secondary-color); }
|
||||
|
||||
.feed-sub{
|
||||
margin-top:.25rem; color:var(--bs-secondary-color);
|
||||
}
|
||||
.feed-sub a{ color:inherit; text-decoration:none; }
|
||||
.feed-sub a:hover{ text-decoration:underline; }
|
||||
|
||||
/* действия: в правый край колонки */
|
||||
.emp-actions{ justify-self:end; }
|
||||
</style>
|
||||
/* Контент */
|
||||
#empList .emp-row .feed-meta{ margin:0; padding:0; min-width:0; }
|
||||
.feed-author{ font-weight:700; line-height:1.25; }
|
||||
.emp-pos{ color:var(--bs-secondary-color); }
|
||||
.feed-sub{ margin-top:.25rem; color:var(--bs-secondary-color); }
|
||||
.feed-sub a{ color:inherit; text-decoration:none; }
|
||||
.feed-sub a:hover{ text-decoration:underline; }
|
||||
|
||||
/* Действия справа */
|
||||
.emp-actions{ justify-self:end; }
|
||||
|
||||
/* Хвост карточки в списке не используем — убираем отступы */
|
||||
#empList .emp-row .feed-actions{ padding:0 !important; }
|
||||
#empList .emp-row .feed-body:empty{ display:none !important; }
|
||||
#empList .emp-row .feed-body{ padding:0 !important; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
{% block content %}
|
||||
<div class="container">
|
||||
<div class="row justify-content-center">
|
||||
@ -142,7 +134,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="empList" class="feed-stream mt-3">
|
||||
<div id="empList" class="feed-stream">
|
||||
{% if employees %}
|
||||
{% for emp in employees %}
|
||||
<article class="emp-row"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user