Corp/frontend/src/components/AppShell.tsx
2026-09-07 19:43:48 +03:00

1096 lines
36 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 {
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<T> = {
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<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-menu-danger-item 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,
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 (
<div className={`app-surface rounded-2xl ${compact ? "p-2" : "p-5"}`}>
<div className="space-y-2 text-sm">
{visibleNavItems.map(
({
href,
label,
icon: Icon,
category,
autoReadOnNavigate,
documentSection: itemDocumentSection,
}) => {
const count = category ? unreadCategoryCounts[category] || 0 : 0;
return (
<Link
key={href}
href={href}
className={navLinkClass(href, itemDocumentSection)}
onClick={() => handleNavClick(category, autoReadOnNavigate)}
title={compact ? label : undefined}
aria-label={compact ? label : undefined}
>
<Icon
size={18}
className={navIconClass(href, itemDocumentSection)}
/>
<span className={compact ? "sr-only" : "flex-1"}>{label}</span>
{count > 0 && (
<span
className={`flex items-center justify-center rounded-full bg-sky-500 text-[10px] font-bold text-white ${compact ? "absolute -right-1 -top-1 h-4 min-w-4 px-1" : "h-5 min-w-[20px] px-1.5"}`}
>
{count > 99 ? "99+" : count}
</span>
)}
</Link>
);
},
)}
</div>
</div>
);
}
function SearchAwareLeftNavContent(
props: Omit<LeftNavContentProps, "documentSection">,
) {
const searchParams = useSearchParams();
const documentSection =
searchParams.get("section") === "regulations" ? "regulations" : "folders";
return <LeftNavContent {...props} documentSection={documentSection} />;
}
function RoutedLeftNavContent(
props: Omit<LeftNavContentProps, "documentSection">,
) {
return (
<Suspense fallback={<LeftNavContent {...props} />}>
<SearchAwareLeftNavContent {...props} />
</Suspense>
);
}
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 (
<aside
className={`hidden shrink-0 transition-[width] duration-200 lg:block ${compact ? "w-14" : "w-64"}`}
>
<div
className={`space-y-4 transition-[width] duration-200 lg:overflow-y-auto ${expanded ? "z-[35] lg:w-64" : ""} ${
fixedDesktop
? `lg:fixed lg:top-0 lg:bottom-0 lg:pt-[4.5rem] lg:pb-8 ${compact ? `lg:left-3 ${expanded ? "lg:w-64" : "lg:w-14"}` : "lg:w-64"}`
: "lg:sticky lg:top-8 lg:max-h-[calc(100dvh-7.5rem)] lg:pb-2"
}`}
onMouseEnter={() => {
if (compact && expandOnHover) setHoverExpanded(true);
}}
onMouseLeave={() => setHoverExpanded(false)}
onFocus={() => {
if (compact && expandOnHover) setHoverExpanded(true);
}}
onBlur={(event) => {
if (!event.currentTarget.contains(event.relatedTarget))
setHoverExpanded(false);
}}
>
<RoutedLeftNavContent compact={compact && !expanded} />
</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 [isWsConnected, setIsWsConnected] = useState(
() =>
typeof WebSocket !== "undefined" &&
wsManager.getState() === WebSocket.OPEN,
);
const pendingRequestInFlightRef = useRef<Promise<void> | null>(null);
const lastPendingRequestSyncAtRef = useRef(0);
const lastPendingRequestUserIdRef = useRef<number | null>(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> | 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 (
<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,
desktopWideMode = false,
desktopImmersiveMode = false,
standaloneAppMode = false,
mobileFullscreen = false,
onDesktopWideModeChange,
suppressPushOnboardingPrompt = false,
}: AppShellProps) {
const {
user,
loading,
status: authStatus,
error: authError,
retryAuthentication,
} = useUser();
const { mobileNavPlacement } = useMobileNavPlacement();
const [desktopCompactPanelBehavior] = useDesktopCompactPanelBehavior();
const expandCompactPanelsOnHover = desktopCompactPanelBehavior === "hover";
const router = useRouter();
const pathname = usePathname();
const [isMobileLeftNavOpen, setIsMobileLeftNavOpen] = useState(false);
const [isMobileCalendarOpen, setIsMobileCalendarOpen] = useState(false);
const prevPathnameRef = useRef(pathname);
const cal = useCalendarModals();
useEffect(() => {
// Если загрузка завершена и пользователь не авторизован - редирект на логин
if (!loading && authStatus === "unauthenticated" && !user) {
router.push("/login");
}
}, [user, loading, authStatus, 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 && authStatus === "recovering") {
return (
<div className="app-shell min-h-screen flex items-center justify-center p-4">
<div className="app-surface w-full max-w-md rounded-lg p-5 text-center">
<Loader2 className="mx-auto mb-3 h-8 w-8 animate-spin text-sky-400" />
<h1 className="text-base font-semibold">
Восстанавливаем соединение
</h1>
<p className="app-text-muted mt-2 text-sm">
{authError || "Сервер временно недоступен."}
</p>
<button
type="button"
className="app-button-primary mt-4 px-4 py-2 text-sm"
onClick={() => void retryAuthentication()}
>
Повторить
</button>
</div>
</div>
);
}
// Если пользователь не авторизован, ничего не показываем (уже редиректим)
if (!user) {
return null;
}
return (
<div
className={`${mobileFullscreen || standaloneAppMode ? "h-[100dvh] overflow-hidden" : "min-h-screen pt-10"} ${desktopImmersiveMode || standaloneAppMode ? "lg:pt-0" : "lg:pt-14"} ${desktopWideMode ? "lg:h-[100dvh] lg:overflow-hidden" : ""} app-shell flex flex-col ${mobileNavPlacement === "bottom" && !mobileFullscreen && !standaloneAppMode ? "pb-[calc(env(safe-area-inset-bottom)+3.75rem)] lg:pb-0" : ""}`}
>
<div
className={
standaloneAppMode ? "hidden" : desktopImmersiveMode ? "lg:hidden" : ""
}
>
<Header
mobileNavPlacement={mobileNavPlacement}
suppressMobileChrome={mobileFullscreen}
onOpenLeftNav={() => setIsMobileLeftNavOpen(true)}
onOpenCalendar={() => setIsMobileCalendarOpen(true)}
/>
</div>
<div
className={`mx-auto flex w-full flex-1 min-h-0 max-w-6xl ${desktopWideMode ? "lg:max-w-none" : ""} ${standaloneAppMode ? "gap-0 px-0 py-0" : desktopImmersiveMode ? "gap-0 px-0 py-0 lg:gap-0 lg:px-3 lg:py-3" : mobileFullscreen ? `gap-0 px-0 py-0 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : "lg:gap-6 lg:px-8"}` : `gap-6 px-4 py-4 sm:px-8 lg:py-4 ${desktopWideMode ? "lg:gap-3 lg:px-3" : ""}`}`}
>
{!desktopImmersiveMode && !standaloneAppMode ? (
<LeftNav
fixedDesktop
compact={desktopWideMode}
expandOnHover={expandCompactPanelsOnHover}
/>
) : null}
<main
className={`flex-1 min-w-0 min-h-0 space-y-6 ${mobileFullscreen ? "overflow-visible" : ""} ${desktopWideMode ? "lg:flex lg:flex-col lg:gap-4 lg:space-y-0 lg:overflow-hidden" : ""}`}
>
{!mobileFullscreen &&
!standaloneAppMode &&
!suppressPushOnboardingPrompt ? (
<PushOnboardingPrompt />
) : null}
{children}
</main>
{!desktopImmersiveMode && !standaloneAppMode ? (
<CalendarSidebar
onOpenCalendarModal={cal.handleOpenCalendarModal}
onOpenEventModal={cal.handleOpenEventModal}
onOpenParticipantsModal={cal.handleOpenParticipantsModal}
eventsRefreshTrigger={cal.eventsRefreshTrigger}
setEventsRefreshTrigger={cal.handleSetEventsRefreshTrigger}
setSidebarEvents={cal.handleSetSidebarEvents}
onCalendarChange={cal.handleCalendarChange}
fixedDesktop
compact={desktopWideMode}
expandOnHover={expandCompactPanelsOnHover}
onExpand={() => onDesktopWideModeChange?.(false)}
/>
) : null}
</div>
{!standaloneAppMode ? (
<MobileLeftDrawer
isOpen={isMobileLeftNavOpen}
onClose={() => setIsMobileLeftNavOpen(false)}
>
<RoutedLeftNavContent
onNavigate={() => setIsMobileLeftNavOpen(false)}
/>
</MobileLeftDrawer>
) : null}
{!standaloneAppMode ? (
<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}
/>
) : null}
{!standaloneAppMode ? <CalendarModals {...cal} /> : null}
<GuestInfoRequestPrompt userId={user.id} />
{!standaloneAppMode && <DailyTimeAllocationPrompt userId={user.id} />}
</div>
);
}