feat: enhance admin page functionality with new components and hooks

- Added `AdminDutyList` and `ReassignSheet` components for improved duty management in the admin panel.
- Introduced `useAdminPage` hook to encapsulate admin-related logic, including user and duty loading, and reassign functionality.
- Updated `frontend.mdc` documentation to reflect new admin components and their usage.
- Improved error handling for API responses, particularly for access denied scenarios.
- Refactored admin page to utilize new components, streamlining the UI and enhancing maintainability.
This commit is contained in:
2026-03-06 10:17:28 +03:00
parent c390a4dd6e
commit a3152a4545
7 changed files with 565 additions and 420 deletions

View File

@@ -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 | | Duty list | `src/components/duty/` — DutyList, DutyTimelineCard, DutyItem |
| Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent | | Day detail | `src/components/day-detail/` — DayDetail (Sheet/Popover), DayDetailContent |
| Current duty view | `src/components/current-duty/CurrentDutyView.tsx` | | Current duty view | `src/components/current-duty/CurrentDutyView.tsx` |
| Admin | `src/components/admin/` — useAdminPage, AdminDutyList, ReassignSheet |
| Contact links | `src/components/contact/ContactLinks.tsx` | | Contact links | `src/components/contact/ContactLinks.tsx` |
| State views | `src/components/states/` — LoadingState, ErrorState, AccessDenied | | 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 | | 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. - **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). - **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. - **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 ## Testing

View File

