From 111ddb2bfd4f6b87c1e24c887fba8520259640ba Mon Sep 17 00:00:00 2001 From: lizerk Date: Fri, 3 Apr 2026 19:53:31 +0300 Subject: [PATCH] Refactor modals and add chat message search --- backend/communications/api/viewsets.py | 107 ++++++ frontend/next-env.d.ts | 2 +- frontend/src/app/equipment/page.tsx | 60 +-- frontend/src/app/messages/[chatId]/page.tsx | 228 ++++++++++- frontend/src/app/messages/page.tsx | 54 +-- frontend/src/app/page.tsx | 359 ++++++++---------- frontend/src/app/procurement/page.tsx | 26 +- frontend/src/app/requests/page.tsx | 31 +- frontend/src/components/AvatarCropper.tsx | 7 +- frontend/src/components/CalendarModal.tsx | 65 ++-- .../components/CalendarParticipantsModal.tsx | 13 +- .../components/CalendarSubscriptionsModal.tsx | 15 +- frontend/src/components/EventModal.tsx | 75 ++-- frontend/src/components/NewChatModal.tsx | 298 +++++++-------- .../src/components/ViewDayEventsModal.tsx | 56 ++- .../src/components/ViewEventDetailsModal.tsx | 85 ++--- .../DocumentAcknowledgementsReport.tsx | 25 +- .../components/documents/DocumentPreview.tsx | 7 +- .../related/RelatedDocumentsList.tsx | 46 +-- .../messages/AddChatMemberModal.tsx | 140 ++++--- .../components/messages/ChatDialogHeader.tsx | 145 ++++--- .../messages/ChatMediaPreviewModal.tsx | 2 +- .../components/messages/ChatMessageItem.tsx | 39 +- .../components/messages/ChatSearchPanel.tsx | 189 +++++++++ .../src/components/messages/EditChatModal.tsx | 57 ++- .../procurement/ProcurementSuppliersPanel.tsx | 21 +- frontend/src/components/ui/Modal.tsx | 95 ++--- .../components/users/EditUserProfileModal.tsx | 71 ++-- .../components/users/EmployeeActionModal.tsx | 167 ++++---- frontend/src/hooks/useChatMessages.ts | 41 ++ frontend/src/lib/api/messages.ts | 8 + frontend/src/types/api.ts | 18 + 32 files changed, 1510 insertions(+), 1042 deletions(-) create mode 100644 frontend/src/components/messages/ChatSearchPanel.tsx diff --git a/backend/communications/api/viewsets.py b/backend/communications/api/viewsets.py index 243eb75f..a73413ca 100644 --- a/backend/communications/api/viewsets.py +++ b/backend/communications/api/viewsets.py @@ -72,6 +72,40 @@ def _parse_bool_query_param(value, default=True): return default +def _build_message_search_snippet(message, query, max_length=140): + query_normalized = (query or '').strip().lower() + content = (message.content or '').strip() + + if content: + if not query_normalized: + return f"{content[:max_length].rstrip()}…" if len(content) > max_length else content + + content_normalized = content.lower() + match_index = content_normalized.find(query_normalized) + + if match_index < 0: + return f"{content[:max_length].rstrip()}…" if len(content) > max_length else content + + context_before = max_length // 3 + context_after = max_length - context_before - len(query_normalized) + start = max(0, match_index - context_before) + end = min(len(content), match_index + len(query_normalized) + context_after) + snippet = content[start:end].strip() + + if start > 0: + snippet = f"…{snippet}" + if end < len(content): + snippet = f"{snippet}…" + return snippet + + attachment_names = [a.file_name for a in getattr(message, 'attachments').all() if a.file_name] + if attachment_names: + joined_names = ', '.join(attachment_names) + return f"Файлы: {joined_names}" + + return 'Сообщение без текста' + + class ChatViewSet(viewsets.ModelViewSet): """ ViewSet для управления чатами @@ -855,6 +889,79 @@ class ChatViewSet(viewsets.ModelViewSet): 'has_more_after': len(after) > after_limit }) + @action(detail=True, methods=['get'], url_path='search-messages') + def search_messages(self, request, pk=None): + """Поиск сообщений внутри одного чата с пагинацией и сниппетами.""" + chat = self.get_object() + + if not user_can_access_chat(chat, request.user): + return Response( + {'error': 'Access denied'}, + status=status.HTTP_403_FORBIDDEN + ) + + query = (request.query_params.get('q') or '').strip() + + try: + limit = min(max(int(request.query_params.get('limit', 20)), 1), 100) + except (ValueError, TypeError): + limit = 20 + + try: + offset = max(int(request.query_params.get('offset', 0)), 0) + except (ValueError, TypeError): + offset = 0 + + if len(query) < 2: + return Response({ + 'query': query, + 'count': 0, + 'offset': offset, + 'next_offset': None, + 'results': [], + }) + + queryset = chat.messages.filter( + is_deleted=False, + ).select_related( + 'author' + ).prefetch_related( + 'attachments' + ).filter( + Q(content__icontains=query) | Q(attachments__file_name__icontains=query) + ).distinct().order_by('-created_at', '-id') + + total_count = queryset.count() + matches = list(queryset[offset:offset + limit]) + next_offset = offset + limit if (offset + limit) < total_count else None + + results = [] + for message in matches: + author = message.author + author_name = ( + f"{getattr(author, 'last_name', '')} {getattr(author, 'first_name', '')}".strip() + or getattr(author, 'username', '') + or 'Сотрудник' + ) + attachments = list(message.attachments.all()) + results.append({ + 'message_id': message.id, + 'content': message.content or '', + 'snippet': _build_message_search_snippet(message, query), + 'author_name': author_name, + 'created_at': message.created_at, + 'attachments_count': len(attachments), + 'has_attachments': bool(attachments), + }) + + return Response({ + 'query': query, + 'count': total_count, + 'offset': offset, + 'next_offset': next_offset, + 'results': results, + }) + @action(detail=True, methods=['post'], url_path='mark-read') def mark_read(self, request, pk=None): """ diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c7..c4b7818f 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/frontend/src/app/equipment/page.tsx b/frontend/src/app/equipment/page.tsx index 3753c32a..aab1775a 100644 --- a/frontend/src/app/equipment/page.tsx +++ b/frontend/src/app/equipment/page.tsx @@ -7,6 +7,7 @@ import { Archive, ArrowRightLeft, ArrowUpDown, ChevronDown, Filter, MessageSquar import { SearchableSelectSingle } from "@/components/shared/SearchableSelect"; import { formatDate, formatMoney } from "@/lib/shared"; import { useEquipmentPage } from "@/hooks/useEquipmentPage"; +import { Modal } from "@/components/ui"; const listModeMeta = [ { value: "all", label: "Весь реестр" }, @@ -611,14 +612,14 @@ export default function EquipmentPage() { )} {/* ===== Modal create/edit ===== */} - {isModalOpen && ( -
{ if (e.target === e.currentTarget) closeModal(); }}> -
-
-

