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:
admin i 2025-09-02 17:13:25 +03:00
parent 0674bd49e6
commit 1228fb8d87
6 changed files with 1007 additions and 608 deletions

View File

@ -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"),
]

View File

@ -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)

View File

@ -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');

View File

@ -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" %}

View File

@ -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 %}

View File

@ -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"