@@ -1,264 +1,37 @@
/** /**
* Admin page: list duties for the month and reassign duty to another user. * 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). * 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"; "use client";
import { useEffect, useState, useCallback, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useAdminPage, AdminDutyList, ReassignSheet } from "@/components/admin";
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 { 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 { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetFooter,
} from "@/components/ui/sheet";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen"; import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { LoadingState } from "@/components/states/LoadingState"; import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState"; 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() { 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 { t, monthName } = useTranslation();
const admin = useAdminPage();
// Telegram BackButton: show on mount when in Mini App, navigate to calendar on click. if (!admin.isAllowed) {
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<UserForAdmin[]>([]);
const [duties, setDuties] = useState<DutyWithUser[]>([]);
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<boolean | null>(null);
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
const [saving, setSaving] = useState(false);
const [reassignError, setReassignError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const sentinelRef = useRef<HTMLLIElement | null>(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) {
return ( 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 className={PAGE_WRAPPER_CLASS}>
<AccessDeniedScreen primaryAction="reload" /> <AccessDeniedScreen primaryAction="reload" />
</div> </div>
); );
} }
if (isAllowed && initDataRaw && adminCheckComplete === null) { if (admin.adminCheckComplete === null) {
return ( 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 className={PAGE_WRAPPER_CLASS}>
<div className="py-4 flex flex-col items-center gap-2"> <div className="py-4 flex flex-col items-center gap-2">
<LoadingState /> <LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p> <p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
@@ -267,11 +40,13 @@ export default function AdminPage() {
); );
} }
if (adminAccessDenied) { if (admin.adminAccessDenied) {
return ( 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 className={PAGE_WRAPPER_CLASS}>
<div className="flex flex-col gap-4 py-6"> <div className="flex flex-col gap-4 py-6">
<p className="text-muted-foreground">{adminAccessDeniedDetail ?? t("admin.access_denied")}</p> <p className="text-muted-foreground">
{admin.adminAccessDeniedDetail ?? t("admin.access_denied")}
</p>
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href="/">{t("admin.back_to_calendar")}</Link> <Link href="/">{t("admin.back_to_calendar")}</Link>
</Button> </Button>
@@ -281,170 +56,66 @@ export default function AdminPage() {
} }
return ( 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 className={PAGE_WRAPPER_CLASS}>
<header className="sticky top-0 z-10 flex items-center justify-between border-b bg-[var(--header-bg)] py-3"> <header className="sticky top-0 z-10 flex items-center justify-between border-b bg-[var(--header-bg)] py-3">
<h1 className="text-lg font-semibold"> <h1 className="text-lg font-semibold">
{t("admin.title")} {monthName(currentMonth.getMonth())} {currentMonth.getFullYear()} {t("admin.title")} {monthName(admin.currentMonth.getMonth())}{" "}
{admin.currentMonth.getFullYear()}
</h1> </h1>
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
<Link href="/">{t("admin.back_to_calendar")}</Link> <Link href="/">{t("admin.back_to_calendar")}</Link>
</Button> </Button>
</header> </header>
{successMessage && ( {admin.successMessage && (
<p className="mt-3 text-sm text-[var(--duty)]" role="status"> <p className="mt-3 text-sm text-[var(--duty)]" role="status">
{successMessage} {admin.successMessage}
</p> </p>
)} )}
{loading && ( {admin.loading && (
<div className="py-4 flex flex-col items-center gap-2"> <div className="py-4 flex flex-col items-center gap-2">
<LoadingState /> <LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p> <p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
</div> </div>
)} )}
{error && !loading && ( {admin.error && !admin.loading && (
<ErrorState message={error} onRetry={() => window.location.reload()} className="my-3" /> <ErrorState
message={admin.error}
onRetry={() => window.location.reload()}
className="my-3"
/>
)} )}
{!loading && !error && ( {!admin.loading && !admin.error && (
<div className="mt-3 flex flex-col gap-2"> <div className="mt-3 flex flex-col gap-2">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t("admin.reassign_duty")}: {t("admin.select_user")} {t("admin.reassign_duty")}: {t("admin.select_user")}
</p> </p>
{dutyOnly.length === 0 ? ( <AdminDutyList
<p className="text-muted-foreground py-4">{t("admin.no_duties")}</p> duties={admin.visibleDuties}
) : ( hasMore={admin.hasMore}
<ul className="flex flex-col gap-1.5" aria-label={t("admin.list_aria")}> sentinelRef={admin.sentinelRef}
{visibleDuties.map((duty) => { onSelectDuty={admin.openReassign}
const start = new Date(duty.start_at); t={t}
const end = new Date(duty.end_at); />
const dateStr = localDateString(start);
const timeStr = `${formatHHMM(duty.start_at)} ${formatHHMM(duty.end_at)}`;
return (
<li key={duty.id}>
<button
type="button"
onClick={() => openReassign(duty)}
aria-label={t("admin.reassign_aria", {
date: dateStr,
time: timeStr,
name: duty.full_name,
})}
className="w-full rounded-lg border border-l-[3px] border-l-duty bg-surface px-3 py-2.5 text-left text-sm transition-colors hover:bg-[var(--surface-hover)] focus-visible:outline-accent"
>
<span className="font-medium">{dateStr}</span>
<span className="mx-2 text-muted-foreground">·</span>
<span className="text-muted-foreground">{timeStr}</span>
<span className="mx-2 text-muted-foreground">·</span>
<span>{duty.full_name}</span>
</button>
</li>
);
})}
{hasMore && (
<li ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
)}
</ul>
)}
</div> </div>
)} )}
{selectedDuty !== null && ( <ReassignSheet
<Sheet open onOpenChange={(open) => !open && closeReassign()}> open={!admin.sheetExiting && admin.selectedDuty !== null}
<SheetContent selectedDuty={admin.selectedDuty}
side="bottom" selectedUserId={admin.selectedUserId}
className="rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)]" setSelectedUserId={admin.setSelectedUserId}
overlayClassName="backdrop-blur-md" users={admin.usersForSelect}
showCloseButton={false} saving={admin.saving}
> reassignError={admin.reassignError}
<div className="relative px-4"> onReassign={admin.handleReassign}
<Button onRequestClose={admin.requestCloseSheet}
type="button" onCloseAnimationEnd={admin.closeReassign}
variant="ghost" t={t}
size="icon" />
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
onClick={closeReassign}
aria-label={t("day_detail.close")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</Button>
<div
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
aria-hidden
/>
<SheetHeader className="p-0">
<SheetTitle>{t("admin.reassign_duty")}</SheetTitle>
<SheetDescription>{t("admin.select_user")}</SheetDescription>
</SheetHeader>
{selectedDuty && (
<div className="flex flex-col gap-4 pt-2 pb-4">
<p className="text-sm text-muted-foreground">
{localDateString(new Date(selectedDuty.start_at))}{" "}
{formatHHMM(selectedDuty.start_at)} {formatHHMM(selectedDuty.end_at)}
</p>
{usersForSelect.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("admin.no_users_for_assign")}</p>
) : (
<div className="flex flex-col gap-2">
<label htmlFor="admin-user-select" className="text-sm font-medium">
{t("admin.select_user")}
</label>
<select
id="admin-user-select"
value={selectedUserId === "" ? "" : String(selectedUserId)}
onChange={(e) => setSelectedUserId(e.target.value === "" ? "" : Number(e.target.value))}
className="rounded-md border bg-background px-3 py-2 text-sm focus-visible:outline-accent"
disabled={saving}
>
{usersForSelect.map((u) => (
<option key={u.id} value={u.id}>
{u.full_name}
{u.username ? ` (@${u.username})` : ""}
</option>
))}
</select>
</div>
)}
{reassignError && (
<p className="text-sm text-destructive" role="alert">
{reassignError}
</p>
)}
</div>
)}
</div>
<SheetFooter className="flex-row justify-end gap-2">
<Button
onClick={handleReassign}
disabled={
saving ||
selectedUserId === "" ||
selectedUserId === selectedDuty?.user_id ||
usersForSelect.length === 0
}
>
{saving ? t("loading") : t("admin.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</div> </div>
); );
} }

View File

@@ -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<HTMLLIElement | null>;
/** Called when user selects a duty to reassign. */
onSelectDuty: (duty: DutyWithUser) => void;
/** Translation function. */
t: (key: string, params?: Record<string, string>) => string;
}
export function AdminDutyList({
duties,
hasMore,
sentinelRef,
onSelectDuty,
t,
}: AdminDutyListProps) {
if (duties.length === 0) {
return <p className="text-muted-foreground py-4">{t("admin.no_duties")}</p>;
}
return (
<ul className="flex flex-col gap-1.5" aria-label={t("admin.list_aria")}>
{duties.map((duty) => {
const dateStr = localDateString(new Date(duty.start_at));
const timeStr = `${formatHHMM(duty.start_at)} ${formatHHMM(duty.end_at)}`;
return (
<li key={duty.id}>
<button
type="button"
onClick={() => onSelectDuty(duty)}
aria-label={t("admin.reassign_aria", {
date: dateStr,
time: timeStr,
name: duty.full_name,
})}
className="w-full rounded-lg border border-l-[3px] border-l-duty bg-surface px-3 py-2.5 text-left text-sm transition-colors hover:bg-[var(--surface-hover)] focus-visible:outline-accent flex flex-col gap-0.5"
>
<span>
<span className="font-medium">{dateStr}</span>
<span className="mx-2 text-muted-foreground">·</span>
<span className="text-muted-foreground">{timeStr}</span>
</span>
<span>{duty.full_name}</span>
</button>
</li>
);
})}
{hasMore && (
<li ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
)}
</ul>
);
}

