- 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.
190 lines
5.6 KiB
TypeScript
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>
|
|
);
|
|
}
|