fix and update
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { CalendarRange, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export function toDatetimeLocalValue(date: Date) {
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
export function defaultInsightsPeriodLocal(days = 7) {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - days);
|
||||
from.setSeconds(0, 0);
|
||||
to.setSeconds(0, 0);
|
||||
return {
|
||||
from: toDatetimeLocalValue(from),
|
||||
to: toDatetimeLocalValue(to)
|
||||
};
|
||||
}
|
||||
|
||||
export function localDatetimeToIso(value: string) {
|
||||
if (!value.trim()) return undefined;
|
||||
const parsed = new Date(value);
|
||||
if (Number.isNaN(parsed.getTime())) return undefined;
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
function pluralDays(value: number) {
|
||||
const mod10 = value % 10;
|
||||
const mod100 = value % 100;
|
||||
if (mod10 === 1 && mod100 !== 11) return 'день';
|
||||
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return 'дня';
|
||||
return 'дней';
|
||||
}
|
||||
|
||||
export function AdminInsightsDateFilter({
|
||||
from,
|
||||
to,
|
||||
retentionDays,
|
||||
onFromChange,
|
||||
onToChange,
|
||||
onReset
|
||||
}: {
|
||||
from: string;
|
||||
to: string;
|
||||
retentionDays: number;
|
||||
onFromChange: (value: string) => void;
|
||||
onToChange: (value: string) => void;
|
||||
onReset: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-[#eceef4] bg-[#fafbff] p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium text-[#667085]">
|
||||
<CalendarRange className="h-4 w-4" />
|
||||
Период просмотра
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto]">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-from">
|
||||
С даты и времени
|
||||
</label>
|
||||
<Input
|
||||
id="insights-from"
|
||||
type="datetime-local"
|
||||
value={from}
|
||||
onChange={(event) => onFromChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-[#667085]" htmlFor="insights-to">
|
||||
По дату и время
|
||||
</label>
|
||||
<Input
|
||||
id="insights-to"
|
||||
type="datetime-local"
|
||||
value={to}
|
||||
onChange={(event) => onToChange(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button type="button" variant="secondary" className="w-full gap-2 sm:w-auto" onClick={onReset}>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Сбросить
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-[#667085]">
|
||||
Журналы хранятся не более {retentionDays} {pluralDays(retentionDays)}. Старые записи удаляются автоматически.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
273
apps/frontend/components/admin/admin-inspector-message-body.tsx
Normal file
273
apps/frontend/components/admin/admin-inspector-message-body.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { Loader2, Trash2 } from 'lucide-react';
|
||||
import { ChatMediaAlbumGrid } from '@/components/chat/chat-media-album-grid';
|
||||
import { MediaImageSkeleton } from '@/components/ui/media-image-skeleton';
|
||||
import { AdminChatMessage, ChatMessage } from '@/lib/api';
|
||||
import { groupChatMessagesForDisplay, parseChatMediaMetadata } from '@/lib/chat-media-album';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AdminChatMediaEntry, adminMessageToChatMessage } from '@/components/admin/use-admin-chat-media';
|
||||
|
||||
function AdminImageBubble({
|
||||
message,
|
||||
media,
|
||||
onOpen
|
||||
}: {
|
||||
message: ChatMessage;
|
||||
media?: AdminChatMediaEntry;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
const fileName = meta.fileName || message.content || 'Фото';
|
||||
|
||||
if (media?.status === 'error') {
|
||||
return <p className="text-sm text-[#667085]">{media.errorMessage ?? 'Фото недоступно'}</p>;
|
||||
}
|
||||
|
||||
if (!media?.blobUrl || media.status === 'loading') {
|
||||
return <MediaImageSkeleton className="max-h-56 w-full max-w-[240px]" rounded="xl" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className="block overflow-hidden rounded-xl" onClick={onOpen}>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={media.blobUrl} alt={fileName} className="max-h-56 max-w-full rounded-xl object-cover" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminInspectorMessageBody({
|
||||
messages,
|
||||
roomId,
|
||||
mediaUrls,
|
||||
onOpenImage
|
||||
}: {
|
||||
messages: AdminChatMessage[];
|
||||
roomId: string;
|
||||
mediaUrls: Record<string, AdminChatMediaEntry>;
|
||||
onOpenImage: (message: ChatMessage) => void;
|
||||
}) {
|
||||
const chatMessages = messages.map((message) => adminMessageToChatMessage(message, roomId));
|
||||
const displayItems = groupChatMessagesForDisplay(chatMessages);
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{displayItems.map((item) => {
|
||||
if (item.kind === 'album') {
|
||||
const captionMessage = item.messages.find((message) => message.content?.trim());
|
||||
return (
|
||||
<div key={item.albumId} className="space-y-2">
|
||||
<ChatMediaAlbumGrid
|
||||
messages={item.messages}
|
||||
mediaUrls={mediaUrls}
|
||||
onImageClick={(message) => onOpenImage(message)}
|
||||
/>
|
||||
{captionMessage?.content ? (
|
||||
<p className="whitespace-pre-wrap break-words text-sm text-[#344054]">{captionMessage.content}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const message = item.message;
|
||||
const adminMessage = messages.find((entry) => entry.id === message.id);
|
||||
if (!adminMessage) return null;
|
||||
|
||||
if (message.isEncrypted) {
|
||||
return (
|
||||
<p key={message.id} className="text-sm text-[#667085]">
|
||||
[E2E недоступно для модерации]
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.storageKey && message.type === 'IMAGE') {
|
||||
return (
|
||||
<AdminImageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
media={message.storageKey ? mediaUrls[message.storageKey] : undefined}
|
||||
onOpen={() => onOpenImage(message)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.type === 'FILE' && message.storageKey) {
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
return (
|
||||
<p key={message.id} className="text-sm text-[#667085]">
|
||||
📎 {meta.fileName || message.content || 'Файл'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.type === 'VOICE' || message.type === 'AUDIO') {
|
||||
return (
|
||||
<p key={message.id} className="text-sm text-[#667085]">
|
||||
🎤 Голосовое сообщение
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.type === 'POLL') {
|
||||
return (
|
||||
<p key={message.id} className="text-sm text-[#667085]">
|
||||
📊 Опрос
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const text = adminMessage.content?.trim() || adminMessage.preview;
|
||||
if (!text || text === '[Пустое сообщение]') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'whitespace-pre-wrap break-words text-sm text-[#344054]',
|
||||
message.type === 'SYSTEM' && 'text-[#667085] italic'
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminInspectorMessageFeed({
|
||||
messages,
|
||||
roomId,
|
||||
mediaUrls,
|
||||
deletingMessageId,
|
||||
onDelete,
|
||||
onOpenImage
|
||||
}: {
|
||||
messages: AdminChatMessage[];
|
||||
roomId: string;
|
||||
mediaUrls: Record<string, AdminChatMediaEntry>;
|
||||
deletingMessageId: string | null;
|
||||
onDelete: (messageId: string) => void;
|
||||
onOpenImage: (message: ChatMessage) => void;
|
||||
}) {
|
||||
const chatMessages = messages.map((message) => adminMessageToChatMessage(message, roomId));
|
||||
const displayItems = groupChatMessagesForDisplay(chatMessages);
|
||||
const adminById = new Map(messages.map((message) => [message.id, message]));
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{displayItems.map((item) => {
|
||||
if (item.kind === 'album') {
|
||||
const anchor = adminById.get(item.messages[0]!.id);
|
||||
if (!anchor) return null;
|
||||
return (
|
||||
<AdminInspectorMessageRow
|
||||
key={item.albumId}
|
||||
message={anchor}
|
||||
roomId={roomId}
|
||||
mediaUrls={mediaUrls}
|
||||
deleting={item.messages.some((message) => deletingMessageId === message.id)}
|
||||
onDelete={() => onDelete(anchor.id)}
|
||||
onOpenImage={onOpenImage}
|
||||
bodyOverride={
|
||||
<AdminInspectorMessageBody
|
||||
messages={item.messages
|
||||
.map((message) => adminById.get(message.id))
|
||||
.filter((entry): entry is AdminChatMessage => Boolean(entry))}
|
||||
roomId={roomId}
|
||||
mediaUrls={mediaUrls}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const adminMessage = adminById.get(item.message.id);
|
||||
if (!adminMessage) return null;
|
||||
|
||||
return (
|
||||
<AdminInspectorMessageRow
|
||||
key={item.message.id}
|
||||
message={adminMessage}
|
||||
roomId={roomId}
|
||||
mediaUrls={mediaUrls}
|
||||
deleting={deletingMessageId === adminMessage.id}
|
||||
onDelete={() => onDelete(adminMessage.id)}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminInspectorMessageRow({
|
||||
message,
|
||||
roomId,
|
||||
mediaUrls,
|
||||
deleting,
|
||||
onDelete,
|
||||
onOpenImage,
|
||||
bodyOverride
|
||||
}: {
|
||||
message: AdminChatMessage;
|
||||
roomId: string;
|
||||
mediaUrls: Record<string, AdminChatMediaEntry>;
|
||||
deleting: boolean;
|
||||
onDelete: () => void;
|
||||
onOpenImage: (message: ChatMessage) => void;
|
||||
bodyOverride?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl border px-3 py-2',
|
||||
message.isDeleted ? 'border-red-100 bg-red-50' : 'border-[#eceef4] bg-[#fafbff]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{message.senderName}</span>
|
||||
<span className="text-xs text-[#667085]">
|
||||
{new Intl.DateTimeFormat('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(new Date(message.createdAt))}
|
||||
</span>
|
||||
{message.isDeleted ? <span className="text-xs font-medium text-red-600">Удалено</span> : null}
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
{bodyOverride ?? (
|
||||
<AdminInspectorMessageBody
|
||||
messages={[message]}
|
||||
roomId={roomId}
|
||||
mediaUrls={mediaUrls}
|
||||
onOpenImage={onOpenImage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!message.isDeleted && !message.isEncrypted ? (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-lg p-2 text-red-600 transition hover:bg-red-50 disabled:opacity-60"
|
||||
disabled={deleting}
|
||||
aria-label="Удалить сообщение"
|
||||
onClick={onDelete}
|
||||
>
|
||||
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Trash2 className="h-4 w-4" />}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
113
apps/frontend/components/admin/use-admin-chat-media.ts
Normal file
113
apps/frontend/components/admin/use-admin-chat-media.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AdminChatMessage, ChatMessage, fetchChatMediaUrl } from '@/lib/api';
|
||||
import { createBlobObjectUrl, fetchAuthenticatedMediaBlob, revokeBlobObjectUrl } from '@/lib/authenticated-media';
|
||||
import { parseChatMediaMetadata } from '@/lib/chat-media-album';
|
||||
|
||||
export type AdminChatMediaEntry = {
|
||||
blobUrl?: string;
|
||||
fileName?: string;
|
||||
mimeType?: string;
|
||||
status?: 'loading' | 'ready' | 'error';
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export function adminMessageToChatMessage(message: AdminChatMessage, roomId: string): ChatMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
roomId: message.roomId ?? roomId,
|
||||
senderId: message.senderId,
|
||||
senderName: message.senderName,
|
||||
senderHasAvatar: false,
|
||||
type: message.type,
|
||||
content: message.content ?? undefined,
|
||||
storageKey: message.storageKey,
|
||||
mimeType: message.mimeType,
|
||||
metadataJson: message.metadataJson,
|
||||
createdAt: message.createdAt,
|
||||
editedAt: message.editedAt,
|
||||
isDeleted: message.isDeleted,
|
||||
isEncrypted: message.isEncrypted
|
||||
};
|
||||
}
|
||||
|
||||
export function useAdminChatMedia(token: string | null, roomId: string | null, messages: AdminChatMessage[]) {
|
||||
const [mediaUrls, setMediaUrls] = useState<Record<string, AdminChatMediaEntry>>({});
|
||||
const mediaUrlsRef = useRef<Record<string, AdminChatMediaEntry>>({});
|
||||
const inFlightRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
mediaUrlsRef.current = mediaUrls;
|
||||
}, [mediaUrls]);
|
||||
|
||||
const loadMedia = useCallback(
|
||||
async (message: AdminChatMessage, force = false) => {
|
||||
if (!token || !roomId || !message.storageKey || message.isEncrypted || message.isDeleted) return;
|
||||
const storageKey = message.storageKey;
|
||||
const existing = mediaUrlsRef.current[storageKey];
|
||||
if (!force && existing?.status === 'ready' && existing.blobUrl) {
|
||||
return;
|
||||
}
|
||||
if (inFlightRef.current.has(storageKey) && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
inFlightRef.current.add(storageKey);
|
||||
setMediaUrls((current) => ({
|
||||
...current,
|
||||
[storageKey]: { ...current[storageKey], status: 'loading', errorMessage: undefined }
|
||||
}));
|
||||
|
||||
const meta = parseChatMediaMetadata(message.metadataJson);
|
||||
const fileName = meta.fileName || message.content || undefined;
|
||||
|
||||
try {
|
||||
const access = await fetchChatMediaUrl(roomId, storageKey, token, fileName);
|
||||
const blob = await fetchAuthenticatedMediaBlob(access.accessUrl, token, message.mimeType);
|
||||
const blobUrl = createBlobObjectUrl(blob);
|
||||
setMediaUrls((current) => {
|
||||
revokeBlobObjectUrl(current[storageKey]?.blobUrl);
|
||||
return {
|
||||
...current,
|
||||
[storageKey]: {
|
||||
blobUrl,
|
||||
fileName,
|
||||
mimeType: message.mimeType,
|
||||
status: 'ready'
|
||||
}
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
setMediaUrls((current) => ({
|
||||
...current,
|
||||
[storageKey]: {
|
||||
...current[storageKey],
|
||||
status: 'error',
|
||||
errorMessage: error instanceof Error ? error.message : 'Не удалось загрузить файл'
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
inFlightRef.current.delete(storageKey);
|
||||
}
|
||||
},
|
||||
[roomId, token]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !roomId) return;
|
||||
messages.forEach((message) => {
|
||||
if (message.storageKey && message.type === 'IMAGE') {
|
||||
void loadMedia(message);
|
||||
}
|
||||
});
|
||||
}, [loadMedia, messages, roomId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(mediaUrlsRef.current).forEach((entry) => revokeBlobObjectUrl(entry.blobUrl));
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { mediaUrls, loadMedia };
|
||||
}
|
||||
@@ -2,18 +2,27 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Activity,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
LogIn,
|
||||
MessagesSquare,
|
||||
ScrollText,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
Trash2,
|
||||
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 { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
@@ -22,11 +31,16 @@ import {
|
||||
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',
|
||||
@@ -67,6 +81,16 @@ export function UserInspectorDialog({
|
||||
}) {
|
||||
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('');
|
||||
@@ -79,6 +103,18 @@ export function UserInspectorDialog({
|
||||
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);
|
||||
@@ -98,8 +134,17 @@ export function UserInspectorDialog({
|
||||
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;
|
||||
@@ -108,19 +153,27 @@ export function UserInspectorDialog({
|
||||
const params = new URLSearchParams();
|
||||
if (debouncedSignInSearch.trim()) params.set('search', debouncedSignInSearch.trim());
|
||||
params.set('limit', '50');
|
||||
const response = await apiFetch<{ total: number; events: SignInEvent[] }>(
|
||||
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);
|
||||
}
|
||||
}, [debouncedSignInSearch, showToast, token, user]);
|
||||
}, [appendDateParams, debouncedSignInSearch, showToast, token, user]);
|
||||
|
||||
const loadActivity = useCallback(async () => {
|
||||
if (!token || !user) return;
|
||||
@@ -129,19 +182,25 @@ export function UserInspectorDialog({
|
||||
const params = new URLSearchParams();
|
||||
if (debouncedActivitySearch.trim()) params.set('search', debouncedActivitySearch.trim());
|
||||
params.set('limit', '50');
|
||||
const response = await apiFetch<{ total: number; items: AdminUserActivityItem[] }>(
|
||||
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);
|
||||
}
|
||||
}, [debouncedActivitySearch, showToast, token, user]);
|
||||
}, [appendDateParams, debouncedActivitySearch, showToast, token, user]);
|
||||
|
||||
const loadRooms = useCallback(async () => {
|
||||
if (!token || !user || !canModerateChats) return;
|
||||
@@ -150,19 +209,21 @@ export function UserInspectorDialog({
|
||||
const params = new URLSearchParams();
|
||||
if (debouncedChatSearch.trim()) params.set('search', debouncedChatSearch.trim());
|
||||
params.set('limit', '50');
|
||||
const response = await apiFetch<{ total: number; rooms: AdminChatRoomSummary[] }>(
|
||||
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);
|
||||
}
|
||||
}, [canModerateChats, debouncedChatSearch, showToast, token, user]);
|
||||
}, [appendDateParams, canModerateChats, debouncedChatSearch, showToast, token, user]);
|
||||
|
||||
const loadRoomMessages = useCallback(async () => {
|
||||
if (!token || !user || !selectedRoomId) return;
|
||||
@@ -171,11 +232,14 @@ export function UserInspectorDialog({
|
||||
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 }
|
||||
@@ -186,7 +250,7 @@ export function UserInspectorDialog({
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}, [debouncedMessageSearch, selectedRoomId, showToast, token, user]);
|
||||
}, [appendDateParams, debouncedMessageSearch, selectedRoomId, showToast, token, user]);
|
||||
|
||||
const loadGlobalSearch = useCallback(async () => {
|
||||
if (!token || !user || !canModerateChats || !debouncedGlobalMessageSearch.trim()) {
|
||||
@@ -199,19 +263,21 @@ export function UserInspectorDialog({
|
||||
const params = new URLSearchParams();
|
||||
params.set('search', debouncedGlobalMessageSearch.trim());
|
||||
params.set('limit', '50');
|
||||
const response = await apiFetch<{ total: number; messages: AdminChatMessage[] }>(
|
||||
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);
|
||||
}
|
||||
}, [canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
|
||||
}, [appendDateParams, canModerateChats, debouncedGlobalMessageSearch, showToast, token, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !user) return;
|
||||
@@ -249,9 +315,19 @@ export function UserInspectorDialog({
|
||||
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('Удалить это сообщение? Действие необратимо для пользователей чата.');
|
||||
@@ -284,19 +360,30 @@ export function UserInspectorDialog({
|
||||
</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">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="sign-in" className="gap-2">
|
||||
<ShieldAlert className="h-4 w-4" />
|
||||
<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="gap-2">
|
||||
<Activity className="h-4 w-4" />
|
||||
<TabsTrigger value="activity" className={inspectorTabTriggerClass}>
|
||||
<ScrollText className="h-4 w-4 shrink-0" />
|
||||
Активность
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="chats" className="gap-2" disabled={!canModerateChats}>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
<TabsTrigger value="chats" className={inspectorTabTriggerClass} disabled={!canModerateChats}>
|
||||
<MessagesSquare className="h-4 w-4 shrink-0" />
|
||||
Чаты
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -521,48 +608,15 @@ export function UserInspectorDialog({
|
||||
Загрузка сообщений...
|
||||
</div>
|
||||
) : roomMessages.length ? (
|
||||
<div className="space-y-2 p-3">
|
||||
{roomMessages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
'rounded-2xl border px-3 py-2',
|
||||
message.isDeleted ? 'border-red-100 bg-red-50' : 'border-[#eceef4] bg-[#fafbff]'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{message.senderName}</span>
|
||||
<span className="text-xs text-[#667085]">{formatDateTime(message.createdAt)}</span>
|
||||
{message.isDeleted ? (
|
||||
<span className="text-xs font-medium text-red-600">Удалено</span>
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-1 whitespace-pre-wrap break-words text-sm text-[#344054]">
|
||||
{message.isEncrypted ? '[E2E недоступно для модерации]' : message.preview}
|
||||
</p>
|
||||
</div>
|
||||
{!message.isDeleted && !message.isEncrypted ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
disabled={deletingMessageId === message.id}
|
||||
aria-label="Удалить сообщение"
|
||||
onClick={() => void handleDeleteMessage(message.id)}
|
||||
>
|
||||
{deletingMessageId === message.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<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>
|
||||
@@ -574,6 +628,15 @@ export function UserInspectorDialog({
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ChatImageLightbox
|
||||
open={lightboxIndex !== null}
|
||||
images={chatImageMessages}
|
||||
index={lightboxIndex ?? 0}
|
||||
mediaUrls={mediaUrls}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onIndexChange={setLightboxIndex}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user