diff --git a/.cursor/rules/frontend.mdc b/.cursor/rules/frontend.mdc index b25a852..c2c34a4 100644 --- a/.cursor/rules/frontend.mdc +++ b/.cursor/rules/frontend.mdc @@ -27,6 +27,7 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served | Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem | | Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent | | Current duty view | `src/components/current-duty/CurrentDutyView.tsx` | +| Admin | `src/components/admin/` — useAdminPage, AdminDutyList, ReassignSheet | | Contact links | `src/components/contact/ContactLinks.tsx` | | State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied | | Hooks | `src/hooks/` — use-telegram-theme, use-telegram-auth, use-month-data, use-swipe, use-media-query, use-sticky-scroll, use-auto-refresh | @@ -42,6 +43,7 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served - **Auth:** `useTelegramAuth` provides initData for API; access gated for non-Telegram except localhost. - **i18n:** `useTranslation()` from store lang; `window.__DT_LANG` set by `/app/config.js` (backend). - **API:** `fetchDuties`, `fetchCalendarEvents` in `src/lib/api.ts`; pass initData, lang, AbortSignal; handle ACCESS_DENIED. +- **Heavy pages:** For feature-heavy routes (e.g. admin), use a custom hook (state, effects, callbacks) plus presentational components; keep the page as a thin layer (early returns + composition). Example: `admin/page.tsx` uses `useAdminPage`, `AdminDutyList`, and `ReassignSheet`. ## Testing diff --git a/webapp-next/src/app/admin/page.tsx b/webapp-next/src/app/admin/page.tsx index 5cbc483..743bccb 100644 --- a/webapp-next/src/app/admin/page.tsx +++ b/webapp-next/src/app/admin/page.tsx @@ -1,264 +1,37 @@ /** * Admin page: list duties for the month and reassign duty to another user. * Visible only to admins (link shown on calendar when GET /api/admin/me returns is_admin). - * Requires GET /api/admin/users and PATCH /api/admin/duties/:id (admin-only). + * Logic and heavy UI live in components/admin (useAdminPage, AdminDutyList, ReassignSheet). */ "use client"; -import { useEffect, useState, useCallback, useRef } from "react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { backButton } from "@telegram-apps/sdk-react"; -import { useAppStore } from "@/store/app-store"; -import { useShallow } from "zustand/react/shallow"; -import { useTelegramAuth } from "@/hooks/use-telegram-auth"; +import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin"; import { useTranslation } from "@/i18n/use-translation"; -import { - fetchDuties, - fetchAdminMe, - fetchAdminUsers, - patchAdminDuty, - AccessDeniedError, - type UserForAdmin, -} from "@/lib/api"; -import type { DutyWithUser } from "@/types"; -import { - firstDayOfMonth, - lastDayOfMonth, - localDateString, - formatHHMM, -} from "@/lib/date-utils"; import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, -} from "@/components/ui/sheet"; import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { LoadingState } from "@/components/states/LoadingState"; import { ErrorState } from "@/components/states/ErrorState"; -const PAGE_SIZE = 20; +const PAGE_WRAPPER_CLASS = + "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"; export default function AdminPage() { - const router = useRouter(); - const { initDataRaw, isLocalhost } = useTelegramAuth(); - const isAllowed = isLocalhost || !!initDataRaw; - - const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang }))); const { t, monthName } = useTranslation(); + const admin = useAdminPage(); - // Telegram BackButton: show on mount when in Mini App, navigate to calendar on click. - useEffect(() => { - if (isLocalhost) return; - let offClick: (() => void) | undefined; - try { - if (backButton.mount.isAvailable()) { - backButton.mount(); - } - if (backButton.show.isAvailable()) { - backButton.show(); - } - if (backButton.onClick.isAvailable()) { - offClick = backButton.onClick(() => router.push("/")); - } - } catch { - // Non-Telegram environment; BackButton not available. - } - return () => { - try { - if (typeof offClick === "function") offClick(); - if (backButton.hide.isAvailable()) { - backButton.hide(); - } - } catch { - // Ignore cleanup errors in non-Telegram environment. - } - }; - }, [isLocalhost, router]); - - const [users, setUsers] = useState([]); - const [duties, setDuties] = useState([]); - const [loadingUsers, setLoadingUsers] = useState(true); - const [loadingDuties, setLoadingDuties] = useState(true); - /** null = not yet checked, true = is admin, false = not admin (then adminAccessDenied is set). */ - const [adminCheckComplete, setAdminCheckComplete] = useState(null); - const [adminAccessDenied, setAdminAccessDenied] = useState(false); - const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState(null); - const [error, setError] = useState(null); - const [selectedDuty, setSelectedDuty] = useState(null); - const [selectedUserId, setSelectedUserId] = useState(""); - const [saving, setSaving] = useState(false); - const [reassignError, setReassignError] = useState(null); - const [successMessage, setSuccessMessage] = useState(null); - const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); - const sentinelRef = useRef(null); - - const currentMonth = useAppStore((s) => s.currentMonth); - const from = localDateString(firstDayOfMonth(currentMonth)); - const to = localDateString(lastDayOfMonth(currentMonth)); - - // Check admin status first; only then load users and duties (avoids extra 403 for non-admins). - useEffect(() => { - if (!isAllowed || !initDataRaw) return; - setAdminCheckComplete(null); - setAdminAccessDenied(false); - fetchAdminMe(initDataRaw, lang) - .then(({ is_admin }) => { - if (!is_admin) { - setAdminAccessDenied(true); - setAdminAccessDeniedDetail(null); - setAdminCheckComplete(false); - } else { - setAdminCheckComplete(true); - } - }) - .catch(() => { - setAdminAccessDenied(true); - setAdminAccessDeniedDetail(null); - setAdminCheckComplete(false); - }); - }, [isAllowed, initDataRaw, lang]); - - useEffect(() => { - if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return; - - const controller = new AbortController(); - setLoadingUsers(true); - fetchAdminUsers(initDataRaw, lang, controller.signal) - .then((list) => { - setUsers(list); - }) - .catch((e) => { - if ((e as Error)?.name === "AbortError") return; - if (e instanceof AccessDeniedError) { - setAdminAccessDenied(true); - setAdminAccessDeniedDetail(e.serverDetail ?? null); - } else { - setError(e instanceof Error ? e.message : String(e)); - } - }) - .finally(() => setLoadingUsers(false)); - return () => controller.abort(); - }, [isAllowed, initDataRaw, lang, adminCheckComplete]); - - useEffect(() => { - if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return; - - const controller = new AbortController(); - setLoadingDuties(true); - setError(null); - fetchDuties(from, to, initDataRaw, lang, controller.signal) - .then((list) => setDuties(list)) - .catch((e) => { - if ((e as Error)?.name === "AbortError") return; - setError(e instanceof Error ? e.message : String(e)); - }) - .finally(() => setLoadingDuties(false)); - return () => controller.abort(); - }, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete]); - - useEffect(() => { - setVisibleCount(PAGE_SIZE); - }, [from, to]); - - const openReassign = useCallback((duty: DutyWithUser) => { - setSelectedDuty(duty); - setSelectedUserId(duty.user_id); - setReassignError(null); - }, []); - - const closeReassign = useCallback(() => { - setSelectedDuty(null); - setSelectedUserId(""); - setReassignError(null); - }, []); - - const handleReassign = useCallback(() => { - if (!selectedDuty || selectedUserId === "" || !initDataRaw) return; - if (selectedUserId === selectedDuty.user_id) { - closeReassign(); - return; - } - setSaving(true); - setReassignError(null); - patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang) - .then((updated) => { - setDuties((prev) => - prev.map((d) => - d.id === updated.id - ? { - ...d, - user_id: updated.user_id, - full_name: - users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name, - } - : d - ) - ); - setSuccessMessage(t("admin.reassign_success")); - closeReassign(); - setTimeout(() => setSuccessMessage(null), 3000); - }) - .catch((e) => { - setReassignError(e instanceof Error ? e.message : String(e)); - }) - .finally(() => setSaving(false)); - }, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign]); - - const now = new Date(); - const dutyOnly = duties - .filter( - (d) => - d.event_type === "duty" && new Date(d.end_at) > now - ) - .sort( - (a, b) => - new Date(a.start_at).getTime() - new Date(b.start_at).getTime() - ); - - /** Users with role_id 1 (user) or 2 (admin) shown in reassign dropdown. */ - const usersForSelect = users.filter( - (u) => u.role_id === 1 || u.role_id === 2 - ); - - const visibleDuties = dutyOnly.slice(0, visibleCount); - const hasMore = visibleCount < dutyOnly.length; - - useEffect(() => { - if (!hasMore || !sentinelRef.current) return; - const el = sentinelRef.current; - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - setVisibleCount((prev) => - Math.min(prev + PAGE_SIZE, dutyOnly.length) - ); - } - }, - { root: null, rootMargin: "200px", threshold: 0 } - ); - observer.observe(el); - return () => observer.disconnect(); - }, [hasMore, dutyOnly.length]); - - const loading = loadingUsers || loadingDuties; - - if (!isAllowed) { + if (!admin.isAllowed) { return ( -
+
); } - if (isAllowed && initDataRaw && adminCheckComplete === null) { + if (admin.adminCheckComplete === null) { return ( -
+

{t("admin.loading_users")}

@@ -267,11 +40,13 @@ export default function AdminPage() { ); } - if (adminAccessDenied) { + if (admin.adminAccessDenied) { return ( -
+
-

{adminAccessDeniedDetail ?? t("admin.access_denied")}

+

+ {admin.adminAccessDeniedDetail ?? t("admin.access_denied")} +

@@ -281,170 +56,66 @@ export default function AdminPage() { } return ( -
+

- {t("admin.title")} — {monthName(currentMonth.getMonth())} {currentMonth.getFullYear()} + {t("admin.title")} — {monthName(admin.currentMonth.getMonth())}{" "} + {admin.currentMonth.getFullYear()}

- {successMessage && ( + {admin.successMessage && (

- {successMessage} + {admin.successMessage}

)} - {loading && ( + {admin.loading && (

{t("admin.loading_users")}

)} - {error && !loading && ( - window.location.reload()} className="my-3" /> + {admin.error && !admin.loading && ( + window.location.reload()} + className="my-3" + /> )} - {!loading && !error && ( + {!admin.loading && !admin.error && (

{t("admin.reassign_duty")}: {t("admin.select_user")}

- {dutyOnly.length === 0 ? ( -

{t("admin.no_duties")}

- ) : ( -
    - {visibleDuties.map((duty) => { - const start = new Date(duty.start_at); - const end = new Date(duty.end_at); - const dateStr = localDateString(start); - const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`; - return ( -
  • - -
  • - ); - })} - {hasMore && ( -
- )} +
)} - {selectedDuty !== null && ( - !open && closeReassign()}> - -
- -
- - {t("admin.reassign_duty")} - {t("admin.select_user")} - - {selectedDuty && ( -
-

- {localDateString(new Date(selectedDuty.start_at))}{" "} - {formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)} -

- {usersForSelect.length === 0 ? ( -

{t("admin.no_users_for_assign")}

- ) : ( -
- - -
- )} - {reassignError && ( -

- {reassignError} -

- )} -
- )} -
- - - - - - )} +
); } diff --git a/webapp-next/src/components/admin/AdminDutyList.tsx b/webapp-next/src/components/admin/AdminDutyList.tsx new file mode 100644 index 0000000..9784ae6 --- /dev/null +++ b/webapp-next/src/components/admin/AdminDutyList.tsx @@ -0,0 +1,66 @@ +/** + * Admin duty list: visible duties with infinite-scroll sentinel. Presentational only. + */ + +"use client"; + +import type { DutyWithUser } from "@/types"; +import { localDateString, formatHHMM } from "@/lib/date-utils"; + +export interface AdminDutyListProps { + /** Duties to show (already sliced to visibleCount by parent). */ + duties: DutyWithUser[]; + /** Whether there are more items; when true, sentinel is rendered for intersection observer. */ + hasMore: boolean; + /** Ref for the sentinel element (infinite scroll). */ + sentinelRef: React.RefObject; + /** Called when user selects a duty to reassign. */ + onSelectDuty: (duty: DutyWithUser) => void; + /** Translation function. */ + t: (key: string, params?: Record) => string; +} + +export function AdminDutyList({ + duties, + hasMore, + sentinelRef, + onSelectDuty, + t, +}: AdminDutyListProps) { + if (duties.length === 0) { + return

{t("admin.no_duties")}

; + } + + return ( +
    + {duties.map((duty) => { + const dateStr = localDateString(new Date(duty.start_at)); + const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`; + return ( +
  • + +
  • + ); + })} + {hasMore && ( +
+ ); +} diff --git a/webapp-next/src/components/admin/ReassignSheet.tsx b/webapp-next/src/components/admin/ReassignSheet.tsx new file mode 100644 index 0000000..ad6617c --- /dev/null +++ b/webapp-next/src/components/admin/ReassignSheet.tsx @@ -0,0 +1,158 @@ +/** + * Bottom sheet for reassigning a duty to another user. Uses design tokens and safe area. + */ + +"use client"; + +import type { DutyWithUser } from "@/types"; +import type { UserForAdmin } from "@/lib/api"; +import { localDateString, formatHHMM } from "@/lib/date-utils"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, +} from "@/components/ui/sheet"; + +export interface ReassignSheetProps { + /** Whether the sheet is open (and not in exiting state). */ + open: boolean; + /** Selected duty to reassign; when null, content may still render with previous duty during close. */ + selectedDuty: DutyWithUser | null; + /** Current selected user id for the select. */ + selectedUserId: number | ""; + /** Called when user changes selection. */ + setSelectedUserId: (value: number | "") => void; + /** Users to show in the dropdown (role_id 1 or 2). */ + users: UserForAdmin[]; + /** Reassign request in progress. */ + saving: boolean; + /** Error message from last reassign attempt. */ + reassignError: string | null; + /** Called when user confirms reassign. */ + onReassign: () => void; + /** Called when user requests close (start close animation). */ + onRequestClose: () => void; + /** Called when close animation ends (clear state). */ + onCloseAnimationEnd: () => void; + /** Translation function. */ + t: (key: string, params?: Record) => string; +} + +export function ReassignSheet({ + open, + selectedDuty, + selectedUserId, + setSelectedUserId, + users, + saving, + reassignError, + onReassign, + onRequestClose, + onCloseAnimationEnd, + t, +}: ReassignSheetProps) { + return ( + !isOpen && onRequestClose()}> + e.preventDefault()} + > +
+ +
+ + {t("admin.reassign_duty")} + {t("admin.select_user")} + + {selectedDuty && ( +
+

+ {localDateString(new Date(selectedDuty.start_at))}{" "} + {formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)} +

+ {users.length === 0 ? ( +

+ {t("admin.no_users_for_assign")} +

+ ) : ( +
+ + +
+ )} + {reassignError && ( +

+ {reassignError} +

+ )} +
+ )} +
+ + + + + + ); +} diff --git a/webapp-next/src/components/admin/index.ts b/webapp-next/src/components/admin/index.ts new file mode 100644 index 0000000..f21526b --- /dev/null +++ b/webapp-next/src/components/admin/index.ts @@ -0,0 +1,9 @@ +/** + * Admin feature: hook and presentational components for the admin page. + */ + +export { useAdminPage } from "./useAdminPage"; +export { AdminDutyList } from "./AdminDutyList"; +export { ReassignSheet } from "./ReassignSheet"; +export type { AdminDutyListProps } from "./AdminDutyList"; +export type { ReassignSheetProps } from "./ReassignSheet"; diff --git a/webapp-next/src/components/admin/useAdminPage.ts b/webapp-next/src/components/admin/useAdminPage.ts new file mode 100644 index 0000000..bf0a641 --- /dev/null +++ b/webapp-next/src/components/admin/useAdminPage.ts @@ -0,0 +1,258 @@ +/** + * Admin page hook: BackButton, admin check, users/duties loading, reassign sheet state, + * infinite scroll, and derived data. Used by the admin page for composition. + */ + +"use client"; + +import { useEffect, useState, useCallback, useRef } from "react"; +import { useRouter } from "next/navigation"; +import { backButton } from "@telegram-apps/sdk-react"; +import { useAppStore } from "@/store/app-store"; +import { useShallow } from "zustand/react/shallow"; +import { useTelegramAuth } from "@/hooks/use-telegram-auth"; +import { useTranslation } from "@/i18n/use-translation"; +import { + fetchDuties, + fetchAdminMe, + fetchAdminUsers, + patchAdminDuty, + AccessDeniedError, + type UserForAdmin, +} from "@/lib/api"; +import type { DutyWithUser } from "@/types"; +import { + firstDayOfMonth, + lastDayOfMonth, + localDateString, +} from "@/lib/date-utils"; + +const PAGE_SIZE = 20; + +export function useAdminPage() { + const router = useRouter(); + const { initDataRaw, isLocalhost } = useTelegramAuth(); + const isAllowed = isLocalhost || !!initDataRaw; + + const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang }))); + const currentMonth = useAppStore((s) => s.currentMonth); + const { t } = useTranslation(); + + const [users, setUsers] = useState([]); + const [duties, setDuties] = useState([]); + const [loadingUsers, setLoadingUsers] = useState(true); + const [loadingDuties, setLoadingDuties] = useState(true); + const [adminCheckComplete, setAdminCheckComplete] = useState(null); + const [adminAccessDenied, setAdminAccessDenied] = useState(false); + const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState(null); + const [error, setError] = useState(null); + const [selectedDuty, setSelectedDuty] = useState(null); + const [selectedUserId, setSelectedUserId] = useState(""); + const [saving, setSaving] = useState(false); + const [reassignError, setReassignError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + const [sheetExiting, setSheetExiting] = useState(false); + const sentinelRef = useRef(null); + + const from = localDateString(firstDayOfMonth(currentMonth)); + const to = localDateString(lastDayOfMonth(currentMonth)); + + useEffect(() => { + if (isLocalhost) return; + let offClick: (() => void) | undefined; + try { + if (backButton.mount.isAvailable()) { + backButton.mount(); + } + if (backButton.show.isAvailable()) { + backButton.show(); + } + if (backButton.onClick.isAvailable()) { + offClick = backButton.onClick(() => router.push("/")); + } + } catch { + // Non-Telegram environment; BackButton not available. + } + return () => { + try { + if (typeof offClick === "function") offClick(); + if (backButton.hide.isAvailable()) { + backButton.hide(); + } + } catch { + // Ignore cleanup errors in non-Telegram environment. + } + }; + }, [isLocalhost, router]); + + useEffect(() => { + if (!isAllowed || !initDataRaw) return; + setAdminCheckComplete(null); + setAdminAccessDenied(false); + fetchAdminMe(initDataRaw, lang) + .then(({ is_admin }) => { + if (!is_admin) { + setAdminAccessDenied(true); + setAdminAccessDeniedDetail(null); + setAdminCheckComplete(false); + } else { + setAdminCheckComplete(true); + } + }) + .catch(() => { + setAdminAccessDenied(true); + setAdminAccessDeniedDetail(null); + setAdminCheckComplete(false); + }); + }, [isAllowed, initDataRaw, lang]); + + useEffect(() => { + if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return; + const controller = new AbortController(); + setLoadingUsers(true); + fetchAdminUsers(initDataRaw, lang, controller.signal) + .then((list) => setUsers(list)) + .catch((e) => { + if ((e as Error)?.name === "AbortError") return; + if (e instanceof AccessDeniedError) { + setAdminAccessDenied(true); + setAdminAccessDeniedDetail(e.serverDetail ?? null); + } else { + setError(e instanceof Error ? e.message : String(e)); + } + }) + .finally(() => setLoadingUsers(false)); + return () => controller.abort(); + }, [isAllowed, initDataRaw, lang, adminCheckComplete]); + + useEffect(() => { + if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return; + const controller = new AbortController(); + setLoadingDuties(true); + setError(null); + fetchDuties(from, to, initDataRaw, lang, controller.signal) + .then((list) => setDuties(list)) + .catch((e) => { + if ((e as Error)?.name === "AbortError") return; + setError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => setLoadingDuties(false)); + return () => controller.abort(); + }, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete]); + + useEffect(() => { + setVisibleCount(PAGE_SIZE); + }, [from, to]); + + const closeReassign = useCallback(() => { + setSelectedDuty(null); + setSelectedUserId(""); + setReassignError(null); + setSheetExiting(false); + }, []); + + useEffect(() => { + if (!sheetExiting) return; + const fallback = window.setTimeout(() => { + closeReassign(); + }, 320); + return () => window.clearTimeout(fallback); + }, [sheetExiting, closeReassign]); + + const openReassign = useCallback((duty: DutyWithUser) => { + setSelectedDuty(duty); + setSelectedUserId(duty.user_id); + setReassignError(null); + }, []); + + const requestCloseSheet = useCallback(() => { + setSheetExiting(true); + }, []); + + const handleReassign = useCallback(() => { + if (!selectedDuty || selectedUserId === "" || !initDataRaw) return; + if (selectedUserId === selectedDuty.user_id) { + closeReassign(); + return; + } + setSaving(true); + setReassignError(null); + patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang) + .then((updated) => { + setDuties((prev) => + prev.map((d) => + d.id === updated.id + ? { + ...d, + user_id: updated.user_id, + full_name: + users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name, + } + : d + ) + ); + setSuccessMessage(t("admin.reassign_success")); + requestCloseSheet(); + setTimeout(() => setSuccessMessage(null), 3000); + }) + .catch((e) => { + setReassignError(e instanceof Error ? e.message : String(e)); + }) + .finally(() => setSaving(false)); + }, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t]); + + const now = new Date(); + const dutyOnly = duties + .filter((d) => d.event_type === "duty" && new Date(d.end_at) > now) + .sort( + (a, b) => + new Date(a.start_at).getTime() - new Date(b.start_at).getTime() + ); + + const usersForSelect = users.filter((u) => u.role_id === 1 || u.role_id === 2); + const visibleDuties = dutyOnly.slice(0, visibleCount); + const hasMore = visibleCount < dutyOnly.length; + const loading = loadingUsers || loadingDuties; + + useEffect(() => { + if (!hasMore || !sentinelRef.current) return; + const el = sentinelRef.current; + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]?.isIntersecting) { + setVisibleCount((prev) => Math.min(prev + PAGE_SIZE, dutyOnly.length)); + } + }, + { root: null, rootMargin: "200px", threshold: 0 } + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasMore, dutyOnly.length]); + + return { + isAllowed, + adminCheckComplete, + adminAccessDenied, + adminAccessDeniedDetail, + error, + loading, + currentMonth, + successMessage, + dutyOnly, + usersForSelect, + visibleDuties, + hasMore, + sentinelRef, + selectedDuty, + selectedUserId, + setSelectedUserId, + saving, + reassignError, + sheetExiting, + openReassign, + requestCloseSheet, + handleReassign, + closeReassign, + }; +} diff --git a/webapp-next/src/lib/api.ts b/webapp-next/src/lib/api.ts index fefdf29..d27a6f5 100644 --- a/webapp-next/src/lib/api.ts +++ b/webapp-next/src/lib/api.ts @@ -128,6 +128,27 @@ function buildFetchOptions( return { headers, signal: controller.signal, cleanup }; } +/** + * Parse 403 response body for user-facing detail. Returns default i18n message if body is invalid. + */ +async function handle403Response( + res: Response, + acceptLang: ApiLang, + defaultI18nKey: string +): Promise { + let detail = translate(acceptLang, defaultI18nKey); + try { + const body = await res.json(); + if (body && (body as { detail?: string }).detail !== undefined) { + const d = (body as { detail: string | { msg?: string } }).detail; + detail = typeof d === "string" ? d : (d.msg ?? JSON.stringify(d)); + } + } catch { + /* ignore */ + } + return detail; +} + /** * Fetch duties for date range. Throws AccessDeniedError on 403. * Rethrows AbortError when the request is cancelled (e.g. stale load). @@ -148,17 +169,7 @@ export async function fetchDuties( const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); if (res.status === 403) { logger.warn("Access denied", from, to); - let detail = translate(acceptLang, "access_denied"); - try { - const body = await res.json(); - if (body && (body as { detail?: string }).detail !== undefined) { - const d = (body as { detail: string | { msg?: string } }).detail; - detail = - typeof d === "string" ? d : (d.msg ?? JSON.stringify(d)); - } - } catch { - /* ignore */ - } + const detail = await handle403Response(res, acceptLang, "access_denied"); throw new AccessDeniedError(API_ACCESS_DENIED, detail); } if (!res.ok) { @@ -197,17 +208,7 @@ export async function fetchCalendarEvents( const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); if (res.status === 403) { logger.warn("Access denied", from, to, "calendar-events"); - let detail = translate(acceptLang, "access_denied"); - try { - const body = await res.json(); - if (body && (body as { detail?: string }).detail !== undefined) { - const d = (body as { detail: string | { msg?: string } }).detail; - detail = - typeof d === "string" ? d : (d.msg ?? JSON.stringify(d)); - } - } catch { - /* ignore */ - } + const detail = await handle403Response(res, acceptLang, "access_denied"); throw new AccessDeniedError(API_ACCESS_DENIED, detail); } if (!res.ok) return []; @@ -273,17 +274,7 @@ export async function fetchAdminUsers( logger.debug("API request", "/api/admin/users"); const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); if (res.status === 403) { - let detail = translate(acceptLang, "admin.access_denied"); - try { - const body = await res.json(); - if (body && (body as { detail?: string }).detail !== undefined) { - const d = (body as { detail: string | { msg?: string } }).detail; - detail = - typeof d === "string" ? d : (d.msg ?? JSON.stringify(d)); - } - } catch { - /* ignore */ - } + const detail = await handle403Response(res, acceptLang, "admin.access_denied"); throw new AccessDeniedError(API_ACCESS_DENIED, detail); } if (!res.ok) { @@ -338,17 +329,7 @@ export async function patchAdminDuty( signal: opts.signal, }); if (res.status === 403) { - let detail = translate(acceptLang, "admin.access_denied"); - try { - const body = await res.json(); - if (body && (body as { detail?: string }).detail !== undefined) { - const d = (body as { detail: string | { msg?: string } }).detail; - detail = - typeof d === "string" ? d : (d.msg ?? JSON.stringify(d)); - } - } catch { - /* ignore */ - } + const detail = await handle403Response(res, acceptLang, "admin.access_denied"); throw new AccessDeniedError(API_ACCESS_DENIED, detail); } const data = await res.json().catch(() => ({}));