refactor(chat): разбиение Chat.tsx (2696→639) — механическая экстракция без смены поведения

Шаг 2.2 (часть 1) плана production-готовности:
- 8 файлов в components/chat/: типы, утилиты, MessageBubble, диалоги,
  TypingIndicator, useChatController (логика перенесена дословно)
- неизменность доказана посимвольной сверкой с оригиналом
- check/lint/build чисто, vitest 104/104
This commit is contained in:
2026-09-08 01:29:37 +03:00
parent 75ec0f25bb
commit 6bbedad291
10 changed files with 2364 additions and 2142 deletions

View File

@@ -348,3 +348,13 @@
- Как проверялось: docker compose config валиден для всех 4 файлов; check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет.
- Подводные камни: пины синхронизированы в серверный бэкап .bak.serverfix (крит. правило №1) — только image-теги, креды бэкапа не тронуты; локальный .env.local требует MINIO_ACCESS_KEY (есть в .env.local.example).
---
## [2.2a] Разбиение Chat.tsx (2696 → 639 строк)
- Статус: ✅ done
- Зачем: гигантские файлы нечитаемы и рискованны в правках (Фаза 2). БЕЗ смены поведения.
- Что изменено: `client/src/pages/Chat.tsx` → 8 файлов в `client/src/components/chat/`: chat-types.ts, chat-utils.ts, MessageBubble.tsx (пузырь + кнопки ботов), NewConversationDialog.tsx, GroupMembersDialog.tsx, HighlightMatch.tsx, TypingIndicator.tsx, useChatController.ts (вся логика страницы дословно). В Chat.tsx — только деструктуризация хука + прежний JSX.
- Как проверялось: ПРОГРАММНАЯ сверка с оригиналом (git show): тело логики == useChatController посимвольно, рендер == оригиналу (кроме замены inline-блока на <TypingIndicator>). Чеклист real-time фич дословно на месте (SSE typing, reportActiveChat в useLayoutEffect, polling fallback 10с, оптимистичная автоотметка прочтения с debounce, кнопки ботов с «нажато»). check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет (поведение идентично доказанно).

View File

