Corp/frontend/src/components/AppShell.tsx
lizerk 3ca4d108b6 refactor: split monolithic api.ts into domain modules, extract AppShell calendar/mobile concerns
- Split frontend/src/lib/api.ts (2042 lines) into 12 focused domain modules:
  client.ts (base HTTP/auth), calendar.ts, documents.ts, employees.ts,
  equipment.ts, feed.ts, messages.ts, notifications.ts, procurement.ts,
  requests.ts, utils.ts, and index.ts barrel that preserves all existing imports
- Extract calendar modal state from AppShell into useCalendarModals hook
- Extract CalendarModals and MobileDrawers into components/layout/
- AppShell: 745 → 460 lines, focused on layout concerns only
- Fix setState-in-effect lint error with startTransition pattern
- Stabilize notifications memo to prevent unnecessary re-renders
- Fix flaky WS test: handle CancelledError in channels-testing cleanup
- All 609 backend tests pass, TypeScript typecheck clean, ESLint clean
2026-04-01 15:56:10 +03:00

465 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { Building2, CalendarDays, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users, Wallet } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react";
import { useUser } from "@/contexts/UserContext";
import { useNotifications } from "@/hooks/useApi";
import { getVerbCategory } from "@/lib/verbTranslations";
import { NotificationCenter, NotificationPanel } from "@/components/NotificationCenter";
import { CalendarSidebar } from "@/components/calendar/CalendarSidebar";
import { useCalendarModals } from "@/hooks/useCalendarModals";
import { CalendarModals } from "@/components/layout/CalendarModals";
import { MobileLeftDrawer, MobileCalendarDrawer } from "@/components/layout/MobileDrawers";
type AppShellProps = {
children: ReactNode;
};
type PageHeaderProps = {
title: string;
subtitle?: string;
badge?: string;
eyebrow?: string;
};
type HeaderProps = {
onOpenLeftNav: () => void;
onOpenCalendar: () => void;
};
type LeftNavContentProps = {
onNavigate?: () => void;
};
const navItems = [
{ href: "/", label: "Лента", icon: HomeIcon, category: "Новости" },
{ href: "/messages", label: "Сообщения", icon: MessageSquare, category: "Сообщения" },
{ href: "/employees", label: "Сотрудники", icon: Users },
{ href: "/departments", label: "Отделы", icon: Building2 },
{ href: "/requests", label: "Заявления", icon: FileSignature, category: "Заявки" },
{ href: "/equipment", label: "Оборудование", icon: Monitor },
{ href: "/procurement", label: "Закупки", icon: ShoppingCart, category: "Закупки" },
{ href: "/documents", label: "Документы", icon: FileText, category: "Документы" },
{ href: "/finances", label: "Финансы", icon: Wallet },
];
function Header({ onOpenLeftNav, onOpenCalendar }: HeaderProps) {
const router = useRouter();
const { user, logout } = useUser();
const [searchQuery, setSearchQuery] = useState("");
const [userMenuOpen, setUserMenuOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isNotificationsOpen, setIsNotificationsOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(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 submitSearch = () => {
const query = searchQuery.trim();
if (!query) return;
router.push(`/search?q=${encodeURIComponent(query)}`);
};
return (
<header className="sticky top-0 z-[40] border-b border-slate-100 bg-white/90 backdrop-blur">
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div className="flex h-10 lg:h-14 items-center justify-between gap-3">
<button
type="button"
onClick={onOpenLeftNav}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Открыть левое меню"
>
<Menu size={20} className="text-gray-700"/>
</button>
<button
type="button"
onClick={() => {
setIsSearchOpen((v) => {
if (!v) setTimeout(() => searchInputRef.current?.focus(), 50);
return !v;
});
setIsNotificationsOpen(false);
}}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Поиск"
>
<Search size={20} className="text-gray-700" />
</button>
<Link href="/" className="flex items-center justify-center">
<img src="/logo.png" alt="Логотип" className="mt-3 lg:mt-0 h-10 w-auto lg:h-11 bg-white lg:bg-transparent rounded pb-0.5 lg:pb-0" />
</Link>
<div className="lg:hidden">
<NotificationCenter variant="mobile" isOpen={isNotificationsOpen} onToggle={() => {
setIsNotificationsOpen((v) => !v);
setIsSearchOpen(false);
}} />
</div>
<button
type="button"
onClick={onOpenCalendar}
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-slate-100 lg:hidden"
aria-label="Открыть календарь"
>
<CalendarDays size={20} className="text-gray-700" />
</button>
<div className="hidden flex-1 items-center justify-center lg:flex">
<div className="relative w-full max-w-xl">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitSearch();
}
}}
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Поиск"
/>
</div>
</div>
<div className="ml-auto hidden items-center gap-1 sm:gap-2 lg:flex">
<NotificationCenter />
<div className="ml-1 relative h-10 w-10" id="user-menu-root">
<div
className="flex h-10 w-10 items-center justify-center overflow-hidden rounded-full bg-sky-400 text-sm font-semibold text-white hover:bg-sky-500 cursor-pointer"
title={userName}
onClick={() => setUserMenuOpen((v) => !v)}
>
{user?.avatar ? (
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
{/* Меню пользователя */}
{userMenuOpen && (
<div className="absolute right-0 top-12 z-[60] w-48 rounded-xl bg-white py-2 shadow-lg ring-1 ring-slate-100 animate-fade-in">
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="w-full text-left px-4 py-2 text-sm hover:bg-sky-50"
onClick={() => { setUserMenuOpen(false); router.push('/settings'); }}
>Настройки</button>
<div className="my-1 border-t border-slate-100" />
<button
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
onClick={() => { setUserMenuOpen(false); logout(); }}
>Выйти</button>
</div>
)}
</div>
</div>
</div>
{/* Мобильная строка поиска */}
<div
className={`overflow-hidden transition-all duration-300 lg:hidden ${
isSearchOpen ? "max-h-20 pb-3" : "max-h-0"
}`}
>
<div className="relative">
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 text-gray-400" />
<input
ref={searchInputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitSearch();
setIsSearchOpen(false);
}
if (e.key === "Escape") {
setIsSearchOpen(false);
}
}}
className="w-full rounded-full border border-gray-200 bg-gray-50 py-2.5 pl-11 pr-4 text-sm text-gray-800 transition focus:border-sky-500 focus:bg-white focus:outline-none focus:ring-2 focus:ring-sky-100"
placeholder="Поиск по сайту..."
/>
</div>
</div>
{/* Мобильная панель уведомлений */}
<div
className={`notification-panel-mobile overflow-hidden transition-all duration-300 lg:hidden ${
isNotificationsOpen ? "max-h-[70vh] pb-3" : "max-h-0"
}`}
>
{isNotificationsOpen && (
<NotificationPanel onClose={() => setIsNotificationsOpen(false)} />
)}
</div>
</div>
</header>
);
}
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const pathname = usePathname();
const { notifications: notificationsData, markCategoryAsRead } = useNotifications();
const notifications = useMemo(() => Array.isArray(notificationsData) ? notificationsData : [], [notificationsData]);
// Подсчет уведомлений по категориям
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {};
notifications.forEach((n: { verb?: string; is_read?: boolean }) => {
// Пропускаем прочитанные
if (!n.verb || n.is_read) return;
const category = getVerbCategory(n.verb);
counts[category] = (counts[category] || 0) + 1;
});
return counts;
}, [notifications]);
const handleNavClick = async (category?: string) => {
// Помечаем уведомления категории как прочитанные
if (category && categoryCounts[category] > 0) {
await markCategoryAsRead(category);
}
// Вызываем callback для закрытия мобильного меню
if (onNavigate) {
onNavigate();
}
};
const navLinkClass = (href: string) =>
`flex items-center gap-3 rounded-lg px-3 py-2 hover:bg-gray-50 ${pathname === href ? "bg-sky-50 text-sky-700 ring-1 ring-sky-100" : "text-gray-700"
}`;
const navIconClass = (href: string) => (pathname === href ? "text-sky-700" : "text-gray-400");
return (
<div className="rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="space-y-2 text-sm text-gray-700">
{navItems.map(({ href, label, icon: Icon, category }) => {
const count = category ? categoryCounts[category] || 0 : 0;
return (
<Link
key={href}
href={href}
className={navLinkClass(href)}
onClick={() => handleNavClick(category)}
>
<Icon size={18} className={navIconClass(href)} />
<span className="flex-1">{label}</span>
{count > 0 && (
<span className="flex h-5 min-w-[20px] items-center justify-center rounded-full bg-sky-500 px-1.5 text-[10px] font-bold text-white">
{count > 99 ? '99+' : count}
</span>
)}
</Link>
);
})}
</div>
</div>
);
}
function LeftNav() {
return (
<aside className="hidden w-64 flex-shrink-0 lg:block lg:sticky lg:self-start lg:top-22 lg:max-h-[calc(100vh-5.5rem)] lg:overflow-y-auto">
<div className="space-y-4 lg:pb-2">
<LeftNavContent />
</div>
</aside>
);
}
export function PageHeader({ title, subtitle, badge, eyebrow = "Раздел" }: PageHeaderProps) {
return (
<div className="flex flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-gray-100">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-sky-600">{eyebrow}</p>
<h1 className="text-2xl font-bold text-gray-900">{title}</h1>
</div>
{badge ? (
<div className="rounded-full bg-sky-50 px-4 py-2 text-xs font-medium text-sky-700 ring-1 ring-sky-100">{badge}</div>
) : null}
</div>
{subtitle ? <p className="text-sm text-gray-600">{subtitle}</p> : null}
</div>
);
}
export function AppShell({ children }: AppShellProps) {
const { user, loading } = useUser();
const router = useRouter();
const pathname = usePathname();
const isMessageDialogPage = pathname.startsWith('/messages/') &&
pathname !== '/messages' &&
!pathname.includes('/settings');
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
const prevPathnameRef = useRef(pathname);
const cal = useCalendarModals();
useEffect(() => {
// Если загрузка завершена и пользователь не авторизован - редирект на логин
if (!loading && !user) {
router.push('/login');
}
}, [user, loading, router]);
// Close mobile drawers on route change
useEffect(() => {
if (prevPathnameRef.current !== pathname) {
prevPathnameRef.current = pathname;
startTransition(() => {
setIsMobileLeftNavOpen(false);
setIsMobileCalendarOpen(false);
});
}
}, [pathname]);
useEffect(() => {
const onResize = () => {
if (window.innerWidth >= 1024) {
setIsMobileLeftNavOpen(false);
setIsMobileCalendarOpen(false);
}
};
window.addEventListener("resize", onResize);
onResize();
return () => {
window.removeEventListener("resize", onResize);
};
}, []);
useEffect(() => {
if (isMobileLeftNavOpen || isMobileCalendarOpen) {
document.body.style.overflow = "hidden";
return;
}
document.body.style.overflow = "";
return () => {
document.body.style.overflow = "";
};
}, [isMobileLeftNavOpen, isMobileCalendarOpen]);
// Показываем загрузку, пока проверяем авторизацию
if (loading) {
return (
<div className="min-h-screen bg-gradient-to-b from-sky-50 via-white to-white flex items-center justify-center">
<div className="text-center">
<div className="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-sky-400 border-t-transparent"></div>
<p className="text-sm text-gray-500">Загрузка...</p>
</div>
</div>
);
}
// Если пользователь не авторизован, ничего не показываем (уже редиректим)
if (!user) {
return null;
}
return (
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden' : 'min-h-screen'} bg-gradient-to-b from-sky-50 via-white to-white text-gray-900 flex flex-col`}>
<Header onOpenLeftNav={() => setIsMobileLeftNavOpen(true)} onOpenCalendar={() => setIsMobileCalendarOpen(true)} />
<div className="mx-auto flex w-full flex-1 min-h-0 max-w-6xl gap-6 px-4 py-4 sm:px-8 lg:py-8">
<LeftNav />
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>{children}</main>
<CalendarSidebar
onOpenCalendarModal={cal.handleOpenCalendarModal}
onOpenEventModal={cal.handleOpenEventModal}
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
eventsRefreshTrigger={cal.eventsRefreshTrigger}
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
setSidebarEvents={cal.handleSetSidebarEvents}
onCalendarChange={cal.handleCalendarChange}
/>
</div>
<MobileLeftDrawer
isOpen={isMobileLeftNavOpen}
onClose={() => setIsMobileLeftNavOpen(false)}
>
<LeftNavContent onNavigate={() => setIsMobileLeftNavOpen(false)} />
</MobileLeftDrawer>
<MobileCalendarDrawer
isOpen={isMobileCalendarOpen}
onClose={() => setIsMobileCalendarOpen(false)}
onOpenCalendarModal={cal.handleOpenCalendarModal}
onOpenEventModal={cal.handleOpenEventModal}
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
eventsRefreshTrigger={cal.eventsRefreshTrigger}
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
setSidebarEvents={cal.handleSetSidebarEvents}
onCalendarChange={cal.handleCalendarChange}
/>
<CalendarModals {...cal} />
</div>
);
}