+
);
}
diff --git a/frontend/src/components/ViewEventDetailsModal.tsx b/frontend/src/components/ViewEventDetailsModal.tsx
index ca4227ab..3eb8f5a1 100644
--- a/frontend/src/components/ViewEventDetailsModal.tsx
+++ b/frontend/src/components/ViewEventDetailsModal.tsx
@@ -1,6 +1,7 @@
"use client";
-import { X, Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react";
+import { Edit2, Trash2, Clock, Calendar, FileText, Users } from "lucide-react";
+import { Modal } from "@/components/ui";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { useState, useEffect } from "react";
@@ -98,37 +99,46 @@ export function ViewEventDetailsModal({
const capitalizedEnd = formatDateTime(event.end).charAt(0).toUpperCase() + formatDateTime(event.end).slice(1);
return (
-
-
-
-
-
-
- {event.title}
-
- {event.rule && (
-
- ⟲
-
- )}
-
-
-
-
Календарь #{event.calendar}
-
-
+
-
+
+ Редактировать
+
+
+
-
-
+ }
+ >
+
+ {/* Event meta */}
+
+ {event.rule && (
+
+ ⟲
+
+ )}
+
+
+
Календарь #{event.calendar}
+
+
{/* Time */}
@@ -229,24 +239,7 @@ export function ViewEventDetailsModal({
)}
-
-
-
- Редактировать
-
-
-
-
-
-
-
+
);
}
diff --git a/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx b/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx
index 0e4ee87e..cad9d8f1 100644
--- a/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx
+++ b/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import { X, Search, CheckCircle, AlertCircle, Users, Download } from "lucide-react";
import { apiClient } from "@/lib/api";
import { toast } from "sonner";
-import { useModal, useClickOutside } from "@/hooks/useModal";
+import { Modal } from "@/components/ui";
interface Employee {
id: number;
@@ -46,17 +46,6 @@ export function DocumentAcknowledgementsReport({
const [search, setSearch] = useState("");
const [activeTab, setActiveTab] = useState<"all" | "acknowledged" | "unacknowledged">("all");
- // Используем модальные хуки для улучшений UX
- const contentRef = useModal({
- isOpen: true,
- onClose,
- closeOnEsc: true,
- trapFocus: true,
- lockBodyScroll: true,
- });
-
- const backdropRef = useClickOutside(onClose, true);
-
useEffect(() => {
loadData();
}, [documentId, search]);
@@ -129,14 +118,7 @@ export function DocumentAcknowledgementsReport({
const displayList = getDisplayList();
return (
-
-
+
{/* Header */}
@@ -294,7 +276,6 @@ export function DocumentAcknowledgementsReport({
)}
-
-
+
);
}
diff --git a/frontend/src/components/documents/DocumentPreview.tsx b/frontend/src/components/documents/DocumentPreview.tsx
index 0d80010e..8fda1af5 100644
--- a/frontend/src/components/documents/DocumentPreview.tsx
+++ b/frontend/src/components/documents/DocumentPreview.tsx
@@ -5,6 +5,7 @@ import { Document as PDFDocument, Page, pdfjs } from "react-pdf";
import { ChevronLeft, ChevronRight, Download, X, FileText, Table } from "lucide-react";
import "react-pdf/dist/Page/AnnotationLayer.css";
import "react-pdf/dist/Page/TextLayer.css";
+import { Modal } from "@/components/ui";
// Configure PDF.js worker
pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
@@ -166,8 +167,7 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
const goToNextPage = () => setPageNumber((prev) => Math.min(prev + 1, numPages));
return (
-
-
+
{})} noHeader noPadding size="xl">
{/* Header */}
{fileName}
@@ -419,7 +419,6 @@ export function DocumentPreview({ fileUrl, fileName, onClose }: DocumentPreviewP
)}
-
-
+
);
}
diff --git a/frontend/src/components/documents/related/RelatedDocumentsList.tsx b/frontend/src/components/documents/related/RelatedDocumentsList.tsx
index ac3c1321..a5767932 100644
--- a/frontend/src/components/documents/related/RelatedDocumentsList.tsx
+++ b/frontend/src/components/documents/related/RelatedDocumentsList.tsx
@@ -1,9 +1,9 @@
'use client';
import { useState, useEffect } from 'react';
-import { X } from 'lucide-react';
import { RelatedDocument, Document } from '@/types/api';
import { apiClient } from '@/lib/api';
+import { Modal } from "@/components/ui";
interface RelatedDocumentsListProps {
documentId: number;
@@ -170,35 +170,16 @@ export default function RelatedDocumentsList({ documentId }: RelatedDocumentsLis
)}
{/* Модальное окно добавления */}
- {showAddModal && (
-
{
- setShowAddModal(false);
- setSearchQuery('');
- setSearchResults([]);
- }}
- >
-
e.stopPropagation()}
- >
-
-
Добавить связанный документ
- {
- setShowAddModal(false);
- setSearchQuery('');
- setSearchResults([]);
- }}
- >
-
-
-
-
-
-
- )}
+
);
}
diff --git a/frontend/src/components/messages/AddChatMemberModal.tsx b/frontend/src/components/messages/AddChatMemberModal.tsx
index c21a6175..e715dc35 100644
--- a/frontend/src/components/messages/AddChatMemberModal.tsx
+++ b/frontend/src/components/messages/AddChatMemberModal.tsx
@@ -6,6 +6,7 @@ import { Users } from "lucide-react";
import { getParticipantInitials, type MemberSearchResult } from "@/lib/messages/chatSettingsUtils";
import { getUserFullName } from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
+import { Modal } from "@/components/ui";
type AddChatMemberModalProps = {
open: boolean;
@@ -28,83 +29,70 @@ export default function AddChatMemberModal({
onSearchChange,
onAddMember,
}: AddChatMemberModalProps) {
- if (!open) return null;
-
return (
-
-
-
-
Добавить участника
-
-
-
-
-
-
-
-
-
onSearchChange(event.target.value)}
- placeholder="Введите имя или email..."
- className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
- autoFocus
- />
-
Минимум 2 символа для поиска
-
-
-
- {memberSearchLoading ? (
-
- ) : memberSearchQuery.trim().length < 2 ? (
-
-
-
Начните вводить имя или email для поиска
-
- ) : memberSearchResults.length === 0 ? (
-
-
Пользователи не найдены
-
- ) : (
-
- {memberSearchResults.map((result) => (
-
-
- {result.avatar ? (
-
- ) : (
- {getParticipantInitials(result.name || getUserFullName(result.last_name, result.first_name))}
- )}
-
-
-
{result.name || getUserFullName(result.last_name, result.first_name)}
- {result.email ?
{result.email}
: null}
-
-
onAddMember(result.id)}
- disabled={actionLoading === `add-member-${result.id}`}
- className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
- >
- {actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
-
-
- ))}
-
- )}
-
+
+
+
onSearchChange(event.target.value)}
+ placeholder="Введите имя или email..."
+ className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
+ autoFocus
+ />
+
Минимум 2 символа для поиска
-
+
+
+ {memberSearchLoading ? (
+
+ ) : memberSearchQuery.trim().length < 2 ? (
+
+
+
Начните вводить имя или email для поиска
+
+ ) : memberSearchResults.length === 0 ? (
+
+
Пользователи не найдены
+
+ ) : (
+
+ {memberSearchResults.map((result) => (
+
+
+ {result.avatar ? (
+
+ ) : (
+ {getParticipantInitials(result.name || getUserFullName(result.last_name, result.first_name))}
+ )}
+
+
+
{result.name || getUserFullName(result.last_name, result.first_name)}
+ {result.email ?
{result.email}
: null}
+
+
onAddMember(result.id)}
+ disabled={actionLoading === `add-member-${result.id}`}
+ className="rounded-lg bg-sky-600 px-3 py-1.5 text-xs font-medium text-white transition hover:bg-sky-700 disabled:opacity-50"
+ >
+ {actionLoading === `add-member-${result.id}` ? "..." : "Добавить"}
+
+
+ ))}
+
+ )}
+
+
);
}
diff --git a/frontend/src/components/messages/ChatDialogHeader.tsx b/frontend/src/components/messages/ChatDialogHeader.tsx
index d2cf2a37..e4a9408a 100644
--- a/frontend/src/components/messages/ChatDialogHeader.tsx
+++ b/frontend/src/components/messages/ChatDialogHeader.tsx
@@ -1,8 +1,9 @@
"use client";
+import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import Image from "next/image";
-import { ArrowLeft, Bell, BellOff, Pin } from "lucide-react";
+import { ArrowLeft, Bell, BellOff, MoreHorizontal, Pin, Search } from "lucide-react";
import type { Chat } from "@/types/api";
import { getChatAvatar, getChatInitials, getChatTitle } from "@/lib/messages/chatUtils";
@@ -16,6 +17,7 @@ type ChatDialogHeaderProps = {
notificationsEnabled: boolean;
onTogglePin: () => void;
onToggleNotifications: () => void;
+ onOpenSearch: () => void;
};
function getChatSubtitle(chat: Chat): string {
@@ -45,13 +47,53 @@ export default function ChatDialogHeader({
notificationsEnabled,
onTogglePin,
onToggleNotifications,
+ onOpenSearch,
}: ChatDialogHeaderProps) {
const avatar = getChatAvatar(chat, currentUserId);
const title = getChatTitle(chat, currentUserId);
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuRef = useRef
(null);
+
+ useEffect(() => {
+ if (!menuOpen) {
+ return;
+ }
+
+ const handlePointerDown = (event: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
+ setMenuOpen(false);
+ }
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ setMenuOpen(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handlePointerDown);
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("mousedown", handlePointerDown);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [menuOpen]);
+
+ const subtitle = getChatSubtitle(chat);
return (
-
-
+
+
+
+
+
+
{avatar ? (
@@ -61,51 +103,68 @@ export default function ChatDialogHeader({
getChatInitials(chat, currentUserId)
)}
-
-
-
{getChatSubtitle(chat)}
-
-
+
+
-
+
-
-
-
-
- {notificationsEnabled ? : }
-
-
-
setMenuOpen((prev) => !prev)}
className="flex h-10 w-10 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 transition hover:bg-gray-50 hover:text-sky-700"
+ aria-label="Дополнительные действия"
+ aria-expanded={menuOpen}
+ aria-haspopup="menu"
+ title="Дополнительные действия"
>
-
-
+
+
+
+ {menuOpen ? (
+
+
{
+ setMenuOpen(false);
+ onOpenSearch();
+ }}
+ className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
+ >
+
+ Поиск по сообщениям
+
+
+
{
+ setMenuOpen(false);
+ onTogglePin();
+ }}
+ className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
+ >
+
+ {isPinned ? "Открепить чат" : "Закрепить чат"}
+
+
+
{
+ setMenuOpen(false);
+ onToggleNotifications();
+ }}
+ className="flex w-full items-center gap-3 px-4 py-3 text-left text-sm font-medium text-gray-700 transition hover:bg-gray-50"
+ >
+ {notificationsEnabled ? (
+
+ ) : (
+
+ )}
+ {notificationsEnabled ? "Отключить уведомления" : "Включить уведомления"}
+
+
+ ) : null}
);
diff --git a/frontend/src/components/messages/ChatMediaPreviewModal.tsx b/frontend/src/components/messages/ChatMediaPreviewModal.tsx
index 5cff0d00..40599e74 100644
--- a/frontend/src/components/messages/ChatMediaPreviewModal.tsx
+++ b/frontend/src/components/messages/ChatMediaPreviewModal.tsx
@@ -12,7 +12,7 @@ type ChatMediaPreviewModalProps = {
export default function ChatMediaPreviewModal({ preview, onClose }: ChatMediaPreviewModalProps) {
return (
-
+
+
+ {isVideo ? "Видео" : "Фото"}
+
+ );
+}
+
type ChatMessageItemProps = {
message: Message;
currentUserId?: number;
repliedMessage?: Message | null;
+ isHighlighted?: boolean;
isActionsOpen: boolean;
canManage: boolean;
canReply: boolean;
@@ -86,6 +109,7 @@ export default function ChatMessageItem({
message,
currentUserId,
repliedMessage,
+ isHighlighted = false,
isActionsOpen,
canManage,
canReply,
@@ -132,7 +156,7 @@ export default function ChatMessageItem({
) : null}
-
+
{hasActions ? (
onOpenMediaPreview({ type: "image", src: fileUrl, name: attachment.file_name })}
- className="block w-full overflow-hidden rounded-lg"
+ className="relative block w-full overflow-hidden rounded-lg"
>
+
onOpenMediaPreview({ type: "video", src: fileUrl, name: attachment.file_name })}
- className="block w-full overflow-hidden rounded-lg"
+ className="relative block w-full overflow-hidden rounded-lg"
>
+
onAttachmentError(attachment.id)}
onLoadedData={() => onAttachmentLoad(attachment.id)}
/>
+
+
+
+
+
);
}
diff --git a/frontend/src/components/messages/ChatSearchPanel.tsx b/frontend/src/components/messages/ChatSearchPanel.tsx
new file mode 100644
index 00000000..8597a5ab
--- /dev/null
+++ b/frontend/src/components/messages/ChatSearchPanel.tsx
@@ -0,0 +1,189 @@
+"use client";
+
+import { ChevronDown, ChevronUp, Loader2, Paperclip, Search, X } from "lucide-react";
+
+import type { ChatMessageSearchResult } from "@/types/api";
+
+type ChatSearchPanelProps = {
+ isOpen: boolean;
+ query: string;
+ loading: boolean;
+ loadingMore: boolean;
+ error: string | null;
+ results: ChatMessageSearchResult[];
+ totalCount: number;
+ nextOffset: number | null;
+ selectedIndex: number;
+ inputRef: React.RefObject
;
+ onQueryChange: (value: string) => void;
+ onClose: () => void;
+ onPrevious: () => void;
+ onNext: () => void;
+ onSelect: (index: number) => void;
+ onLoadMore: () => void;
+ onSubmitSelection: () => void;
+};
+
+function formatSearchDate(value: string): string {
+ const date = new Date(value);
+ if (Number.isNaN(date.getTime())) return "";
+
+ return date.toLocaleString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export default function ChatSearchPanel({
+ isOpen,
+ query,
+ loading,
+ loadingMore,
+ error,
+ results,
+ totalCount,
+ nextOffset,
+ selectedIndex,
+ inputRef,
+ onQueryChange,
+ onClose,
+ onPrevious,
+ onNext,
+ onSelect,
+ onLoadMore,
+ onSubmitSelection,
+}: ChatSearchPanelProps) {
+ if (!isOpen) {
+ return null;
+ }
+
+ const canNavigate = results.length > 0;
+ const selectedLabel = canNavigate ? `${selectedIndex + 1}/${totalCount}` : totalCount > 0 ? `0/${totalCount}` : "0";
+
+ return (
+
+
+
+
+ onQueryChange(event.target.value)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter") {
+ event.preventDefault();
+ onSubmitSelection();
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ onPrevious();
+ }
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ onNext();
+ }
+ }}
+ placeholder="Поиск по сообщениям"
+ className="h-10 w-full rounded-full border border-gray-200 bg-gray-50 pl-10 pr-4 text-sm text-gray-800 outline-none transition focus:border-sky-500 focus:bg-white focus:ring-2 focus:ring-sky-100"
+ />
+
+
+
+
+
+
+ {selectedLabel}
+
+
+
+
+
+
+
+
+
+
+
+ {query.trim().length < 2 ? (
+
Введите минимум 2 символа для поиска по истории чата.
+ ) : loading && results.length === 0 ? (
+
+
+ Ищем совпадения...
+
+ ) : error ? (
+
{error}
+ ) : results.length === 0 ? (
+
Совпадений не найдено.
+ ) : (
+
+
+ Найдено: {totalCount}
+ Можно перейти Enter или кликом
+
+
+
+
+ {results.map((result, index) => (
+
onSelect(index)}
+ className={`block w-full rounded-xl px-3 py-2 text-left transition ${index === selectedIndex ? "bg-sky-50 ring-1 ring-sky-200" : "bg-white hover:bg-gray-100"}`}
+ >
+
+
{result.author_name}
+
{formatSearchDate(result.created_at)}
+
+ {result.snippet}
+ {result.has_attachments ? (
+
+
+
Вложений: {result.attachments_count}
+
+ ) : null}
+
+ ))}
+
+
+
+ {nextOffset !== null ? (
+
+ {loadingMore ? : null}
+ {loadingMore ? "Загрузка..." : "Показать еще"}
+
+ ) : null}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/messages/EditChatModal.tsx b/frontend/src/components/messages/EditChatModal.tsx
index 6d466d1d..71350959 100644
--- a/frontend/src/components/messages/EditChatModal.tsx
+++ b/frontend/src/components/messages/EditChatModal.tsx
@@ -6,6 +6,7 @@ import Image from "next/image";
import { getChatAvatar, getChatInitials } from "@/lib/messages/chatUtils";
import { resolveMediaUrl } from "@/lib/url";
import type { Chat } from "@/types/api";
+import { Modal } from "@/components/ui";
type EditChatModalProps = {
chat: Chat | null;
@@ -36,15 +37,30 @@ export default function EditChatModal({
onAvatarChange,
onSave,
}: EditChatModalProps) {
- if (!open || !chat) return null;
-
- const avatar = getChatAvatar(chat, currentUserId);
+ const footerContent = (
+
+
+ Отмена
+
+
+ {actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
+
+
+ );
return (
-
-
-
Редактировать чат
-
+
+ {chat && (
Аватар
@@ -52,8 +68,8 @@ export default function EditChatModal({
{editAvatarPreview ? (
- ) : avatar ? (
-
+ ) : getChatAvatar(chat, currentUserId) ? (
+
) : (
getChatInitials(chat, currentUserId)
)}
@@ -96,26 +112,7 @@ export default function EditChatModal({
/>
-
-
-
- Отмена
-
-
- {actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
-
-
-
-
+ )}
+
);
}
diff --git a/frontend/src/components/procurement/ProcurementSuppliersPanel.tsx b/frontend/src/components/procurement/ProcurementSuppliersPanel.tsx
index 53847080..2056a700 100644
--- a/frontend/src/components/procurement/ProcurementSuppliersPanel.tsx
+++ b/frontend/src/components/procurement/ProcurementSuppliersPanel.tsx
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { apiClient } from "@/lib/api";
import type { ProcurementSupplier } from "@/types/api";
import { Pencil, Plus, Star, Trash2 } from "lucide-react";
+import { Modal } from "@/components/ui";
type Props = {
canManage: boolean;
@@ -215,11 +216,13 @@ export default function ProcurementSuppliersPanel({ canManage }: Props) {
- {formOpen && canManage && (
- { if (e.target === e.currentTarget) closeForm(); }}>
-
-
{editing ? "Редактировать поставщика" : "Новый поставщик"}
-
+
+ Отмена
+ Сохранить
+
+ }>
+
-
- )}
+
);
}
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
index 0cd9405c..1ac3eb67 100644
--- a/frontend/src/components/ui/Modal.tsx
+++ b/frontend/src/components/ui/Modal.tsx
@@ -15,6 +15,10 @@ export interface ModalProps {
closeOnEsc?: boolean;
footer?: ReactNode;
className?: string;
+ /** Render children directly without padding wrappers (for custom layouts like flex-col) */
+ noPadding?: boolean;
+ /** Hide the built-in header (caller manages its own header inside children) */
+ noHeader?: boolean;
}
export function Modal({
@@ -28,19 +32,18 @@ export function Modal({
closeOnEsc = true,
footer,
className = "",
+ noPadding = false,
+ noHeader = false,
}: ModalProps) {
const [isAnimating, setIsAnimating] = useState(false);
const modalRef = useRef(null);
const contentRef = useRef(null);
- // Анимация появления
useEffect(() => {
if (isOpen) {
setIsAnimating(true);
- // Блокируем прокрутку body
document.body.style.overflow = "hidden";
} else {
- // Разблокируем прокрутку при закрытии
document.body.style.overflow = "";
}
@@ -49,7 +52,6 @@ export function Modal({
};
}, [isOpen]);
- // ESC для закрытия
useEffect(() => {
if (!isOpen || !closeOnEsc) return;
@@ -63,7 +65,6 @@ export function Modal({
return () => document.removeEventListener("keydown", handleEsc);
}, [isOpen, closeOnEsc, onClose]);
- // Focus trap
useEffect(() => {
if (!isOpen || !contentRef.current) return;
@@ -76,20 +77,17 @@ export function Modal({
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
- // Фокус на первый элемент при открытии
firstElement?.focus();
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab") return;
if (e.shiftKey) {
- // Shift + Tab
if (document.activeElement === firstElement) {
lastElement?.focus();
e.preventDefault();
}
} else {
- // Tab
if (document.activeElement === lastElement) {
firstElement?.focus();
e.preventDefault();
@@ -119,10 +117,13 @@ export function Modal({
const modalSizeClass = `w-full ${sizeClasses[size]} max-h-[95vh] sm:max-h-[90vh]`;
+ const showHeader = !noHeader && (title || showCloseButton);
+
return (
- {/* Header */}
- {(title || showCloseButton) && (
-
- {title && (
-
- {title}
-
+ {noPadding ? (
+ children
+ ) : (
+ <>
+ {/* Header */}
+ {showHeader && (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {showCloseButton && (
+
+
+
+ )}
+
+
)}
-
- {showCloseButton && (
-
-
-
- )}
+
+ {/* Content */}
+
+ {children}
-
+
+ {/* Footer */}
+ {footer && (
+
{footer}
+ )}
+
+ {/* Bottom padding */}
+
+ >
)}
-
- {/* Content */}
-
- {children}
-
-
- {/* Footer */}
- {footer && (
-
{footer}
- )}
-
- {/* Bottom padding */}
-
);
diff --git a/frontend/src/components/users/EditUserProfileModal.tsx b/frontend/src/components/users/EditUserProfileModal.tsx
index 7344b168..4271ab79 100644
--- a/frontend/src/components/users/EditUserProfileModal.tsx
+++ b/frontend/src/components/users/EditUserProfileModal.tsx
@@ -1,7 +1,7 @@
"use client";
import type { ChangeEvent } from "react";
-import { X } from "lucide-react";
+import { Modal } from "@/components/ui";
import type { User } from "@/types/api";
import type { UserProfileEditForm, UserProfileTextField } from "@/lib/users/userDetailUtils";
@@ -32,25 +32,37 @@ export default function EditUserProfileModal({
onTextFieldChange,
person,
}: EditUserProfileModalProps) {
- if (!isOpen || !person) {
- return null;
- }
+ const footerContent = (
+
+
+ Отмена
+
+
+ {actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
+
+
+ );
return (
-
-
-
-
Редактировать профиль
-
-
-
-
-
+
+ {person && (
@@ -212,26 +224,7 @@ export default function EditUserProfileModal({
-
-
-
- Отмена
-
-
- {actionLoading === "edit" ? "Сохранение..." : "Сохранить"}
-
-
-
-
+ )}
+
);
}
\ No newline at end of file
diff --git a/frontend/src/components/users/EmployeeActionModal.tsx b/frontend/src/components/users/EmployeeActionModal.tsx
index 0a604207..1a3a3cc3 100644
--- a/frontend/src/components/users/EmployeeActionModal.tsx
+++ b/frontend/src/components/users/EmployeeActionModal.tsx
@@ -1,6 +1,6 @@
"use client";
-import { X } from "lucide-react";
+import { Modal } from "@/components/ui";
import type { EmployeeActionField, EmployeeActionForm } from "@/lib/users/userDetailUtils";
type EmployeeActionOption = {
@@ -27,94 +27,83 @@ export default function EmployeeActionModal({
onFieldChange,
onSave,
}: EmployeeActionModalProps) {
- if (!isOpen) {
- return null;
- }
-
- return (
-
-
-
-
- {form.editingId ? "Редактировать событие" : "Кадровое событие"}
-
-
-
-
-
-
-
-
-
- Тип события *
-
- onFieldChange("type", event.target.value)}
- className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
- >
- Выберите тип события
- {actionTypes.map((type) => (
-
- {type.label}
-
- ))}
-
-
-
-
-
- Дата *
-
- onFieldChange("date", event.target.value)}
- className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
- />
-
-
-
-
- Комментарий
-
-
-
-
-
-
- Отмена
-
-
- {actionLoading === "action" ? "Сохранение..." : "Сохранить"}
-
-
-
+ const footerContent = (
+
+
+ Отмена
+
+
+ {actionLoading === "action" ? "Сохранение..." : "Сохранить"}
+
);
+
+ return (
+
+
+
+
+ Тип события *
+
+ onFieldChange("type", event.target.value)}
+ className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
+ >
+ Выберите тип события
+ {actionTypes.map((type) => (
+
+ {type.label}
+
+ ))}
+
+
+
+
+
+ Дата *
+
+ onFieldChange("date", event.target.value)}
+ className="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/20"
+ />
+
+
+
+
+ Комментарий
+
+
+
+
+ );
}
\ No newline at end of file
diff --git a/frontend/src/hooks/useChatMessages.ts b/frontend/src/hooks/useChatMessages.ts
index 97a154bb..97d360c1 100644
--- a/frontend/src/hooks/useChatMessages.ts
+++ b/frontend/src/hooks/useChatMessages.ts
@@ -70,6 +70,12 @@ export interface UseChatMessagesReturn {
anchorId: number | null;
anchorIndex: number | null;
} | null>;
+ /** Jump to a specific message by id and load surrounding context */
+ jumpToMessage: (messageId: number) => Promise<{
+ hasMoreAfter: boolean;
+ anchorId: number | null;
+ anchorIndex: number | null;
+ } | null>;
/** Set anchorRetryTick (used to retry anchor scroll) */
setAnchorRetryTick: React.Dispatch
>;
/** Mark initial scroll as done */
@@ -527,6 +533,40 @@ export function useChatMessages({
}
}, [chat?.last_read_message_id, chatId, hasMoreNewer, historyNavigationMode]);
+ const jumpToMessage = useCallback(async (messageId: number) => {
+ if (!chatId || Number.isNaN(chatId) || !messageId) return null;
+
+ try {
+ setMessagesLoading(true);
+ setHistoryNavigationMode(true);
+
+ const around = await apiClient.getChatMessagesAround(chatId, { limit: 30, around_id: messageId });
+ const norm = uniqueMessagesById(around.messages || []);
+ if (norm.length === 0) return null;
+
+ setMessages(norm);
+ setHasMoreOlder(typeof around.has_more_before === "boolean" ? around.has_more_before : norm.length >= 50);
+ setHasMoreNewer(Boolean(around.has_more_after));
+ setInitialAnchorId(around.anchor_id ?? messageId);
+ setInitialAnchorIndex(typeof around.anchor_index === "number" ? around.anchor_index : norm.findIndex(m => m.id === (around.anchor_id ?? messageId)));
+ setAllowOneOlderProbe(Boolean(around.anchor_id ?? messageId));
+
+ initialScrolledRef.current = false;
+ setAnchorRetryTick(0);
+
+ return {
+ hasMoreAfter: Boolean(around.has_more_after),
+ anchorId: around.anchor_id ?? messageId,
+ anchorIndex: typeof around.anchor_index === "number" ? around.anchor_index : norm.findIndex(m => m.id === (around.anchor_id ?? messageId)),
+ };
+ } catch (e) {
+ console.error("Ошибка перехода к найденному сообщению:", e);
+ return null;
+ } finally {
+ setMessagesLoading(false);
+ }
+ }, [chatId]);
+
/* ── scroll tracking helpers ── */
const markInitialScrollDone = useCallback(() => { initialScrolledRef.current = true; }, []);
@@ -567,6 +607,7 @@ export function useChatMessages({
markPendingFailed,
jumpToDate,
returnToUnread,
+ jumpToMessage,
setAnchorRetryTick,
markInitialScrollDone,
isInitialScrollPending,
diff --git a/frontend/src/lib/api/messages.ts b/frontend/src/lib/api/messages.ts
index 868e9f5b..087e54c1 100644
--- a/frontend/src/lib/api/messages.ts
+++ b/frontend/src/lib/api/messages.ts
@@ -1,4 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
+import type { ChatMessageSearchResponse } from '@/types/api';
import type { RequestFn, GetTokenFn } from './utils';
export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) {
@@ -87,6 +88,13 @@ export function createMessagesApi(request: RequestFn, getToken: GetTokenFn) {
const qs = qp.toString();
return request(`/api/v1/communications/chats/${chatId}/messages-around/${qs ? '?' + qs : ''}`);
},
+ searchChatMessages: (chatId: number | string, params: { q: string; limit?: number; offset?: number }) => {
+ const qp = new URLSearchParams();
+ qp.append('q', params.q);
+ if (params.limit) qp.append('limit', params.limit.toString());
+ if (typeof params.offset === 'number') qp.append('offset', params.offset.toString());
+ return request(`/api/v1/communications/chats/${chatId}/search-messages/?${qp.toString()}`) as Promise;
+ },
sendMessage: (chatId: number | string, text: string, replyTo?: number) => uploadMessage(chatId, text, [], replyTo),
sendMessageWithFiles: (chatId: number | string, text: string, files: File[], replyTo?: number) => uploadMessage(chatId, text, files, replyTo),
updateMessage: (messageId: number, text: string) => request(`/api/v1/communications/messages/${messageId}/`, { method: 'PATCH', body: JSON.stringify({ content: text }) }),
diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts
index 43072853..ace05f0d 100644
--- a/frontend/src/types/api.ts
+++ b/frontend/src/types/api.ts
@@ -555,6 +555,24 @@ export interface MessageAttachment {
is_local?: boolean;
}
+export interface ChatMessageSearchResult {
+ message_id: number;
+ content: string;
+ snippet: string;
+ author_name: string;
+ created_at: string;
+ attachments_count: number;
+ has_attachments: boolean;
+}
+
+export interface ChatMessageSearchResponse {
+ query: string;
+ count: number;
+ offset: number;
+ next_offset: number | null;
+ results: ChatMessageSearchResult[];
+}
+
// Calendar types (django-scheduler)
export interface CalendarEvent {
id: number;