{modalMode === "create" ? "Добавить оборудование" : "Редактировать оборудование"}

- + + +
- + }> {actionError &&

{actionError}

}
@@ -740,30 +741,20 @@ export default function EquipmentPage() { />
+ - {/* Buttons */} -
- - + + + {operationModal === "transfer" && } + {operationModal === "writeoff" && } + {operationModal === "maintenance" && }
-
- - )} - - {operationModal && selectedEquipment && ( -
{ if (e.target === e.currentTarget) closeOperationModal(); }}> -
-
-

- {operationModal === "transfer" && "Перевод оборудования"} - {operationModal === "writeoff" && "Списание оборудования"} - {operationModal === "maintenance" && "Добавить обслуживание"} -

- -
- + }> {operationModal === "transfer" && (
)} - -
- - {operationModal === "transfer" && } - {operationModal === "writeoff" && } - {operationModal === "maintenance" && } -
-
-
- )} + {/* ===== Attachment preview removed (no attachment fields in model) ===== */} diff --git a/frontend/src/app/messages/[chatId]/page.tsx b/frontend/src/app/messages/[chatId]/page.tsx index 36791bcc..74dd5cb4 100644 --- a/frontend/src/app/messages/[chatId]/page.tsx +++ b/frontend/src/app/messages/[chatId]/page.tsx @@ -7,12 +7,13 @@ import { AppShell } from "../../../components/AppShell"; import ChatDialogHeader from "@/components/messages/ChatDialogHeader"; import ChatMediaPreviewModal from "@/components/messages/ChatMediaPreviewModal"; import ChatMessageItem, { MediaPreview } from "@/components/messages/ChatMessageItem"; +import ChatSearchPanel from "@/components/messages/ChatSearchPanel"; import MessageActionsMenu from "@/components/messages/MessageActionsMenu"; import MessageReadersModal from "@/components/messages/MessageReadersModal"; import MessageComposer from "@/components/messages/MessageComposer"; import ReactionPickerModal from "@/components/messages/ReactionPickerModal"; import { apiClient } from "@/lib/api"; -import type { Message } from "@/types/api"; +import type { ChatMessageSearchResponse, ChatMessageSearchResult, Message } from "@/types/api"; import { useUser } from "@/contexts/UserContext"; import { useChatFallbackSync } from "@/hooks/useChatFallbackSync"; import { useSilentChatReloadGuard } from "@/hooks/useSilentChatReloadGuard"; @@ -69,6 +70,8 @@ export default function MessageDialogPage() { const messagesViewportRef = useRef(null); const fileInputRef = useRef(null); const messageInputRef = useRef(null); + const searchInputRef = useRef(null); + const highlightTimerRef = useRef(null); /* ────────────────────────────────────────────────────── * HOOK: Chat messages (state, pagination, pending) @@ -141,6 +144,16 @@ export default function MessageDialogPage() { const [isPinned, setIsPinned] = useState(false); const [notificationsEnabled, setNotificationsEnabled] = useState(true); const [jumpingToDate, setJumpingToDate] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searchTotalCount, setSearchTotalCount] = useState(0); + const [selectedSearchResultIndex, setSelectedSearchResultIndex] = useState(0); + const [searchLoading, setSearchLoading] = useState(false); + const [searchLoadingMore, setSearchLoadingMore] = useState(false); + const [searchError, setSearchError] = useState(null); + const [nextSearchOffset, setNextSearchOffset] = useState(null); + const [highlightedMessageId, setHighlightedMessageId] = useState(null); // Sync settings from chat useEffect(() => { @@ -150,6 +163,17 @@ export default function MessageDialogPage() { } }, [cm.chat]); + useEffect(() => { + if (!isSearchOpen) { + return; + } + + requestAnimationFrame(() => { + searchInputRef.current?.focus(); + searchInputRef.current?.select(); + }); + }, [isSearchOpen]); + /* ── membership & permissions ── */ const myMembership = useMemo(() => { if (!cm.chat?.memberships || !user?.id) return null; @@ -313,6 +337,7 @@ export default function MessageDialogPage() { /* ── cleanup timers ── */ useEffect(() => () => { if (typingTimeoutRef.current) clearTimeout(typingTimeoutRef.current); + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); }, []); useLayoutEffect(() => { @@ -602,6 +627,171 @@ export default function MessageDialogPage() { } catch (e) { console.error("Ошибка переключения уведомлений:", e); } }; + const openSearchPanel = useCallback(() => { + setIsSearchOpen(true); + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + setReactionPickerForMessageId(null); + setShowComposerEmojiPicker(false); + scroll.setIsDateNavigatorOpen(false); + }, [scroll]); + + const closeSearchPanel = useCallback(() => { + setIsSearchOpen(false); + setSearchQuery(""); + setSearchResults([]); + setSearchTotalCount(0); + setSelectedSearchResultIndex(0); + setSearchLoading(false); + setSearchLoadingMore(false); + setSearchError(null); + setNextSearchOffset(null); + }, []); + + const highlightFoundMessage = useCallback((messageId: number) => { + if (highlightTimerRef.current) clearTimeout(highlightTimerRef.current); + setHighlightedMessageId(messageId); + highlightTimerRef.current = setTimeout(() => { + setHighlightedMessageId(null); + highlightTimerRef.current = null; + }, 2200); + }, []); + + const runSearch = useCallback(async (query: string, options?: { append?: boolean; offset?: number }) => { + const normalizedQuery = query.trim(); + if (!chatId || normalizedQuery.length < 2) { + setSearchResults([]); + setSearchTotalCount(0); + setSelectedSearchResultIndex(0); + setSearchError(null); + setNextSearchOffset(null); + return; + } + + const append = Boolean(options?.append); + if (append) { + setSearchLoadingMore(true); + } else { + setSearchLoading(true); + } + setSearchError(null); + + try { + const response: ChatMessageSearchResponse = await apiClient.searchChatMessages(chatId, { + q: normalizedQuery, + limit: 20, + offset: options?.offset ?? 0, + }); + + setSearchTotalCount(response.count); + setNextSearchOffset(response.next_offset); + setSearchResults((prev) => { + if (!append) { + return response.results; + } + + const seen = new Set(prev.map((item) => item.message_id)); + const merged = [...prev]; + response.results.forEach((item: ChatMessageSearchResult) => { + if (!seen.has(item.message_id)) { + seen.add(item.message_id); + merged.push(item); + } + }); + return merged; + }); + + if (!append) { + setSelectedSearchResultIndex(0); + } + } catch (e) { + console.error("Ошибка поиска по сообщениям:", e); + setSearchError("Не удалось выполнить поиск по сообщениям."); + } finally { + if (append) { + setSearchLoadingMore(false); + } else { + setSearchLoading(false); + } + } + }, [chatId]); + + useEffect(() => { + if (!isSearchOpen) { + return; + } + + const normalizedQuery = searchQuery.trim(); + if (normalizedQuery.length < 2) { + setSearchResults([]); + setSearchTotalCount(0); + setSelectedSearchResultIndex(0); + setSearchError(null); + setNextSearchOffset(null); + setSearchLoading(false); + setSearchLoadingMore(false); + return; + } + + const timer = setTimeout(() => { + void runSearch(normalizedQuery, { append: false, offset: 0 }); + }, 250); + + return () => clearTimeout(timer); + }, [isSearchOpen, runSearch, searchQuery]); + + const handleSelectSearchResult = useCallback(async (index: number) => { + const result = searchResults[index]; + if (!result) { + return; + } + + setSelectedSearchResultIndex(index); + const jumped = await cm.jumpToMessage(result.message_id); + if (jumped) { + highlightFoundMessage(result.message_id); + setExpandedReplyActionForId(null); + setActionsMenuAnchor(null); + setReactionPickerForMessageId(null); + setIsReadersModalOpen(false); + } + }, [cm, highlightFoundMessage, searchResults]); + + const handleSubmitSelectedSearchResult = useCallback(() => { + if (searchResults.length === 0) { + return; + } + + void handleSelectSearchResult(selectedSearchResultIndex); + }, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]); + + const handlePreviousSearchResult = useCallback(() => { + if (searchResults.length === 0) { + return; + } + + const nextIndex = selectedSearchResultIndex <= 0 ? searchResults.length - 1 : selectedSearchResultIndex - 1; + void handleSelectSearchResult(nextIndex); + }, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]); + + const handleNextSearchResult = useCallback(() => { + if (searchResults.length === 0) { + return; + } + + const nextIndex = selectedSearchResultIndex >= searchResults.length - 1 ? 0 : selectedSearchResultIndex + 1; + void handleSelectSearchResult(nextIndex); + }, [handleSelectSearchResult, searchResults.length, selectedSearchResultIndex]); + + const handleLoadMoreSearchResults = useCallback(() => { + const normalizedQuery = searchQuery.trim(); + if (!normalizedQuery || nextSearchOffset === null || searchLoadingMore) { + return; + } + + void runSearch(normalizedQuery, { append: true, offset: nextSearchOffset }); + }, [nextSearchOffset, runSearch, searchLoadingMore, searchQuery]); + /* ── global click / escape dismissals ── */ useEffect(() => { @@ -628,11 +818,12 @@ export default function MessageDialogPage() { setReactionPickerForMessageId(null); setShowComposerEmojiPicker(false); scroll.setIsDateNavigatorOpen(false); + if (isSearchOpen) closeSearchPanel(); } }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [scroll]); + }, [closeSearchPanel, isSearchOpen, scroll]); // Mobile keyboard viewport adjustment useEffect(() => { @@ -655,7 +846,14 @@ export default function MessageDialogPage() { if (!c?.containerRef?.current) return; const v = c.containerRef.current; const t = e.target as HTMLElement | null; - if (!t || v.contains(t) || t.closest("textarea,input,button,a,video,audio,[contenteditable='true']")) return; + if ( + !t || + v.contains(t) || + t.closest("[data-overlay-root='true'], [role='dialog'], [aria-modal='true']") || + t.closest("textarea,input,button,a,video,audio,[contenteditable='true']") + ) { + return; + } e.preventDefault(); v.scrollTop += e.deltaY; }; @@ -691,6 +889,29 @@ export default function MessageDialogPage() { notificationsEnabled={notificationsEnabled} onTogglePin={handleTogglePin} onToggleNotifications={handleToggleNotifications} + onOpenSearch={openSearchPanel} + /> + + { + void handleSelectSearchResult(index); + }} + onLoadMore={handleLoadMoreSearchResults} + onSubmitSelection={handleSubmitSelectedSearchResult} />
@@ -756,6 +977,7 @@ export default function MessageDialogPage() { message={message} currentUserId={user?.id} repliedMessage={repliedMessage} + isHighlighted={highlightedMessageId === message.id} isActionsOpen={expandedReplyActionForId === message.id} canManage={!message.is_optimistic && canManageMessage(message)} canReply={!message.is_deleted && !message.is_optimistic} diff --git a/frontend/src/app/messages/page.tsx b/frontend/src/app/messages/page.tsx index 93ab0132..8f693016 100644 --- a/frontend/src/app/messages/page.tsx +++ b/frontend/src/app/messages/page.tsx @@ -11,6 +11,7 @@ import Link from "next/link"; import Image from "next/image"; import { useUser } from "@/contexts/UserContext"; import { useRouter } from "next/navigation"; +import { Modal } from "@/components/ui"; type UnreadFilter = "all" | "unread" | "read"; type PinnedFilter = "all" | "pinned" | "unpinned"; @@ -661,19 +662,25 @@ export default function MessagesPage() { )} {/* Модальное окно создания чата */} - {showCreateModal && ( -
-
-
-

Создать чат

- + setShowCreateModal(false)} title="Создать чат" size="sm" closeOnEsc={!creating} footer={ +
+ +
- + }>
{/* Тип чата */}
@@ -799,29 +806,8 @@ export default function MessagesPage() { )}
- - {/* Кнопки */} -
- - -
-
-
- )} + ); } diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 6c6b8b16..7b345da6 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -2,6 +2,7 @@ import { Heart, ImageIcon, MessageSquare, Paperclip, Pencil, Plus, Send, Trash2, X } from "lucide-react"; import { AppShell } from "../components/AppShell"; +import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; import { resolveMediaUrl } from "@/lib/url"; import { useEffect, useRef, useState } from "react"; @@ -683,152 +684,180 @@ export default function Home() { )}
- {createPostOpen ? ( -
- + +
+ } + > +
+ {createError ? ( +
{createError}
+ ) : null} -
-
-

- {editingPostId ? "Редактировать публикацию" : "Создать публикацию"} -

-