feat: implement admin panel functionality in Mini App

- Added new API endpoints for admin features: `GET /api/admin/me`, `GET /api/admin/users`, and `PATCH /api/admin/duties/:id` to manage user duties.
- Introduced `UserForAdmin` and `AdminDutyReassignBody` schemas for handling admin-related data.
- Updated documentation to include Mini App design guidelines and admin panel functionalities.
- Enhanced tests for admin API to ensure proper access control and functionality.
- Improved error handling and localization for admin actions.
This commit is contained in:
2026-03-06 09:57:26 +03:00
parent 68b1884b73
commit c390a4dd6e
28 changed files with 2045 additions and 15 deletions

View File

@@ -0,0 +1,246 @@
/**
* Component tests for admin page: render, access denied, duty list, reassign sheet.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import AdminPage from "./page";
import { resetAppStore } from "@/test/test-utils";
import { useAppStore } from "@/store/app-store";
vi.mock("@/hooks/use-telegram-auth", () => ({
useTelegramAuth: vi.fn(),
}));
vi.mock("next/navigation", () => ({
useRouter: vi.fn(() => ({ push: vi.fn() })),
}));
const mockUseTelegramAuth = vi.mocked(
await import("@/hooks/use-telegram-auth").then((m) => m.useTelegramAuth)
);
const sampleUsers = [
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
];
const sampleDuties: Array<{
id: number;
user_id: number;
start_at: string;
end_at: string;
full_name: string;
event_type: string;
phone: string | null;
username: string | null;
}> = [
{
id: 10,
user_id: 1,
start_at: "2030-01-15T09:00:00Z",
end_at: "2030-01-15T18:00:00Z",
full_name: "Alice",
event_type: "duty",
phone: null,
username: "alice",
},
];
function mockFetchForAdmin(
users: Array<{ id: number; full_name: string; username: string | null; role_id?: number }> = sampleUsers,
duties = sampleDuties,
options?: { adminMe?: { is_admin: boolean } }
) {
const adminMe = options?.adminMe ?? { is_admin: true };
vi.stubGlobal(
"fetch",
vi.fn((url: string, init?: RequestInit) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(adminMe),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(users),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(duties),
} as Response);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
})
);
}
describe("AdminPage", () => {
beforeEach(() => {
resetAppStore();
useAppStore.getState().setCurrentMonth(new Date(2025, 0, 1));
mockUseTelegramAuth.mockReturnValue({
initDataRaw: "test-init",
startParam: undefined,
isLocalhost: true,
});
});
it("shows admin title and back link when allowed and data loaded", async () => {
mockFetchForAdmin();
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByRole("heading", { name: /admin|админка/i })).toBeInTheDocument();
});
expect(screen.getByRole("link", { name: /back to calendar|назад к календарю/i })).toBeInTheDocument();
});
it("shows access denied when fetchAdminMe returns is_admin false", async () => {
mockFetchForAdmin(sampleUsers, [], { adminMe: { is_admin: false } });
render(<AdminPage />);
await waitFor(
() => {
expect(
screen.getByText(/Access only for administrators|Доступ только для администраторов/i)
).toBeInTheDocument();
},
{ timeout: 2000 }
);
});
it("shows access denied message when GET /api/admin/users returns 403", async () => {
vi.stubGlobal(
"fetch",
vi.fn((url: string) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: true }),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: false,
status: 403,
json: () => Promise.resolve({ detail: "Admin only" }),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve([]),
} as Response);
}
return Promise.reject(new Error(`Unexpected: ${url}`));
})
);
render(<AdminPage />);
await waitFor(
() => {
expect(
screen.getByText(/Admin only|Access only for administrators|Доступ только для администраторов/i)
).toBeInTheDocument();
},
{ timeout: 2000 }
);
});
it("shows duty row and opens reassign sheet on click", async () => {
mockFetchForAdmin();
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
const dutyButton = screen.getByRole("button", { name: /Alice/ });
fireEvent.click(dutyButton);
await waitFor(() => {
expect(
screen.getByLabelText(/select user|выберите пользователя/i)
).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /save|сохранить/i })).toBeInTheDocument();
});
it("shows no users message in sheet when usersForSelect is empty", async () => {
mockFetchForAdmin([], sampleDuties);
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
await waitFor(() => {
expect(
screen.getByText(/No users available for assignment|Нет пользователей для назначения/i)
).toBeInTheDocument();
});
});
it("shows success message after successful reassign", async () => {
mockFetchForAdmin();
vi.stubGlobal(
"fetch",
vi.fn((url: string, init?: RequestInit) => {
if (url.includes("/api/admin/me")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ is_admin: true }),
} as Response);
}
if (url.includes("/api/admin/users")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(sampleUsers),
} as Response);
}
if (url.includes("/api/duties")) {
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve(sampleDuties),
} as Response);
}
if (url.includes("/api/admin/duties/") && init?.method === "PATCH") {
return Promise.resolve({
ok: true,
status: 200,
json: () =>
Promise.resolve({
id: 10,
user_id: 2,
start_at: "2030-01-15T09:00:00Z",
end_at: "2030-01-15T18:00:00Z",
}),
} as Response);
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
})
);
render(<AdminPage />);
await waitFor(() => {
expect(screen.getByText("Alice")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /Alice/ }));
await waitFor(() => {
expect(screen.getByLabelText(/select user|выберите пользователя/i)).toBeInTheDocument();
});
const select = screen.getByLabelText(/select user|выберите пользователя/i);
fireEvent.change(select, { target: { value: "2" } });
fireEvent.click(screen.getByRole("button", { name: /save|сохранить/i }));
await waitFor(() => {
expect(
screen.getByText(/Duty reassigned|Дежурство переназначено/i)
).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,450 @@
/**
* Admin page: list duties for the month and reassign duty to another user.
* Visible only to admins (link shown on calendar when GET /api/admin/me returns is_admin).
* Requires GET /api/admin/users and PATCH /api/admin/duties/:id (admin-only).
*/
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { backButton } from "@telegram-apps/sdk-react";
import { useAppStore } from "@/store/app-store";
import { useShallow } from "zustand/react/shallow";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useTranslation } from "@/i18n/use-translation";
import {
fetchDuties,
fetchAdminMe,
fetchAdminUsers,
patchAdminDuty,
AccessDeniedError,
type UserForAdmin,
} from "@/lib/api";
import type { DutyWithUser } from "@/types";
import {
firstDayOfMonth,
lastDayOfMonth,
localDateString,
formatHHMM,
} from "@/lib/date-utils";
import { Button } from "@/components/ui/button";
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
SheetDescription,
SheetFooter,
} from "@/components/ui/sheet";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { LoadingState } from "@/components/states/LoadingState";
import { ErrorState } from "@/components/states/ErrorState";
const PAGE_SIZE = 20;
export default function AdminPage() {
const router = useRouter();
const { initDataRaw, isLocalhost } = useTelegramAuth();
const isAllowed = isLocalhost || !!initDataRaw;
const { lang } = useAppStore(useShallow((s) => ({ lang: s.lang })));
const { t, monthName } = useTranslation();
// Telegram BackButton: show on mount when in Mini App, navigate to calendar on click.
useEffect(() => {
if (isLocalhost) return;
let offClick: (() => void) | undefined;
try {
if (backButton.mount.isAvailable()) {
backButton.mount();
}
if (backButton.show.isAvailable()) {
backButton.show();
}
if (backButton.onClick.isAvailable()) {
offClick = backButton.onClick(() => router.push("/"));
}
} catch {
// Non-Telegram environment; BackButton not available.
}
return () => {
try {
if (typeof offClick === "function") offClick();
if (backButton.hide.isAvailable()) {
backButton.hide();
}
} catch {
// Ignore cleanup errors in non-Telegram environment.
}
};
}, [isLocalhost, router]);
const [users, setUsers] = useState<UserForAdmin[]>([]);
const [duties, setDuties] = useState<DutyWithUser[]>([]);
const [loadingUsers, setLoadingUsers] = useState(true);
const [loadingDuties, setLoadingDuties] = useState(true);
/** null = not yet checked, true = is admin, false = not admin (then adminAccessDenied is set). */
const [adminCheckComplete, setAdminCheckComplete] = useState<boolean | null>(null);
const [adminAccessDenied, setAdminAccessDenied] = useState(false);
const [adminAccessDeniedDetail, setAdminAccessDeniedDetail] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [selectedDuty, setSelectedDuty] = useState<DutyWithUser | null>(null);
const [selectedUserId, setSelectedUserId] = useState<number | "">("");
const [saving, setSaving] = useState(false);
const [reassignError, setReassignError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const sentinelRef = useRef<HTMLLIElement | null>(null);
const currentMonth = useAppStore((s) => s.currentMonth);
const from = localDateString(firstDayOfMonth(currentMonth));
const to = localDateString(lastDayOfMonth(currentMonth));
// Check admin status first; only then load users and duties (avoids extra 403 for non-admins).
useEffect(() => {
if (!isAllowed || !initDataRaw) return;
setAdminCheckComplete(null);
setAdminAccessDenied(false);
fetchAdminMe(initDataRaw, lang)
.then(({ is_admin }) => {
if (!is_admin) {
setAdminAccessDenied(true);
setAdminAccessDeniedDetail(null);
setAdminCheckComplete(false);
} else {
setAdminCheckComplete(true);
}
})
.catch(() => {
setAdminAccessDenied(true);
setAdminAccessDeniedDetail(null);
setAdminCheckComplete(false);
});
}, [isAllowed, initDataRaw, lang]);
useEffect(() => {
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
const controller = new AbortController();
setLoadingUsers(true);
fetchAdminUsers(initDataRaw, lang, controller.signal)
.then((list) => {
setUsers(list);
})
.catch((e) => {
if ((e as Error)?.name === "AbortError") return;
if (e instanceof AccessDeniedError) {
setAdminAccessDenied(true);
setAdminAccessDeniedDetail(e.serverDetail ?? null);
} else {
setError(e instanceof Error ? e.message : String(e));
}
})
.finally(() => setLoadingUsers(false));
return () => controller.abort();
}, [isAllowed, initDataRaw, lang, adminCheckComplete]);
useEffect(() => {
if (!isAllowed || !initDataRaw || adminCheckComplete !== true) return;
const controller = new AbortController();
setLoadingDuties(true);
setError(null);
fetchDuties(from, to, initDataRaw, lang, controller.signal)
.then((list) => setDuties(list))
.catch((e) => {
if ((e as Error)?.name === "AbortError") return;
setError(e instanceof Error ? e.message : String(e));
})
.finally(() => setLoadingDuties(false));
return () => controller.abort();
}, [isAllowed, initDataRaw, lang, from, to, adminCheckComplete]);
useEffect(() => {
setVisibleCount(PAGE_SIZE);
}, [from, to]);
const openReassign = useCallback((duty: DutyWithUser) => {
setSelectedDuty(duty);
setSelectedUserId(duty.user_id);
setReassignError(null);
}, []);
const closeReassign = useCallback(() => {
setSelectedDuty(null);
setSelectedUserId("");
setReassignError(null);
}, []);
const handleReassign = useCallback(() => {
if (!selectedDuty || selectedUserId === "" || !initDataRaw) return;
if (selectedUserId === selectedDuty.user_id) {
closeReassign();
return;
}
setSaving(true);
setReassignError(null);
patchAdminDuty(selectedDuty.id, selectedUserId, initDataRaw, lang)
.then((updated) => {
setDuties((prev) =>
prev.map((d) =>
d.id === updated.id
? {
...d,
user_id: updated.user_id,
full_name:
users.find((u) => u.id === updated.user_id)?.full_name ?? d.full_name,
}
: d
)
);
setSuccessMessage(t("admin.reassign_success"));
closeReassign();
setTimeout(() => setSuccessMessage(null), 3000);
})
.catch((e) => {
setReassignError(e instanceof Error ? e.message : String(e));
})
.finally(() => setSaving(false));
}, [selectedDuty, selectedUserId, initDataRaw, lang, users, closeReassign]);
const now = new Date();
const dutyOnly = duties
.filter(
(d) =>
d.event_type === "duty" && new Date(d.end_at) > now
)
.sort(
(a, b) =>
new Date(a.start_at).getTime() - new Date(b.start_at).getTime()
);
/** Users with role_id 1 (user) or 2 (admin) shown in reassign dropdown. */
const usersForSelect = users.filter(
(u) => u.role_id === 1 || u.role_id === 2
);
const visibleDuties = dutyOnly.slice(0, visibleCount);
const hasMore = visibleCount < dutyOnly.length;
useEffect(() => {
if (!hasMore || !sentinelRef.current) return;
const el = sentinelRef.current;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
setVisibleCount((prev) =>
Math.min(prev + PAGE_SIZE, dutyOnly.length)
);
}
},
{ root: null, rootMargin: "200px", threshold: 0 }
);
observer.observe(el);
return () => observer.disconnect();
}, [hasMore, dutyOnly.length]);
const loading = loadingUsers || loadingDuties;
if (!isAllowed) {
return (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<AccessDeniedScreen primaryAction="reload" />
</div>
);
}
if (isAllowed && initDataRaw && adminCheckComplete === null) {
return (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<div className="py-4 flex flex-col items-center gap-2">
<LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
</div>
</div>
);
}
if (adminAccessDenied) {
return (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<div className="flex flex-col gap-4 py-6">
<p className="text-muted-foreground">{adminAccessDeniedDetail ?? t("admin.access_denied")}</p>
<Button asChild variant="outline">
<Link href="/">{t("admin.back_to_calendar")}</Link>
</Button>
</div>
</div>
);
}
return (
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<header className="sticky top-0 z-10 flex items-center justify-between border-b bg-[var(--header-bg)] py-3">
<h1 className="text-lg font-semibold">
{t("admin.title")} {monthName(currentMonth.getMonth())} {currentMonth.getFullYear()}
</h1>
<Button asChild variant="ghost" size="sm">
<Link href="/">{t("admin.back_to_calendar")}</Link>
</Button>
</header>
{successMessage && (
<p className="mt-3 text-sm text-[var(--duty)]" role="status">
{successMessage}
</p>
)}
{loading && (
<div className="py-4 flex flex-col items-center gap-2">
<LoadingState />
<p className="text-sm text-muted-foreground">{t("admin.loading_users")}</p>
</div>
)}
{error && !loading && (
<ErrorState message={error} onRetry={() => window.location.reload()} className="my-3" />
)}
{!loading && !error && (
<div className="mt-3 flex flex-col gap-2">
<p className="text-sm text-muted-foreground">
{t("admin.reassign_duty")}: {t("admin.select_user")}
</p>
{dutyOnly.length === 0 ? (
<p className="text-muted-foreground py-4">{t("admin.no_duties")}</p>
) : (
<ul className="flex flex-col gap-1.5" aria-label={t("admin.list_aria")}>
{visibleDuties.map((duty) => {
const start = new Date(duty.start_at);
const end = new Date(duty.end_at);
const dateStr = localDateString(start);
const timeStr = `${formatHHMM(duty.start_at)} ${formatHHMM(duty.end_at)}`;
return (
<li key={duty.id}>
<button
type="button"
onClick={() => openReassign(duty)}
aria-label={t("admin.reassign_aria", {
date: dateStr,
time: timeStr,
name: duty.full_name,
})}
className="w-full rounded-lg border border-l-[3px] border-l-duty bg-surface px-3 py-2.5 text-left text-sm transition-colors hover:bg-[var(--surface-hover)] focus-visible:outline-accent"
>
<span className="font-medium">{dateStr}</span>
<span className="mx-2 text-muted-foreground">·</span>
<span className="text-muted-foreground">{timeStr}</span>
<span className="mx-2 text-muted-foreground">·</span>
<span>{duty.full_name}</span>
</button>
</li>
);
})}
{hasMore && (
<li ref={sentinelRef} className="h-2" data-sentinel aria-hidden="true" />
)}
</ul>
)}
</div>
)}
{selectedDuty !== null && (
<Sheet open onOpenChange={(open) => !open && closeReassign()}>
<SheetContent
side="bottom"
className="rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)]"
overlayClassName="backdrop-blur-md"
showCloseButton={false}
>
<div className="relative px-4">
<Button
type="button"
variant="ghost"
size="icon"
className="absolute top-2 right-2 h-8 w-8 text-muted hover:text-[var(--text)] rounded-lg"
onClick={closeReassign}
aria-label={t("day_detail.close")}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</Button>
<div
className="w-10 h-1 rounded-full bg-[var(--handle-bg)] mx-auto mb-2"
aria-hidden
/>
<SheetHeader className="p-0">
<SheetTitle>{t("admin.reassign_duty")}</SheetTitle>
<SheetDescription>{t("admin.select_user")}</SheetDescription>
</SheetHeader>
{selectedDuty && (
<div className="flex flex-col gap-4 pt-2 pb-4">
<p className="text-sm text-muted-foreground">
{localDateString(new Date(selectedDuty.start_at))}{" "}
{formatHHMM(selectedDuty.start_at)} {formatHHMM(selectedDuty.end_at)}
</p>
{usersForSelect.length === 0 ? (
<p className="text-sm text-muted-foreground">{t("admin.no_users_for_assign")}</p>
) : (
<div className="flex flex-col gap-2">
<label htmlFor="admin-user-select" className="text-sm font-medium">
{t("admin.select_user")}
</label>
<select
id="admin-user-select"
value={selectedUserId === "" ? "" : String(selectedUserId)}
onChange={(e) => setSelectedUserId(e.target.value === "" ? "" : Number(e.target.value))}
className="rounded-md border bg-background px-3 py-2 text-sm focus-visible:outline-accent"
disabled={saving}
>
{usersForSelect.map((u) => (
<option key={u.id} value={u.id}>
{u.full_name}
{u.username ? ` (@${u.username})` : ""}
</option>
))}
</select>
</div>
)}
{reassignError && (
<p className="text-sm text-destructive" role="alert">
{reassignError}
</p>
)}
</div>
)}
</div>
<SheetFooter className="flex-row justify-end gap-2">
<Button
onClick={handleReassign}
disabled={
saving ||
selectedUserId === "" ||
selectedUserId === selectedDuty?.user_id ||
usersForSelect.length === 0
}
>
{saving ? t("loading") : t("admin.save")}
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
)}
</div>
);
}

