feat: enhance admin page and testing functionality
- Updated admin page to include navigation buttons for month selection, improving user experience. - Refactored `AdminDutyList` to group duties by date, enhancing the display and organization of duties. - Improved error handling in `ReassignSheet` by using i18n keys for error messages, ensuring better localization support. - Enhanced tests for admin page and components to reflect recent changes, ensuring accuracy in functionality and accessibility. - Added event dispatch for configuration loading in the app configuration, improving integration with the Telegram Mini App.
This commit is contained in:
@@ -55,7 +55,7 @@ function mockFetchForAdmin(
|
||||
const adminMe = options?.adminMe ?? { is_admin: true };
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn((url: string, init?: RequestInit) => {
|
||||
vi.fn((url: string, _init?: RequestInit) => {
|
||||
if (url.includes("/api/admin/me")) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
@@ -163,7 +163,7 @@ describe("AdminPage", () => {
|
||||
fireEvent.click(dutyButton);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByLabelText(/select user|выберите пользователя/i)
|
||||
screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole("button", { name: /save|сохранить/i })).toBeInTheDocument();
|
||||
@@ -231,10 +231,9 @@ describe("AdminPage", () => {
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/select user|выберите пользователя/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })).toBeInTheDocument();
|
||||
});
|
||||
const select = screen.getByLabelText(/select user|выберите пользователя/i);
|
||||
fireEvent.change(select, { target: { value: "2" } });
|
||||
fireEvent.click(screen.getByRole("radio", { name: /Bob/ }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i }));
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useTranslation } from "@/i18n/use-translation";
|
||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||
import { LoadingState } from "@/components/states/LoadingState";
|
||||
import { ErrorState } from "@/components/states/ErrorState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon } from "lucide-react";
|
||||
|
||||
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";
|
||||
@@ -50,27 +52,54 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const month = admin.currentMonth.getMonth();
|
||||
const year = admin.currentMonth.getFullYear();
|
||||
const month = admin.adminMonth.getMonth();
|
||||
const year = admin.adminMonth.getFullYear();
|
||||
|
||||
return (
|
||||
<div className={PAGE_WRAPPER_CLASS}>
|
||||
<header className="sticky top-0 z-10 flex flex-col items-center border-b bg-[var(--header-bg)] py-3">
|
||||
<h1
|
||||
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
|
||||
aria-label={`${t("admin.title")}, ${monthName(month)} ${year}`}
|
||||
>
|
||||
<span className="text-xs font-normal leading-none text-muted">
|
||||
{year}
|
||||
</span>
|
||||
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
|
||||
{monthName(month)}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex w-full items-center justify-between px-1">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||
aria-label={t("nav.prev_month")}
|
||||
disabled={admin.loading}
|
||||
onClick={admin.onPrevMonth}
|
||||
>
|
||||
<ChevronLeftIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
<h1
|
||||
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
|
||||
aria-label={`${t("admin.title")}, ${monthName(month)} ${year}`}
|
||||
>
|
||||
<span className="text-xs font-normal leading-none text-muted">
|
||||
{year}
|
||||
</span>
|
||||
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
|
||||
{monthName(month)}
|
||||
</span>
|
||||
</h1>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||
aria-label={t("nav.next_month")}
|
||||
disabled={admin.loading}
|
||||
onClick={admin.onNextMonth}
|
||||
>
|
||||
<ChevronRightIcon className="size-5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground m-0 mt-1">
|
||||
{admin.loading ? "…" : t("admin.duties_count", { count: String(admin.dutyOnly.length) })}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{admin.successMessage && (
|
||||
<p className="mt-3 text-sm text-[var(--duty)]" role="status">
|
||||
<p className="mt-3 text-sm text-[var(--duty)]" role="status" aria-live="polite">
|
||||
{admin.successMessage}
|
||||
</p>
|
||||
)}
|
||||
@@ -96,7 +125,7 @@ export default function AdminPage() {
|
||||
{t("admin.reassign_duty")}: {t("admin.select_user")}
|
||||
</p>
|
||||
<AdminDutyList
|
||||
duties={admin.visibleDuties}
|
||||
groups={admin.visibleGroups}
|
||||
hasMore={admin.hasMore}
|
||||
sentinelRef={admin.sentinelRef}
|
||||
onSelectDuty={admin.openReassign}
|
||||
@@ -112,7 +141,7 @@ export default function AdminPage() {
|
||||
setSelectedUserId={admin.setSelectedUserId}
|
||||
users={admin.usersForSelect}
|
||||
saving={admin.saving}
|
||||
reassignError={admin.reassignError}
|
||||
reassignErrorKey={admin.reassignErrorKey}
|
||||
onReassign={admin.handleReassign}
|
||||
onRequestClose={admin.requestCloseSheet}
|
||||
onCloseAnimationEnd={admin.closeReassign}
|
||||
|
||||
@@ -50,12 +50,11 @@ describe("Page", () => {
|
||||
});
|
||||
|
||||
it("sets document title for ru when store lang is ru", async () => {
|
||||
(globalThis.window as unknown as { __DT_LANG?: string }).__DT_LANG = "ru";
|
||||
useAppStore.getState().setLang("ru");
|
||||
render(<Page />);
|
||||
await screen.findByRole("grid", { name: "Calendar" });
|
||||
await waitFor(() => {
|
||||
expect(document.title).toBe("Календарь дежурств");
|
||||
});
|
||||
expect(document.title).toBe("Календарь дежурств");
|
||||
expect(document.documentElement.lang).toBe("ru");
|
||||
});
|
||||
|
||||
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
||||
|
||||
@@ -4,16 +4,51 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import { localDateString, formatHHMM } from "@/lib/date-utils";
|
||||
import { localDateString, formatHHMM, dateKeyToDDMM } from "@/lib/date-utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface AdminDutyGroup {
|
||||
dateKey: string;
|
||||
duties: DutyWithUser[];
|
||||
}
|
||||
|
||||
/** Empty state when there are no duties: message and "Back to calendar" CTA. */
|
||||
function AdminEmptyState({
|
||||
t,
|
||||
}: {
|
||||
t: (key: string, params?: Record<string, string>) => string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-4 py-8 text-center">
|
||||
<h2 className="text-[1.1rem] font-semibold leading-tight m-0">
|
||||
{t("admin.no_duties")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground m-0 max-w-[280px]">
|
||||
{t("duty.none_this_month_hint")}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => router.push("/")}
|
||||
className="bg-surface text-accent hover:bg-[var(--surface-hover)]"
|
||||
>
|
||||
{t("admin.back_to_calendar")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface AdminDutyListProps {
|
||||
/** Duties to show (already sliced to visibleCount by parent). */
|
||||
duties: DutyWithUser[];
|
||||
/** Duty groups by date (already sliced to visibleCount by parent). */
|
||||
groups: AdminDutyGroup[];
|
||||
/** 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>;
|
||||
sentinelRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Called when user selects a duty to reassign. */
|
||||
onSelectDuty: (duty: DutyWithUser) => void;
|
||||
/** Translation function. */
|
||||
@@ -21,46 +56,73 @@ export interface AdminDutyListProps {
|
||||
}
|
||||
|
||||
export function AdminDutyList({
|
||||
duties,
|
||||
groups,
|
||||
hasMore,
|
||||
sentinelRef,
|
||||
onSelectDuty,
|
||||
t,
|
||||
}: AdminDutyListProps) {
|
||||
if (duties.length === 0) {
|
||||
return <p className="text-muted-foreground py-4">{t("admin.no_duties")}</p>;
|
||||
if (groups.length === 0) {
|
||||
return <AdminEmptyState t={t} />;
|
||||
}
|
||||
|
||||
const todayKey = localDateString(new Date());
|
||||
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)}`;
|
||||
<div className="flex flex-col gap-3" role="list" aria-label={t("admin.list_aria")}>
|
||||
{groups.map(({ dateKey, duties }) => {
|
||||
const isToday = dateKey === todayKey;
|
||||
const dateLabel = isToday ? t("duty.today") : dateKeyToDDMM(dateKey);
|
||||
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"
|
||||
<section
|
||||
key={dateKey}
|
||||
className="flex flex-col gap-1.5"
|
||||
aria-label={t("admin.section_aria", { date: dateLabel })}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
"text-[0.75rem] font-medium text-muted m-0 pl-0.5",
|
||||
isToday && "border-l-[3px] border-l-today pl-2 text-[var(--section-header)]"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{dateLabel}
|
||||
</p>
|
||||
<ul className="flex flex-col gap-1.5 list-none m-0 p-0">
|
||||
{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={cn(
|
||||
"w-full min-h-0 rounded-lg border border-l-[3px] bg-surface px-2.5 py-2 shadow-sm text-left text-sm transition-colors",
|
||||
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent flex flex-col gap-0.5",
|
||||
isToday ? "border-l-today" : "border-l-duty"
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
{hasMore && (
|
||||
<li ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
|
||||
<div ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
} from "@/components/ui/sheet";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export interface ReassignSheetProps {
|
||||
/** Whether the sheet is open (and not in exiting state). */
|
||||
@@ -30,8 +31,8 @@ export interface ReassignSheetProps {
|
||||
users: UserForAdmin[];
|
||||
/** Reassign request in progress. */
|
||||
saving: boolean;
|
||||
/** Error message from last reassign attempt. */
|
||||
reassignError: string | null;
|
||||
/** i18n key for error message from last reassign attempt; null when no error. */
|
||||
reassignErrorKey: string | null;
|
||||
/** Called when user confirms reassign. */
|
||||
onReassign: () => void;
|
||||
/** Called when user requests close (start close animation). */
|
||||
@@ -49,7 +50,7 @@ export function ReassignSheet({
|
||||
setSelectedUserId,
|
||||
users,
|
||||
saving,
|
||||
reassignError,
|
||||
reassignErrorKey,
|
||||
onReassign,
|
||||
onRequestClose,
|
||||
onCloseAnimationEnd,
|
||||
@@ -59,13 +60,13 @@ export function ReassignSheet({
|
||||
<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))]"
|
||||
className="flex max-h-[85vh] flex-col rounded-t-2xl bg-[var(--surface)] p-0 pt-3"
|
||||
overlayClassName="backdrop-blur-md"
|
||||
showCloseButton={false}
|
||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="relative px-4">
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto px-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
@@ -104,42 +105,62 @@ export function ReassignSheet({
|
||||
{localDateString(new Date(selectedDuty.start_at))}{" "}
|
||||
{formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("admin.current_assignee")}: {selectedDuty.full_name}
|
||||
</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
|
||||
className="flex flex-col gap-1 max-h-[40vh] overflow-y-auto rounded-lg border border-border py-1"
|
||||
role="radiogroup"
|
||||
aria-label={t("admin.select_user")}
|
||||
>
|
||||
{users.map((u) => {
|
||||
const isCurrent = u.id === selectedDuty.user_id;
|
||||
const isSelected = selectedUserId === u.id;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isSelected}
|
||||
disabled={saving}
|
||||
onClick={() => setSelectedUserId(u.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-0.5 rounded-md px-3 py-2.5 text-left text-sm transition-colors",
|
||||
"hover:bg-[var(--surface-hover)] focus-visible:outline-accent focus-visible:ring-2 focus-visible:ring-accent",
|
||||
isCurrent && "bg-[var(--surface-today-tint)]",
|
||||
isSelected && "ring-2 ring-accent ring-inset"
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{u.full_name}
|
||||
{isCurrent && (
|
||||
<span className="ml-2 text-xs text-muted-foreground font-normal">
|
||||
({t("admin.current_assignee")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{u.username && (
|
||||
<span className="text-xs text-muted-foreground">@{u.username}</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{reassignError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{reassignError}
|
||||
{reassignErrorKey && (
|
||||
<p id="admin-reassign-error" className="text-sm text-destructive" role="alert">
|
||||
{t(reassignErrorKey)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SheetFooter className="flex-row justify-end gap-2">
|
||||
<SheetFooter className="flex-shrink-0 flex-row justify-end gap-2 border-t border-border bg-[var(--surface)] px-4 py-3 pb-[calc(12px+env(safe-area-inset-bottom,0px))]">
|
||||
<Button
|
||||
onClick={onReassign}
|
||||
disabled={
|
||||
@@ -148,6 +169,7 @@ export function ReassignSheet({
|
||||
selectedUserId === selectedDuty?.user_id ||
|
||||
users.length === 0
|
||||
}
|
||||
aria-describedby={reassignErrorKey ? "admin-reassign-error" : undefined}
|
||||
>
|
||||
{saving ? t("loading") : t("admin.save")}
|
||||
</Button>
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
export { useAdminPage } from "./useAdminPage";
|
||||
export { AdminDutyList } from "./AdminDutyList";
|
||||
export { ReassignSheet } from "./ReassignSheet";
|
||||
export type { AdminDutyListProps } from "./AdminDutyList";
|
||||
export type { AdminDutyListProps, AdminDutyGroup } from "./AdminDutyList";
|
||||
export type { ReassignSheetProps } from "./ReassignSheet";
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { backButton } from "@telegram-apps/sdk-react";
|
||||
import { useTelegramSdkReady } from "@/components/providers/TelegramProvider";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
AccessDeniedError,
|
||||
type UserForAdmin,
|
||||
} from "@/lib/api";
|
||||
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||
import type { DutyWithUser } from "@/types";
|
||||
import {
|
||||
firstDayOfMonth,
|
||||
@@ -31,6 +33,7 @@ const PAGE_SIZE = 20;
|
||||
|
||||
export function useAdminPage() {
|
||||
const router = useRouter();
|
||||
const { sdkReady } = useTelegramSdkReady();
|
||||
const { initDataRaw, isLocalhost } = useTelegramAuth();
|
||||
const isAllowed = isLocalhost || !!initDataRaw;
|
||||
|
||||
@@ -38,6 +41,9 @@ export function useAdminPage() {
|
||||
const currentMonth = useAppStore((s) => s.currentMonth);
|
||||
const { t } = useTranslation();
|
||||
|
||||
/** Local month for admin view; does not change global calendar month. */
|
||||
const [adminMonth, setAdminMonth] = useState<Date>(() => new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1));
|
||||
|
||||
const [users, setUsers] = useState<UserForAdmin[]>([]);
|
||||
const [duties, setDuties] = useState<DutyWithUser[]>([]);
|
||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||
@@ -49,17 +55,26 @@ export function useAdminPage() {
|
||||
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 [reassignErrorKey, setReassignErrorKey] = 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 sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
const navigateHomeRef = useRef(() => router.push("/"));
|
||||
navigateHomeRef.current = () => router.push("/");
|
||||
|
||||
const from = localDateString(firstDayOfMonth(currentMonth));
|
||||
const to = localDateString(lastDayOfMonth(currentMonth));
|
||||
const from = localDateString(firstDayOfMonth(adminMonth));
|
||||
const to = localDateString(lastDayOfMonth(adminMonth));
|
||||
|
||||
const onPrevMonth = useCallback(() => {
|
||||
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() - 1, 1));
|
||||
}, []);
|
||||
const onNextMonth = useCallback(() => {
|
||||
setAdminMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + 1, 1));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLocalhost) return;
|
||||
if (!sdkReady || isLocalhost) return;
|
||||
let offClick: (() => void) | undefined;
|
||||
try {
|
||||
if (backButton.mount.isAvailable()) {
|
||||
@@ -69,7 +84,7 @@ export function useAdminPage() {
|
||||
backButton.show();
|
||||
}
|
||||
if (backButton.onClick.isAvailable()) {
|
||||
offClick = backButton.onClick(() => router.push("/"));
|
||||
offClick = backButton.onClick(() => navigateHomeRef.current());
|
||||
}
|
||||
} catch {
|
||||
// Non-Telegram environment; BackButton not available.
|
||||
@@ -84,7 +99,7 @@ export function useAdminPage() {
|
||||
// Ignore cleanup errors in non-Telegram environment.
|
||||
}
|
||||
};
|
||||
}, [isLocalhost, router]);
|
||||
}, [sdkReady, isLocalhost, router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAllowed || !initDataRaw) return;
|
||||
@@ -148,7 +163,7 @@ export function useAdminPage() {
|
||||
const closeReassign = useCallback(() => {
|
||||
setSelectedDuty(null);
|
||||
setSelectedUserId("");
|
||||
setReassignError(null);
|
||||
setReassignErrorKey(null);
|
||||
setSheetExiting(false);
|
||||
}, []);
|
||||
|
||||
@@ -163,7 +178,7 @@ export function useAdminPage() {
|
||||
const openReassign = useCallback((duty: DutyWithUser) => {
|
||||
setSelectedDuty(duty);
|
||||
setSelectedUserId(duty.user_id);
|
||||
setReassignError(null);
|
||||
setReassignErrorKey(null);
|
||||
}, []);
|
||||
|
||||
const requestCloseSheet = useCallback(() => {
|
||||
@@ -177,7 +192,7 @@ export function useAdminPage() {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setReassignError(null);
|
||||
setReassignErrorKey(null);
|
||||
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
|
||||
.then((updated) => {
|
||||
setDuties((prev) =>
|
||||
@@ -193,11 +208,27 @@ export function useAdminPage() {
|
||||
)
|
||||
);
|
||||
setSuccessMessage(t("admin.reassign_success"));
|
||||
try {
|
||||
triggerHapticLight();
|
||||
} catch {
|
||||
// Haptic not available (e.g. non-Telegram).
|
||||
}
|
||||
requestCloseSheet();
|
||||
setTimeout(() => setSuccessMessage(null), 3000);
|
||||
})
|
||||
.catch((e) => {
|
||||
setReassignError(e instanceof Error ? e.message : String(e));
|
||||
if (e instanceof AccessDeniedError) {
|
||||
setReassignErrorKey("admin.reassign_error_denied");
|
||||
} else if (e instanceof Error && /not found|не найден/i.test(e.message)) {
|
||||
setReassignErrorKey("admin.reassign_error_not_found");
|
||||
} else if (
|
||||
e instanceof TypeError ||
|
||||
(e instanceof Error && (e.message === "Failed to fetch" || e.message === "Load failed"))
|
||||
) {
|
||||
setReassignErrorKey("admin.reassign_error_network");
|
||||
} else {
|
||||
setReassignErrorKey("admin.reassign_error_generic");
|
||||
}
|
||||
})
|
||||
.finally(() => setSaving(false));
|
||||
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t]);
|
||||
@@ -215,6 +246,17 @@ export function useAdminPage() {
|
||||
const hasMore = visibleCount < dutyOnly.length;
|
||||
const loading = loadingUsers || loadingDuties;
|
||||
|
||||
/** Group visible duties by date (dateKey) for sectioned list. Order preserved by insertion. */
|
||||
const visibleGroups = (() => {
|
||||
const map = new Map<string, DutyWithUser[]>();
|
||||
for (const d of visibleDuties) {
|
||||
const key = localDateString(new Date(d.start_at));
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(d);
|
||||
}
|
||||
return Array.from(map.entries()).map(([dateKey, duties]) => ({ dateKey, duties }));
|
||||
})();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMore || !sentinelRef.current) return;
|
||||
const el = sentinelRef.current;
|
||||
@@ -237,18 +279,21 @@ export function useAdminPage() {
|
||||
adminAccessDeniedDetail,
|
||||
error,
|
||||
loading,
|
||||
currentMonth,
|
||||
adminMonth,
|
||||
onPrevMonth,
|
||||
onNextMonth,
|
||||
successMessage,
|
||||
dutyOnly,
|
||||
usersForSelect,
|
||||
visibleDuties,
|
||||
visibleGroups,
|
||||
hasMore,
|
||||
sentinelRef,
|
||||
selectedDuty,
|
||||
selectedUserId,
|
||||
setSelectedUserId,
|
||||
saving,
|
||||
reassignError,
|
||||
reassignErrorKey,
|
||||
sheetExiting,
|
||||
openReassign,
|
||||
requestCloseSheet,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import {
|
||||
init,
|
||||
mountMiniAppSync,
|
||||
@@ -12,6 +12,24 @@ import {
|
||||
} from "@telegram-apps/sdk-react";
|
||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
||||
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
|
||||
const EVENT_CONFIG_LOADED = "dt-config-loaded";
|
||||
|
||||
export interface TelegramSdkContextValue {
|
||||
/** True after init() and sync mounts have run; safe to use backButton etc. */
|
||||
sdkReady: boolean;
|
||||
}
|
||||
|
||||
const TelegramSdkContext = createContext<TelegramSdkContextValue>({
|
||||
sdkReady: false,
|
||||
});
|
||||
|
||||
export function useTelegramSdkReady(): TelegramSdkContextValue {
|
||||
return useContext(TelegramSdkContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the app with Telegram Mini App SDK initialization.
|
||||
@@ -22,12 +40,34 @@ import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
|
||||
* lib/telegram-ready when the first visible screen has finished loading.
|
||||
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
||||
* Syncs lang from window.__DT_LANG on mount and when config.js fires dt-config-loaded.
|
||||
*/
|
||||
export function TelegramProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [sdkReady, setSdkReady] = useState(false);
|
||||
const setLang = useAppStore((s) => s.setLang);
|
||||
const lang = useAppStore((s) => s.lang);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Sync lang from backend config: on mount and when config.js has loaded (all routes, including admin).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setLang(getLang());
|
||||
const onConfigLoaded = () => setLang(getLang());
|
||||
window.addEventListener(EVENT_CONFIG_LOADED, onConfigLoaded);
|
||||
return () => window.removeEventListener(EVENT_CONFIG_LOADED, onConfigLoaded);
|
||||
}, [setLang]);
|
||||
|
||||
// Apply lang to document (title and html lang) so all routes including admin get correct title.
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = lang;
|
||||
document.title = t("app.title");
|
||||
}, [lang, t]);
|
||||
|
||||
useEffect(() => {
|
||||
const cleanup = init({ acceptCustomStyles: true });
|
||||
|
||||
@@ -46,6 +86,8 @@ export function TelegramProvider({
|
||||
|
||||
applyAndroidPerformanceClass();
|
||||
|
||||
setSdkReady(true);
|
||||
|
||||
let unbindViewportCssVars: (() => void) | undefined;
|
||||
if (mountViewport.isAvailable()) {
|
||||
mountViewport()
|
||||
@@ -60,11 +102,16 @@ export function TelegramProvider({
|
||||
}
|
||||
|
||||
return () => {
|
||||
setSdkReady(false);
|
||||
unbindViewportCssVars?.();
|
||||
unmountViewport();
|
||||
cleanup();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <>{children}</>;
|
||||
return (
|
||||
<TelegramSdkContext.Provider value={{ sdkReady }}>
|
||||
{children}
|
||||
</TelegramSdkContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { getLang } from "@/i18n/messages";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
||||
|
||||
@@ -19,24 +18,18 @@ export interface UseAppInitParams {
|
||||
}
|
||||
|
||||
/**
|
||||
* Syncs language from backend config, applies document lang/title, handles access denied
|
||||
* when not allowed, and routes to current duty view when opened via startParam=duty.
|
||||
* Applies document lang/title from store (when this hook runs, e.g. main page).
|
||||
* Handles access denied when not allowed and routes to current duty view when opened via startParam=duty.
|
||||
* Language is synced from window.__DT_LANG in TelegramProvider (all routes).
|
||||
*/
|
||||
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
||||
const setLang = useAppStore((s) => s.setLang);
|
||||
const lang = useAppStore((s) => s.lang);
|
||||
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
||||
const setLoading = useAppStore((s) => s.setLoading);
|
||||
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Sync lang from backend config (window.__DT_LANG).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
setLang(getLang());
|
||||
}, [setLang]);
|
||||
|
||||
// Apply lang to document (title and html lang) for accessibility and i18n.
|
||||
// Apply lang to document (title and html lang) when main page is mounted (tests render Page without TelegramProvider).
|
||||
useEffect(() => {
|
||||
if (typeof document === "undefined") return;
|
||||
document.documentElement.lang = lang;
|
||||
|
||||
@@ -86,6 +86,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
||||
"admin.title": "Admin",
|
||||
"admin.reassign_duty": "Reassign duty",
|
||||
"admin.select_user": "Select user",
|
||||
"admin.current_assignee": "Current",
|
||||
"admin.reassign_success": "Duty reassigned",
|
||||
"admin.access_denied": "Access only for administrators.",
|
||||
"admin.duty_not_found": "Duty not found",
|
||||
@@ -95,8 +96,14 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
||||
"admin.no_duties": "No duties this month.",
|
||||
"admin.no_users_for_assign": "No users available for assignment.",
|
||||
"admin.save": "Save",
|
||||
"admin.duties_count": "{count} duties this month",
|
||||
"admin.list_aria": "List of duties to reassign",
|
||||
"admin.reassign_aria": "Reassign duty: {date}, {time}, {name}",
|
||||
"admin.section_aria": "Duties for {date}",
|
||||
"admin.reassign_error_generic": "Could not reassign duty. Try again.",
|
||||
"admin.reassign_error_denied": "Access denied.",
|
||||
"admin.reassign_error_not_found": "Duty or user not found.",
|
||||
"admin.reassign_error_network": "Network error. Check your connection.",
|
||||
},
|
||||
ru: {
|
||||
"app.title": "Календарь дежурств",
|
||||
@@ -178,6 +185,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
||||
"admin.title": "Админка",
|
||||
"admin.reassign_duty": "Переназначить дежурного",
|
||||
"admin.select_user": "Выберите пользователя",
|
||||
"admin.current_assignee": "Текущий",
|
||||
"admin.reassign_success": "Дежурство переназначено",
|
||||
"admin.access_denied": "Доступ только для администраторов.",
|
||||
"admin.duty_not_found": "Дежурство не найдено",
|
||||
@@ -187,8 +195,14 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
||||
"admin.no_duties": "В этом месяце дежурств нет.",
|
||||
"admin.no_users_for_assign": "Нет пользователей для назначения.",
|
||||
"admin.save": "Сохранить",
|
||||
"admin.duties_count": "{count} дежурств в этом месяце",
|
||||
"admin.list_aria": "Список дежурств для перераспределения",
|
||||
"admin.reassign_aria": "Переназначить дежурство: {date}, {time}, {name}",
|
||||
"admin.section_aria": "Дежурства за {date}",
|
||||
"admin.reassign_error_generic": "Не удалось переназначить дежурство. Попробуйте снова.",
|
||||
"admin.reassign_error_denied": "Доступ запрещён.",
|
||||
"admin.reassign_error_not_found": "Дежурство или пользователь не найдены.",
|
||||
"admin.reassign_error_network": "Ошибка сети. Проверьте подключение.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user