644 lines
29 KiB
TypeScript
644 lines
29 KiB
TypeScript
'use client';
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||
import {
|
||
CheckCircle2,
|
||
Loader2,
|
||
LogIn,
|
||
MessagesSquare,
|
||
ScrollText,
|
||
Search,
|
||
XCircle
|
||
} from 'lucide-react';
|
||
import { AdminInspectorMessageFeed } from '@/components/admin/admin-inspector-message-body';
|
||
import {
|
||
AdminInsightsDateFilter,
|
||
defaultInsightsPeriodLocal,
|
||
localDatetimeToIso
|
||
} from '@/components/admin/admin-insights-date-filter';
|
||
import {
|
||
adminMessageToChatMessage,
|
||
useAdminChatMedia
|
||
} from '@/components/admin/use-admin-chat-media';
|
||
import { ChatImageLightbox } from '@/components/chat/chat-image-lightbox';
|
||
import { VerificationBadge } from '@/components/id/verification-badge';
|
||
import { useToast } from '@/components/id/toast-provider';
|
||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||
import {
|
||
AdminChatMessage,
|
||
AdminChatRoomSummary,
|
||
AdminUser,
|
||
AdminUserActivityItem,
|
||
ChatMessage,
|
||
SignInEvent,
|
||
apiFetch
|
||
} from '@/lib/api';
|
||
import { collectChatImageMessages } from '@/lib/chat-media-album';
|
||
import { cn } from '@/lib/utils';
|
||
|
||
const inspectorTabTriggerClass =
|
||
'flex items-center justify-center gap-2 rounded-lg px-2 py-2.5 text-sm font-medium text-[#667085] shadow-none transition data-[state=active]:bg-white data-[state=active]:text-[#1f2430] data-[state=active]:shadow-sm';
|
||
|
||
function formatDateTime(value: string) {
|
||
return new Intl.DateTimeFormat('ru-RU', {
|
||
day: '2-digit',
|
||
month: 'short',
|
||
year: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
}).format(new Date(value));
|
||
}
|
||
|
||
function useDebouncedValue<T>(value: T, delayMs = 350) {
|
||
const [debounced, setDebounced] = useState(value);
|
||
useEffect(() => {
|
||
const timer = window.setTimeout(() => setDebounced(value), delayMs);
|
||
return () => window.clearTimeout(timer);
|
||
}, [delayMs, value]);
|
||
return debounced;
|
||
}
|
||
|
||
const roomTypeLabels: Record<string, string> = {
|
||
GENERAL: 'Общий',
|
||
DIRECT: 'Личный',
|
||
GROUP: 'Группа'
|
||
};
|
||
|
||
export function UserInspectorDialog({
|
||
user,
|
||
token,
|
||
canModerateChats,
|
||
open,
|
||
onOpenChange
|
||
}: {
|
||
user: AdminUser | null;
|
||
token: string | null;
|
||
canModerateChats: boolean;
|
||
open: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
}) {
|
||
const { showToast } = useToast();
|
||
const [tab, setTab] = useState('sign-in');
|
||
const defaultPeriod = useMemo(() => defaultInsightsPeriodLocal(7), []);
|
||
const [periodFrom, setPeriodFrom] = useState(defaultPeriod.from);
|
||
const [periodTo, setPeriodTo] = useState(defaultPeriod.to);
|
||
const [retentionDays, setRetentionDays] = useState(7);
|
||
|
||
const resetPeriod = useCallback(() => {
|
||
const next = defaultInsightsPeriodLocal(retentionDays);
|
||
setPeriodFrom(next.from);
|
||
setPeriodTo(next.to);
|
||
}, [retentionDays]);
|
||
|
||
const [signInSearch, setSignInSearch] = useState('');
|
||
const [activitySearch, setActivitySearch] = useState('');
|
||
const [chatSearch, setChatSearch] = useState('');
|
||
const [messageSearch, setMessageSearch] = useState('');
|
||
const [globalMessageSearch, setGlobalMessageSearch] = useState('');
|
||
|
||
const debouncedSignInSearch = useDebouncedValue(signInSearch);
|
||
const debouncedActivitySearch = useDebouncedValue(activitySearch);
|
||
const debouncedChatSearch = useDebouncedValue(chatSearch);
|
||
const debouncedMessageSearch = useDebouncedValue(messageSearch);
|
||
const debouncedGlobalMessageSearch = useDebouncedValue(globalMessageSearch);
|
||
const debouncedPeriodFrom = useDebouncedValue(periodFrom, 500);
|
||
const debouncedPeriodTo = useDebouncedValue(periodTo, 500);
|
||
|
||
const appendDateParams = useCallback(
|
||
(params: URLSearchParams) => {
|
||
const fromIso = localDatetimeToIso(debouncedPeriodFrom);
|
||
const toIso = localDatetimeToIso(debouncedPeriodTo);
|
||
if (fromIso) params.set('dateFrom', fromIso);
|
||
if (toIso) params.set('dateTo', toIso);
|
||
},
|
||
[debouncedPeriodFrom, debouncedPeriodTo]
|
||
);
|
||
|
||
const [signInEvents, setSignInEvents] = useState<SignInEvent[]>([]);
|
||
const [signInTotal, setSignInTotal] = useState(0);
|
||
const [activityItems, setActivityItems] = useState<AdminUserActivityItem[]>([]);
|
||
const [activityTotal, setActivityTotal] = useState(0);
|
||
const [rooms, setRooms] = useState<AdminChatRoomSummary[]>([]);
|
||
const [roomsTotal, setRoomsTotal] = useState(0);
|
||
const [selectedRoomId, setSelectedRoomId] = useState<string | null>(null);
|
||
const [roomMessages, setRoomMessages] = useState<AdminChatMessage[]>([]);
|
||
const [roomContext, setRoomContext] = useState<{ name: string; groupName: string; type: string } | null>(null);
|
||
const [searchResults, setSearchResults] = useState<AdminChatMessage[]>([]);
|
||
const [searchTotal, setSearchTotal] = useState(0);
|
||
|
||
const [loadingSignIn, setLoadingSignIn] = useState(false);
|
||
const [loadingActivity, setLoadingActivity] = useState(false);
|
||
const [loadingRooms, setLoadingRooms] = useState(false);
|
||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||
const [loadingSearch, setLoadingSearch] = useState(false);
|
||
const [deletingMessageId, setDeletingMessageId] = useState<string | null>(null);
|
||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||
|
||
const selectedRoom = useMemo(() => rooms.find((room) => room.id === selectedRoomId) ?? null, [rooms, selectedRoomId]);
|
||
const { mediaUrls } = useAdminChatMedia(token, selectedRoomId, roomMessages);
|
||
const chatImageMessages = useMemo(
|
||
() =>
|
||
selectedRoomId
|
||
? collectChatImageMessages(roomMessages.map((message) => adminMessageToChatMessage(message, selectedRoomId)))
|
||
: [],
|
||
[roomMessages, selectedRoomId]
|
||
);
|
||
|
||
const loadSignIn = useCallback(async () => {
|
||
if (!token || !user) return;
|
||
setLoadingSignIn(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (debouncedSignInSearch.trim()) params.set('search', debouncedSignInSearch.trim());
|
||
params.set('limit', '50');
|
||
appendDateParams(params);
|
||
const response = await apiFetch<{
|
||
total: number;
|
||
events: SignInEvent[];
|
||
retentionDays?: number;
|
||
periodFrom?: string;
|
||
periodTo?: string;
|
||
}>(
|
||
`/admin/users/${user.id}/sign-in-history?${params.toString()}`,
|
||
{},
|
||
token
|
||
);
|
||
setSignInEvents(response.events ?? []);
|
||
setSignInTotal(response.total ?? 0);
|
||
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить историю входов');
|
||
} finally {
|
||
setLoadingSignIn(false);
|
||
}
|
||
}, [appendDateParams, debouncedSignInSearch, showToast, token, user]);
|
||
|
||
const loadActivity = useCallback(async () => {
|
||
if (!token || !user) return;
|
||
setLoadingActivity(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (debouncedActivitySearch.trim()) params.set('search', debouncedActivitySearch.trim());
|
||
params.set('limit', '50');
|
||
appendDateParams(params);
|
||
const response = await apiFetch<{
|
||
total: number;
|
||
items: AdminUserActivityItem[];
|
||
retentionDays?: number;
|
||
}>(
|
||
`/admin/users/${user.id}/activity?${params.toString()}`,
|
||
{},
|
||
token
|
||
);
|
||
setActivityItems(response.items ?? []);
|
||
setActivityTotal(response.total ?? 0);
|
||
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить активность');
|
||
} finally {
|
||
setLoadingActivity(false);
|
||
}
|
||
}, [appendDateParams, debouncedActivitySearch, showToast, token, user]);
|
||
|
||
const loadRooms = useCallback(async () => {
|
||
if (!token || !user || !canModerateChats) return;
|
||
setLoadingRooms(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (debouncedChatSearch.trim()) params.set('search', debouncedChatSearch.trim());
|
||
params.set('limit', '50');
|
||
appendDateParams(params);
|
||
const response = await apiFetch<{ total: number; rooms: AdminChatRoomSummary[]; retentionDays?: number }>(
|
||
`/admin/users/${user.id}/chats?${params.toString()}`,
|
||
{},
|
||
token
|
||
);
|
||
setRooms(response.rooms ?? []);
|
||
setRoomsTotal(response.total ?? 0);
|
||
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить чаты');
|
||
} finally {
|
||
setLoadingRooms(false);
|
||
}
|
||
}, [appendDateParams, canModerateChats, debouncedChatSearch, showToast, token, user]);
|
||
|
||
const loadRoomMessages = useCallback(async () => {
|
||
if (!token || !user || !selectedRoomId) return;
|
||
setLoadingMessages(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (debouncedMessageSearch.trim()) params.set('search', debouncedMessageSearch.trim());
|
||
params.set('limit', '80');
|
||
appendDateParams(params);
|
||
const response = await apiFetch<{
|
||
room?: { id: string; name: string; type: string; groupName: string };
|
||
messages: AdminChatMessage[];
|
||
retentionDays?: number;
|
||
}>(`/admin/users/${user.id}/chats/${selectedRoomId}/messages?${params.toString()}`, {}, token);
|
||
setRoomMessages(response.messages ?? []);
|
||
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||
setRoomContext(
|
||
response.room
|
||
? { name: response.room.name, groupName: response.room.groupName, type: response.room.type }
|
||
: null
|
||
);
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : 'Не удалось загрузить сообщения');
|
||
} finally {
|
||
setLoadingMessages(false);
|
||
}
|
||
}, [appendDateParams, debouncedMessageSearch, selectedRoomId, showToast, token, user]);
|
||
|
||
const loadGlobalSearch = useCallback(async () => {
|
||
if (!token || !user || !canModerateChats || !debouncedGlobalMessageSearch.trim()) {
|
||
setSearchResults([]);
|
||
setSearchTotal(0);
|
||
return;
|
||
}
|
||
setLoadingSearch(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set('search', debouncedGlobalMessageSearch.trim());
|
||
params.set('limit', '50');
|
||
appendDateParams(params);
|
||
const response = await apiFetch<{ total: number; messages: AdminChatMessage[]; retentionDays?: number }>(
|
||
`/admin/users/${user.id}/chats/search?${params.toString()}`,
|
||
{},
|
||
token
|
||
);
|
||
setSearchResults(response.messages ?? []);
|
||
setSearchTotal(response.total ?? 0);
|
||
if (response.retentionDays) setRetentionDays(response.retentionDays);
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : 'Не удалось выполнить поиск');
|
||
} finally {
|
||
setLoadingSearch(false);
|
||
}
|
||
}, [appendDateParams, canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !user) return;
|
||
if (tab === 'sign-in') void loadSignIn();
|
||
}, [loadSignIn, open, tab, user]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !user) return;
|
||
if (tab === 'activity') void loadActivity();
|
||
}, [loadActivity, open, tab, user]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !user || !canModerateChats) return;
|
||
if (tab === 'chats') void loadRooms();
|
||
}, [canModerateChats, loadRooms, open, tab, user]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !user || !selectedRoomId) return;
|
||
void loadRoomMessages();
|
||
}, [loadRoomMessages, open, selectedRoomId, user]);
|
||
|
||
useEffect(() => {
|
||
if (!open || !user || tab !== 'chats') return;
|
||
void loadGlobalSearch();
|
||
}, [loadGlobalSearch, open, tab, user]);
|
||
|
||
useEffect(() => {
|
||
if (!open) {
|
||
setTab('sign-in');
|
||
setSignInSearch('');
|
||
setActivitySearch('');
|
||
setChatSearch('');
|
||
setMessageSearch('');
|
||
setGlobalMessageSearch('');
|
||
setSelectedRoomId(null);
|
||
setRoomMessages([]);
|
||
setSearchResults([]);
|
||
setLightboxIndex(null);
|
||
const next = defaultInsightsPeriodLocal(7);
|
||
setPeriodFrom(next.from);
|
||
setPeriodTo(next.to);
|
||
setRetentionDays(7);
|
||
}
|
||
}, [open]);
|
||
|
||
function openImageLightbox(message: ChatMessage) {
|
||
const index = chatImageMessages.findIndex((item) => item.id === message.id);
|
||
if (index >= 0) setLightboxIndex(index);
|
||
}
|
||
|
||
async function handleDeleteMessage(messageId: string) {
|
||
if (!token || !user || !canModerateChats) return;
|
||
const confirmed = window.confirm('Удалить это сообщение? Действие необратимо для пользователей чата.');
|
||
if (!confirmed) return;
|
||
setDeletingMessageId(messageId);
|
||
try {
|
||
await apiFetch(`/admin/users/${user.id}/chat-messages/${messageId}`, { method: 'DELETE' }, token);
|
||
setRoomMessages((prev) => prev.filter((item) => item.id !== messageId));
|
||
setSearchResults((prev) => prev.filter((item) => item.id !== messageId));
|
||
showToast('Сообщение удалено');
|
||
} catch (error) {
|
||
showToast(error instanceof Error ? error.message : 'Не удалось удалить сообщение');
|
||
} finally {
|
||
setDeletingMessageId(null);
|
||
}
|
||
}
|
||
|
||
if (!user) return null;
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="flex max-h-[90vh] w-[min(960px,calc(100vw-2rem))] flex-col gap-0 overflow-hidden p-0">
|
||
<DialogHeader className="border-b border-[#eceef4] px-6 py-5">
|
||
<DialogTitle className="flex items-center gap-3 text-left text-xl font-semibold">
|
||
<span>{user.displayName}</span>
|
||
{user.isVerified ? <VerificationBadge verificationIcon={user.verificationIcon} size="sm" /> : null}
|
||
</DialogTitle>
|
||
<p className="text-left text-sm text-[#667085]">
|
||
{[user.email, user.phone, user.username].filter(Boolean).join(' · ') || 'Контакты не указаны'}
|
||
</p>
|
||
</DialogHeader>
|
||
|
||
<div className="border-b border-[#eceef4] px-6 py-4">
|
||
<AdminInsightsDateFilter
|
||
from={periodFrom}
|
||
to={periodTo}
|
||
retentionDays={retentionDays}
|
||
onFromChange={setPeriodFrom}
|
||
onToChange={setPeriodTo}
|
||
onReset={resetPeriod}
|
||
/>
|
||
</div>
|
||
|
||
<Tabs value={tab} onValueChange={setTab} className="flex min-h-0 flex-1 flex-col">
|
||
<div className="border-b border-[#eceef4] px-6 pt-4 pb-4">
|
||
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 rounded-xl bg-[#f4f5f8] p-1">
|
||
<TabsTrigger value="sign-in" className={inspectorTabTriggerClass}>
|
||
<LogIn className="h-4 w-4 shrink-0" />
|
||
Входы
|
||
</TabsTrigger>
|
||
<TabsTrigger value="activity" className={inspectorTabTriggerClass}>
|
||
<ScrollText className="h-4 w-4 shrink-0" />
|
||
Активность
|
||
</TabsTrigger>
|
||
<TabsTrigger value="chats" className={inspectorTabTriggerClass} disabled={!canModerateChats}>
|
||
<MessagesSquare className="h-4 w-4 shrink-0" />
|
||
Чаты
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
</div>
|
||
|
||
<TabsContent value="sign-in" className="mt-0 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-6 py-4">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||
<Input
|
||
value={signInSearch}
|
||
onChange={(event) => setSignInSearch(event.target.value)}
|
||
placeholder="Поиск по IP, устройству, user-agent, причине..."
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-[#667085]">Найдено: {signInTotal}</p>
|
||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[#eceef4]">
|
||
{loadingSignIn ? (
|
||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
Загрузка...
|
||
</div>
|
||
) : signInEvents.length ? (
|
||
<div className="divide-y divide-[#eceef4]">
|
||
{signInEvents.map((event) => (
|
||
<div key={event.id} className="flex gap-3 px-4 py-3">
|
||
{event.success ? (
|
||
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600" />
|
||
) : (
|
||
<XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
|
||
)}
|
||
<div className="min-w-0 flex-1">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-medium">{event.success ? 'Успешный вход' : 'Неудачная попытка'}</span>
|
||
<span className="text-xs text-[#667085]">{formatDateTime(event.createdAt)}</span>
|
||
</div>
|
||
<p className="mt-1 text-sm text-[#667085]">
|
||
{[event.deviceName, event.ipAddress].filter(Boolean).join(' · ') || 'Устройство не определено'}
|
||
</p>
|
||
{event.reason ? <p className="mt-1 text-sm text-red-600">{event.reason}</p> : null}
|
||
{event.userAgent ? (
|
||
<p className="mt-1 truncate text-xs text-[#98a2b3]" title={event.userAgent}>
|
||
{event.userAgent}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="py-16 text-center text-sm text-[#667085]">События входа не найдены</p>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="activity" className="mt-0 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-6 py-4">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||
<Input
|
||
value={activitySearch}
|
||
onChange={(event) => setActivitySearch(event.target.value)}
|
||
placeholder="Поиск по действиям, документам, чатам, OAuth..."
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-[#667085]">Найдено: {activityTotal}</p>
|
||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[#eceef4]">
|
||
{loadingActivity ? (
|
||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
Загрузка...
|
||
</div>
|
||
) : activityItems.length ? (
|
||
<div className="divide-y divide-[#eceef4]">
|
||
{activityItems.map((item) => (
|
||
<div key={item.id} className="px-4 py-3">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<span className="font-medium">{item.title}</span>
|
||
<span className="rounded-full bg-[#f4f5f8] px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-[#667085]">
|
||
{item.action}
|
||
</span>
|
||
<span className="text-xs text-[#667085]">{formatDateTime(item.createdAt)}</span>
|
||
</div>
|
||
{item.detail ? <p className="mt-1 text-sm text-[#667085]">{item.detail}</p> : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="py-16 text-center text-sm text-[#667085]">Активность не найдена</p>
|
||
)}
|
||
</div>
|
||
</TabsContent>
|
||
|
||
<TabsContent value="chats" className="mt-0 flex min-h-0 flex-1 flex-col gap-4 overflow-hidden px-6 py-4">
|
||
{!canModerateChats ? (
|
||
<p className="py-8 text-center text-sm text-[#667085]">Недостаточно прав для модерации чатов</p>
|
||
) : (
|
||
<>
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||
<Input
|
||
value={globalMessageSearch}
|
||
onChange={(event) => setGlobalMessageSearch(event.target.value)}
|
||
placeholder="Глобальный поиск по сообщениям пользователя..."
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
{debouncedGlobalMessageSearch.trim() ? (
|
||
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] p-3">
|
||
<p className="mb-2 text-xs font-medium text-[#667085]">
|
||
Результаты поиска по сообщениям: {searchTotal}
|
||
</p>
|
||
{loadingSearch ? (
|
||
<div className="flex items-center gap-2 py-4 text-sm text-[#667085]">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
Поиск...
|
||
</div>
|
||
) : searchResults.length ? (
|
||
<div className="max-h-40 space-y-2 overflow-y-auto">
|
||
{searchResults.map((message) => (
|
||
<button
|
||
key={message.id}
|
||
type="button"
|
||
className="flex w-full items-start gap-3 rounded-xl bg-white px-3 py-2 text-left hover:bg-[#f4f5f8]"
|
||
onClick={() => {
|
||
if (message.roomId) setSelectedRoomId(message.roomId);
|
||
}}
|
||
>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-xs text-[#667085]">
|
||
{message.roomName} · {message.groupName} · {formatDateTime(message.createdAt)}
|
||
</p>
|
||
<p className="text-sm">
|
||
<span className="font-medium">{message.senderName}: </span>
|
||
{message.preview}
|
||
</p>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="py-2 text-sm text-[#667085]">Сообщения не найдены</p>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
|
||
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||
<div className="flex min-h-0 flex-col gap-3">
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||
<Input
|
||
value={chatSearch}
|
||
onChange={(event) => setChatSearch(event.target.value)}
|
||
placeholder="Поиск чатов..."
|
||
className="pl-10"
|
||
/>
|
||
</div>
|
||
<p className="text-xs text-[#667085]">Чатов: {roomsTotal}. E2E и боты скрыты.</p>
|
||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[#eceef4]">
|
||
{loadingRooms ? (
|
||
<div className="flex items-center justify-center gap-2 py-10 text-[#667085]">
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
</div>
|
||
) : rooms.length ? (
|
||
<div className="divide-y divide-[#eceef4]">
|
||
{rooms.map((room) => (
|
||
<button
|
||
key={room.id}
|
||
type="button"
|
||
onClick={() => setSelectedRoomId(room.id)}
|
||
className={cn(
|
||
'w-full px-3 py-3 text-left transition hover:bg-[#f8f9fb]',
|
||
selectedRoomId === room.id && 'bg-[#eef4ff]'
|
||
)}
|
||
>
|
||
<div className="flex items-center justify-between gap-2">
|
||
<span className="truncate font-medium">{room.name}</span>
|
||
<span className="shrink-0 rounded-full bg-[#f4f5f8] px-2 py-0.5 text-[10px] font-semibold text-[#667085]">
|
||
{roomTypeLabels[room.type] ?? room.type}
|
||
</span>
|
||
</div>
|
||
<p className="mt-1 truncate text-xs text-[#667085]">{room.groupName}</p>
|
||
{room.lastMessage ? (
|
||
<p className="mt-1 truncate text-xs text-[#98a2b3]">{room.lastMessage.preview}</p>
|
||
) : null}
|
||
</button>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="px-3 py-10 text-center text-sm text-[#667085]">Чаты не найдены</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-col gap-3">
|
||
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] px-4 py-3">
|
||
<p className="font-medium">{selectedRoom?.name ?? 'Выберите чат'}</p>
|
||
<p className="text-xs text-[#667085]">
|
||
{roomContext
|
||
? `${roomContext.groupName} · ${roomTypeLabels[roomContext.type] ?? roomContext.type}`
|
||
: selectedRoom
|
||
? `${selectedRoom.groupName} · ${selectedRoom.memberCount} участников`
|
||
: 'Обычные чаты доступны для модерации'}
|
||
</p>
|
||
</div>
|
||
<div className="relative">
|
||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[#667085]" />
|
||
<Input
|
||
value={messageSearch}
|
||
onChange={(event) => setMessageSearch(event.target.value)}
|
||
placeholder="Поиск в выбранном чате..."
|
||
className="pl-10"
|
||
disabled={!selectedRoomId}
|
||
/>
|
||
</div>
|
||
<div className="min-h-0 flex-1 overflow-y-auto rounded-2xl border border-[#eceef4] bg-white">
|
||
{!selectedRoomId ? (
|
||
<p className="py-16 text-center text-sm text-[#667085]">Выберите чат слева</p>
|
||
) : loadingMessages ? (
|
||
<div className="flex items-center justify-center gap-2 py-16 text-[#667085]">
|
||
<Loader2 className="h-5 w-5 animate-spin" />
|
||
Загрузка сообщений...
|
||
</div>
|
||
) : roomMessages.length ? (
|
||
<div className="p-3">
|
||
<AdminInspectorMessageFeed
|
||
messages={roomMessages}
|
||
roomId={selectedRoomId}
|
||
mediaUrls={mediaUrls}
|
||
deletingMessageId={deletingMessageId}
|
||
onDelete={(messageId) => void handleDeleteMessage(messageId)}
|
||
onOpenImage={openImageLightbox}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<p className="py-16 text-center text-sm text-[#667085]">Сообщения не найдены</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</>
|
||
)}
|
||
</TabsContent>
|
||
</Tabs>
|
||
|
||
<ChatImageLightbox
|
||
open={lightboxIndex !== null}
|
||
images={chatImageMessages}
|
||
index={lightboxIndex ?? 0}
|
||
mediaUrls={mediaUrls}
|
||
onClose={() => setLightboxIndex(null)}
|
||
onIndexChange={setLightboxIndex}
|
||
/>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|