feat: Улучшения страницы сотрудника и миграция на ChatMembership

Backend:
- Полная миграция на ChatMembership для всех типов чатов
- Удалены проверки participants M2M поля
- Добавлена обработка participants при создании чатов
- API методы для создания/редактирования/удаления кадровых событий

Frontend:
- Редизайн страницы пользователя с lucide-react иконами
- Добавлено редактирование профиля (ФИО, контакты, аватар)
- Функциональные кадровые события (создание/редактирование/удаление)
- История кадровых событий с цветовой кодировкой
- Исправлено создание дубликатов чатов (поиск по member_ids)
- Удалены визуальные статусные индикаторы (is_active ≠ online)
- Добавлен статус сотрудника из последнего action

Fixes:
- type 'private' вместо 'direct' для личных чатов
- ChatMembership проверки в user_can_access_chat()
- member_ids в ChatListSerializer для поиска чатов
This commit is contained in:
lizerk 2026-03-16 18:21:24 +03:00
parent e414358e76
commit f28eda6929
13 changed files with 1157 additions and 181 deletions

View File

@ -42,6 +42,7 @@ class ChatListSerializer(serializers.ModelSerializer):
last_message = serializers.SerializerMethodField()
unread_count = serializers.SerializerMethodField()
participant_names = serializers.SerializerMethodField()
member_ids = serializers.SerializerMethodField()
is_pinned = serializers.SerializerMethodField()
notifications_enabled = serializers.SerializerMethodField()
last_read_message_id = serializers.SerializerMethodField()
@ -59,7 +60,7 @@ class ChatListSerializer(serializers.ModelSerializer):
# DEPRECATED: для обратной совместимости
'is_main',
# Messages & status
'last_message', 'unread_count', 'participant_names',
'last_message', 'unread_count', 'participant_names', 'member_ids',
'is_pinned', 'notifications_enabled', 'last_read_message_id'
]
@ -86,13 +87,17 @@ class ChatListSerializer(serializers.ModelSerializer):
return None
def get_participant_names(self, obj):
"""Имена участников (для приватных чатов)
MIGRATION: Используем memberships вместо participants
"""
"""Имена участников (для приватных чатов)"""
if obj.type == 'private':
active_members = obj.memberships.filter(is_active=True).select_related('user')[:5]
return [m.user.get_full_name() for m in active_members]
return []
def get_member_ids(self, obj):
"""ID участников (из prefetch, для поиска существующего чата)"""
return list(
obj.memberships.filter(is_active=True).values_list('user_id', flat=True)
)
def get_is_pinned(self, obj):
"""Закреплен ли чат для текущего пользователя (из prefetch)"""

View File

