"use client"; import { FolderKanban, AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, ScrollText, Search, Send, ShoppingCart, UserRoundPlus, Users, Wallet, } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter, useSearchParams } from "next/navigation"; import { ReactNode, Suspense, startTransition, useCallback, useEffect, useMemo, useRef, useState, } from "react"; import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext"; import { useUser } from "@/contexts/UserContext"; import { useNotifications } from "@/hooks/useApi"; import { NotificationCenter, NotificationPanel, } from "@/components/NotificationCenter"; import { CalendarSidebar } from "@/components/calendar/CalendarSidebar"; import { PushOnboardingPrompt } from "@/components/PushOnboardingPrompt"; import { useCalendarModals } from "@/hooks/useCalendarModals"; import { useDesktopCompactPanelBehavior } from "@/hooks/useDesktopCompactPanelBehavior"; import { CalendarModals } from "@/components/layout/CalendarModals"; import { MobileLeftDrawer, MobileCalendarDrawer, } from "@/components/layout/MobileDrawers"; import { usePwa } from "@/contexts/PwaContext"; import { Modal } from "@/components/ui"; import { apiClient } from "@/lib/api"; import { NAV_NOTIFICATION_CATEGORIES } from "@/lib/navigation-notifications"; import wsManager from "@/lib/websocketManager"; import type { GuestVisit, GuestVisitComment } from "@/types/api"; import { DailyTimeAllocationPrompt } from "@/components/projects/DailyTimeAllocationPrompt"; type AppShellProps = { children: ReactNode; desktopWideMode?: boolean; desktopImmersiveMode?: boolean; standaloneAppMode?: boolean; mobileFullscreen?: boolean; onDesktopWideModeChange?: (enabled: boolean) => void; suppressPushOnboardingPrompt?: boolean; }; type PageHeaderProps = { title: string; subtitle?: string; badge?: string; eyebrow?: string; }; type HeaderProps = { mobileNavPlacement: "top" | "bottom"; suppressMobileChrome?: boolean; onOpenLeftNav: () => void; onOpenCalendar: () => void; }; type LeftNavContentProps = { onNavigate?: () => void; compact?: boolean; documentSection?: "folders" | "regulations"; }; type PaginatedResponse = { count?: number; next?: string | null; results?: T[]; }; const navItems = [ { href: "/", label: "Лента", icon: HomeIcon, category: NAV_NOTIFICATION_CATEGORIES.feed, }, { href: "/messages", label: "Сообщения", icon: MessageSquare, category: NAV_NOTIFICATION_CATEGORIES.messages, }, { href: "/employees", label: "Сотрудники", icon: Users }, { href: "/departments", label: "Отделы", icon: Building2 }, { href: "/attendance", label: "Посещаемость", icon: CalendarCheck }, { href: "/requests", label: "Заявления", icon: FileSignature, category: NAV_NOTIFICATION_CATEGORIES.requests, autoReadOnNavigate: false, }, { href: "/guests", label: "Гости", icon: UserRoundPlus, category: NAV_NOTIFICATION_CATEGORIES.guests, }, // { href: "/equipment", label: "Оборудование", icon: Monitor }, { href: "/procurement", label: "Закупки", icon: ShoppingCart, category: NAV_NOTIFICATION_CATEGORIES.procurement, autoReadOnNavigate: false, }, // Раздел сохранён для дальнейшей разработки; интеграции доступны в Django admin. // { href: "/products", label: "Товары", icon: PackageSearch }, { href: "/projects", label: "Проекты", icon: FolderKanban }, { href: "/tasks", label: "Доска", icon: Kanban, category: NAV_NOTIFICATION_CATEGORIES.tasks, autoReadOnNavigate: false, }, { href: "/documents?section=folders", label: "Документы", icon: FileText, category: NAV_NOTIFICATION_CATEGORIES.documents, documentSection: "folders" as const, }, { href: "/documents?section=regulations", label: "Регламенты", icon: ScrollText, category: NAV_NOTIFICATION_CATEGORIES.regulations, autoReadOnNavigate: false, documentSection: "regulations" as const, }, { href: "/finances", label: "Финансы", icon: Wallet }, ]; function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNav, onOpenCalendar, }: HeaderProps) { const router = useRouter(); const { user, logout } = useUser(); const { canInstall, install } = usePwa(); const [searchQuery, setSearchQuery] = useState(""); const [userMenuOpen, setUserMenuOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const [isNotificationsOpen, setIsNotificationsOpen] = useState(false); const searchInputRef = useRef(null); // Закрытие меню при клике вне useEffect(() => { if (!userMenuOpen) return; const handler = (e: MouseEvent) => { const target = e.target as Node; const desktop = document.getElementById("user-menu-root"); const mobile = document.getElementById("user-menu-root-mobile"); const insideDesktop = desktop && desktop.contains(target); const insideMobile = mobile && mobile.contains(target); if (!insideDesktop && !insideMobile) { setUserMenuOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [userMenuOpen]); // Закрытие панели уведомлений при клике вне useEffect(() => { if (!isNotificationsOpen) return; const handler = (e: MouseEvent) => { const target = e.target as Node; const notificationButton = document.querySelector( '[aria-label="Уведомления"]', ); const notificationPanel = document.querySelector( ".notification-panel-mobile", ); const insideButton = notificationButton && notificationButton.contains(target); const insidePanel = notificationPanel && notificationPanel.contains(target); if (!insideButton && !insidePanel) { setIsNotificationsOpen(false); } }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [isNotificationsOpen]); const userInitials = user ? `${user.last_name?.[0] || ""}${user.first_name?.[0] || ""}` : "Г"; const userName = user ? `${user.last_name} ${user.first_name}`.trim() : "Гость"; const isBottomMobileNav = mobileNavPlacement === "bottom"; const submitSearch = () => { const query = searchQuery.trim(); if (!query) return; router.push(`/search?q=${encodeURIComponent(query)}`); }; return (
{/* eslint-disable-next-line @next/next/no-img-element */} Логотип
{ setIsNotificationsOpen((v) => !v); setIsSearchOpen(false); }} />
{ e.preventDefault(); submitSearch(); }} > setSearchQuery(e.target.value)} enterKeyHint="search" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); } }} className="app-input w-full rounded-full py-2.5 pl-11 pr-4 text-sm" placeholder="Поиск" />
setUserMenuOpen((v) => !v)} > {user?.avatar ? ( /* eslint-disable-next-line @next/next/no-img-element */ {userName} ) : ( userInitials )}
{/* Меню пользователя */} {userMenuOpen && (
{canInstall ? ( ) : null}
)}
{/* Мобильная строка поиска */}
{ e.preventDefault(); submitSearch(); setIsSearchOpen(false); }} > setSearchQuery(e.target.value)} enterKeyHint="search" onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submitSearch(); setIsSearchOpen(false); } if (e.key === "Escape") { setIsSearchOpen(false); } }} className="app-input w-full rounded-full py-2.5 pl-11 pr-12 text-sm" placeholder="Поиск по сайту..." />
{/* Мобильная панель уведомлений */}
{isNotificationsOpen && (
setIsNotificationsOpen(false)} />
)}
); } function LeftNavContent({ onNavigate, compact = false, documentSection = "folders", }: LeftNavContentProps) { const pathname = usePathname(); const { user } = useUser(); const { unreadCategoryCounts, markCategoryAsRead } = useNotifications(); const canManageAttendance = Boolean( user?.auth?.is_staff || user?.auth?.is_superuser, ); const visibleNavItems = useMemo( () => navItems.filter( (item) => item.href !== "/attendance" || canManageAttendance, ), [canManageAttendance], ); const handleNavClick = async ( category?: string, autoReadOnNavigate = true, ) => { // Помечаем уведомления категории как прочитанные if ( autoReadOnNavigate && category && category !== "Сообщения" && (unreadCategoryCounts[category] || 0) > 0 ) { await markCategoryAsRead(category); } // Вызываем callback для закрытия мобильного меню if (onNavigate) { onNavigate(); } }; const isNavItemActive = ( href: string, itemDocumentSection?: "folders" | "regulations", ) => { const hrefPath = href.split("?", 1)[0]; if (hrefPath === "/documents" && itemDocumentSection) { const isDocumentsPath = pathname === "/documents" || pathname.startsWith("/documents/"); return isDocumentsPath && documentSection === itemDocumentSection; } return ( pathname === hrefPath || (hrefPath !== "/" && pathname.startsWith(`${hrefPath}/`)) ); }; const navLinkClass = ( href: string, itemDocumentSection?: "folders" | "regulations", ) => `relative flex items-center rounded-lg transition ${compact ? "h-10 w-10 justify-center p-0" : "gap-3 px-3 py-2"} ${ isNavItemActive(href, itemDocumentSection) ? "app-selected" : "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]" }`; const navIconClass = ( href: string, itemDocumentSection?: "folders" | "regulations", ) => isNavItemActive(href, itemDocumentSection) ? "app-accent-text" : "app-text-muted"; return (
{visibleNavItems.map( ({ href, label, icon: Icon, category, autoReadOnNavigate, documentSection: itemDocumentSection, }) => { const count = category ? unreadCategoryCounts[category] || 0 : 0; return ( handleNavClick(category, autoReadOnNavigate)} title={compact ? label : undefined} aria-label={compact ? label : undefined} > {label} {count > 0 && ( {count > 99 ? "99+" : count} )} ); }, )}
); } function SearchAwareLeftNavContent( props: Omit, ) { const searchParams = useSearchParams(); const documentSection = searchParams.get("section") === "regulations" ? "regulations" : "folders"; return ; } function RoutedLeftNavContent( props: Omit, ) { return ( }> ); } function LeftNav({ fixedDesktop = false, compact = false, expandOnHover = true, }: { fixedDesktop?: boolean; compact?: boolean; expandOnHover?: boolean; }) { const [hoverExpanded, setHoverExpanded] = useState(false); const expanded = compact && expandOnHover && hoverExpanded; return ( ); } export function PageHeader({ title, subtitle, badge, eyebrow, }: PageHeaderProps) { return (

{eyebrow}

{title}

{badge ? (
{badge}
) : null}
{subtitle ?

{subtitle}

: null}
); } const toResults = (payload: PaginatedResponse | T[]): T[] => { if (Array.isArray(payload)) return payload; return payload.results || []; }; const guestDisplayName = (visit: GuestVisit): string => visit.guest.full_name || [visit.guest.last_name, visit.guest.first_name, visit.guest.patronymic] .filter(Boolean) .join(" ") || `Гость #${visit.guest.id}`; const latestInfoRequestText = ( visit: GuestVisit, comments: GuestVisitComment[], ): string => { const requestComment = [...comments] .reverse() .find( (comment) => comment.metadata?.guest_visit_comment_type === "info_request", ); if (requestComment?.text) return requestComment.text; const requestEvent = [...(visit.events || [])] .reverse() .find( (event) => event.event_type === "needs_info_requested" && event.comment, ); return ( requestEvent?.comment || "Администратор запросил дополнительную информацию по гостевой заявке." ); }; function GuestInfoRequestPrompt({ userId }: { userId?: number | null }) { const [visit, setVisit] = useState(null); const [comments, setComments] = useState([]); const [answer, setAnswer] = useState(""); const [loading, setLoading] = useState(false); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); const [isWsConnected, setIsWsConnected] = useState( () => typeof WebSocket !== "undefined" && wsManager.getState() === WebSocket.OPEN, ); const pendingRequestInFlightRef = useRef | null>(null); const lastPendingRequestSyncAtRef = useRef(0); const lastPendingRequestUserIdRef = useRef(null); const loadPendingRequest = useCallback( (force = false) => { if (!userId) return Promise.resolve(); if (lastPendingRequestUserIdRef.current !== userId) { lastPendingRequestUserIdRef.current = userId; lastPendingRequestSyncAtRef.current = 0; } if (pendingRequestInFlightRef.current) return pendingRequestInFlightRef.current; const now = Date.now(); if (!force && now - lastPendingRequestSyncAtRef.current < 5_000) { return Promise.resolve(); } lastPendingRequestSyncAtRef.current = now; const request = (async () => { try { setLoading(true); const response = (await apiClient.getGuestVisits({ scope: "mine", status: "needs_info", ordering: "-updated_at", page: 1, limit: 1, })) as PaginatedResponse | GuestVisit[]; const pendingVisit = toResults(response)[0] || null; setVisit(pendingVisit); if (!pendingVisit) { setComments([]); setAnswer(""); return; } const commentResponse = (await apiClient.getGuestVisitComments( pendingVisit.id, )) as GuestVisitComment[]; setComments(commentResponse); } catch { setVisit(null); setComments([]); } finally { setLoading(false); pendingRequestInFlightRef.current = null; } })(); pendingRequestInFlightRef.current = request; return request; }, [userId], ); useEffect(() => { if (!userId) return; const initialLoad = window.setTimeout(() => void loadPendingRequest(), 0); const unsubscribeMessages = wsManager.subscribe((event) => { if (event.type !== "notification") return; const notification = event.notification as { verb?: string } | undefined; if (notification?.verb?.startsWith("guest_")) { void loadPendingRequest(true); } }); const unsubscribeStatus = wsManager.subscribeStatus((status) => { setIsWsConnected(status.isConnected); }); return () => { window.clearTimeout(initialLoad); unsubscribeMessages(); unsubscribeStatus(); }; }, [loadPendingRequest, userId]); useEffect(() => { if (!userId) return; const interval = window.setInterval( () => void loadPendingRequest(), isWsConnected ? 5 * 60_000 : 30_000, ); const handleFocus = () => void loadPendingRequest(); const handleVisibility = () => { if (document.visibilityState === "visible") void loadPendingRequest(); }; window.addEventListener("focus", handleFocus); document.addEventListener("visibilitychange", handleVisibility); return () => { window.clearInterval(interval); window.removeEventListener("focus", handleFocus); document.removeEventListener("visibilitychange", handleVisibility); }; }, [isWsConnected, loadPendingRequest, userId]); const question = useMemo( () => (visit ? latestInfoRequestText(visit, comments) : ""), [comments, visit], ); const submitAnswer = async () => { if (!visit || !answer.trim()) return; try { setSubmitting(true); setError(null); await apiClient.provideGuestVisitInfo(visit.id, { comment: answer.trim(), }); setAnswer(""); await loadPendingRequest(); } catch (err) { setError(String((err as Error)?.message || "Не удалось отправить ответ")); } finally { setSubmitting(false); } }; return ( undefined} title="Требуется информация" size="md" showCloseButton={false} closeOnEsc={false} closeOnClickOutside={false} footer={
} >

{visit ? guestDisplayName(visit) : "Гостевая заявка"}

{loading ? "Загружаем запрос..." : question}