feat(ui): улучшить список чатов + полировка лент и приглашений
- communications: редизайн chat_list — поиск, быстрые фильтры (все/личные/отделы/глобальный), сниппет последнего сообщения и дата, аккуратные карточки - employees: исправлен InviteToDepartmentForm (queryset через departments_links), кнопка «Пригласить» и ссылка на список сотрудников отдела - employees: список сотрудников — вертикальный список с чистой типографикой - feed: форма публикации понимает контекст (компания/отдел/личное), скрывает лишние поля; post_detail — правильная кнопка «назад» (в нужную ленту), кнопка «редактировать» для автора - feed: urls/views — добавлены/update маршруты для редактирования/удаления постов (и подготовка для CRUD комментариев)
This commit is contained in:
parent
2c85086f78
commit
2433adfbde
@ -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
|
||||
|
||||
|
||||
# =========================
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 %}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user