@@ -0,0 +1,237 @@
import { useState } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarColor, getUserInitials } from '@/lib/avatar';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Trash2, UserPlus, UserMinus,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Conversation, ConvUser } from './chat-types';
// ── Group Members Dialog (unified: view + add) ─────────────────────────────────
export function GroupMembersDialog({
open,
onOpenChange,
conversation,
currentUserId,
currentUserRole,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
conversation: Conversation;
currentUserId: number;
currentUserRole: string;
}) {
const { toast } = useToast();
const [addSearch, setAddSearch] = useState('');
const [showAdd, setShowAdd] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
// Определяем, является ли текущий пользователь "администратором группы"
// Для «Наша Фирма» (isDefault) — только org admin; для остальных групп — создатель или org admin
const isGroupAdmin = conversation.isDefault
? currentUserRole === 'admin'
: (conversation.createdBy === currentUserId || currentUserRole === 'admin');
const canLeave = !conversation.isDefault && conversation.createdBy !== currentUserId;
const canDeleteGroup = isGroupAdmin && !conversation.isDefault;
// Список пользователей для добавления
const { data: usersData } = useQuery<{ success: boolean; users: ConvUser[] }>({
queryKey: ['/api/users/list'],
enabled: open && isGroupAdmin && showAdd,
});
const existingIds = new Set(conversation.members.map(m => m.id));
const availableToAdd = (usersData?.users ?? [])
.filter(u => !existingIds.has(u.id))
.filter(u => !addSearch || `${u.firstName} ${u.middleName || ''} ${u.lastName}`.toLowerCase().includes(addSearch.toLowerCase()));
const addMutation = useMutation({
mutationFn: async (targetUserId: number) => {
const res = await apiRequest('POST', `/api/messenger/conversations/${conversation.id}/members`, { targetUserId });
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
setShowAdd(false);
setAddSearch('');
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const removeMutation = useMutation({
mutationFn: async (memberId: number) => {
const res = await apiRequest('DELETE', `/api/messenger/conversations/${conversation.id}/members/${memberId}`);
const data = await res.json();
if (!data.success) throw new Error(data.error);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
if (removeMutation.variables === currentUserId) onOpenChange(false);
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
const deleteGroupMutation = useMutation({
mutationFn: async () => {
const res = await apiRequest('DELETE', `/api/messenger/conversations/${conversation.id}`);
const data = await res.json();
if (!data.success) throw new Error(data.error);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
onOpenChange(false);
toast({ title: 'Группа удалена' });
},
onError: (err: any) => toast({ variant: 'destructive', title: err.message }),
});
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) { setShowAdd(false); setAddSearch(''); } }}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>{conversation.displayName} · участники</DialogTitle>
</DialogHeader>
{/* Список участников */}
<ScrollArea className="h-56 border rounded-md">
<div className="p-1 space-y-0.5">
{conversation.members.map(m => {
const isMe = m.id === currentUserId;
const isCreatorTag = conversation.createdBy === m.id && !conversation.isDefault;
const canRemove = isGroupAdmin && m.id !== currentUserId;
return (
<div key={m.id} className="flex items-center gap-2 px-2 py-1.5 rounded">
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className={cn(getAvatarColor(m.id), "text-white text-[8px]")}>{getUserInitials(m.firstName, m.lastName, m.middleName)}</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">
{m.firstName} {m.middleName || ''} {m.lastName}
{isMe && <span className="text-muted-foreground font-normal"> (я)</span>}
{isCreatorTag && <span className="text-muted-foreground font-normal"> · создатель</span>}
</div>
{m.position && <div className="text-xs text-muted-foreground truncate">{m.position}</div>}
</div>
{canRemove && (
<Button
size="icon" variant="ghost"
className="h-5 w-5 text-destructive shrink-0"
onClick={() => removeMutation.mutate(m.id)}
disabled={removeMutation.isPending}
>
<UserMinus className="h-3 w-3" />
</Button>
)}
</div>
);
})}
</div>
</ScrollArea>
{/* Добавить участника (только для группового администратора) */}
{isGroupAdmin && (
<>
{!showAdd ? (
<Button variant="outline" size="sm" className="w-full" onClick={() => setShowAdd(true)}>
<UserPlus className="w-3.5 h-3.5 mr-1.5" />
Добавить участника
</Button>
) : (
<div className="space-y-2">
<Input
autoFocus
placeholder="Поиск пользователя..."
value={addSearch}
onChange={e => setAddSearch(e.target.value)}
className="h-8 text-xs"
/>
<ScrollArea className="h-36 border rounded-md">
<div className="p-1 space-y-0.5">
{availableToAdd.map(u => (
<div
key={u.id}
className="flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted"
onClick={() => addMutation.mutate(u.id)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className={cn(getAvatarColor(u.id), "text-white text-[8px]")}>{getUserInitials(u.firstName, u.lastName, u.middleName)}</AvatarFallback>
</Avatar>
<span className="text-xs">{u.firstName} {u.middleName || ''} {u.lastName}</span>
</div>
))}
{availableToAdd.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">Нет доступных пользователей</div>
)}
</div>
</ScrollArea>
<Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => { setShowAdd(false); setAddSearch(''); }}>
Отмена
</Button>
</div>
)}
</>
)}
{/* Удалить группу (только для создателя или админа) */}
{canDeleteGroup && (
<Button
variant="destructive" size="sm" className="w-full"
onClick={() => setDeleteConfirmOpen(true)}
disabled={deleteGroupMutation.isPending}
>
<Trash2 className="w-3.5 h-3.5 mr-1.5" />
Удалить группу
</Button>
)}
{/* Покинуть группу (только для не-создателей, не «Наша Фирма») */}
{canLeave && (
<Button
variant="destructive" size="sm" className="w-full"
onClick={() => removeMutation.mutate(currentUserId)}
disabled={removeMutation.isPending}
>
Покинуть группу
</Button>
)}
</DialogContent>
{/* Подтверждение удаления группы */}
<AlertDialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить группу?</AlertDialogTitle>
<AlertDialogDescription>
Группа «{conversation.displayName}» будет удалена навсегда. Это действие нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteGroupMutation.mutate()}
>
Удалить группу
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}

View File

@@ -0,0 +1,14 @@
// ── Highlight matching substring in search results ────────────────────────────
export function HighlightMatch({ text, query }: { text: string; query: string }) {
if (!query.trim()) return <>{text}</>;
const idx = text.toLowerCase().indexOf(query.toLowerCase());
if (idx === -1) return <>{text}</>;
return (
<>
{text.slice(0, idx)}
<mark className="bg-warning/30 text-inherit rounded-sm px-0">{text.slice(idx, idx + query.length)}</mark>
{text.slice(idx + query.length)}
</>
);
}

View File

@@ -0,0 +1,284 @@
import { useState } from 'react';
import { format } from 'date-fns';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarColor, getUserInitials } from '@/lib/avatar';
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Reply, Edit, Check, MoreVertical, Trash2, Clock, Smile,
} from 'lucide-react';
import { AttachmentDisplay } from '@/components/chat/ChatAttachments';
import { cn } from '@/lib/utils';
import { ReactionBar, type ReactionGroup } from '@/components/chat/ReactionBar';
import { PollCard, type PollData } from '@/components/chat/PollCard';
import type { BotButton, ConvMessage } from './chat-types';
import { groupButtonsByRow } from './chat-utils';
// ── Message bubble ─────────────────────────────────────────────────────────────
export function MessageBubble({
msg, isOwn, onReply, onEdit, onDelete, reactions, poll, onReactionsChange, onPollChange,
onOpenUserChat, onOpenBotChat, onButtonClick, pressedButtons,
}: {
msg: ConvMessage;
isOwn: boolean;
onReply: (m: ConvMessage) => void;
onEdit: (m: ConvMessage) => void;
onDelete: (m: ConvMessage) => void;
reactions?: ReactionGroup[];
poll?: PollData;
onReactionsChange?: (r: ReactionGroup[]) => void;
onPollChange?: (p: PollData) => void;
onOpenUserChat?: (userId: number) => void;
onOpenBotChat?: (botId: number) => void;
onButtonClick?: (m: ConvMessage, button: BotButton) => void;
pressedButtons?: Set<string>;
}) {
const [pickerOpen, setPickerOpen] = useState(false);
const isEdited = msg.updatedAt && msg.updatedAt !== msg.createdAt;
// Мобильное меню действий (<md): кнопка «⋯» всегда видима и открывает DropdownMenu.
// Отдельная кнопка (а не тап по пузырю) выбрана, чтобы не конфликтовать с выделением
// текста и тапами по ссылкам/вложениям внутри сообщения. Desktop hover-тулбар ниже
// сохранён без изменений.
const mobileActionsMenu = (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 mb-0.5 md:hidden"
title="Действия с сообщением"
aria-label="Действия с сообщением"
>
<MoreVertical className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={isOwn ? 'end' : 'start'} side="top" className="min-w-[10rem]">
<DropdownMenuItem onSelect={() => onReply(msg)}>
<Reply className="h-4 w-4 mr-2" />
Ответить
</DropdownMenuItem>
{onReactionsChange && (
<DropdownMenuItem onSelect={() => setPickerOpen(true)}>
<Smile className="h-4 w-4 mr-2" />
Реакция
</DropdownMenuItem>
)}
{isOwn && (
<DropdownMenuItem onSelect={() => onEdit(msg)}>
<Edit className="h-4 w-4 mr-2" />
Редактировать
</DropdownMenuItem>
)}
{isOwn && (
<DropdownMenuItem
className="text-destructive focus:text-destructive"
onSelect={() => onDelete(msg)}
>
<Trash2 className="h-4 w-4 mr-2" />
Удалить
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
if (msg.isDeleted) {
return (
<div className={cn("flex", isOwn ? "justify-end" : "justify-start")} data-msg-id={msg.id}>
<span className="text-xs text-muted-foreground italic px-3 py-1">Сообщение удалено</span>
</div>
);
}
return (
<div className={cn("flex group", isOwn ? "justify-end" : "justify-start")} data-msg-id={msg.id}>
{!isOwn && (
<Avatar className="w-6 h-6 mr-2 mt-1 shrink-0">
{msg.bot?.avatarUrl
? <img src={msg.bot.avatarUrl} alt={msg.bot.name} className="w-full h-full object-cover rounded-full outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10" />
: (
<AvatarFallback className={cn("text-[9px]", msg.author ? `${getAvatarColor(msg.author.id)} text-white` : "")}>
{msg.bot
? <span className="text-[10px]">🤖</span>
: msg.author ? getUserInitials(msg.author.firstName, msg.author.lastName, msg.author.middleName) : '?'
}
</AvatarFallback>
)
}
</Avatar>
)}
<div className={cn("max-w-[85%] md:max-w-[70%] flex flex-col", isOwn ? "items-end" : "items-start")}>
{!isOwn && (
<span className="text-xs text-muted-foreground mb-0.5 px-1">
{msg.bot ? (
<button
className="hover:underline cursor-pointer text-left"
onClick={(e) => { e.stopPropagation(); onOpenBotChat?.(msg.bot!.id); }}
>
{msg.bot.name}
</button>
) : (
msg.author ? (
<button
className="hover:underline cursor-pointer text-left"
onClick={(e) => { e.stopPropagation(); onOpenUserChat?.(msg.author!.id); }}
>
{msg.author.firstName} {msg.author.middleName || ''} {msg.author.lastName}
</button>
) : ''
)}
</span>
)}
{msg.replyTo && (
<button
type="button"
className="mb-1 px-2 py-1 rounded border-l-2 border-primary bg-muted/50 text-xs text-muted-foreground max-w-full text-left w-full cursor-pointer hover:bg-muted transition-colors"
onClick={() => {
const el = document.querySelector(`[data-msg-id="${msg.replyTo!.id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
el.classList.add('bg-warning/20', 'transition-colors', 'duration-500');
setTimeout(() => {
el.classList.remove('bg-warning/20', 'transition-colors', 'duration-500');
}, 2000);
}
}}
>
<span className="font-medium">{msg.replyTo.author?.firstName} {msg.replyTo.author?.middleName || ''} {msg.replyTo.author?.lastName}:</span>{' '}
<span className="line-clamp-1">{msg.replyTo.message}</span>
</button>
)}
<div className="flex items-end gap-1 min-w-0">
{isOwn && (
<>
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5 mb-0.5 hidden md:flex">
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onReply(msg)} title="Ответить" aria-label="Ответить">
<Reply className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => onReactionsChange && setPickerOpen(v => !v)}
title="Реакция"
aria-label="Реакция"
>
<Smile className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onEdit(msg)} title="Редактировать" aria-label="Редактировать">
<Edit className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5 text-destructive" onClick={() => onDelete(msg)} title="Удалить" aria-label="Удалить">
<Trash2 className="h-3 w-3" />
</Button>
</div>
{mobileActionsMenu}
</>
)}
<div
className={cn(
"rounded-2xl px-3 py-2 text-sm break-words whitespace-pre-wrap select-text max-w-full min-w-0 [overflow-wrap:anywhere]",
isOwn
? "bg-primary text-primary-foreground rounded-br-sm"
: "bg-muted rounded-bl-sm",
pickerOpen && "ring-2 ring-primary/40"
)}
>
{msg.message}
</div>
{!isOwn && (
<>
{mobileActionsMenu}
<div className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5 mb-0.5 hidden md:flex">
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => onReply(msg)} title="Ответить" aria-label="Ответить">
<Reply className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5"
onClick={() => onReactionsChange && setPickerOpen(v => !v)}
title="Реакция"
aria-label="Реакция"
>
<Smile className="h-3 w-3" />
</Button>
</div>
</>
)}
</div>
{msg.attachments && msg.attachments.length > 0 && (
<AttachmentDisplay attachments={msg.attachments} isOwn={isOwn} />
)}
{/* Опрос */}
{poll && onPollChange && (
<PollCard poll={poll} onPollChange={onPollChange} isOwn={isOwn} />
)}
{/* Реакции — пикер открывается по клику на пузырёк */}
{onReactionsChange && (
<ReactionBar
messageId={msg.id}
messageType="conv"
reactions={reactions ?? []}
onReactionsChange={onReactionsChange}
className={isOwn ? "justify-end" : ""}
pickerOpen={pickerOpen}
onPickerOpenChange={setPickerOpen}
/>
)}
{/* Кнопки бота */}
{msg.botButtons && msg.botButtons.length > 0 && onButtonClick && !msg.isDeleted && (
<div className={cn("flex flex-col gap-1.5 mt-1.5 max-w-full", isOwn ? "items-end" : "items-start")}>
{groupButtonsByRow(msg.botButtons).map((row, rowIdx) => (
<div key={rowIdx} className="flex flex-wrap gap-1.5">
{row.map((btn, btnIdx) => {
const btnKey = `${msg.id}:${btn.callbackData}`;
const isPressed = pressedButtons?.has(btnKey) ?? false;
return (
<Button
key={btnIdx}
variant={isPressed ? 'default' : btn.style === 'primary' ? 'default' : btn.style === 'destructive' ? 'destructive' : btn.style === 'outline' ? 'outline' : 'secondary'}
size="sm"
className={cn(
"h-7 text-xs px-3 py-1 rounded-full",
isPressed && "bg-success text-success-foreground hover:bg-success/90"
)}
disabled={isPressed}
onClick={() => onButtonClick(msg, btn)}
>
{isPressed && <Check className="w-3 h-3 mr-1" />}
{btn.text}
</Button>
);
})}
</div>
))}
</div>
)}
<span className="text-xs text-muted-foreground mt-0.5 px-1 flex items-center gap-1">
{msg.pending && (
<>
<Clock className="w-3 h-3" />
<span>отправка</span>
</>
)}
{format(new Date(msg.createdAt), 'HH:mm')}
{isEdited && ' · изменено'}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,414 @@
import { useState, useMemo, useCallback } from 'react';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Link } from 'wouter';
import { apiRequest, queryClient } from '@/lib/queryClient';
import { useToast } from '@/hooks/use-toast';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { getAvatarColor, getUserInitials } from '@/lib/avatar';
import { Badge } from '@/components/ui/badge';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
} from '@/components/ui/dialog';
import {
Users, Check, Link2, ExternalLink,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { Conversation, ConvUser, ExternalUser, OrgConnectionItem } from './chat-types';
// ── New Conversation Dialog ────────────────────────────────────────────────────
export function NewConversationDialog({
open,
onOpenChange,
onCreated,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onCreated: (conv: Conversation) => void;
}) {
const [tab, setTab] = useState<'internal' | 'external'>('internal');
const [mode, setMode] = useState<'direct' | 'group'>('direct');
const [groupName, setGroupName] = useState('');
const [search, setSearch] = useState('');
const [selectedIds, setSelectedIds] = useState<number[]>([]);
// External state
const [selectedConnId, setSelectedConnId] = useState<number | null>(null);
const [extSearch, setExtSearch] = useState('');
const [extSelectedIds, setExtSelectedIds] = useState<number[]>([]);
const [extGroupName, setExtGroupName] = useState('');
const { toast } = useToast();
// Internal users
const { data: usersData } = useQuery<{ success: boolean; users: ConvUser[] }>({
queryKey: ['/api/users/list'],
enabled: open && tab === 'internal',
});
const allUsers = usersData?.users ?? [];
const filtered = useMemo(() => {
const list = allUsers.filter(u =>
!search || `${u.firstName} ${u.middleName || ''} ${u.lastName} ${u.email}`.toLowerCase().includes(search.toLowerCase())
);
list.sort((a, b) => {
const aSel = selectedIds.includes(a.id);
const bSel = selectedIds.includes(b.id);
if (aSel === bSel) return 0;
return aSel ? -1 : 1;
});
return list;
}, [allUsers, search, selectedIds]);
// Connections list — uses non-admin endpoint (active only, no tokens)
const { data: connData } = useQuery<{ success: boolean; connections: OrgConnectionItem[] }>({
queryKey: ['/api/org-connections/active'],
enabled: open && tab === 'external',
});
const activeConns = (connData?.connections ?? []).filter(c => c.status === 'active');
// External users for selected connection
const { data: extUsersData, isLoading: extUsersLoading } = useQuery<{ success: boolean; users: ExternalUser[]; partnerOrgId: number }>({
queryKey: ['/api/org-connections', selectedConnId, 'users'],
enabled: open && tab === 'external' && !!selectedConnId,
queryFn: async () => {
const res = await apiRequest('GET', `/api/org-connections/${selectedConnId}/users`);
return res.json();
},
});
const extUsers = useMemo(() => {
const list = (extUsersData?.users ?? []).filter(u =>
!extSearch || `${u.firstName} ${u.middleName || ''} ${u.lastName}`.toLowerCase().includes(extSearch.toLowerCase())
);
list.sort((a, b) => {
const aSel = extSelectedIds.includes(a.id);
const bSel = extSelectedIds.includes(b.id);
if (aSel === bSel) return 0;
return aSel ? -1 : 1;
});
return list;
}, [extUsersData, extSearch, extSelectedIds]);
const selectedConn = activeConns.find(c => c.id === selectedConnId);
const reset = () => {
setSelectedIds([]);
setGroupName('');
setSearch('');
setExtSelectedIds([]);
setExtGroupName('');
setExtSearch('');
setSelectedConnId(null);
};
const createMutation = useMutation({
mutationFn: async () => {
if (tab === 'internal') {
if (mode === 'direct') {
if (selectedIds.length !== 1) throw new Error('Выберите одного пользователя');
const res = await apiRequest('POST', '/api/messenger/conversations', {
type: 'direct',
targetUserId: selectedIds[0],
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
} else {
if (!groupName.trim()) throw new Error('Введите название чата');
if (selectedIds.length === 0) throw new Error('Добавьте хотя бы одного участника');
const res = await apiRequest('POST', '/api/messenger/conversations', {
type: 'group',
name: groupName.trim(),
memberIds: selectedIds,
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
}
} else {
// External
if (!selectedConnId) throw new Error('Выберите подключение');
if (mode === 'direct') {
if (extSelectedIds.length !== 1) throw new Error('Выберите одного пользователя');
const res = await apiRequest('POST', '/api/messenger/conversations', {
type: 'external_direct',
connectionId: selectedConnId,
targetUserId: extSelectedIds[0],
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
} else {
if (!extGroupName.trim()) throw new Error('Введите название группы');
if (extSelectedIds.length === 0) throw new Error('Добавьте хотя бы одного участника');
const res = await apiRequest('POST', '/api/messenger/conversations', {
type: 'external_group',
connectionId: selectedConnId,
name: extGroupName.trim(),
memberIds: extSelectedIds,
});
const data = await res.json();
if (!data.success) throw new Error(data.error);
return data.conversation as Conversation;
}
}
},
onSuccess: (conv) => {
queryClient.invalidateQueries({ queryKey: ['/api/messenger/conversations'] });
onCreated(conv);
onOpenChange(false);
reset();
},
onError: (err: any) => {
toast({ variant: 'destructive', title: err.message });
},
});
const toggleUser = useCallback((id: number) => {
if (mode === 'direct') {
// Автопереключение в групповой режим при выборе второго пользователя
if (selectedIds.length === 1 && selectedIds[0] !== id) {
setMode('group');
setSelectedIds([selectedIds[0], id]);
} else {
setSelectedIds([id]);
}
} else {
setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
}
}, [mode, selectedIds]);
const toggleExtUser = useCallback((id: number) => {
if (mode === 'direct') {
// Автопереключение в групповой режим при выборе второго пользователя
if (extSelectedIds.length === 1 && extSelectedIds[0] !== id) {
setMode('group');
setExtSelectedIds([extSelectedIds[0], id]);
} else {
setExtSelectedIds([id]);
}
} else {
setExtSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]);
}
}, [mode, extSelectedIds]);
const getConnName = (conn: OrgConnectionItem) => {
const partner = conn.isInitiator ? conn.partnerOrg : conn.initiatorOrg;
return partner?.displayName ?? 'Партнёр';
};
return (
<Dialog open={open} onOpenChange={(v) => { onOpenChange(v); if (!v) reset(); }}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Новый чат</DialogTitle>
</DialogHeader>
{/* Tab: внутренний / внешний */}
<div className="flex gap-1 mb-3 border border-border rounded p-0.5 bg-muted/30">
<button
className={cn(
"flex-1 flex items-center justify-center gap-1.5 text-xs py-1 rounded transition-colors",
tab === 'internal' ? "bg-background shadow-sm font-medium" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => { setTab('internal'); reset(); }}
>
<Users className="w-3.5 h-3.5" />
Моя организация
</button>
<button
className={cn(
"flex-1 flex items-center justify-center gap-1.5 text-xs py-1 rounded transition-colors",
tab === 'external' ? "bg-background shadow-sm font-medium" : "text-muted-foreground hover:text-foreground"
)}
onClick={() => { setTab('external'); reset(); }}
>
<Link2 className="w-3.5 h-3.5" />
Внешние контакты
</button>
</div>
{/* Тип чата */}
<div className="flex items-center gap-2 mb-3">
<div className="flex gap-2">
<Button
size="sm"
variant={mode === 'direct' ? 'default' : 'outline'}
className="h-7 px-3 text-xs"
onClick={() => { setMode('direct'); setSelectedIds([]); setExtSelectedIds([]); }}
>
Личный
</Button>
<Button
size="sm"
variant={mode === 'group' ? 'default' : 'outline'}
className="h-7 px-3 text-xs"
onClick={() => { setMode('group'); setSelectedIds([]); setExtSelectedIds([]); }}
>
<Users className="w-3.5 h-3.5 mr-1.5" />
Группа
</Button>
</div>
<Badge variant={mode === 'group' ? 'default' : 'secondary'} className="text-[10px] h-5 px-1.5">
{mode === 'group' ? 'Групповой чат' : 'Личный чат'}
</Badge>
</div>
{/* ── Internal ── */}
{tab === 'internal' && (
<>
{mode === 'group' && (
<Input
placeholder="Название группы"
value={groupName}
onChange={e => setGroupName(e.target.value)}
className="mb-2 h-8 text-sm"
/>
)}
<Input
placeholder="Поиск пользователей..."
value={search}
onChange={e => setSearch(e.target.value)}
className="mb-2 h-8 text-sm"
/>
<ScrollArea className="h-52 border rounded-md">
<div className="p-1 space-y-0.5">
{filtered.map(u => (
<div
key={u.id}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted transition-colors",
selectedIds.includes(u.id) && "bg-primary/10"
)}
onClick={() => toggleUser(u.id)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className={cn(getAvatarColor(u.id), "text-white text-[8px]")}>
{getUserInitials(u.firstName, u.lastName, u.middleName)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{u.firstName} {u.middleName || ''} {u.lastName}</div>
{u.position && <div className="text-xs text-muted-foreground truncate">{u.position}</div>}
</div>
{selectedIds.includes(u.id) && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
</div>
))}
{filtered.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">Пользователи не найдены</div>
)}
</div>
</ScrollArea>
</>
)}
{/* ── External ── */}
{tab === 'external' && (
<>
{activeConns.length === 0 ? (
<div className="border border-border rounded p-4 text-center space-y-2">
<Link2 className="w-6 h-6 mx-auto text-muted-foreground opacity-50" />
<p className="text-xs text-muted-foreground">
Нет активных подключений. Настройте кросс-организационное подключение в разделе{' '}
<Link href="/settings" className="text-primary underline underline-offset-2">Настройки → Подключения</Link>.
</p>
</div>
) : (
<>
{/* Выбор подключения */}
{!selectedConnId ? (
<div className="space-y-1.5">
<p className="text-xs text-muted-foreground">Выберите партнёрскую организацию:</p>
{activeConns.map(conn => (
<div
key={conn.id}
className="flex items-center gap-2 px-3 py-2 rounded border border-border cursor-pointer hover:bg-muted transition-colors"
onClick={() => setSelectedConnId(conn.id)}
>
<ExternalLink className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
<span className="text-sm font-medium">{getConnName(conn)}</span>
</div>
))}
</div>
) : (
<>
{/* Заголовок партнёра + кнопка назад */}
<div className="flex items-center gap-2 mb-2">
<button
className="text-xs text-muted-foreground hover:text-foreground"
onClick={() => { setSelectedConnId(null); setExtSelectedIds([]); }}
>
← Назад
</button>
<span className="text-xs font-medium">{selectedConn ? getConnName(selectedConn) : ''}</span>
</div>
{mode === 'group' && (
<Input
placeholder="Название группы"
value={extGroupName}
onChange={e => setExtGroupName(e.target.value)}
className="mb-2 h-8 text-sm"
/>
)}
<Input
placeholder="Поиск пользователей..."
value={extSearch}
onChange={e => setExtSearch(e.target.value)}
className="mb-2 h-8 text-sm"
/>
<ScrollArea className="h-44 border rounded-md">
<div className="p-1 space-y-0.5">
{extUsersLoading && (
<div className="text-xs text-muted-foreground text-center py-4">Загрузка...</div>
)}
{!extUsersLoading && extUsers.map(u => (
<div
key={u.id}
className={cn(
"flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer hover:bg-muted transition-colors",
extSelectedIds.includes(u.id) && "bg-primary/10"
)}
onClick={() => toggleExtUser(u.id)}
>
<Avatar className="w-6 h-6 shrink-0">
<AvatarFallback className="text-[9px]">
{getUserInitials(u.firstName, u.lastName, u.middleName)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium truncate">{u.firstName} {u.middleName || ''} {u.lastName}</div>
{u.position && <div className="text-xs text-muted-foreground truncate">{u.position}</div>}
</div>
{extSelectedIds.includes(u.id) && <Check className="w-3.5 h-3.5 text-primary shrink-0" />}
</div>
))}
{!extUsersLoading && extUsers.length === 0 && (
<div className="text-xs text-muted-foreground text-center py-4">Нет пользователей</div>
)}
</div>
</ScrollArea>
</>
)}
</>
)}
</>
)}
<DialogFooter>
<Button
size="sm"
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending}
>
{createMutation.isPending ? 'Создание...' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,25 @@
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
// ── Bot typing indicator ───────────────────────────────────────────────────────
export function TypingIndicator({ typingBots }: { typingBots: Set<string> }) {
if (typingBots.size === 0) return null;
return (
<div className="flex items-start gap-2 mt-3">
<Avatar className="w-6 h-6 shrink-0 mt-0.5">
<AvatarFallback className="text-[10px]">🤖</AvatarFallback>
</Avatar>
<div className="bg-muted rounded-2xl rounded-bl-sm px-3 py-2 text-sm text-muted-foreground flex items-center gap-1">
<span className="truncate max-w-[200px]">
{Array.from(typingBots).join(', ')}
</span>
<span>печатает</span>
<span className="inline-flex gap-0.5 ml-0.5">
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '0ms' }} />
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-1 h-1 rounded-full bg-muted-foreground animate-bounce" style={{ animationDelay: '300ms' }} />
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import type { ChatAttachment } from '@/components/chat/ChatAttachments';
// ── Types ─────────────────────────────────────────────────────────────────────
export interface ConvUser {
id: number;
firstName: string;
lastName: string;
middleName?: string;
email?: string | null;
position?: string;
lastReadAt?: string | null;
}
export interface ConvBot {
id: number;
name: string;
avatarUrl?: string | null;
}
export interface BotButton {
text: string;
callbackData: string;
style?: 'primary' | 'secondary' | 'destructive' | 'outline';
row?: number;
}
export interface ConvMessage {
id: number;
conversationId: number;
message: string;
replyToId?: number | null;
mentionedUserIds?: number[] | null;
attachments?: ChatAttachment[] | null;
botButtons?: BotButton[] | null;
createdAt: string;
updatedAt: string;
isDeleted: boolean;
author: { id: number; firstName: string; lastName: string; middleName?: string } | null;
bot?: ConvBot | null;
replyTo?: { id: number; message: string; author: { id: number; firstName: string; lastName: string; middleName?: string } | null } | null;
pending?: boolean;
queueItemId?: string;
attachmentQueueItemId?: string;
}
export interface Conversation {
id: number;
type: 'direct' | 'group' | 'bot_direct';
name?: string | null;
displayName: string;
organizationId: number;
createdBy: number;
createdAt: string;
updatedAt: string;
members: ConvUser[];
unreadCount: number;
lastMessage?: ConvMessage | null;
isExternal?: boolean;
isDefault?: boolean;
isMuted?: boolean;
bot?: ConvBot | null;
botId?: number | null;
}
// ── Types for connections ──────────────────────────────────────────────────────
export interface OrgConnectionItem {
id: number;
status: string;
isInitiator: boolean;
initiatorOrg: { id: number; displayName: string } | null;
partnerOrg: { id: number; displayName: string } | null;
}
export interface ExternalUser {
id: number;
firstName: string;
lastName: string;
middleName?: string;
position?: string | null;
organizationId: number;
}

View File

@@ -0,0 +1,37 @@
import { format, isToday, isYesterday } from 'date-fns';
import type { BotButton, ConvMessage } from './chat-types';
// ── Helpers ────────────────────────────────────────────────────────────────────
export function formatDateHeader(date: Date) {
if (isToday(date)) return 'Сегодня';
if (isYesterday(date)) return 'Вчера';
return format(date, 'dd.MM.yyyy');
}
export function groupByDate(messages: ConvMessage[]) {
const groups: { dateKey: string; date: Date; messages: ConvMessage[] }[] = [];
for (const msg of messages) {
if (!msg.createdAt) continue;
const d = new Date(msg.createdAt);
if (isNaN(d.getTime())) continue;
const key = format(d, 'yyyy-MM-dd');
const last = groups[groups.length - 1];
if (last && last.dateKey === key) {
last.messages.push(msg);
} else {
groups.push({ dateKey: key, date: d, messages: [msg] });
}
}
return groups;
}
export function groupButtonsByRow(buttons: BotButton[]): BotButton[][] {
const rows = new Map<number, BotButton[]>();
for (const btn of buttons) {
const rowIdx = btn.row ?? 0;
if (!rows.has(rowIdx)) rows.set(rowIdx, []);
rows.get(rowIdx)!.push(btn);
}
return Array.from(rows.entries()).sort((a, b) => a[0] - b[0]).map(([, btns]) => btns);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff