From 02a586a1c5dfb849c45e19d956a84e1089e3b8e3 Mon Sep 17 00:00:00 2001 From: Nikolay Tatarinov Date: Fri, 6 Mar 2026 12:04:16 +0300 Subject: [PATCH] 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. --- duty_teller/api/app.py | 3 +- webapp-next/src/app/admin/page.test.tsx | 9 +- webapp-next/src/app/admin/page.tsx | 61 ++++++--- webapp-next/src/app/page.test.tsx | 7 +- .../src/components/admin/AdminDutyList.tsx | 124 +++++++++++++----- .../src/components/admin/ReassignSheet.tsx | 80 +++++++---- webapp-next/src/components/admin/index.ts | 2 +- .../src/components/admin/useAdminPage.ts | 71 ++++++++-- .../components/providers/TelegramProvider.tsx | 51 ++++++- webapp-next/src/hooks/use-app-init.ts | 15 +-- webapp-next/src/i18n/messages.ts | 14 ++ 11 files changed, 324 insertions(+), 113 deletions(-) diff --git a/duty_teller/api/app.py b/duty_teller/api/app.py index c96b092..87bc7bb 100644 --- a/duty_teller/api/app.py +++ b/duty_teller/api/app.py @@ -169,7 +169,8 @@ def app_config_js() -> Response: tz = _safe_tz_string(config.DUTY_DISPLAY_TZ) tz_js = f'\nwindow.__DT_TZ = "{tz}";' if tz else "\nwindow.__DT_TZ = undefined;" 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( content=body, diff --git a/webapp-next/src/app/admin/page.test.tsx b/webapp-next/src/app/admin/page.test.tsx index 84eb44c..c9ed174 100644 --- a/webapp-next/src/app/admin/page.test.tsx +++ b/webapp-next/src/app/admin/page.test.tsx @@ -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( diff --git a/webapp-next/src/app/admin/page.tsx b/webapp-next/src/app/admin/page.tsx index 16ad3da..bd10418 100644 --- a/webapp-next/src/app/admin/page.tsx +++ b/webapp-next/src/app/admin/page.tsx @@ -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 (
-

- - {year} - - - {monthName(month)} - -

+
+ +

+ + {year} + + + {monthName(month)} + +

+ +
+

+ {admin.loading ? "…" : t("admin.duties_count", { count: String(admin.dutyOnly.length) })} +

{admin.successMessage && ( -

+

{admin.successMessage}

)} @@ -96,7 +125,7 @@ export default function AdminPage() { {t("admin.reassign_duty")}: {t("admin.select_user")}

{ }); 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(); 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 () => { diff --git a/webapp-next/src/components/admin/AdminDutyList.tsx b/webapp-next/src/components/admin/AdminDutyList.tsx index 9784ae6..89c7043 100644 --- a/webapp-next/src/components/admin/AdminDutyList.tsx +++ b/webapp-next/src/components/admin/AdminDutyList.tsx @@ -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; +}) { + const router = useRouter(); + return ( +
+

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

+

+ {t("duty.none_this_month_hint")} +

+ +
+ ); +} 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; + sentinelRef: React.RefObject; /** 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

{t("admin.no_duties")}

; + if (groups.length === 0) { + return ; } + const todayKey = localDateString(new Date()); return ( -
    - {duties.map((duty) => { - const dateStr = localDateString(new Date(duty.start_at)); - const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`; +
    + {groups.map(({ dateKey, duties }) => { + const isToday = dateKey === todayKey; + const dateLabel = isToday ? t("duty.today") : dateKeyToDDMM(dateKey); return ( -
  • - -
  • + {dateLabel} +

    +
      + {duties.map((duty) => { + const dateStr = localDateString(new Date(duty.start_at)); + const timeStr = `${formatHHMM(duty.start_at)} – ${formatHHMM(duty.end_at)}`; + return ( +
    • + +
    • + ); + })} +
    + ); })} {hasMore && ( -
+
); } diff --git a/webapp-next/src/components/admin/ReassignSheet.tsx b/webapp-next/src/components/admin/ReassignSheet.tsx index ad6617c..506c044 100644 --- a/webapp-next/src/components/admin/ReassignSheet.tsx +++ b/webapp-next/src/components/admin/ReassignSheet.tsx @@ -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({ !isOpen && onRequestClose()}> e.preventDefault()} > -
+
+ ); + })}
)} - {reassignError && ( -

- {reassignError} + {reassignErrorKey && ( +

)}
)} - + diff --git a/webapp-next/src/components/admin/index.ts b/webapp-next/src/components/admin/index.ts index f21526b..2d0a706 100644 --- a/webapp-next/src/components/admin/index.ts +++ b/webapp-next/src/components/admin/index.ts @@ -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"; diff --git a/webapp-next/src/components/admin/useAdminPage.ts b/webapp-next/src/components/admin/useAdminPage.ts index bf0a641..f2f1e90 100644 --- a/webapp-next/src/components/admin/useAdminPage.ts +++ b/webapp-next/src/components/admin/useAdminPage.ts @@ -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(() => new Date(currentMonth.getFullYear(), currentMonth.getMonth(), 1)); + const [users, setUsers] = useState([]); const [duties, setDuties] = useState([]); const [loadingUsers, setLoadingUsers] = useState(true); @@ -49,17 +55,26 @@ export function useAdminPage() { const [selectedDuty, setSelectedDuty] = useState(null); const [selectedUserId, setSelectedUserId] = useState(""); const [saving, setSaving] = useState(false); - const [reassignError, setReassignError] = useState(null); + const [reassignErrorKey, setReassignErrorKey] = useState(null); const [successMessage, setSuccessMessage] = useState(null); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [sheetExiting, setSheetExiting] = useState(false); - const sentinelRef = useRef(null); + const sentinelRef = useRef(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(); + 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, diff --git a/webapp-next/src/components/providers/TelegramProvider.tsx b/webapp-next/src/components/providers/TelegramProvider.tsx index 2d75a9b..199df94 100644 --- a/webapp-next/src/components/providers/TelegramProvider.tsx +++ b/webapp-next/src/components/providers/TelegramProvider.tsx @@ -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({ + 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 ( + + {children} + + ); } diff --git a/webapp-next/src/hooks/use-app-init.ts b/webapp-next/src/hooks/use-app-init.ts index 9de172b..d288b77 100644 --- a/webapp-next/src/hooks/use-app-init.ts +++ b/webapp-next/src/hooks/use-app-init.ts @@ -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; diff --git a/webapp-next/src/i18n/messages.ts b/webapp-next/src/i18n/messages.ts index fc81926..341ad9a 100644 --- a/webapp-next/src/i18n/messages.ts +++ b/webapp-next/src/i18n/messages.ts @@ -86,6 +86,7 @@ export const MESSAGES: Record> = { "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> = { "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> = { "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> = { "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": "Ошибка сети. Проверьте подключение.", }, };