Files
duty-teller/webapp-next/src/components/CalendarPage.tsx
Nikolay Tatarinov c390a4dd6e feat: implement admin panel functionality in Mini App
- Added new API endpoints for admin features: `GET /api/admin/me`, `GET /api/admin/users`, and `PATCH /api/admin/duties/:id` to manage user duties.
- Introduced `UserForAdmin` and `AdminDutyReassignBody` schemas for handling admin-related data.
- Updated documentation to include Mini App design guidelines and admin panel functionalities.
- Enhanced tests for admin API to ensure proper access control and functionality.
- Improved error handling and localization for admin actions.
2026-03-06 09:57:26 +03:00

190 lines
5.6 KiB
TypeScript

/**
* Calendar view layout: header, grid, duty list, day detail.
* Composes calendar UI and owns sticky scroll, swipe, month data, and day-detail ref.
*/
"use client";
import { useRef, useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useMonthData } from "@/hooks/use-month-data";
import { useSwipe } from "@/hooks/use-swipe";
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
import { useTranslation } from "@/i18n/use-translation";
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
import { DutyList } from "@/components/duty/DutyList";
import { DayDetail, type DayDetailHandle } from "@/components/day-detail";
import { ErrorState } from "@/components/states/ErrorState";
/** Fallback height (px) until ResizeObserver reports the sticky block size. Matches --calendar-block-min-height + pb-2 (260 + 8). */
const STICKY_HEIGHT_FALLBACK_PX = 268;
export interface CalendarPageProps {
/** Whether the user is allowed (for data loading). */
isAllowed: boolean;
/** Raw initData string for API auth. */
initDataRaw: string | undefined;
}
export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
const dayDetailRef = useRef<DayDetailHandle>(null);
const calendarStickyRef = useRef<HTMLDivElement>(null);
const [stickyBlockHeight, setStickyBlockHeight] = useState(STICKY_HEIGHT_FALLBACK_PX);
useEffect(() => {
const el = calendarStickyRef.current;
if (!el) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) setStickyBlockHeight(entry.contentRect.height);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
const {
currentMonth,
pendingMonth,
loading,
error,
accessDenied,
duties,
calendarEvents,
selectedDay,
isAdmin,
nextMonth,
prevMonth,
setCurrentMonth,
setSelectedDay,
setAppContentReady,
} = useAppStore(
useShallow((s) => ({
currentMonth: s.currentMonth,
pendingMonth: s.pendingMonth,
loading: s.loading,
error: s.error,
accessDenied: s.accessDenied,
duties: s.duties,
calendarEvents: s.calendarEvents,
selectedDay: s.selectedDay,
isAdmin: s.isAdmin,
nextMonth: s.nextMonth,
prevMonth: s.prevMonth,
setCurrentMonth: s.setCurrentMonth,
setSelectedDay: s.setSelectedDay,
setAppContentReady: s.setAppContentReady,
}))
);
const { t } = useTranslation();
const { retry } = useMonthData({
initDataRaw,
enabled: isAllowed,
});
const now = new Date();
const isCurrentMonth =
currentMonth.getFullYear() === now.getFullYear() &&
currentMonth.getMonth() === now.getMonth();
useAutoRefresh(retry, isCurrentMonth);
const navDisabled = loading || accessDenied || selectedDay !== null;
const handlePrevMonth = useCallback(() => {
if (navDisabled) return;
prevMonth();
}, [navDisabled, prevMonth]);
const handleNextMonth = useCallback(() => {
if (navDisabled) return;
nextMonth();
}, [navDisabled, nextMonth]);
useSwipe(
calendarStickyRef,
handleNextMonth,
handlePrevMonth,
{ threshold: 50, disabled: navDisabled }
);
useStickyScroll(calendarStickyRef);
const handleDayClick = useCallback(
(dateKey: string, anchorRect: DOMRect) => {
const [y, m] = dateKey.split("-").map(Number);
if (
y !== currentMonth.getFullYear() ||
m !== currentMonth.getMonth() + 1
) {
return;
}
dayDetailRef.current?.openWithRect(dateKey, anchorRect);
},
[currentMonth]
);
const handleCloseDayDetail = useCallback(() => {
setSelectedDay(null);
}, [setSelectedDay]);
const readyCalledRef = useRef(false);
// Mark content ready when first load finishes or access denied, so page can call ready() and show content.
useEffect(() => {
if ((!loading || accessDenied) && !readyCalledRef.current) {
readyCalledRef.current = true;
setAppContentReady(true);
}
}, [loading, accessDenied, setAppContentReady]);
return (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<div
ref={calendarStickyRef}
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
>
<CalendarHeader
month={currentMonth}
disabled={navDisabled}
onPrevMonth={handlePrevMonth}
onNextMonth={handleNextMonth}
trailingContent={
isAdmin ? (
<Link
href="/admin"
className="text-sm text-accent hover:underline focus-visible:outline-accent rounded"
>
{t("admin.link")}
</Link>
) : undefined
}
/>
<CalendarGrid
currentMonth={currentMonth}
duties={duties}
calendarEvents={calendarEvents}
onDayClick={handleDayClick}
/>
</div>
{error && (
<ErrorState message={error} onRetry={retry} className="my-3" />
)}
{!error && (
<DutyList
scrollMarginTop={stickyBlockHeight}
className="mt-2"
/>
)}
<DayDetail
ref={dayDetailRef}
duties={duties}
calendarEvents={calendarEvents}
onClose={handleCloseDayDetail}
/>
</div>
);
}