View File

@@ -31,7 +31,7 @@ export default function GlobalError({
/>
</head>
<body className="antialiased">
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<div className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">
{translate(lang, "error_boundary.message")}
</h1>

View File

@@ -11,7 +11,7 @@ import { useTranslation } from "@/i18n/use-translation";
export default function NotFound() {
const { t } = useTranslation();
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<div className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground">
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
<p className="text-muted-foreground">{t("not_found.description")}</p>
<Link

View File

@@ -12,6 +12,8 @@ import { useTelegramTheme } from "@/hooks/use-telegram-theme";
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
import { useAppInit } from "@/hooks/use-app-init";
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
import { fetchAdminMe } from "@/lib/api";
import { getLang } from "@/i18n/messages";
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
import { CalendarPage } from "@/components/CalendarPage";
@@ -24,6 +26,15 @@ export default function Home() {
useAppInit({ isAllowed, startParam });
const setIsAdmin = useAppStore((s) => s.setIsAdmin);
useEffect(() => {
if (!isAllowed || !initDataRaw) {
setIsAdmin(false);
return;
}
fetchAdminMe(initDataRaw, getLang()).then(({ is_admin }) => setIsAdmin(is_admin));
}, [isAllowed, initDataRaw, setIsAdmin]);
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
useAppStore(
useShallow((s: AppState) => ({
@@ -50,7 +61,7 @@ export default function Home() {
const content = accessDenied ? (
<AccessDeniedScreen primaryAction="reload" />
) : currentView === "currentDuty" ? (
<div className="content-safe mx-auto flex min-h-screen w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<div className="content-safe mx-auto flex min-h-[var(--tg-viewport-stable-height,100vh)] w-full max-w-[var(--max-width-app)] flex-col bg-background px-3 pb-6">
<CurrentDutyView
onBack={handleBackFromCurrentDuty}
openedFromPin={startParam === "duty"}