+ )}
+
{/* File Preview Modal */}
{previewFile && (
@@ -768,87 +754,78 @@ export default function DocumentsPage() {
)}
{/* Create Folder Modal */}
- {showCreateFolder && (
-
-
-
-
Создать папку
-
-
+
setShowCreateFolder(false)}
+ title="Создать папку"
+ size="sm"
+ >
+
+ try {
+ await apiClient.createFolder({
+ name: name.trim(),
+ parent: selectedFolderId,
+ });
+ setShowCreateFolder(false);
+ loadFolders();
+ } catch (err) {
+ console.error('Ошибка создания папки:', err);
+ alert('Не удалось создать папку');
+ }
+ }}
+ className="space-y-4"
+ >
+
+
+
-
- )}
+
+ {selectedFolderId && (
+
+
+
+ Будет создана в выбранной папке
+
+
+ )}
+
+
+
+
+
+
+
{/* Acknowledgements Report Modal */}
{showAcknowledgementsReport && (
diff --git a/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx b/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx
index 46b49b55..51dc1d7b 100644
--- a/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx
+++ b/frontend/src/components/documents/DocumentAcknowledgementsReport.tsx
@@ -4,6 +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";
interface Employee {
id: number;
@@ -45,6 +46,17 @@ 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]);
@@ -117,8 +129,14 @@ export function DocumentAcknowledgementsReport({
const displayList = getDisplayList();
return (
-
-
+
+
{/* Header */}
diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx
new file mode 100644
index 00000000..9b3e5cc4
--- /dev/null
+++ b/frontend/src/components/ui/Modal.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import { useEffect, useRef, type ReactNode } from "react";
+import { X, Maximize2, Minimize2 } from "lucide-react";
+import { useState } from "react";
+
+export interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ title?: string;
+ children: ReactNode;
+ size?: "sm" | "md" | "lg" | "xl" | "full";
+ showCloseButton?: boolean;
+ closeOnClickOutside?: boolean;
+ closeOnEsc?: boolean;
+ showFullscreenToggle?: boolean;
+ footer?: ReactNode;
+ className?: string;
+}
+
+export function Modal({
+ isOpen,
+ onClose,
+ title,
+ children,
+ size = "md",
+ showCloseButton = true,
+ closeOnClickOutside = true,
+ closeOnEsc = true,
+ showFullscreenToggle = false,
+ footer,
+ className = "",
+}: ModalProps) {
+ const [isFullscreen, setIsFullscreen] = useState(false);
+ 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 = "";
+ }
+
+ return () => {
+ document.body.style.overflow = "";
+ };
+ }, [isOpen]);
+
+ // ESC для закрытия
+ useEffect(() => {
+ if (!isOpen || !closeOnEsc) return;
+
+ const handleEsc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("keydown", handleEsc);
+ return () => document.removeEventListener("keydown", handleEsc);
+ }, [isOpen, closeOnEsc, onClose]);
+
+ // Focus trap
+ useEffect(() => {
+ if (!isOpen || !contentRef.current) return;
+
+ const focusableElements = contentRef.current.querySelectorAll(
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
+ );
+
+ if (focusableElements.length === 0) return;
+
+ 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();
+ }
+ }
+ };
+
+ document.addEventListener("keydown", handleTab);
+ return () => document.removeEventListener("keydown", handleTab);
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ const handleBackdropClick = (e: React.MouseEvent) => {
+ if (closeOnClickOutside && e.target === modalRef.current) {
+ onClose();
+ }
+ };
+
+ const sizeClasses = {
+ sm: "max-w-md",
+ md: "max-w-2xl",
+ lg: "max-w-4xl",
+ xl: "max-w-6xl",
+ full: "max-w-[95vw]",
+ };
+
+ const modalSizeClass = isFullscreen
+ ? "w-screen h-screen max-w-none rounded-none"
+ : `w-full ${sizeClasses[size]} max-h-[90vh]`;
+
+ return (
+
+
+ {/* Header */}
+ {(title || showCloseButton || showFullscreenToggle) && (
+
+ {title && (
+
+ {title}
+
+ )}
+
+ {showFullscreenToggle && (
+
+ )}
+ {showCloseButton && (
+
+ )}
+
+
+ )}
+
+ {/* Content */}
+
{children}
+
+ {/* Footer */}
+ {footer && (
+
{footer}
+ )}
+
+
+ );
+}
diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts
new file mode 100644
index 00000000..1ae2df91
--- /dev/null
+++ b/frontend/src/components/ui/index.ts
@@ -0,0 +1,2 @@
+export { Modal } from "./Modal";
+export type { ModalProps } from "./Modal";
diff --git a/frontend/src/hooks/useModal.ts b/frontend/src/hooks/useModal.ts
new file mode 100644
index 00000000..619967bc
--- /dev/null
+++ b/frontend/src/hooks/useModal.ts
@@ -0,0 +1,143 @@
+"use client";
+
+import { useEffect, useRef, RefObject } from "react";
+
+export interface UseModalOptions {
+ isOpen: boolean;
+ onClose?: () => void;
+ closeOnEsc?: boolean;
+ trapFocus?: boolean;
+ lockBodyScroll?: boolean;
+}
+
+/**
+ * Хук для универсального управления модальными окнами
+ * - Закрытие по ESC
+ * - Trap focus внутри модала
+ * - Блокировка прокрутки body
+ */
+export function useModal({
+ isOpen,
+ onClose,
+ closeOnEsc = true,
+ trapFocus = true,
+ lockBodyScroll = true,
+}: UseModalOptions) {
+ const modalRef = useRef(null);
+
+ // Блокировка прокрутки body
+ useEffect(() => {
+ if (!lockBodyScroll) return;
+
+ if (isOpen) {
+ const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;
+ document.body.style.overflow = "hidden";
+ document.body.style.paddingRight = `${scrollbarWidth}px`;
+ } else {
+ document.body.style.overflow = "";
+ document.body.style.paddingRight = "";
+ }
+
+ return () => {
+ document.body.style.overflow = "";
+ document.body.style.paddingRight = "";
+ };
+ }, [isOpen, lockBodyScroll]);
+
+ // Закрытие по ESC
+ useEffect(() => {
+ if (!isOpen || !closeOnEsc || !onClose) return;
+
+ const handleEsc = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("keydown", handleEsc);
+ return () => document.removeEventListener("keydown", handleEsc);
+ }, [isOpen, closeOnEsc, onClose]);
+
+ // Focus trap
+ useEffect(() => {
+ if (!isOpen || !trapFocus || !modalRef.current) return;
+
+ const modalElement = modalRef.current;
+ const focusableElements = modalElement.querySelectorAll(
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
+ );
+
+ if (focusableElements.length === 0) return;
+
+ const firstElement = focusableElements[0];
+ const lastElement = focusableElements[focusableElements.length - 1];
+
+ // Сохраняем ранее сфокусированный элемент
+ const previouslyFocused = document.activeElement as HTMLElement;
+
+ // Фокус на первый элемент при открытии
+ setTimeout(() => firstElement?.focus(), 0);
+
+ const handleTab = (e: KeyboardEvent) => {
+ if (e.key !== "Tab") return;
+
+ // Проверяем, что фокус внутри модала
+ if (!modalElement.contains(document.activeElement)) {
+ e.preventDefault();
+ firstElement?.focus();
+ return;
+ }
+
+ if (e.shiftKey) {
+ // Shift + Tab
+ if (document.activeElement === firstElement) {
+ lastElement?.focus();
+ e.preventDefault();
+ }
+ } else {
+ // Tab
+ if (document.activeElement === lastElement) {
+ firstElement?.focus();
+ e.preventDefault();
+ }
+ }
+ };
+
+ document.addEventListener("keydown", handleTab);
+
+ return () => {
+ document.removeEventListener("keydown", handleTab);
+ // Возвращаем фокус на предыдущий элемент
+ if (previouslyFocused && document.body.contains(previouslyFocused)) {
+ previouslyFocused.focus();
+ }
+ };
+ }, [isOpen, trapFocus]);
+
+ return modalRef;
+}
+
+/**
+ * Хук для закрытия модала по клику вне его области
+ */
+export function useClickOutside(
+ callback: () => void,
+ enabled = true
+) {
+ const ref = useRef(null);
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const handleClick = (e: MouseEvent) => {
+ if (ref.current && e.target === ref.current) {
+ callback();
+ }
+ };
+
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, [callback, enabled]);
+
+ return ref;
+}