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:
246
webapp-next/src/app/admin/page.test.tsx
Normal file
246
webapp-next/src/app/admin/page.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
450
webapp-next/src/app/admin/page.tsx
Normal file
450
webapp-next/src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -6,12 +6,14 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useEffect, useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { useAppStore } from "@/store/app-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useMonthData } from "@/hooks/use-month-data";
|
||||
import { useSwipe } from "@/hooks/use-swipe";
|
||||
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
||||
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
||||
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
||||
import { DutyList } from "@/components/duty/DutyList";
|
||||
@@ -53,6 +55,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
duties,
|
||||
calendarEvents,
|
||||
selectedDay,
|
||||
isAdmin,
|
||||
nextMonth,
|
||||
prevMonth,
|
||||
setCurrentMonth,
|
||||
@@ -68,6 +71,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
duties: s.duties,
|
||||
calendarEvents: s.calendarEvents,
|
||||
selectedDay: s.selectedDay,
|
||||
isAdmin: s.isAdmin,
|
||||
nextMonth: s.nextMonth,
|
||||
prevMonth: s.prevMonth,
|
||||
setCurrentMonth: s.setCurrentMonth,
|
||||
@@ -76,6 +80,8 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
}))
|
||||
);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { retry } = useMonthData({
|
||||
initDataRaw,
|
||||
enabled: isAllowed,
|
||||
@@ -133,7 +139,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
}, [loading, accessDenied, setAppContentReady]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div
|
||||
ref={calendarStickyRef}
|
||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
||||
@@ -143,6 +149,16 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
||||
disabled={navDisabled}
|
||||
onPrevMonth={handlePrevMonth}
|
||||
onNextMonth={handleNextMonth}
|
||||
trailingContent={
|
||||
isAdmin ? (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="text-sm text-accent hover:underline focus-visible:outline-accent rounded"
|
||||
>
|
||||
{t("admin.link")}
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<CalendarGrid
|
||||
currentMonth={currentMonth}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslation } from "@/i18n/use-translation";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -20,6 +21,8 @@ export interface CalendarHeaderProps {
|
||||
disabled?: boolean;
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
/** Optional content shown above the nav row (e.g. Admin link). */
|
||||
trailingContent?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +31,7 @@ export function CalendarHeader({
|
||||
disabled = false,
|
||||
onPrevMonth,
|
||||
onNextMonth,
|
||||
trailingContent,
|
||||
className,
|
||||
}: CalendarHeaderProps) {
|
||||
const { t, monthName, weekdayLabels } = useTranslation();
|
||||
@@ -37,6 +41,9 @@ export function CalendarHeader({
|
||||
|
||||
return (
|
||||
<header className={cn("flex flex-col", className)}>
|
||||
{trailingContent != null && (
|
||||
<div className="flex justify-end mb-1 min-h-[1.5rem]">{trailingContent}</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -204,7 +204,7 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
||||
<SheetContent
|
||||
side="bottom"
|
||||
className={cn(
|
||||
"rounded-t-2xl pt-3 pb-[calc(24px+env(safe-area-inset-bottom,0px))] max-h-[70vh] bg-[var(--surface)]",
|
||||
"rounded-t-2xl pt-3 max-h-[70vh] bg-[var(--surface)]",
|
||||
className
|
||||
)}
|
||||
overlayClassName="backdrop-blur-md"
|
||||
|
||||
@@ -60,7 +60,7 @@ export function AccessDeniedScreen({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex min-h-screen flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"
|
||||
className="flex min-h-[var(--tg-viewport-stable-height,100vh)] flex-col items-center justify-center gap-4 bg-background px-4 text-foreground"
|
||||
role="alert"
|
||||
>
|
||||
<h1 className="text-xl font-semibold">
|
||||
|
||||
@@ -94,7 +94,7 @@ function SheetContent({
|
||||
side === "top" &&
|
||||
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
side === "bottom" &&
|
||||
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
"inset-x-0 bottom-0 h-auto border-t pb-[calc(24px+var(--tg-viewport-content-safe-area-inset-bottom,env(safe-area-inset-bottom,0px)))] data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -42,7 +42,7 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[0_4px_12px_rgba(0,0,0,0.4)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
"z-50 w-fit max-w-[min(98vw,380px)] origin-(--radix-tooltip-content-transform-origin) animate-in rounded-lg bg-surface px-3 py-2 text-[0.85rem] leading-snug text-[var(--text)] shadow-[var(--shadow-card)] fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -82,6 +82,21 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
||||
"not_found.open_calendar": "Open calendar",
|
||||
"access_denied.hint": "Open the app again from Telegram.",
|
||||
"access_denied.reload": "Reload",
|
||||
"admin.link": "Admin",
|
||||
"admin.title": "Admin",
|
||||
"admin.reassign_duty": "Reassign duty",
|
||||
"admin.select_user": "Select user",
|
||||
"admin.reassign_success": "Duty reassigned",
|
||||
"admin.access_denied": "Access only for administrators.",
|
||||
"admin.duty_not_found": "Duty not found",
|
||||
"admin.user_not_found": "User not found",
|
||||
"admin.back_to_calendar": "Back to calendar",
|
||||
"admin.loading_users": "Loading users…",
|
||||
"admin.no_duties": "No duties this month.",
|
||||
"admin.no_users_for_assign": "No users available for assignment.",
|
||||
"admin.save": "Save",
|
||||
"admin.list_aria": "List of duties to reassign",
|
||||
"admin.reassign_aria": "Reassign duty: {date}, {time}, {name}",
|
||||
},
|
||||
ru: {
|
||||
"app.title": "Календарь дежурств",
|
||||
@@ -159,6 +174,21 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
||||
"not_found.open_calendar": "Открыть календарь",
|
||||
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
||||
"access_denied.reload": "Обновить",
|
||||
"admin.link": "Админка",
|
||||
"admin.title": "Админка",
|
||||
"admin.reassign_duty": "Переназначить дежурного",
|
||||
"admin.select_user": "Выберите пользователя",
|
||||
"admin.reassign_success": "Дежурство переназначено",
|
||||
"admin.access_denied": "Доступ только для администраторов.",
|
||||
"admin.duty_not_found": "Дежурство не найдено",
|
||||
"admin.user_not_found": "Пользователь не найден",
|
||||
"admin.back_to_calendar": "Назад к календарю",
|
||||
"admin.loading_users": "Загрузка пользователей…",
|
||||
"admin.no_duties": "В этом месяце дежурств нет.",
|
||||
"admin.no_users_for_assign": "Нет пользователей для назначения.",
|
||||
"admin.save": "Сохранить",
|
||||
"admin.list_aria": "Список дежурств для перераспределения",
|
||||
"admin.reassign_aria": "Переназначить дежурство: {date}, {time}, {name}",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,14 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { fetchDuties, fetchCalendarEvents, AccessDeniedError } from "./api";
|
||||
import {
|
||||
fetchDuties,
|
||||
fetchCalendarEvents,
|
||||
fetchAdminMe,
|
||||
fetchAdminUsers,
|
||||
patchAdminDuty,
|
||||
AccessDeniedError,
|
||||
} from "./api";
|
||||
|
||||
describe("fetchDuties", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
@@ -174,3 +181,160 @@ describe("fetchCalendarEvents", () => {
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAdminMe", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ is_admin: true }),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("fetch", originalFetch);
|
||||
});
|
||||
|
||||
it("returns is_admin true on 200", async () => {
|
||||
const result = await fetchAdminMe("init-data", "en");
|
||||
expect(result).toEqual({ is_admin: true });
|
||||
});
|
||||
|
||||
it("returns is_admin false on 403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
} as Response);
|
||||
const result = await fetchAdminMe("init-data", "en");
|
||||
expect(result).toEqual({ is_admin: false });
|
||||
});
|
||||
|
||||
it("returns is_admin false on non-200 and non-403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 500,
|
||||
} as Response);
|
||||
const result = await fetchAdminMe("init-data", "en");
|
||||
expect(result).toEqual({ is_admin: false });
|
||||
});
|
||||
|
||||
it("returns is_admin false when response is not boolean", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve({ is_admin: "yes" }),
|
||||
} as Response);
|
||||
const result = await fetchAdminMe("init-data", "en");
|
||||
expect(result).toEqual({ is_admin: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchAdminUsers", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const validUsers = [
|
||||
{ id: 1, full_name: "Alice", username: "alice", role_id: 1 },
|
||||
{ id: 2, full_name: "Bob", username: null, role_id: 2 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(validUsers),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("fetch", originalFetch);
|
||||
});
|
||||
|
||||
it("returns users array on 200", async () => {
|
||||
const result = await fetchAdminUsers("init-data", "en");
|
||||
expect(result).toEqual(validUsers);
|
||||
});
|
||||
|
||||
it("throws AccessDeniedError on 403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ detail: "Admin only" }),
|
||||
} as Response);
|
||||
await expect(fetchAdminUsers("init-data", "en")).rejects.toThrow(AccessDeniedError);
|
||||
await expect(fetchAdminUsers("init-data", "en")).rejects.toMatchObject({
|
||||
message: "ACCESS_DENIED",
|
||||
serverDetail: "Admin only",
|
||||
});
|
||||
});
|
||||
|
||||
it("filters invalid items from response", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () =>
|
||||
Promise.resolve([
|
||||
validUsers[0],
|
||||
{ id: 2, full_name: "Bob", username: 123, role_id: 2 },
|
||||
{ id: 3 },
|
||||
]),
|
||||
} as Response);
|
||||
const result = await fetchAdminUsers("init-data", "en");
|
||||
expect(result).toEqual([validUsers[0]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patchAdminDuty", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const updatedDuty = {
|
||||
id: 5,
|
||||
user_id: 2,
|
||||
start_at: "2025-03-01T09:00:00Z",
|
||||
end_at: "2025-03-01T18:00:00Z",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn());
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(updatedDuty),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.stubGlobal("fetch", originalFetch);
|
||||
});
|
||||
|
||||
it("sends PATCH with user_id and returns updated duty", async () => {
|
||||
const result = await patchAdminDuty(5, 2, "init-data", "en");
|
||||
expect(result).toEqual(updatedDuty);
|
||||
const fetchCall = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(fetchCall[1]?.method).toBe("PATCH");
|
||||
expect(fetchCall[1]?.body).toBe(JSON.stringify({ user_id: 2 }));
|
||||
});
|
||||
|
||||
it("throws AccessDeniedError on 403", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 403,
|
||||
json: () => Promise.resolve({ detail: "Admin only" }),
|
||||
} as Response);
|
||||
await expect(
|
||||
patchAdminDuty(5, 2, "init-data", "en")
|
||||
).rejects.toThrow(AccessDeniedError);
|
||||
});
|
||||
|
||||
it("throws with server detail on 400", async () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
json: () => Promise.resolve({ detail: "User not found" }),
|
||||
} as Response);
|
||||
await expect(
|
||||
patchAdminDuty(5, 999, "init-data", "en")
|
||||
).rejects.toThrow("User not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,6 +11,22 @@ import { translate } from "@/i18n/messages";
|
||||
|
||||
type ApiLang = "ru" | "en";
|
||||
|
||||
/** User summary for admin dropdown (GET /api/admin/users). */
|
||||
export interface UserForAdmin {
|
||||
id: number;
|
||||
full_name: string;
|
||||
username: string | null;
|
||||
role_id: number | null;
|
||||
}
|
||||
|
||||
/** Minimal duty shape returned by PATCH /api/admin/duties/:id. */
|
||||
export interface DutyReassignResponse {
|
||||
id: number;
|
||||
user_id: number;
|
||||
start_at: string;
|
||||
end_at: string;
|
||||
}
|
||||
|
||||
/** Minimal runtime check for a single duty item (required fields). */
|
||||
function isDutyWithUser(x: unknown): x is DutyWithUser {
|
||||
if (!x || typeof x !== "object") return false;
|
||||
@@ -207,3 +223,153 @@ export async function fetchCalendarEvents(
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch admin status for the current user (GET /api/admin/me).
|
||||
* Returns { is_admin: true } or { is_admin: false }. On 403, treat as not admin.
|
||||
*/
|
||||
export async function fetchAdminMe(
|
||||
initData: string,
|
||||
acceptLang: ApiLang
|
||||
): Promise<{ is_admin: boolean }> {
|
||||
const base =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${base}/api/admin/me`;
|
||||
const opts = buildFetchOptions(initData, acceptLang);
|
||||
try {
|
||||
logger.debug("API request", "/api/admin/me");
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
return { is_admin: false };
|
||||
}
|
||||
if (!res.ok) {
|
||||
return { is_admin: false };
|
||||
}
|
||||
const data = (await res.json()) as { is_admin?: boolean };
|
||||
return {
|
||||
is_admin: typeof data?.is_admin === "boolean" ? data.is_admin : false,
|
||||
};
|
||||
} catch (e) {
|
||||
logger.warn("API request failed", "/api/admin/me", e);
|
||||
return { is_admin: false };
|
||||
} finally {
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch users for admin dropdown (GET /api/admin/users). Admin only; throws AccessDeniedError on 403.
|
||||
*/
|
||||
export async function fetchAdminUsers(
|
||||
initData: string,
|
||||
acceptLang: ApiLang,
|
||||
signal?: AbortSignal | null
|
||||
): Promise<UserForAdmin[]> {
|
||||
const base =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${base}/api/admin/users`;
|
||||
const opts = buildFetchOptions(initData, acceptLang, signal);
|
||||
try {
|
||||
logger.debug("API request", "/api/admin/users");
|
||||
const res = await fetch(url, { headers: opts.headers, signal: opts.signal });
|
||||
if (res.status === 403) {
|
||||
let detail = translate(acceptLang, "admin.access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && (body as { detail?: string }).detail !== undefined) {
|
||||
const d = (body as { detail: string | { msg?: string } }).detail;
|
||||
detail =
|
||||
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
if (!res.ok) {
|
||||
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||
}
|
||||
const data = await res.json();
|
||||
if (!Array.isArray(data)) return [];
|
||||
return data.filter(
|
||||
(x: unknown): x is UserForAdmin =>
|
||||
x != null &&
|
||||
typeof (x as UserForAdmin).id === "number" &&
|
||||
typeof (x as UserForAdmin).full_name === "string" &&
|
||||
((x as UserForAdmin).username === null ||
|
||||
typeof (x as UserForAdmin).username === "string") &&
|
||||
((x as UserForAdmin).role_id === null ||
|
||||
typeof (x as UserForAdmin).role_id === "number")
|
||||
);
|
||||
} catch (e) {
|
||||
if ((e as Error).name === "AbortError" || e instanceof AccessDeniedError) {
|
||||
throw e;
|
||||
}
|
||||
logger.error("API request failed", "/api/admin/users", e);
|
||||
throw e;
|
||||
} finally {
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign a duty to another user (PATCH /api/admin/duties/:id). Admin only.
|
||||
* Returns updated duty (id, user_id, start_at, end_at). Throws on 403/404/400.
|
||||
*/
|
||||
export async function patchAdminDuty(
|
||||
dutyId: number,
|
||||
userId: number,
|
||||
initData: string,
|
||||
acceptLang: ApiLang
|
||||
): Promise<DutyReassignResponse> {
|
||||
const base =
|
||||
typeof window !== "undefined" ? window.location.origin : "";
|
||||
const url = `${base}/api/admin/duties/${dutyId}`;
|
||||
const opts = buildFetchOptions(initData, acceptLang);
|
||||
try {
|
||||
logger.debug("API request", "PATCH", url, { user_id: userId });
|
||||
const res = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
...opts.headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ user_id: userId }),
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (res.status === 403) {
|
||||
let detail = translate(acceptLang, "admin.access_denied");
|
||||
try {
|
||||
const body = await res.json();
|
||||
if (body && (body as { detail?: string }).detail !== undefined) {
|
||||
const d = (body as { detail: string | { msg?: string } }).detail;
|
||||
detail =
|
||||
typeof d === "string" ? d : (d.msg ?? JSON.stringify(d));
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw new AccessDeniedError(API_ACCESS_DENIED, detail);
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
data && typeof (data as { detail?: string }).detail === "string"
|
||||
? (data as { detail: string }).detail
|
||||
: translate(acceptLang, "error_generic");
|
||||
throw new Error(msg);
|
||||
}
|
||||
const out = data as DutyReassignResponse;
|
||||
if (
|
||||
typeof out?.id !== "number" ||
|
||||
typeof out?.user_id !== "number" ||
|
||||
typeof out?.start_at !== "string" ||
|
||||
typeof out?.end_at !== "string"
|
||||
) {
|
||||
throw new Error(translate(acceptLang, "error_load_failed"));
|
||||
}
|
||||
return out;
|
||||
} finally {
|
||||
opts.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface AppState {
|
||||
selectedDay: string | null;
|
||||
/** True when the first visible screen has finished loading; used to hide content until ready(). */
|
||||
appContentReady: boolean;
|
||||
/** True when GET /api/admin/me returned is_admin: true; used to show Admin link. */
|
||||
isAdmin: boolean;
|
||||
|
||||
setCurrentMonth: (d: Date) => void;
|
||||
nextMonth: () => void;
|
||||
@@ -44,8 +46,9 @@ export interface AppState {
|
||||
setCurrentView: (v: CurrentView) => void;
|
||||
setSelectedDay: (key: string | null) => void;
|
||||
setAppContentReady: (v: boolean) => void;
|
||||
setIsAdmin: (v: boolean) => void;
|
||||
/** Batch multiple state updates into a single re-render. */
|
||||
batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay" | "appContentReady">>) => void;
|
||||
batchUpdate: (partial: Partial<Pick<AppState, "currentMonth" | "pendingMonth" | "lang" | "duties" | "calendarEvents" | "dataForMonthKey" | "loading" | "error" | "accessDenied" | "accessDeniedDetail" | "currentView" | "selectedDay" | "appContentReady" | "isAdmin">>) => void;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -71,6 +74,7 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
currentView: getInitialView(),
|
||||
selectedDay: null,
|
||||
appContentReady: false,
|
||||
isAdmin: false,
|
||||
|
||||
setCurrentMonth: (d) => set({ currentMonth: d }),
|
||||
nextMonth: () =>
|
||||
@@ -91,5 +95,6 @@ export const useAppStore = create<AppState>((set) => ({
|
||||
setCurrentView: (v) => set({ currentView: v }),
|
||||
setSelectedDay: (key) => set({ selectedDay: key }),
|
||||
setAppContentReady: (v) => set({ appContentReady: v }),
|
||||
setIsAdmin: (v) => set({ isAdmin: v }),
|
||||
batchUpdate: (partial) => set(partial),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user