diff --git a/frontend/src/components/documents/search/AdvancedSearch.tsx b/frontend/src/components/documents/search/AdvancedSearch.tsx new file mode 100644 index 00000000..26f23b57 --- /dev/null +++ b/frontend/src/components/documents/search/AdvancedSearch.tsx @@ -0,0 +1,521 @@ +"use client"; + +import { useState, useCallback, useEffect, useMemo } from "react"; +import { Search, X, Filter, SlidersHorizontal, Save, Clock, ChevronDown } from "lucide-react"; +import Fuse from "fuse.js"; + +export interface SearchFilters { + query: string; + documentTypes?: string[]; + dateFrom?: string; + dateTo?: string; + statuses?: string[]; + tags?: string[]; + authors?: string[]; + sortBy?: "relevance" | "date" | "title" | "author"; + sortOrder?: "asc" | "desc"; +} + +export interface SearchResult { + id: number; + title: string; + description: string; + content?: string; + type?: string; + status?: string; + uploaded_at: string; + uploaded_by?: string; + tags?: string[]; + score?: number; + highlights?: string[]; +} + +interface AdvancedSearchProps { + onSearch: (filters: SearchFilters) => void; + results?: SearchResult[]; + isLoading?: boolean; + availableTypes?: Array<{ id: string; name: string }>; + availableStatuses?: Array<{ id: string; name: string }>; + availableTags?: Array<{ id: string; name: string }>; + availableAuthors?: Array<{ id: string; name: string }>; +} + +const DEFAULT_FILTERS: SearchFilters = { + query: "", + documentTypes: [], + dateFrom: "", + dateTo: "", + statuses: [], + tags: [], + authors: [], + sortBy: "relevance", + sortOrder: "desc", +}; + +export function AdvancedSearch({ + onSearch, + results = [], + isLoading = false, + availableTypes = [], + availableStatuses = [], + availableTags = [], + availableAuthors = [], +}: AdvancedSearchProps) { + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [showFilters, setShowFilters] = useState(false); + const [savedSearches, setSavedSearches] = useState>( + [] + ); + const [showSavedSearches, setShowSavedSearches] = useState(false); + const [saveSearchName, setSaveSearchName] = useState(""); + + // Загрузка сохраненных поисков из localStorage + useEffect(() => { + const saved = localStorage.getItem("savedSearches"); + if (saved) { + try { + setSavedSearches(JSON.parse(saved)); + } catch (e) { + console.error("Error loading saved searches:", e); + } + } + }, []); + + // Обновление фильтра + const updateFilter = useCallback( + (key: K, value: SearchFilters[K]) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }, + [] + ); + + // Выполнение поиска + const handleSearch = useCallback(() => { + onSearch(filters); + }, [filters, onSearch]); + + // Сброс фильтров + const resetFilters = useCallback(() => { + setFilters(DEFAULT_FILTERS); + onSearch(DEFAULT_FILTERS); + }, [onSearch]); + + // Сохранение поиска + const saveSearch = useCallback(() => { + if (!saveSearchName.trim()) return; + + const newSearch = { + name: saveSearchName.trim(), + filters: { ...filters }, + }; + + const updated = [...savedSearches, newSearch]; + setSavedSearches(updated); + localStorage.setItem("savedSearches", JSON.stringify(updated)); + setSaveSearchName(""); + setShowSavedSearches(false); + }, [saveSearchName, filters, savedSearches]); + + // Загрузка сохраненного поиска + const loadSearch = useCallback( + (search: { name: string; filters: SearchFilters }) => { + setFilters(search.filters); + onSearch(search.filters); + setShowSavedSearches(false); + }, + [onSearch] + ); + + // Удаление сохраненного поиска + const deleteSavedSearch = useCallback( + (index: number) => { + const updated = savedSearches.filter((_, i) => i !== index); + setSavedSearches(updated); + localStorage.setItem("savedSearches", JSON.stringify(updated)); + }, + [savedSearches] + ); + + // Fuzzy search для подсветки + const fuse = useMemo(() => { + if (!results.length || !filters.query) return null; + + return new Fuse(results, { + keys: ["title", "description", "content"], + includeScore: true, + includeMatches: true, + threshold: 0.4, + }); + }, [results, filters.query]); + + // Подсветка совпадений + const highlightText = (text: string, query: string) => { + if (!query) return text; + + const regex = new RegExp(`(${query})`, "gi"); + const parts = text.split(regex); + + return parts.map((part, index) => + regex.test(part) ? ( + + {part} + + ) : ( + {part} + ) + ); + }; + + // Активные фильтры (для отображения badges) + const activeFiltersCount = useMemo(() => { + let count = 0; + if (filters.documentTypes && filters.documentTypes.length > 0) count++; + if (filters.dateFrom) count++; + if (filters.dateTo) count++; + if (filters.statuses && filters.statuses.length > 0) count++; + if (filters.tags && filters.tags.length > 0) count++; + if (filters.authors && filters.authors.length > 0) count++; + return count; + }, [filters]); + + return ( +
+ {/* Поисковая строка */} +
+
+ + updateFilter("query", e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="Поиск документов..." + className="w-full rounded-lg border border-gray-300 py-2.5 pl-10 pr-10 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-100" + /> + {filters.query && ( + + )} +
+ + + + + + +
+ + {/* Сохраненные поиски */} + {showSavedSearches && ( +
+
+

Сохраненные поиски

+ +
+ + {savedSearches.length > 0 ? ( +
+ {savedSearches.map((search, index) => ( +
+ + +
+ ))} +
+ ) : ( +

Нет сохраненных поисков

+ )} + + {/* Сохранить текущий поиск */} +
+ setSaveSearchName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && saveSearch()} + placeholder="Название поиска..." + className="flex-1 rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none" + /> + +
+
+ )} + + {/* Панель фильтров */} + {showFilters && ( +
+
+

Расширенные фильтры

+ +
+ +
+ {/* Тип документа */} + {availableTypes.length > 0 && ( +
+ + +
+ )} + + {/* Статус */} + {availableStatuses.length > 0 && ( +
+ + +
+ )} + + {/* Теги */} + {availableTags.length > 0 && ( +
+ + +
+ )} + + {/* Дата от */} +
+ + updateFilter("dateFrom", e.target.value)} + className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none" + /> +
+ + {/* Дата до */} +
+ + updateFilter("dateTo", e.target.value)} + className="w-full rounded border border-gray-300 px-2 py-1 text-sm focus:border-sky-500 focus:outline-none" + /> +
+ + {/* Автор */} + {availableAuthors.length > 0 && ( +
+ + +
+ )} +
+ + {/* Сортировка */} +
+ + + + +
+
+ )} + + {/* Результаты поиска */} + {results.length > 0 && ( +
+

+ Найдено результатов: {results.length} +

+ + {results.map((result) => ( +
+

+ {highlightText(result.title, filters.query)} +

+ {result.description && ( +

+ {highlightText(result.description, filters.query)} +

+ )} +
+ {result.type && ( + {result.type} + )} + {result.status && ( + + {result.status} + + )} + {result.uploaded_by && Автор: {result.uploaded_by}} + {result.uploaded_at && ( + {new Date(result.uploaded_at).toLocaleDateString("ru-RU")} + )} +
+
+ ))} +
+ )} + + {/* Пустое состояние */} + {!isLoading && results.length === 0 && filters.query && ( +
+ +

Ничего не найдено

+

Попробуйте изменить параметры поиска

+
+ )} +
+ ); +} diff --git a/frontend/src/components/documents/search/index.ts b/frontend/src/components/documents/search/index.ts new file mode 100644 index 00000000..619e105d --- /dev/null +++ b/frontend/src/components/documents/search/index.ts @@ -0,0 +1 @@ +export { AdvancedSearch, type SearchFilters, type SearchResult } from "./AdvancedSearch"; diff --git a/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx b/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx new file mode 100644 index 00000000..327abbfa --- /dev/null +++ b/frontend/src/components/documents/viewer/EnhancedPDFViewer.tsx @@ -0,0 +1,365 @@ +"use client"; + +import { useState, useCallback, useEffect } from "react"; +import { Document, Page, pdfjs } from "react-pdf"; +import "react-pdf/dist/esm/Page/AnnotationLayer.css"; +import "react-pdf/dist/esm/Page/TextLayer.css"; +import { + ZoomIn, + ZoomOut, + RotateCw, + ChevronLeft, + ChevronRight, + Maximize, + Printer, + Search, + X, + Sidebar, +} from "lucide-react"; + +// Настройка worker для react-pdf +pdfjs.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.js`; + +type FitMode = "width" | "page" | "custom"; + +interface EnhancedPDFViewerProps { + fileUrl: string; + fileName?: string; + onClose?: () => void; +} + +export function EnhancedPDFViewer({ fileUrl, fileName, onClose }: EnhancedPDFViewerProps) { + const [numPages, setNumPages] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [scale, setScale] = useState(1.0); + const [rotation, setRotation] = useState(0); + const [fitMode, setFitMode] = useState("width"); + const [showSidebar, setShowSidebar] = useState(true); + const [showSearch, setShowSearch] = useState(false); + const [searchText, setSearchText] = useState(""); + const [containerWidth, setContainerWidth] = useState(800); + + // Обработка успешной загрузки документа + const onDocumentLoadSuccess = useCallback(({ numPages }: { numPages: number }) => { + setNumPages(numPages); + setCurrentPage(1); + }, []); + + // Навигация по страницам + const goToPage = useCallback( + (page: number) => { + if (page >= 1 && page <= numPages) { + setCurrentPage(page); + } + }, + [numPages] + ); + + const nextPage = useCallback(() => { + goToPage(currentPage + 1); + }, [currentPage, goToPage]); + + const previousPage = useCallback(() => { + goToPage(currentPage - 1); + }, [currentPage, goToPage]); + + // Управление масштабом + const zoomIn = useCallback(() => { + setScale((prev) => Math.min(prev + 0.25, 3.0)); + setFitMode("custom"); + }, []); + + const zoomOut = useCallback(() => { + setScale((prev) => Math.max(prev - 0.25, 0.5)); + setFitMode("custom"); + }, []); + + const fitToWidth = useCallback(() => { + setFitMode("width"); + }, []); + + const fitToPage = useCallback(() => { + setFitMode("page"); + }, []); + + // Поворот + const rotateClockwise = useCallback(() => { + setRotation((prev) => (prev + 90) % 360); + }, []); + + // Печать + const handlePrint = useCallback(() => { + window.print(); + }, []); + + // Горячие клавиши + useEffect(() => { + const handleKeyPress = (e: KeyboardEvent) => { + // Игнорировать, если фокус в поле ввода + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { + return; + } + + switch (e.key) { + case "ArrowLeft": + previousPage(); + break; + case "ArrowRight": + nextPage(); + break; + case "+": + case "=": + zoomIn(); + break; + case "-": + zoomOut(); + break; + case "r": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + rotateClockwise(); + } + break; + case "f": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + setShowSearch((prev) => !prev); + } + break; + case "p": + if (e.ctrlKey || e.metaKey) { + e.preventDefault(); + handlePrint(); + } + break; + case "Escape": + setShowSearch(false); + if (onClose) onClose(); + break; + } + }; + + window.addEventListener("keydown", handleKeyPress); + return () => window.removeEventListener("keydown", handleKeyPress); + }, [currentPage, nextPage, previousPage, zoomIn, zoomOut, rotateClockwise, handlePrint, onClose]); + + // Вычисление ширины для режима fitToWidth + const getPageWidth = useCallback(() => { + if (fitMode === "width") { + return containerWidth - 40; // С учетом padding + } + return undefined; + }, [fitMode, containerWidth]); + + return ( +
+ {/* Шапка */} +
+
+ + {fileName || "Документ"} +
+ + {/* Поиск */} + {showSearch && ( +
+ setSearchText(e.target.value)} + placeholder="Поиск в документе..." + className="rounded border border-gray-600 bg-gray-700 px-3 py-1 text-sm text-white focus:border-sky-500 focus:outline-none" + autoFocus + /> + +
+ )} + + +
+ + {/* Панель инструментов */} +
+ {/* Навигация */} +
+ + + goToPage(Number(e.target.value))} + className="w-12 rounded border border-gray-600 bg-gray-700 px-1 text-center text-sm" + min={1} + max={numPages} + />{" "} + / {numPages} + + +
+ + {/* Масштаб */} +
+ + {Math.round(scale * 100)}% + + + +
+ + {/* Дополнительные инструменты */} +
+ + + +
+
+ + {/* Основная область */} +
+ {/* Боковая панель с миниатюрами */} + {showSidebar && ( +
+
+ {Array.from(new Array(numPages), (_, index) => ( + + ))} +
+
+ )} + + {/* Область просмотра */} +
{ + if (el) { + setContainerWidth(el.clientWidth); + } + }} + > +
+ + Загрузка документа... +
+ } + error={ +
+ Ошибка загрузки документа +
+ } + > + + +
+
+
+ + ); +} diff --git a/frontend/src/components/documents/viewer/index.ts b/frontend/src/components/documents/viewer/index.ts new file mode 100644 index 00000000..b44eb011 --- /dev/null +++ b/frontend/src/components/documents/viewer/index.ts @@ -0,0 +1 @@ +export { EnhancedPDFViewer } from "./EnhancedPDFViewer";