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:
@@ -348,3 +348,13 @@
|
|||||||
- Как проверялось: docker compose config валиден для всех 4 файлов; check/lint/build чисто; vitest 104/104.
|
- Как проверялось: docker compose config валиден для всех 4 файлов; check/lint/build чисто; vitest 104/104.
|
||||||
- Влияние на поиск/UX: нет.
|
- Влияние на поиск/UX: нет.
|
||||||
- Подводные камни: пины синхронизированы в серверный бэкап .bak.serverfix (крит. правило №1) — только image-теги, креды бэкапа не тронуты; локальный .env.local требует MINIO_ACCESS_KEY (есть в .env.local.example).
|
- Подводные камни: пины синхронизированы в серверный бэкап .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: нет (поведение идентично доказанно).
|
||||||
|
|||||||
237
client/src/components/chat/GroupMembersDialog.tsx
Normal file
237
client/src/components/chat/GroupMembersDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
14
client/src/components/chat/HighlightMatch.tsx
Normal file
14
client/src/components/chat/HighlightMatch.tsx
Normal 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)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
client/src/components/chat/MessageBubble.tsx
Normal file
284
client/src/components/chat/MessageBubble.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
414
client/src/components/chat/NewConversationDialog.tsx
Normal file
414
client/src/components/chat/NewConversationDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
client/src/components/chat/TypingIndicator.tsx
Normal file
25
client/src/components/chat/TypingIndicator.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
83
client/src/components/chat/chat-types.ts
Normal file
83
client/src/components/chat/chat-types.ts
Normal 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;
|
||||||
|
}
|
||||||
37
client/src/components/chat/chat-utils.ts
Normal file
37
client/src/components/chat/chat-utils.ts
Normal 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);
|
||||||
|
}
|
||||||
1175
client/src/components/chat/useChatController.ts
Normal file
1175
client/src/components/chat/useChatController.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user