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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
66
webapp-next/src/components/admin/AdminDutyList.tsx
Normal file
66
webapp-next/src/components/admin/AdminDutyList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
158
webapp-next/src/components/admin/ReassignSheet.tsx
Normal file
158
webapp-next/src/components/admin/ReassignSheet.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
webapp-next/src/components/admin/index.ts
Normal file
9
webapp-next/src/components/admin/index.ts
Normal 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";
|
||||||
258
webapp-next/src/components/admin/useAdminPage.ts
Normal file
258
webapp-next/src/components/admin/useAdminPage.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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(() => ({}));
|
||||||
|
|||||||
Reference in New Issue
Block a user