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:
@@ -169,7 +169,8 @@ def app_config_js() -> Response:
|
|||||||
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
tz = _safe_tz_string(config.DUTY_DISPLAY_TZ)
|
||||||
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;"
|
||||||
body = (
|
body = (
|
||||||
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}'
|
f'window.__DT_LANG = "{lang}";\nwindow.__DT_LOG_LEVEL = "{log_level}";{tz_js}\n'
|
||||||
|
'if (typeof window !== "undefined") window.dispatchEvent(new Event("dt-config-loaded"));'
|
||||||
)
|
)
|
||||||
return Response(
|
return Response(
|
||||||
content=body,
|
content=body,
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ function mockFetchForAdmin(
|
|||||||
const adminMe = options?.adminMe ?? { is_admin: true };
|
const adminMe = options?.adminMe ?? { is_admin: true };
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
vi.fn((url: string, init?: RequestInit) => {
|
vi.fn((url: string, _init?: RequestInit) => {
|
||||||
if (url.includes("/api/admin/me")) {
|
if (url.includes("/api/admin/me")) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -163,7 +163,7 @@ describe("AdminPage", () => {
|
|||||||
fireEvent.click(dutyButton);
|
fireEvent.click(dutyButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
screen.getByLabelText(/select user|выберите пользователя/i)
|
screen.getByRole("radiogroup", { name: /select user|выберите пользователя/i })
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.getByRole("button", { name: /save|сохранить/i })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: /save|сохранить/i })).toBeInTheDocument();
|
||||||
@@ -231,10 +231,9 @@ describe("AdminPage", () => {
|
|||||||
});
|
});
|
||||||
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
|
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
|
||||||
await waitFor(() => {
|
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.click(screen.getByRole("radio", { name: /Bob/ }));
|
||||||
fireEvent.change(select, { target: { value: "2" } });
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i }));
|
fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i }));
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { useTranslation } from "@/i18n/use-translation";
|
|||||||
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";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { ChevronLeft as ChevronLeftIcon, ChevronRight as ChevronRightIcon } from "lucide-react";
|
||||||
|
|
||||||
const PAGE_WRAPPER_CLASS =
|
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";
|
"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 month = admin.adminMonth.getMonth();
|
||||||
const year = admin.currentMonth.getFullYear();
|
const year = admin.adminMonth.getFullYear();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={PAGE_WRAPPER_CLASS}>
|
<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">
|
<header className="sticky top-0 z-10 flex flex-col items-center border-b bg-[var(--header-bg)] py-3">
|
||||||
<h1
|
<div className="flex w-full items-center justify-between px-1">
|
||||||
className="m-0 flex flex-col items-center justify-center gap-0 leading-none"
|
<Button
|
||||||
aria-label={`${t("admin.title")}, ${monthName(month)} ${year}`}
|
type="button"
|
||||||
>
|
variant="secondary"
|
||||||
<span className="text-xs font-normal leading-none text-muted">
|
size="icon"
|
||||||
{year}
|
className="size-10 rounded-[10px] bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95 disabled:opacity-50"
|
||||||
</span>
|
aria-label={t("nav.prev_month")}
|
||||||
<span className="text-[1.1rem] font-semibold leading-tight sm:text-[1.25rem]">
|
disabled={admin.loading}
|
||||||
{monthName(month)}
|
onClick={admin.onPrevMonth}
|
||||||
</span>
|
>
|
||||||
</h1>
|
<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>
|
</header>
|
||||||
|
|
||||||
{admin.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" aria-live="polite">
|
||||||
{admin.successMessage}
|
{admin.successMessage}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -96,7 +125,7 @@ export default function AdminPage() {
|
|||||||
{t("admin.reassign_duty")}: {t("admin.select_user")}
|
{t("admin.reassign_duty")}: {t("admin.select_user")}
|
||||||
</p>
|
</p>
|
||||||
<AdminDutyList
|
<AdminDutyList
|
||||||
duties={admin.visibleDuties}
|
groups={admin.visibleGroups}
|
||||||
hasMore={admin.hasMore}
|
hasMore={admin.hasMore}
|
||||||
sentinelRef={admin.sentinelRef}
|
sentinelRef={admin.sentinelRef}
|
||||||
onSelectDuty={admin.openReassign}
|
onSelectDuty={admin.openReassign}
|
||||||
@@ -112,7 +141,7 @@ export default function AdminPage() {
|
|||||||
setSelectedUserId={admin.setSelectedUserId}
|
setSelectedUserId={admin.setSelectedUserId}
|
||||||
users={admin.usersForSelect}
|
users={admin.usersForSelect}
|
||||||
saving={admin.saving}
|
saving={admin.saving}
|
||||||
reassignError={admin.reassignError}
|
reassignErrorKey={admin.reassignErrorKey}
|
||||||
onReassign={admin.handleReassign}
|
onReassign={admin.handleReassign}
|
||||||
onRequestClose={admin.requestCloseSheet}
|
onRequestClose={admin.requestCloseSheet}
|
||||||
onCloseAnimationEnd={admin.closeReassign}
|
onCloseAnimationEnd={admin.closeReassign}
|
||||||
|
|||||||
@@ -50,12 +50,11 @@ describe("Page", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("sets document title for ru when store lang is ru", async () => {
|
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 />);
|
render(<Page />);
|
||||||
await screen.findByRole("grid", { name: "Calendar" });
|
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 () => {
|
it("renders AccessDeniedScreen when not allowed and delay has passed", async () => {
|
||||||
|
|||||||
@@ -4,16 +4,51 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import type { DutyWithUser } from "@/types";
|
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 {
|
export interface AdminDutyListProps {
|
||||||
/** Duties to show (already sliced to visibleCount by parent). */
|
/** Duty groups by date (already sliced to visibleCount by parent). */
|
||||||
duties: DutyWithUser[];
|
groups: AdminDutyGroup[];
|
||||||
/** Whether there are more items; when true, sentinel is rendered for intersection observer. */
|
/** Whether there are more items; when true, sentinel is rendered for intersection observer. */
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
/** Ref for the sentinel element (infinite scroll). */
|
/** 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. */
|
/** Called when user selects a duty to reassign. */
|
||||||
onSelectDuty: (duty: DutyWithUser) => void;
|
onSelectDuty: (duty: DutyWithUser) => void;
|
||||||
/** Translation function. */
|
/** Translation function. */
|
||||||
@@ -21,46 +56,73 @@ export interface AdminDutyListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function AdminDutyList({
|
export function AdminDutyList({
|
||||||
duties,
|
groups,
|
||||||
hasMore,
|
hasMore,
|
||||||
sentinelRef,
|
sentinelRef,
|
||||||
onSelectDuty,
|
onSelectDuty,
|
||||||
t,
|
t,
|
||||||
}: AdminDutyListProps) {
|
}: AdminDutyListProps) {
|
||||||
if (duties.length === 0) {
|
if (groups.length === 0) {
|
||||||
return <p className="text-muted-foreground py-4">{t("admin.no_duties")}</p>;
|
return <AdminEmptyState t={t} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const todayKey = localDateString(new Date());
|
||||||
return (
|
return (
|
||||||
<ul className="flex flex-col gap-1.5" aria-label={t("admin.list_aria")}>
|
<div className="flex flex-col gap-3" role="list" aria-label={t("admin.list_aria")}>
|
||||||
{duties.map((duty) => {
|
{groups.map(({ dateKey, duties }) => {
|
||||||
const dateStr = localDateString(new Date(duty.start_at));
|
const isToday = dateKey === todayKey;
|
||||||
const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
const dateLabel = isToday ? t("duty.today") : dateKeyToDDMM(dateKey);
|
||||||
return (
|
return (
|
||||||
<li key={duty.id}>
|
<section
|
||||||
<button
|
key={dateKey}
|
||||||
type="button"
|
className="flex flex-col gap-1.5"
|
||||||
onClick={() => onSelectDuty(duty)}
|
aria-label={t("admin.section_aria", { date: dateLabel })}
|
||||||
aria-label={t("admin.reassign_aria", {
|
>
|
||||||
date: dateStr,
|
<p
|
||||||
time: timeStr,
|
className={cn(
|
||||||
name: duty.full_name,
|
"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)]"
|
||||||
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>
|
{dateLabel}
|
||||||
<span className="font-medium">{dateStr}</span>
|
</p>
|
||||||
<span className="mx-2 text-muted-foreground">·</span>
|
<ul className="flex flex-col gap-1.5 list-none m-0 p-0">
|
||||||
<span className="text-muted-foreground">{timeStr}</span>
|
{duties.map((duty) => {
|
||||||
</span>
|
const dateStr = localDateString(new Date(duty.start_at));
|
||||||
<span>{duty.full_name}</span>
|
const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`;
|
||||||
</button>
|
return (
|
||||||
</li>
|
<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 && (
|
{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,
|
SheetDescription,
|
||||||
SheetFooter,
|
SheetFooter,
|
||||||
} from "@/components/ui/sheet";
|
} from "@/components/ui/sheet";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export interface ReassignSheetProps {
|
export interface ReassignSheetProps {
|
||||||
/** Whether the sheet is open (and not in exiting state). */
|
/** Whether the sheet is open (and not in exiting state). */
|
||||||
@@ -30,8 +31,8 @@ export interface ReassignSheetProps {
|
|||||||
users: UserForAdmin[];
|
users: UserForAdmin[];
|
||||||
/** Reassign request in progress. */
|
/** Reassign request in progress. */
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
/** Error message from last reassign attempt. */
|
/** i18n key for error message from last reassign attempt; null when no error. */
|
||||||
reassignError: string | null;
|
reassignErrorKey: string | null;
|
||||||
/** Called when user confirms reassign. */
|
/** Called when user confirms reassign. */
|
||||||
onReassign: () => void;
|
onReassign: () => void;
|
||||||
/** Called when user requests close (start close animation). */
|
/** Called when user requests close (start close animation). */
|
||||||
@@ -49,7 +50,7 @@ export function ReassignSheet({
|
|||||||
setSelectedUserId,
|
setSelectedUserId,
|
||||||
users,
|
users,
|
||||||
saving,
|
saving,
|
||||||
reassignError,
|
reassignErrorKey,
|
||||||
onReassign,
|
onReassign,
|
||||||
onRequestClose,
|
onRequestClose,
|
||||||
onCloseAnimationEnd,
|
onCloseAnimationEnd,
|
||||||
@@ -59,13 +60,13 @@ export function ReassignSheet({
|
|||||||
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onRequestClose()}>
|
<Sheet open={open} onOpenChange={(isOpen) => !isOpen && onRequestClose()}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
side="bottom"
|
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"
|
overlayClassName="backdrop-blur-md"
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
onCloseAnimationEnd={onCloseAnimationEnd}
|
onCloseAnimationEnd={onCloseAnimationEnd}
|
||||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||||
>
|
>
|
||||||
<div className="relative px-4">
|
<div className="relative min-h-0 flex-1 overflow-y-auto px-4">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -104,42 +105,62 @@ export function ReassignSheet({
|
|||||||
{localDateString(new Date(selectedDuty.start_at))}{" "}
|
{localDateString(new Date(selectedDuty.start_at))}{" "}
|
||||||
{formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)}
|
{formatHHMM(selectedDuty.start_at)} – {formatHHMM(selectedDuty.end_at)}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("admin.current_assignee")}: {selectedDuty.full_name}
|
||||||
|
</p>
|
||||||
{users.length === 0 ? (
|
{users.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{t("admin.no_users_for_assign")}
|
{t("admin.no_users_for_assign")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div
|
||||||
<label htmlFor="admin-user-select" className="text-sm font-medium">
|
className="flex flex-col gap-1 max-h-[40vh] overflow-y-auto rounded-lg border border-border py-1"
|
||||||
{t("admin.select_user")}
|
role="radiogroup"
|
||||||
</label>
|
aria-label={t("admin.select_user")}
|
||||||
<select
|
>
|
||||||
id="admin-user-select"
|
{users.map((u) => {
|
||||||
value={selectedUserId === "" ? "" : String(selectedUserId)}
|
const isCurrent = u.id === selectedDuty.user_id;
|
||||||
onChange={(e) =>
|
const isSelected = selectedUserId === u.id;
|
||||||
setSelectedUserId(e.target.value === "" ? "" : Number(e.target.value))
|
return (
|
||||||
}
|
<button
|
||||||
className="rounded-md border bg-background px-3 py-2 text-sm focus-visible:outline-accent"
|
key={u.id}
|
||||||
disabled={saving}
|
type="button"
|
||||||
>
|
role="radio"
|
||||||
{users.map((u) => (
|
aria-checked={isSelected}
|
||||||
<option key={u.id} value={u.id}>
|
disabled={saving}
|
||||||
{u.full_name}
|
onClick={() => setSelectedUserId(u.id)}
|
||||||
{u.username ? ` (@${u.username})` : ""}
|
className={cn(
|
||||||
</option>
|
"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",
|
||||||
</select>
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{reassignError && (
|
{reassignErrorKey && (
|
||||||
<p className="text-sm text-destructive" role="alert">
|
<p id="admin-reassign-error" className="text-sm text-destructive" role="alert">
|
||||||
{reassignError}
|
{t(reassignErrorKey)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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
|
<Button
|
||||||
onClick={onReassign}
|
onClick={onReassign}
|
||||||
disabled={
|
disabled={
|
||||||
@@ -148,6 +169,7 @@ export function ReassignSheet({
|
|||||||
selectedUserId === selectedDuty?.user_id ||
|
selectedUserId === selectedDuty?.user_id ||
|
||||||
users.length === 0
|
users.length === 0
|
||||||
}
|
}
|
||||||
|
aria-describedby={reassignErrorKey ? "admin-reassign-error" : undefined}
|
||||||
>
|
>
|
||||||
{saving ? t("loading") : t("admin.save")}
|
{saving ? t("loading") : t("admin.save")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
export { useAdminPage } from "./useAdminPage";
|
export { useAdminPage } from "./useAdminPage";
|
||||||
export { AdminDutyList } from "./AdminDutyList";
|
export { AdminDutyList } from "./AdminDutyList";
|
||||||
export { ReassignSheet } from "./ReassignSheet";
|
export { ReassignSheet } from "./ReassignSheet";
|
||||||
export type { AdminDutyListProps } from "./AdminDutyList";
|
export type { AdminDutyListProps, AdminDutyGroup } from "./AdminDutyList";
|
||||||
export type { ReassignSheetProps } from "./ReassignSheet";
|
export type { ReassignSheetProps } from "./ReassignSheet";
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { backButton } from "@telegram-apps/sdk-react";
|
import { backButton } from "@telegram-apps/sdk-react";
|
||||||
|
import { useTelegramSdkReady } from "@/components/providers/TelegramProvider";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
@@ -20,6 +21,7 @@ import {
|
|||||||
AccessDeniedError,
|
AccessDeniedError,
|
||||||
type UserForAdmin,
|
type UserForAdmin,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { triggerHapticLight } from "@/lib/telegram-haptic";
|
||||||
import type { DutyWithUser } from "@/types";
|
import type { DutyWithUser } from "@/types";
|
||||||
import {
|
import {
|
||||||
firstDayOfMonth,
|
firstDayOfMonth,
|
||||||
@@ -31,6 +33,7 @@ const PAGE_SIZE = 20;
|
|||||||
|
|
||||||
export function useAdminPage() {
|
export function useAdminPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { sdkReady } = useTelegramSdkReady();
|
||||||
const { initDataRaw, isLocalhost } = useTelegramAuth();
|
const { initDataRaw, isLocalhost } = useTelegramAuth();
|
||||||
const isAllowed = isLocalhost || !!initDataRaw;
|
const isAllowed = isLocalhost || !!initDataRaw;
|
||||||
|
|
||||||
@@ -38,6 +41,9 @@ export function useAdminPage() {
|
|||||||
const currentMonth = useAppStore((s) => s.currentMonth);
|
const currentMonth = useAppStore((s) => s.currentMonth);
|
||||||
const { t } = useTranslation();
|
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 [users, setUsers] = useState<UserForAdmin[]>([]);
|
||||||
const [duties, setDuties] = useState<DutyWithUser[]>([]);
|
const [duties, setDuties] = useState<DutyWithUser[]>([]);
|
||||||
const [loadingUsers, setLoadingUsers] = useState(true);
|
const [loadingUsers, setLoadingUsers] = useState(true);
|
||||||
@@ -49,17 +55,26 @@ export function useAdminPage() {
|
|||||||
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
|
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
|
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
|
||||||
const [saving, setSaving] = useState(false);
|
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 [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||||
const [sheetExiting, setSheetExiting] = useState(false);
|
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 from = localDateString(firstDayOfMonth(adminMonth));
|
||||||
const to = localDateString(lastDayOfMonth(currentMonth));
|
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(() => {
|
useEffect(() => {
|
||||||
if (isLocalhost) return;
|
if (!sdkReady || isLocalhost) return;
|
||||||
let offClick: (() => void) | undefined;
|
let offClick: (() => void) | undefined;
|
||||||
try {
|
try {
|
||||||
if (backButton.mount.isAvailable()) {
|
if (backButton.mount.isAvailable()) {
|
||||||
@@ -69,7 +84,7 @@ export function useAdminPage() {
|
|||||||
backButton.show();
|
backButton.show();
|
||||||
}
|
}
|
||||||
if (backButton.onClick.isAvailable()) {
|
if (backButton.onClick.isAvailable()) {
|
||||||
offClick = backButton.onClick(() => router.push("/"));
|
offClick = backButton.onClick(() => navigateHomeRef.current());
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Non-Telegram environment; BackButton not available.
|
// Non-Telegram environment; BackButton not available.
|
||||||
@@ -84,7 +99,7 @@ export function useAdminPage() {
|
|||||||
// Ignore cleanup errors in non-Telegram environment.
|
// Ignore cleanup errors in non-Telegram environment.
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [isLocalhost, router]);
|
}, [sdkReady, isLocalhost, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAllowed || !initDataRaw) return;
|
if (!isAllowed || !initDataRaw) return;
|
||||||
@@ -148,7 +163,7 @@ export function useAdminPage() {
|
|||||||
const closeReassign = useCallback(() => {
|
const closeReassign = useCallback(() => {
|
||||||
setSelectedDuty(null);
|
setSelectedDuty(null);
|
||||||
setSelectedUserId("");
|
setSelectedUserId("");
|
||||||
setReassignError(null);
|
setReassignErrorKey(null);
|
||||||
setSheetExiting(false);
|
setSheetExiting(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -163,7 +178,7 @@ export function useAdminPage() {
|
|||||||
const openReassign = useCallback((duty: DutyWithUser) => {
|
const openReassign = useCallback((duty: DutyWithUser) => {
|
||||||
setSelectedDuty(duty);
|
setSelectedDuty(duty);
|
||||||
setSelectedUserId(duty.user_id);
|
setSelectedUserId(duty.user_id);
|
||||||
setReassignError(null);
|
setReassignErrorKey(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const requestCloseSheet = useCallback(() => {
|
const requestCloseSheet = useCallback(() => {
|
||||||
@@ -177,7 +192,7 @@ export function useAdminPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setReassignError(null);
|
setReassignErrorKey(null);
|
||||||
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
|
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
|
||||||
.then((updated) => {
|
.then((updated) => {
|
||||||
setDuties((prev) =>
|
setDuties((prev) =>
|
||||||
@@ -193,11 +208,27 @@ export function useAdminPage() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
setSuccessMessage(t("admin.reassign_success"));
|
setSuccessMessage(t("admin.reassign_success"));
|
||||||
|
try {
|
||||||
|
triggerHapticLight();
|
||||||
|
} catch {
|
||||||
|
// Haptic not available (e.g. non-Telegram).
|
||||||
|
}
|
||||||
requestCloseSheet();
|
requestCloseSheet();
|
||||||
setTimeout(() => setSuccessMessage(null), 3000);
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.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));
|
.finally(() => setSaving(false));
|
||||||
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t]);
|
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign, requestCloseSheet, t]);
|
||||||
@@ -215,6 +246,17 @@ export function useAdminPage() {
|
|||||||
const hasMore = visibleCount < dutyOnly.length;
|
const hasMore = visibleCount < dutyOnly.length;
|
||||||
const loading = loadingUsers || loadingDuties;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!hasMore || !sentinelRef.current) return;
|
if (!hasMore || !sentinelRef.current) return;
|
||||||
const el = sentinelRef.current;
|
const el = sentinelRef.current;
|
||||||
@@ -237,18 +279,21 @@ export function useAdminPage() {
|
|||||||
adminAccessDeniedDetail,
|
adminAccessDeniedDetail,
|
||||||
error,
|
error,
|
||||||
loading,
|
loading,
|
||||||
currentMonth,
|
adminMonth,
|
||||||
|
onPrevMonth,
|
||||||
|
onNextMonth,
|
||||||
successMessage,
|
successMessage,
|
||||||
dutyOnly,
|
dutyOnly,
|
||||||
usersForSelect,
|
usersForSelect,
|
||||||
visibleDuties,
|
visibleDuties,
|
||||||
|
visibleGroups,
|
||||||
hasMore,
|
hasMore,
|
||||||
sentinelRef,
|
sentinelRef,
|
||||||
selectedDuty,
|
selectedDuty,
|
||||||
selectedUserId,
|
selectedUserId,
|
||||||
setSelectedUserId,
|
setSelectedUserId,
|
||||||
saving,
|
saving,
|
||||||
reassignError,
|
reassignErrorKey,
|
||||||
sheetExiting,
|
sheetExiting,
|
||||||
openReassign,
|
openReassign,
|
||||||
requestCloseSheet,
|
requestCloseSheet,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect } from "react";
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
init,
|
init,
|
||||||
mountMiniAppSync,
|
mountMiniAppSync,
|
||||||
@@ -12,6 +12,24 @@ import {
|
|||||||
} from "@telegram-apps/sdk-react";
|
} from "@telegram-apps/sdk-react";
|
||||||
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
import { fixSurfaceContrast } from "@/hooks/use-telegram-theme";
|
||||||
import { applyAndroidPerformanceClass } from "@/lib/telegram-android-perf";
|
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.
|
* 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.
|
* 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);
|
* Theme is set before first paint by the inline script in layout.tsx (URL hash);
|
||||||
* useTelegramTheme() in the app handles ongoing theme changes.
|
* 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({
|
export function TelegramProvider({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
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(() => {
|
useEffect(() => {
|
||||||
const cleanup = init({ acceptCustomStyles: true });
|
const cleanup = init({ acceptCustomStyles: true });
|
||||||
|
|
||||||
@@ -46,6 +86,8 @@ export function TelegramProvider({
|
|||||||
|
|
||||||
applyAndroidPerformanceClass();
|
applyAndroidPerformanceClass();
|
||||||
|
|
||||||
|
setSdkReady(true);
|
||||||
|
|
||||||
let unbindViewportCssVars: (() => void) | undefined;
|
let unbindViewportCssVars: (() => void) | undefined;
|
||||||
if (mountViewport.isAvailable()) {
|
if (mountViewport.isAvailable()) {
|
||||||
mountViewport()
|
mountViewport()
|
||||||
@@ -60,11 +102,16 @@ export function TelegramProvider({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
setSdkReady(false);
|
||||||
unbindViewportCssVars?.();
|
unbindViewportCssVars?.();
|
||||||
unmountViewport();
|
unmountViewport();
|
||||||
cleanup();
|
cleanup();
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return (
|
||||||
|
<TelegramSdkContext.Provider value={{ sdkReady }}>
|
||||||
|
{children}
|
||||||
|
</TelegramSdkContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
|
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { getLang } from "@/i18n/messages";
|
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { RETRY_DELAY_MS } from "@/lib/constants";
|
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
|
* Applies document lang/title from store (when this hook runs, e.g. main page).
|
||||||
* when not allowed, and routes to current duty view when opened via startParam=duty.
|
* 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 {
|
export function useAppInit({ isAllowed, startParam }: UseAppInitParams): void {
|
||||||
const setLang = useAppStore((s) => s.setLang);
|
|
||||||
const lang = useAppStore((s) => s.lang);
|
const lang = useAppStore((s) => s.lang);
|
||||||
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
const setAccessDenied = useAppStore((s) => s.setAccessDenied);
|
||||||
const setLoading = useAppStore((s) => s.setLoading);
|
const setLoading = useAppStore((s) => s.setLoading);
|
||||||
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
const setCurrentView = useAppStore((s) => s.setCurrentView);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
// Sync lang from backend config (window.__DT_LANG).
|
// Apply lang to document (title and html lang) when main page is mounted (tests render Page without TelegramProvider).
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
setLang(getLang());
|
|
||||||
}, [setLang]);
|
|
||||||
|
|
||||||
// Apply lang to document (title and html lang) for accessibility and i18n.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof document === "undefined") return;
|
if (typeof document === "undefined") return;
|
||||||
document.documentElement.lang = lang;
|
document.documentElement.lang = lang;
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"admin.title": "Admin",
|
"admin.title": "Admin",
|
||||||
"admin.reassign_duty": "Reassign duty",
|
"admin.reassign_duty": "Reassign duty",
|
||||||
"admin.select_user": "Select user",
|
"admin.select_user": "Select user",
|
||||||
|
"admin.current_assignee": "Current",
|
||||||
"admin.reassign_success": "Duty reassigned",
|
"admin.reassign_success": "Duty reassigned",
|
||||||
"admin.access_denied": "Access only for administrators.",
|
"admin.access_denied": "Access only for administrators.",
|
||||||
"admin.duty_not_found": "Duty not found",
|
"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_duties": "No duties this month.",
|
||||||
"admin.no_users_for_assign": "No users available for assignment.",
|
"admin.no_users_for_assign": "No users available for assignment.",
|
||||||
"admin.save": "Save",
|
"admin.save": "Save",
|
||||||
|
"admin.duties_count": "{count} duties this month",
|
||||||
"admin.list_aria": "List of duties to reassign",
|
"admin.list_aria": "List of duties to reassign",
|
||||||
"admin.reassign_aria": "Reassign duty: {date}, {time}, {name}",
|
"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: {
|
ru: {
|
||||||
"app.title": "Календарь дежурств",
|
"app.title": "Календарь дежурств",
|
||||||
@@ -178,6 +185,7 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"admin.title": "Админка",
|
"admin.title": "Админка",
|
||||||
"admin.reassign_duty": "Переназначить дежурного",
|
"admin.reassign_duty": "Переназначить дежурного",
|
||||||
"admin.select_user": "Выберите пользователя",
|
"admin.select_user": "Выберите пользователя",
|
||||||
|
"admin.current_assignee": "Текущий",
|
||||||
"admin.reassign_success": "Дежурство переназначено",
|
"admin.reassign_success": "Дежурство переназначено",
|
||||||
"admin.access_denied": "Доступ только для администраторов.",
|
"admin.access_denied": "Доступ только для администраторов.",
|
||||||
"admin.duty_not_found": "Дежурство не найдено",
|
"admin.duty_not_found": "Дежурство не найдено",
|
||||||
@@ -187,8 +195,14 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"admin.no_duties": "В этом месяце дежурств нет.",
|
"admin.no_duties": "В этом месяце дежурств нет.",
|
||||||
"admin.no_users_for_assign": "Нет пользователей для назначения.",
|
"admin.no_users_for_assign": "Нет пользователей для назначения.",
|
||||||
"admin.save": "Сохранить",
|
"admin.save": "Сохранить",
|
||||||
|
"admin.duties_count": "{count} дежурств в этом месяце",
|
||||||
"admin.list_aria": "Список дежурств для перераспределения",
|
"admin.list_aria": "Список дежурств для перераспределения",
|
||||||
"admin.reassign_aria": "Переназначить дежурство: {date}, {time}, {name}",
|
"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