"use client"; import { AlertTriangle, Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Kanban, Loader2, Menu, MessageSquare, Search, Send, ShoppingCart, UserRoundPlus, Users } from "lucide-react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import { ReactNode, 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 { 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 type { GuestVisit, GuestVisitComment } from "@/types/api"; type AppShellProps = { children: ReactNode; }; 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; }; type PaginatedResponse = { count?: number; next?: string | null; results?: T[]; }; const navItems = [ { href: "/", label: "Лента", icon: HomeIcon, category: "Новости" }, { href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" }, { href: "/employees", label: "Сотрудники", icon: Users }, { href: "/departments", label: "Отделы", icon: Building2 }, { href: "/attendance", label: "Посещаемость", icon: CalendarCheck }, { href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" }, { href: "/guests", label: "Гости", icon: UserRoundPlus, category: "Гости" }, // { href: "/equipment", label: "Оборудование", icon: Monitor }, { href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки", autoReadOnNavigate: false }, { href: "/tasks", label: "Доска", icon: Kanban, category: "Доска" }, { href: "/documents", label: "Документы", icon: FileText, category: "Документы" }, // { 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 }: 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 navLinkClass = (href: string) => `flex items-center gap-3 rounded-lg px-3 py-2 transition ${pathname === href ? "app-selected" : "text-[var(--foreground)] hover:bg-[var(--surface-secondary)]" }`; const navIconClass = (href: string) => (pathname === href ? "app-accent-text" : "app-text-muted"); return (
{visibleNavItems.map(({ href, label, icon: Icon, category, autoReadOnNavigate }) => { const count = category ? unreadCategoryCounts[category] || 0 : 0; return ( handleNavClick(category, autoReadOnNavigate)} > {label} {count > 0 && ( {count > 99 ? '99+' : count} )} ); })}
); } function LeftNav({ fixedDesktop = false }: { fixedDesktop?: boolean }) { 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 loadPendingRequest = useCallback(async () => { if (!userId) return; 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); } }, [userId]); useEffect(() => { if (!userId) return; void loadPendingRequest(); const interval = window.setInterval(() => void loadPendingRequest(), 30000); 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); }; }, [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}