feat(ui): улучшить список чатов + полировка лент и приглашений

- communications: редизайн chat_list — поиск, быстрые фильтры (все/личные/отделы/глобальный), сниппет последнего сообщения и дата, аккуратные карточки
- employees: исправлен InviteToDepartmentForm (queryset через departments_links), кнопка «Пригласить» и ссылка на список сотрудников отдела
- employees: список сотрудников — вертикальный список с чистой типографикой
- feed: форма публикации понимает контекст (компания/отдел/личное), скрывает лишние поля; post_detail — правильная кнопка «назад» (в нужную ленту), кнопка «редактировать» для автора
- feed: urls/views — добавлены/update маршруты для редактирования/удаления постов (и подготовка для CRUD комментариев)
This commit is contained in:
admin i 2025-08-12 08:11:12 +03:00
parent 2c85086f78
commit 2433adfbde
3 changed files with 172 additions and 53 deletions

View File

@ -121,18 +121,27 @@ class DepartmentForm(forms.ModelForm):
class InviteToDepartmentForm(forms.Form):
"""Форма приглашения в отдел — динамически наполняется списком сотрудников"""
employee = forms.ModelChoiceField(
queryset=Employee.objects.none(), label="Сотрудник"
queryset=Employee.objects.none(),
label="Сотрудник",
widget=forms.Select(attrs={"class": "form-select"})
)
def __init__(self, department, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["employee"].queryset = Employee.objects.exclude(
employeedepartment__department=department,
employeedepartment__is_active=True,
)
qs = (Employee.objects
.all()
# НЕ показываем тех, кто уже активен в отделе
.exclude(
departments_links__department=department,
departments_links__is_active=True,
)
# по желанию: не показывать руководителя, если он уже назначен
.exclude(id=department.head_id if department.head_id else None)
.order_by("last_name", "first_name"))
self.fields["employee"].queryset = qs
# =========================

View File

@ -116,7 +116,11 @@ def resend_sms(request):
@login_required
def profile(request):
employee = request.user
posts = Post.objects.filter(author=employee).order_by("-created_at")[:10]
posts = (
Post.objects.filter(author=employee)
.select_related("author")
.order_by("-created_at")[:10]
)
show_edit = False
if request.method == "POST":
@ -147,7 +151,11 @@ def employee_detail(request, pk):
employee = get_object_or_404(Employee, pk=pk)
if employee == request.user:
return redirect("employees:profile")
posts = Post.objects.filter(author=employee).order_by("-created_at")[:10]
posts = (
Post.objects.filter(author=employee)
.select_related("author")
.order_by("-created_at")[:10]
)
return render(
request,
"employees/profile.html",
@ -166,29 +174,30 @@ def avatar_remove(request):
# =========================
# Сотрудники
# =========================
class EmployeeListView(ListView):
class EmployeeListView(LoginRequiredMixin, ListView):
model = Employee
template_name = "employees/employees_list.html"
context_object_name = "employees"
ordering = ["last_name", "first_name"]
def get_queryset(self):
qs = super().get_queryset().order_by("last_name", "first_name")
qs = super().get_queryset().order_by("last_name", "first_name").select_related()
dept_id = self.request.GET.get("department")
if dept_id:
# Активные связи сотрудник-отдел
from .models import EmployeeDepartment
employee_ids = (
EmployeeDepartment.objects
.filter(department_id=dept_id, is_active=True)
.values_list("employee_id", flat=True)
)
qs = qs.filter(id__in=employee_ids)
# фильтруем по активным связям в отделе, без отдельного запроса на ids:
qs = qs.filter(
departments_links__department_id=dept_id,
departments_links__is_active=True,
).distinct()
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["filter_department_id"] = self.request.GET.get("department")
dept_id = self.request.GET.get("department")
ctx["filter_department_id"] = dept_id
ctx["current_department"] = (
Department.objects.filter(pk=dept_id).first() if dept_id else None
)
return ctx
@ -212,8 +221,7 @@ class DepartmentDetailView(LoginRequiredMixin, DetailView):
department = self.object
# Список позиций (EmployeePosition) связан через related_name="employees"
ctx["emp_links"] = (
EmployeeDepartment.objects
.filter(department=department, is_active=True)
EmployeeDepartment.objects.filter(department=department, is_active=True)
.select_related("employee")
.order_by("employee__last_name", "employee__first_name")
)
@ -281,20 +289,16 @@ def invite_to_department(request, pk):
emp_dep.is_active = True
emp_dep.save()
# На всякий случай поддержим участников у чата (хотя у нас есть динамическая выборка)
chat, _ = Chat.objects.get_or_create(
type="department", department=department, is_main=True
)
chat.participants.add(employee)
if department.head:
chat.participants.add(department.head)
messages.success(
request, f"{employee.get_full_name()} приглашён в отдел и чат!"
)
# участников в чат можно не добавлять — get_participants собирает динамически
messages.success(request, f"{employee.get_full_name()} приглашён в отдел!")
return redirect("employees:department_detail", pk=pk)
else:
form = InviteToDepartmentForm(department)
if not form.fields["employee"].queryset.exists():
messages.info(
request,
"Нет сотрудников, которых можно пригласить — похоже, все уже в отделе.",
)
return render(
request,

View File

@ -1,40 +1,146 @@
{# backend/templates/communications/chat_list.html #}
{% extends "base.html" %}
{% load static django_bootstrap5 %}
{% block title %}Мои чаты{% endblock %}
{% block content %}
<div class="container-fluid px-0">
<h2 class="mb-4"><i class="bi-chat-dots me-2"></i> Мои чаты</h2>
<div class="row g-3">
<div class="container">
<!-- Header -->
<div class="d-flex flex-column flex-md-row align-items-md-center justify-content-between gap-2 mb-3">
<div class="d-flex align-items-center gap-2">
<i class="bi-chat-dots fs-3 text-primary"></i>
<h2 class="mb-0 fw-bold">Мои чаты</h2>
</div>
<div class="d-flex align-items-center gap-2">
<div class="input-group">
<span class="input-group-text"><i class="bi-search"></i></span>
<input type="text" id="chatSearch" class="form-control" placeholder="Поиск по названию/участникам…">
</div>
<a href="{% url 'employees:employees_list' %}" class="btn btn-outline-primary">
<i class="bi-person-plus"></i> Новый личный чат
</a>
</div>
</div>
<!-- Filters -->
<div class="mb-3">
<div class="btn-group" role="group" aria-label="Фильтр чатов">
<button type="button" class="btn btn-sm btn-outline-secondary active" data-filter="all">Все</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-filter="private">Личные</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-filter="department">Отделы</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-filter="global">Глобальный</button>
</div>
</div>
<!-- Grid -->
<div class="row g-3" id="chatGrid">
{% for chat in chats %}
<div class="col-12 col-md-6 col-lg-4">
<div class="card shadow-sm h-100">
<div class="card-body d-flex flex-column justify-content-between">
<h5 class="card-title">
{% if chat.type == 'global' %}
<i class="bi-globe"></i> Глобальный чат
{% elif chat.type == 'department' %}
<i class="bi-diagram-3"></i> {{ chat.department.name }}
{% with last=chat.messages.last %}
<div class="col-12 col-md-6 col-lg-4 chat-item"
data-type="{{ chat.type }}"
data-title="
{% if chat.type == 'global' %}
глобальный чат
{% elif chat.type == 'department' %}
{{ chat.department.name }}
{% else %}
{% for u in chat.participants.all %}{% if u != user %}{{ u.get_full_name|default:u }} {% endif %}{% endfor %}
{% endif %}
">
<div class="card shadow-sm h-100 border-0">
<div class="card-body d-flex flex-column">
<div class="d-flex align-items-start justify-content-between">
<div class="d-flex align-items-center gap-2">
{% if chat.type == 'global' %}
<span class="badge bg-primary-subtle text-primary border"><i class="bi-globe"></i></span>
{% elif chat.type == 'department' %}
<span class="badge bg-success-subtle text-success border"><i class="bi-diagram-3"></i></span>
{% else %}
<span class="badge bg-info-subtle text-info border"><i class="bi-person"></i></span>
{% endif %}
<h5 class="card-title mb-0">
{% if chat.type == 'global' %}
Глобальный чат
{% elif chat.type == 'department' %}
Чат отдела: {{ chat.department.name }}
{% else %}
{% with names="" %}
{% for u in chat.participants.all %}
{% if u != user %}{{ u.get_full_name|default:u }}{% if not forloop.last %}, {% endif %}{% endif %}
{% endfor %}
{% endwith %}
{% endif %}
</h5>
</div>
<small class="text-muted">
{% if last %}{{ last.created_at|date:"d.m.Y H:i" }}{% endif %}
</small>
</div>
<p class="text-secondary small mt-2 mb-3">
{% if last %}
{{ last.author.get_full_name|default:last.author }}: {{ last.content|truncatechars:90 }}
{% else %}
<i class="bi-person-gear"></i>
{% for u in chat.participants.all %}
{% if u != user %}{{ u.get_full_name|default:u }}{% endif %}
{% endfor %}
Сообщений пока нет.
{% endif %}
</h5>
<a href="{% url 'communications:chat_detail' chat.pk %}" class="stretched-link"></a>
<p class="card-text small mt-3 text-secondary">
Сообщений: {{ chat.messages.count }}
</p>
<div class="mt-auto d-flex align-items-center justify-content-between">
<span class="small text-muted">
<i class="bi-chat-left-text"></i> {{ chat.messages.count }}
</span>
<a href="{% url 'communications:chat_detail' chat.pk %}" class="btn btn-sm btn-outline-primary">
Открыть
</a>
</div>
</div>
</div>
</div>
{% endwith %}
{% empty %}
<div class="col-12">
<div class="alert alert-secondary">Чатов пока нет.</div>
<div class="alert alert-light border rounded-3">
Чатов пока нет. Начните с <a href="{% url 'employees:employees_list' %}">поиска сотрудника</a> и откройте личный чат.
</div>
</div>
{% endfor %}
</div>
</div>
<!-- Simple filtering -->
<script>
(function () {
const search = document.getElementById('chatSearch');
const btns = document.querySelectorAll('[data-filter]');
const items = Array.from(document.querySelectorAll('.chat-item'));
let activeFilter = 'all';
function norm(s){ return (s || '').toLowerCase().trim(); }
function apply() {
const q = norm(search ? search.value : '');
items.forEach(el => {
const type = el.getAttribute('data-type');
const title = norm(el.getAttribute('data-title'));
const passType = (activeFilter === 'all') || (type === activeFilter);
const passSearch = !q || title.includes(q);
el.style.display = (passType && passSearch) ? '' : 'none';
});
}
btns.forEach(b => {
b.addEventListener('click', () => {
btns.forEach(x => x.classList.remove('active'));
b.classList.add('active');
activeFilter = b.getAttribute('data-filter');
apply();
});
});
if (search) search.addEventListener('input', apply);
})();
</script>
{% endblock %}