Corp/frontend/src/components/AppShell.tsx
2026-07-06 13:47:03 +03:00

692 lines
26 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 { 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<T> = {
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<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 isBottomMobileNav = mobileNavPlacement === "bottom";
const submitSearch = () => {
const query = searchQuery.trim();
if (!query) return;
router.push(`/search?q=${encodeURIComponent(query)}`);
};
return (
<header
className={`app-header z-[40] backdrop-blur ${
isBottomMobileNav
? "fixed inset-x-0 bottom-0 border-t lg:fixed lg:inset-x-0 lg:top-0 lg:bottom-auto lg:border-b lg:border-t-0"
: "fixed inset-x-0 top-0 border-b"
} ${suppressMobileChrome ? "hidden lg:block" : ""} shrink-0`}
>
<div className="mx-auto max-w-6xl px-4 sm:px-8">
<div
className={`flex h-10 items-center justify-between gap-3 lg:h-14 ${
isBottomMobileNav ? "pb-[max(env(safe-area-inset-bottom),0px)]" : ""
}`}
>
<button
type="button"
onClick={onOpenLeftNav}
className="app-icon-button flex h-10 w-10 items-center justify-center rounded-full lg:hidden"
aria-label="Открыть левое меню"
>
<Menu size={20} />
</button>
<button
type="button"
onClick={() => {
setIsSearchOpen((v) => {
if (!v) setTimeout(() => searchInputRef.current?.focus(), 50);
return !v;
});
setIsNotificationsOpen(false);
}}
className="app-icon-button flex h-10 w-10 items-center justify-center rounded-full lg:hidden"
aria-label="Поиск"
>
<Search size={20} />
</button>
<Link href="/" className="flex items-center justify-center">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/logo.png"
alt="Логотип"
className={`h-10 w-auto rounded bg-transparent pb-0.5 lg:h-11 lg:pb-0 ${
isBottomMobileNav ? "mt-0" : "mt-3 lg:mt-0"
}`}
/>
</Link>
<div className="lg:hidden">
<NotificationCenter variant="mobile" isOpen={isNotificationsOpen} onToggle={() => {
setIsNotificationsOpen((v) => !v);
setIsSearchOpen(false);
}} />
</div>
<button
type="button"
onClick={onOpenCalendar}
className="app-icon-button flex h-10 w-10 items-center justify-center rounded-full lg:hidden"
aria-label="Открыть календарь"
>
<CalendarDays size={20} />
</button>
<div className="hidden flex-1 items-center justify-center lg:flex">
<form
className="relative w-full max-w-xl"
onSubmit={(e) => {
e.preventDefault();
submitSearch();
}}
>
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 app-text-muted" />
<input
type="search"
value={searchQuery}
onChange={(e) => 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="Поиск"
/>
</form>
</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="app-avatar-fallback flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-full text-sm font-semibold hover:bg-[var(--accent-soft-strong)]"
title={userName}
onClick={() => setUserMenuOpen((v) => !v)}
>
{user?.avatar ? (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={user.avatar} alt={userName} className="h-full w-full object-cover" />
) : (
userInitials
)}
</div>
{/* Меню пользователя */}
{userMenuOpen && (
<div className="app-menu absolute right-0 top-12 z-[60] w-48 rounded-xl py-2 animate-fade-in">
<button
className="app-action-ghost w-full px-4 py-2 text-left text-sm transition"
onClick={() => { setUserMenuOpen(false); router.push('/profile'); }}
>Мой профиль</button>
<button
className="app-action-ghost w-full px-4 py-2 text-left text-sm transition"
onClick={() => { setUserMenuOpen(false); router.push('/settings'); }}
>Настройки</button>
{canInstall ? (
<button
className="app-action-ghost flex w-full items-center gap-2 px-4 py-2 text-left text-sm transition"
onClick={() => {
setUserMenuOpen(false);
void install();
}}
>
<Download size={16} />
Установить
</button>
) : null}
<div className="app-divider my-1 border-t" />
<button
className="app-action-danger w-full px-4 py-2 text-left text-sm transition"
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"
} ${isBottomMobileNav ? "absolute inset-x-0 bottom-full px-4 sm:px-8" : ""}`}
>
<form
className={`relative ${isBottomMobileNav ? "mx-auto max-w-6xl" : ""}`}
onSubmit={(e) => {
e.preventDefault();
submitSearch();
setIsSearchOpen(false);
}}
>
<Search size={16} className="pointer-events-none absolute left-4 top-1/2 -translate-y-1/2 app-text-muted" />
<input
ref={searchInputRef}
type="search"
value={searchQuery}
onChange={(e) => 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="Поиск по сайту..."
/>
<button
type="submit"
className="app-action-primary absolute right-1.5 top-1/2 inline-flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-full"
aria-label="Найти"
title="Найти"
>
<Search size={14} />
</button>
</form>
</div>
{/* Мобильная панель уведомлений */}
<div
className={`notification-panel-mobile overflow-hidden transition-all duration-300 lg:hidden ${
isNotificationsOpen ? "max-h-[70vh] pb-3" : "max-h-0"
} ${isBottomMobileNav ? "absolute inset-x-0 bottom-full px-4 sm:px-8" : ""}`}
>
{isNotificationsOpen && (
<div className={isBottomMobileNav ? "mx-auto max-w-6xl" : ""}>
<NotificationPanel onClose={() => setIsNotificationsOpen(false)} />
</div>
)}
</div>
</div>
</header>
);
}
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 (
<div className="app-surface rounded-2xl p-5">
<div className="space-y-2 text-sm">
{visibleNavItems.map(({ href, label, icon: Icon, category, autoReadOnNavigate }) => {
const count = category ? unreadCategoryCounts[category] || 0 : 0;
return (
<Link
key={href}
href={href}
className={navLinkClass(href)}
onClick={() => handleNavClick(category, autoReadOnNavigate)}
>
<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({ fixedDesktop = false }: { fixedDesktop?: boolean }) {
return (
<aside className="hidden w-64 flex-shrink-0 lg:block">
<div
className={`space-y-4 lg:overflow-y-auto ${
fixedDesktop
? "lg:fixed lg:top-0 lg:bottom-0 lg:w-64 lg:pt-[4.5rem] lg:pb-8"
: "lg:sticky lg:top-8 lg:max-h-[calc(100dvh-7.5rem)] lg:pb-2"
}`}
>
<LeftNavContent />
</div>
</aside>
);
}
export function PageHeader({ title, subtitle, badge, eyebrow }: PageHeaderProps) {
return (
<div className="app-surface flex flex-col gap-3 rounded-2xl p-5">
<div className="flex items-center justify-between gap-3">
<div>
<p className="app-accent-text text-sm font-semibold">{eyebrow}</p>
<h1 className="text-2xl font-bold text-[var(--foreground)]">{title}</h1>
</div>
{badge ? (
<div className="app-badge app-badge-accent px-4 py-2 text-xs font-medium">{badge}</div>
) : null}
</div>
{subtitle ? <p className="app-text-muted text-sm">{subtitle}</p> : null}
</div>
);
}
const toResults = <T,>(payload: PaginatedResponse<T> | 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<GuestVisit | null>(null);
const [comments, setComments] = useState<GuestVisitComment[]>([]);
const [answer, setAnswer] = useState("");
const [loading, setLoading] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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> | 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 (
<Modal
isOpen={Boolean(visit)}
onClose={() => undefined}
title="Требуется информация"
size="md"
showCloseButton={false}
closeOnEsc={false}
closeOnClickOutside={false}
footer={
<div className="flex justify-end">
<button
type="button"
onClick={() => void submitAnswer()}
disabled={submitting || !answer.trim()}
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-60"
>
{submitting ? <Loader2 className="animate-spin" size={15} /> : <Send size={15} />}
Отправить ответ
</button>
</div>
}
>
<div className="space-y-4">
<div className="app-feedback-warning flex items-start gap-3 rounded-xl p-4 text-sm">
<AlertTriangle className="mt-0.5 shrink-0" size={18} />
<div className="min-w-0">
<p className="font-semibold text-[var(--foreground)]">{visit ? guestDisplayName(visit) : "Гостевая заявка"}</p>
<p className="mt-1 whitespace-pre-wrap">{loading ? "Загружаем запрос..." : question}</p>
</div>
</div>
<label className="block">
<span className="app-text-muted mb-1 block text-xs font-medium">Ответ *</span>
<textarea
value={answer}
onChange={(event) => setAnswer(event.target.value)}
rows={5}
className="app-input w-full resize-none rounded-lg p-3 text-sm"
placeholder="Напишите уточнение для администратора"
/>
</label>
{error ? <p className="app-feedback-danger rounded-lg px-3 py-2 text-sm">{error}</p> : null}
</div>
</Modal>
);
}
export function AppShell({ children }: AppShellProps) {
const { user, loading } = useUser();
const { mobileNavPlacement } = useMobileNavPlacement();
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="app-shell min-h-screen 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="app-text-muted text-sm">Загрузка...</p>
</div>
</div>
);
}
// Если пользователь не авторизован, ничего не показываем (уже редиректим)
if (!user) {
return null;
}
return (
<div className={`${isMessageDialogPage ? 'h-[100dvh] overflow-hidden lg:pt-14' : 'min-h-screen pt-10 lg:pt-14'} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !isMessageDialogPage ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}>
<Header
mobileNavPlacement={mobileNavPlacement}
suppressMobileChrome={isMessageDialogPage}
onOpenLeftNav={() => setIsMobileLeftNavOpen(true)}
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
/>
<div className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${isMessageDialogPage ? 'gap-0 px-0 py-0 lg:gap-6 lg:px-8 lg:py-4' : 'gap-6 px-4 py-4 sm:px-8 lg:py-4'}`}>
<LeftNav fixedDesktop />
<main className={`flex-1 min-w-0 min-h-0 space-y-6 ${isMessageDialogPage ? 'overflow-visible' : ''}`}>
{!isMessageDialogPage ? <PushOnboardingPrompt /> : null}
{children}
</main>
<CalendarSidebar
onOpenCalendarModal={cal.handleOpenCalendarModal}
onOpenEventModal={cal.handleOpenEventModal}
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
eventsRefreshTrigger={cal.eventsRefreshTrigger}
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
setSidebarEvents={cal.handleSetSidebarEvents}
onCalendarChange={cal.handleCalendarChange}
fixedDesktop
/>
</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} />
<GuestInfoRequestPrompt userId={user.id} />
</div>
);
}