@ -89,7 +89,6 @@ class ChatViewSet(viewsets.ModelViewSet):
user = self.request.user
from django.contrib.contenttypes.models import ContentType
# MIGRATION: Используем только memberships вместо participants
queryset = Chat.objects.filter(
Q(memberships__user=user, memberships__is_active=True)
| Q(include_all_users=True)
@ -97,7 +96,6 @@ class ChatViewSet(viewsets.ModelViewSet):
).select_related(
'created_by', 'context_content_type'
).prefetch_related(
'participants',
# Prefetch ChatMembership с информацией о пользователе
Prefetch(
'memberships',
@ -132,31 +130,38 @@ class ChatViewSet(viewsets.ModelViewSet):
# Устанавливаем создателя
chat = serializer.save(created_by=request.user)
# MIGRATION: Создаем membership для всех типов кроме глобальных (убрали participants.add)
if chat.type != 'global':
# Для чатов с управлением участниками создаем membership с ролью admin
if chat.type in ['group', 'channel', 'announcement']:
ChatMembership.objects.create(
chat=chat,
user=request.user,
role='admin',
invited_by=request.user,
is_active=True
)
# Для других типов (private, direct) создаем обычного участника
else:
ChatMembership.objects.create(
chat=chat,
user=request.user,
role='member',
invited_by=request.user,
is_active=True
)
# Для глобальных чатов устанавливаем include_all_users=True
if chat.type == 'global':
chat.include_all_users = True
chat.save()
else:
role = 'admin' if chat.type in ['group', 'channel', 'announcement'] else 'member'
ChatMembership.objects.create(
chat=chat,
user=request.user,
role=role,
invited_by=request.user,
is_active=True
)
# Добавляем остальных участников из запроса
participant_ids = request.data.get('participants', [])
if participant_ids:
for user_id in participant_ids:
if user_id == request.user.id:
continue
try:
participant = User.objects.get(id=user_id)
ChatMembership.objects.get_or_create(
chat=chat,
user=participant,
defaults={
'role': 'member',
'invited_by': request.user,
'is_active': True
}
)
except User.DoesNotExist:
logger.warning(f"User {user_id} not found when creating chat {chat.id}")
headers = self.get_success_headers(serializer.data)
return Response(

View File

@ -551,7 +551,6 @@ class ChatConsumerMixin:
chat_ids = list(
Chat.objects.filter(
Q(type="global")
| Q(type="private", participants=user)
| Q(id__in=membership_chat_ids)
| Q(include_all_users=True)
).values_list("id", flat=True).distinct()
@ -582,21 +581,21 @@ class ChatConsumerMixin:
return True
if chat.type == "private":
return chat.participants.filter(pk=user.pk).exists()
return ChatMembership.objects.filter(
chat=chat, user=user, is_active=True
).exists()
if chat.type == "group":
return (
chat.participants.filter(pk=user.pk).exists()
or ChatMembership.objects.filter(chat=chat, user=user).exists()
)
return ChatMembership.objects.filter(
chat=chat, user=user, is_active=True
).exists()
if chat.type in ("channel", "announcement", "comments"):
if chat.include_all_users:
return user.is_active
return (
chat.participants.filter(pk=user.pk).exists()
or ChatMembership.objects.filter(chat=chat, user=user).exists()
)
return ChatMembership.objects.filter(
chat=chat, user=user, is_active=True
).exists()
return False

View File

@ -62,9 +62,9 @@ def user_can_access_chat(chat: Chat, user) -> bool:
Правила доступа по типам чатов:
- global: доступ всем
- private: только участники (participants)
- group: участники (participants) или ChatMembership
- channel/announcement: include_all_users или participants/membership
- private: активное ChatMembership
- group: активное ChatMembership (или context-based resolver)
- channel/announcement: include_all_users или активное ChatMembership
- comments: context-based доступ через get_participants()
Args:
@ -84,10 +84,9 @@ def user_can_access_chat(chat: Chat, user) -> bool:
return True
if chat.type == "private":
# Личные чаты только через participants
# Обходим prefetch cache используя прямой запрос
from .models import Chat
return Chat.objects.filter(pk=chat.pk, participants=user).exists()
return ChatMembership.objects.filter(
chat=chat, user=user, is_active=True
).exists()
if chat.type == "group":
# Групповые чаты с контекстным объектом используют resolver
@ -102,34 +101,21 @@ def user_can_access_chat(chat: Chat, user) -> bool:
f"[user_can_access_chat] Failed to resolve for chat "
f"{chat.id}: {e}"
)
# Обычные групповые чаты через participants ИЛИ ChatMembership
from .models import Chat
in_participants = Chat.objects.filter(
pk=chat.pk, participants=user
return ChatMembership.objects.filter(
chat=chat, user=user, is_active=True
).exists()
in_membership = ChatMembership.objects.filter(
chat=chat, user=user
).exists()
return in_participants or in_membership
if chat.type in ("channel", "announcement"):
# Для каналов и объявлений может быть include_all_users или membership
if chat.include_all_users:
return user.is_active
# Проверяем и participants и membership для гибкости
from .models import Chat
return (
Chat.objects.filter(pk=chat.pk, participants=user).exists()
or ChatMembership.objects.filter(chat=chat, user=user).exists()
)
return ChatMembership.objects.filter(
chat=chat, user=user, is_active=True
).exists()
if chat.type == "comments":
# Чаты-комментарии ВСЕГДА используют context-based доступ
# Чаты-комментарии используют context-based доступ через get_participants()
if chat.context_object_id:
try:
# get_participants() вызовет resolver который учитывает
# права доступа к контекстному объекту
participants = chat.get_participants()
return participants.filter(pk=user.pk).exists()
except Exception as e:
@ -140,12 +126,8 @@ def user_can_access_chat(chat: Chat, user) -> bool:
f"comments chat {chat.id}: {e}"
)
return False
# Fallback для старых чатов без контекста
from .models import Chat
return (
Chat.objects.filter(pk=chat.pk, participants=user).exists()
or ChatMembership.objects.filter(chat=chat, user=user).exists()
)
return ChatMembership.objects.filter(
chat=chat, user=user, is_active=True
).exists()
return False

View File

@ -13,10 +13,11 @@ export default function EmployeesPage() {
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [showOnlyActive, setShowOnlyActive] = useState(false);
const loaderRef = useRef<HTMLDivElement | null>(null);
const isFetchingRef = useRef(false);
const loadEmployees = async (pageToLoad: number, append: boolean) => {
const loadEmployees = async (pageToLoad: number, append: boolean, activeOnly: boolean) => {
if (isFetchingRef.current) return;
try {
@ -28,7 +29,11 @@ export default function EmployeesPage() {
}
setError(null);
const response = await apiClient.getEmployees({ page: pageToLoad, limit: 20 });
const response = await apiClient.getEmployees({
page: pageToLoad,
limit: 20,
...(activeOnly && { is_active: true })
});
const nextChunk = response.results || [];
setEmployees((prev) => (append ? [...prev, ...nextChunk] : nextChunk));
@ -45,8 +50,8 @@ export default function EmployeesPage() {
};
useEffect(() => {
loadEmployees(1, false);
}, []);
loadEmployees(1, false, showOnlyActive);
}, [showOnlyActive]);
useEffect(() => {
if (!hasMore || loading) return;
@ -59,7 +64,7 @@ export default function EmployeesPage() {
const first = entries[0];
if (!first?.isIntersecting) return;
if (isFetchingRef.current || loadingMore || !hasMore) return;
loadEmployees(page + 1, true);
loadEmployees(page + 1, true, showOnlyActive);
},
{
root: null,
@ -106,43 +111,50 @@ export default function EmployeesPage() {
return (
<AppShell>
<div className="mb-4 flex items-center gap-3">
<button
onClick={() => setShowOnlyActive(false)}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
!showOnlyActive
? 'bg-sky-400 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Все сотрудники
</button>
<button
onClick={() => setShowOnlyActive(true)}
className={`rounded-xl px-4 py-2 text-sm font-medium transition ${
showOnlyActive
? 'bg-sky-400 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Только активные
</button>
</div>
<div className="space-y-3">
{sortedEmployees.map((employee) => {
const fullName = `${employee.last_name} ${employee.first_name} ${employee.patronymic || ''}`.trim();
const initials = `${employee.last_name?.[0] || ''}${employee.first_name?.[0] || ''}`;
const position = employee.position?.name || 'Сотрудник';
const isOnline = employee.is_active;
const statusText = isOnline ? 'Активен' : 'Неактивен';
const dotColor = isOnline ? 'bg-green-500' : 'bg-gray-400';
const statusColor = isOnline ? 'text-green-600' : 'text-gray-500';
return (
<Link key={employee.id} href={`/users/${employee.id}`} className="flex items-center gap-4 rounded-2xl bg-white p-4 shadow-sm ring-1 ring-gray-100 transition hover:bg-gray-50">
<div className="relative h-12 w-12">
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{employee.avatar ? (
<img src={employee.avatar} alt={fullName} className="h-full w-full object-cover" />
) : (
initials
)}
</div>
{isOnline ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
<div className="flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white">
{employee.avatar ? (
<img src={employee.avatar} alt={fullName} className="h-full w-full object-cover" />
) : (
initials
)}
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-gray-900">{fullName}</p>
<p className="text-xs text-gray-500">{position}</p>
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
<span className={`h-2 w-2 rounded-full ${dotColor}`} />
<span className={statusColor}>{statusText}</span>
{employee.email && (
<>
<span className="mx-1 text-gray-300">•</span>
<span className="text-gray-500">{employee.email}</span>
</>
)}
</div>
{employee.email && (
<p className="mt-1 text-xs text-gray-500">{employee.email}</p>
)}
</div>
</Link>
);

View File

@ -1188,12 +1188,8 @@ export default function MessageDialogPage() {
)}
</div>
<Link href={`/messages/${chatId}/settings`} className="hover:opacity-80 transition">
<div className="flex items-center gap-2">
<div>
<p className="text-sm font-semibold text-gray-900">{getChatTitle(chat, user?.id)}</p>
{/* Индикатор WebSocket соединения */}
{isConnected && (
<span className="inline-flex h-2 w-2 rounded-full bg-green-500" title="Real-time подключен"></span>
)}
</div>
<p className="text-xs text-gray-500">{(chat.type || chat.chat_type) === "group" ? "Групповой чат" : "Диалог"}</p>
</Link>
@ -1302,9 +1298,6 @@ export default function MessageDialogPage() {
getMessageInitials(message)
)}
</div>
{message.author?.is_active ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-2.5 w-2.5 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
</div>
) : null}

View File

@ -161,15 +161,6 @@ function getChatSortTimestamp(chat: Chat): number {
return Number.isNaN(chatCreatedTs) ? 0 : chatCreatedTs;
}
function isChatOnline(chat: Chat, currentUserId?: number): boolean {
const chatKind = chat.chat_type || chat.type;
if (chatKind !== "direct" && chatKind !== "private") return false;
const otherParticipant = (chat.participants || [])
.filter((p): p is Exclude<typeof p, number> => typeof p === "object" && p !== null)
.find((p) => p.id !== currentUserId);
return Boolean(otherParticipant?.is_active);
}
export default function MessagesPage() {
const { user } = useUser();
const router = useRouter();
@ -710,10 +701,6 @@ export default function MessagesPage() {
<span className="absolute -bottom-0.5 -left-0.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-sky-600 ring-2 ring-white">
{getChatTypeIcon(chat)}
</span>
{/* Онлайн-статус */}
{isChatOnline(chat, user?.id) ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
{/* Счетчик непрочитанных */}
{(chat.unread_count ?? 0) > 0 ? (
<span className="absolute -top-1 -right-1 z-10 flex h-5 min-w-[20px] items-center justify-center rounded-full bg-red-500 px-1.5 text-[10px] font-bold text-white ring-2 ring-white">

View File

@ -534,10 +534,6 @@ export default function Home() {
const authorInitials = post.author
? `${post.author.last_name?.[0] || ''}${post.author.first_name?.[0] || ''}`
: 'А';
const isAuthorOnline =
typeof post.author?.is_active === 'boolean'
? post.author.is_active
: Boolean(post.author?.id && user?.id && post.author.id === user.id && user.is_active);
const postText = (post.body || post.content || "").trim();
@ -564,9 +560,6 @@ export default function Home() {
authorInitials
)}
</div>
{isAuthorOnline ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
</div>
<div>
<p className="text-sm font-semibold text-gray-900">{authorName}</p>

View File

@ -203,7 +203,6 @@ export default function ProfilePage() {
) : (
<div className="flex h-16 w-16 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-lg font-semibold text-white">{initials}</div>
)}
{user.is_active ? <span className="absolute -bottom-0.5 -right-0.5 z-20 h-3.5 w-3.5 rounded-full bg-sky-400 ring-2 ring-white" /> : null}
</div>
<input
ref={avatarInputRef}

File diff suppressed because it is too large Load Diff

View File

@ -189,9 +189,6 @@ function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
userInitials
)}
</div>
{user?.is_active ? (
<span className="absolute -bottom-0.5 -right-0.5 z-10 h-3 w-3 rounded-full bg-sky-400 ring-2 ring-white" />
) : null}
{/* Меню пользователя */}
{userMenuOpen && (
<div className="absolute right-0 top-12 z-[60] w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
@ -605,9 +602,6 @@ export function AppShell({ children }: AppShellProps) {
</div>
<div className="min-w-0 flex-1 text-left">
<p className="text-sm font-semibold text-gray-900">{userName}</p>
{user?.is_active && (
<p className="text-xs text-sky-600">Онлайн</p>
)}
</div>
<ChevronDown
size={20}

View File

@ -246,12 +246,13 @@ class ApiClient {
}
// Сотрудники
async getEmployees(params?: { search?: string; department?: string; page?: number; limit?: number }): Promise<any> {
async getEmployees(params?: { search?: string; department?: string; page?: number; limit?: number; is_active?: boolean }): Promise<any> {
const queryParams = new URLSearchParams();
if (params?.search) queryParams.append('search', params.search);
if (params?.department) queryParams.append('department', params.department);
if (params?.page) queryParams.append('page', params.page.toString());
if (params?.limit) queryParams.append('limit', params.limit.toString());
if (params?.is_active !== undefined) queryParams.append('active', params.is_active.toString());
const url = `/api/v1/employees/${queryParams.toString() ? '?' + queryParams.toString() : ''}`;
return this.request(url);
@ -261,6 +262,74 @@ class ApiClient {
return this.request(`/api/v1/employees/${id}/`);
}
async updateEmployee(id: number | string, data: {
first_name?: string;
last_name?: string;
patronymic?: string;
email?: string;
phone_number?: string;
telegram?: string;
whatsapp?: string;
wechat?: string;
birth_date?: string;
gender?: string;
position_id?: number;
}): Promise<any> {
return this.request(`/api/v1/employees/${id}/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
}
async uploadEmployeeAvatar(id: number | string, file: File): Promise<any> {
const formData = new FormData();
formData.append('avatar', file);
return this.request(`/api/v1/employees/${id}/`, {
method: 'PATCH',
body: formData,
});
}
/**
* Создать кадровое событие (action) для сотрудника
*/
async createEmployeeAction(data: {
employee: number;
action: string;
date: string;
comment?: string;
}): Promise<any> {
return this.request('/api/v1/employee-actions/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/**
* Обновить кадровое событие
*/
async updateEmployeeAction(actionId: number, data: {
action?: string;
date?: string;
comment?: string;
}): Promise<any> {
return this.request(`/api/v1/employee-actions/${actionId}/`, {
method: 'PATCH',
body: JSON.stringify(data),
});
}
/**
* Удалить кадровое событие
*/
async deleteEmployeeAction(actionId: number): Promise<any> {
return this.request(`/api/v1/employee-actions/${actionId}/`, {
method: 'DELETE',
});
}
// Отделы
async getDepartments(params?: { search?: string; page?: number; limit?: number }): Promise<any> {
const queryParams = new URLSearchParams();

View File

@ -8,10 +8,14 @@ export interface Skill {
export interface EmployeeAction {
id: number;
employee: number;
action_type: string;
description?: string;
action: string;
action_display?: string;
date: string;
created_at: string;
date_display?: string;
comment?: string;
extra?: Record<string, any>;
history?: any;
created_at?: string;
}
export interface EmployeeDepartment {
@ -33,11 +37,11 @@ export interface User {
position?: Position;
departments?: EmployeeDepartment[]; // массив отделов с ролями (было department)
avatar?: string;
is_active: boolean;
is_active: boolean; // ВАЖНО: активный сотрудник (не уволен), НЕ онлайн статус!
telegram?: string;
whatsapp?: string;
wechat?: string;
gender?: string;
gender?: string | number; // 1 - мужской, 2 - женский, 0 - не указан
birth_date?: string;
skills?: Skill[];
actions?: EmployeeAction[];