View File

@@ -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, string>) => string;
}
export function ReassignSheet({
open,
selectedDuty,
selectedUserId,
setSelectedUserId,
users,
saving,
reassignError,
onReassign,
onRequestClose,
onCloseAnimationEnd,
t,
}: ReassignSheetProps) {
return (
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onRequestClose()}>
<SheetContent
side="bottom"
className="rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)] pb-[calc(24px+env(safe-area-inset-bottom,0px))]"
overlayClassName="backdrop-blur-md"
showCloseButton={false}
onCloseAnimationEnd={onCloseAnimationEnd}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="relative px-4">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
onClick={onRequestClose}
aria-label={t("day_detail.close")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</Button>
<div
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
aria-hidden
/>
<SheetHeader className="p-0">
<SheetTitle>{t("admin.reassign_duty")}</SheetTitle>
<SheetDescription>{t("admin.select_user")}</SheetDescription>
</SheetHeader>
{selectedDuty && (
<div className="flex flex-col gap-4 pt-2 pb-4">
<p className="text-sm text-muted-foreground">
{localDateString(new Date(selectedDuty.start_at))}{" "}
{formatHHMM(selectedDuty.start_at)} {formatHHMM(selectedDuty.end_at)}
</p>
{users.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t("admin.no_users_for_assign")}
</p>
) : (
<div className="flex flex-col gap-2">
<label htmlFor="admin-user-select" className="text-sm font-medium">
{t("admin.select_user")}
</label>
<select
id="admin-user-select"
value={selectedUserId === "" ? "" : String(selectedUserId)}
onChange={(e) =>
setSelectedUserId(e.target.value === "" ? "" : Number(e.target.value))
}
className="rounded-md border bg-background px-3 py-2 text-sm focus-visible:outline-accent"
disabled={saving}
>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.full_name}
{u.username ? ` (@${u.username})` : ""}
</option>
))}
</select>
</div>
)}
{reassignError && (
<p className="text-sm text-destructive" role="alert">
{reassignError}
</p>
)}
</div>
)}
</div>
<SheetFooter className="flex-row justify-end gap-2">
<Button
onClick={onReassign}
disabled={
saving ||
selectedUserId === "" ||
selectedUserId === selectedDuty?.user_id ||
users.length === 0
}
>
{saving ? t("loading") : t("admin.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
);
}

View File

@@ -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";

View File

@@ -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<UserForAdmin[]>([]);
const [duties, setDuties] = useState<DutyWithUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [loadingDuties, setLoadingDuties] = useState(true);
const [adminCheckComplete, setAdminCheckComplete] = useState<boolean | null>(null);
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
const [saving, setSaving] = useState(false);
const [reassignError, setReassignError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [sheetExiting, setSheetExiting] = useState(false);
const sentinelRef = useRef<HTMLLIElement | null>(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,
};
}

View File

@@ -128,6 +128,27 @@ function buildFetchOptions(
return { headers, signal: controller.signal, cleanup }; 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<string> {
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. * Fetch duties for date range. Throws AccessDeniedError on 403.
* Rethrows AbortError when the request is cancelled (e.g. stale load). * 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 }); const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) { if (res.status === 403) {
logger.warn("Access denied", from, to); logger.warn("Access denied", from, to);
let detail = translate(acceptLang, "access_denied"); const detail = await handle403Response(res, 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 */
}
throw new AccessDeniedError(API_ACCESS_DENIED, detail); throw new AccessDeniedError(API_ACCESS_DENIED, detail);
} }
if (!res.ok) { if (!res.ok) {
@@ -197,17 +208,7 @@ export async function fetchCalendarEvents(
const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) { if (res.status === 403) {
logger.warn("Access denied", from, to, "calendar-events"); logger.warn("Access denied", from, to, "calendar-events");
let detail = translate(acceptLang, "access_denied"); const detail = await handle403Response(res, 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 */
}
throw new AccessDeniedError(API_ACCESS_DENIED, detail); throw new AccessDeniedError(API_ACCESS_DENIED, detail);
} }
if (!res.ok) return []; if (!res.ok) return [];
@@ -273,17 +274,7 @@ export async function fetchAdminUsers(
logger.debug("API request", "/api/admin/users"); logger.debug("API request", "/api/admin/users");
const res = await fetch(url, { headers: opts.headers, signal: opts.signal }); const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
if (res.status === 403) { if (res.status === 403) {
let detail = translate(acceptLang, "admin.access_denied"); const detail = await handle403Response(res, 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 */
}
throw new AccessDeniedError(API_ACCESS_DENIED, detail); throw new AccessDeniedError(API_ACCESS_DENIED, detail);
} }
if (!res.ok) { if (!res.ok) {
@@ -338,17 +329,7 @@ export async function patchAdminDuty(
signal: opts.signal, signal: opts.signal,
}); });
if (res.status === 403) { if (res.status === 403) {
let detail = translate(acceptLang, "admin.access_denied"); const detail = await handle403Response(res, 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 */
}
throw new AccessDeniedError(API_ACCESS_DENIED, detail); throw new AccessDeniedError(API_ACCESS_DENIED, detail);
} }
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));