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:
@@ -50,4 +50,15 @@ The Mini App lives in `webapp-next/`. It is built as a static export and served
|
|||||||
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
- **Run:** `cd webapp-next && npm test` (or `npm run test`). Build: `npm run build`.
|
||||||
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
- **Coverage:** Unit tests for lib (api, date-utils, calendar-data, i18n, etc.) and component tests for calendar, duty list, day detail, current duty, states.
|
||||||
|
|
||||||
|
## Design guideline
|
||||||
|
|
||||||
|
When adding or changing UI in the Mini App, **follow the [Mini App design guideline](../../docs/miniapp-design.md)**:
|
||||||
|
|
||||||
|
- Use only design tokens from `globals.css` and Tailwind/shadcn aliases (no hardcoded colours).
|
||||||
|
- Page wrappers: `content-safe`, `max-w-[var(--max-width-app)]`, viewport height; respect safe area for sheets/modals.
|
||||||
|
- Reuse component patterns (buttons, cards, calendar grid, timeline list) and left-stripe semantics (`border-l-duty`, `border-l-today`, etc.).
|
||||||
|
- Add ARIA labels and roles for interactive elements and grids; respect `prefers-reduced-motion` and `data-perf="low"` for animations.
|
||||||
|
|
||||||
|
Use the checklist in the design doc when introducing new screens or components.
|
||||||
|
|
||||||
Consider these rules when changing the Mini App or adding frontend features.
|
Consider these rules when changing the Mini App or adding frontend features.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
|
|||||||
| Duty-schedule parser | `duty_teller/importers/` |
|
| Duty-schedule parser | `duty_teller/importers/` |
|
||||||
| Config (env vars) | `duty_teller/config.py` |
|
| Config (env vars) | `duty_teller/config.py` |
|
||||||
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
| Miniapp frontend | `webapp-next/` (Next.js, Tailwind, shadcn/ui; static export in `webapp-next/out/`) |
|
||||||
|
| Admin panel (Mini App) | `webapp-next/src/app/admin/page.tsx`; API: `GET /api/admin/me`, `GET /api/admin/users`, `PATCH /api/admin/duties/:id` |
|
||||||
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
| Migrations | `alembic/` (config in `pyproject.toml` under `[tool.alembic]`) |
|
||||||
|
|
||||||
## Running and testing
|
## Running and testing
|
||||||
@@ -35,6 +36,7 @@ Duty Teller is a Telegram bot plus Mini App for team duty shift calendar and gro
|
|||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
|
- User and architecture docs: [docs/](docs/), [docs/architecture.md](docs/architecture.md).
|
||||||
|
- [Mini App design guideline](docs/miniapp-design.md) — Theme, layout, safe areas, component patterns, accessibility for webapp-next.
|
||||||
- Configuration reference: [docs/configuration.md](docs/configuration.md).
|
- Configuration reference: [docs/configuration.md](docs/configuration.md).
|
||||||
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
|
- Build docs: `pip install -e ".[docs]"`, then `mkdocs build` / `mkdocs serve`.
|
||||||
|
|
||||||
@@ -48,4 +50,5 @@ Docstrings and code comments must be in English (Google-style docstrings). UI st
|
|||||||
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
|
- **Config:** Environment variables (e.g. `.env`); no hardcoded secrets.
|
||||||
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
|
- **Database:** One logical transaction per `session_scope` — a single `commit` at the end of the business operation (e.g. in `run_import`). Repository helpers used inside such a flow (e.g. `get_or_create_user_by_full_name`) accept `commit=False` and let the caller commit once.
|
||||||
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
|
- **Error handling:** Do not send `str(exception)` from parsers or DB to the user. Use generic i18n keys (e.g. `import.parse_error_generic`, `import.import_error_generic`) and log the full exception server-side.
|
||||||
|
- **Mini App (webapp-next):** When adding or changing UI in `webapp-next/`, follow the [Mini App design guideline](docs/miniapp-design.md): use only design tokens and Tailwind aliases from it, apply layout/safe-area/accessibility rules, and use the checklist for new screens or components.
|
||||||
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.
|
- **Cursor:** The project does not version `.cursor/`. You can mirror this file in `.cursor/rules/` locally; [AGENTS.md](AGENTS.md) is the single versioned reference for AI and maintainers.
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ High-level architecture of Duty Teller: components, data flow, and package relat
|
|||||||
- **Miniapp → API**
|
- **Miniapp → API**
|
||||||
Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`.
|
Browser opens `/app`; frontend calls `GET /api/duties` and `GET /api/calendar-events` with date range. FastAPI dependencies: DB session, Telegram initData validation (`require_miniapp_username`), date validation. Data is read via `duty_teller.db.repository`.
|
||||||
|
|
||||||
|
- **Admin panel (Mini App)**
|
||||||
|
Admins see an "Admin" link on the calendar (when `GET /api/admin/me` returns `is_admin: true`). The admin page at `/app/admin` lists duties for the current month and allows reassigning a duty to another user. It uses `GET /api/admin/users` (admin-only) for the user dropdown and `PATCH /api/admin/duties/:id` with `{ user_id }` to reassign. All admin endpoints require valid initData; `/users` and PATCH `/duties` additionally require the user to have the admin role (`require_admin_telegram_id`). PATCH error messages (e.g. duty not found, user not found) use the request `Accept-Language` header for i18n. The reassign dropdown shows only users with role `user` or `admin` (role_id 1 or 2 per migration 007).
|
||||||
|
|
||||||
- **Import**
|
- **Import**
|
||||||
Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()` → `DutyScheduleResult` → `duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`).
|
Admin sends JSON file via `/import_duty_schedule`. Handler reads file → `duty_teller.importers.duty_schedule.parse_duty_schedule()` → `DutyScheduleResult` → `duty_teller.services.import_service.run_import()` → repository (`get_or_create_user_by_full_name`, `delete_duties_in_range`, `insert_duty`).
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ Telegram bot for team duty shift calendar and group reminder. The bot and web UI
|
|||||||
|
|
||||||
- [Configuration](configuration.md) — Environment variables (types, defaults, examples).
|
- [Configuration](configuration.md) — Environment variables (types, defaults, examples).
|
||||||
- [Architecture](architecture.md) — Components, data flow, package relationships.
|
- [Architecture](architecture.md) — Components, data flow, package relationships.
|
||||||
|
- [Mini App design](miniapp-design.md) — Design guideline for the Telegram Mini App (webapp-next): theme, layout, components, accessibility.
|
||||||
- [Import format](import-format.md) — Duty-schedule JSON format and example.
|
- [Import format](import-format.md) — Duty-schedule JSON format and example.
|
||||||
- [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations.
|
- [Runbook](runbook.md) — Running the app, logs, common errors, DB and migrations.
|
||||||
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
|
- [API Reference](api-reference.md) — Generated from code (api, db, services, handlers, importers, config).
|
||||||
|
|||||||
217
docs/miniapp-design.md
Normal file
217
docs/miniapp-design.md
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
# Mini App Design Guideline
|
||||||
|
|
||||||
|
This document defines the design rules for the Duty Teller Mini App (Next.js frontend in `webapp-next/`). It aligns with [Telegram’s official Mini App design guidelines](https://core.telegram.org/bots/webapps#designing-mini-apps) (Color Schemes and Design Guidelines) and codifies the current implementation (calendar, duty list, admin) as the reference for new screens and components.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Telegram design principles
|
||||||
|
|
||||||
|
Telegram’s guidelines state:
|
||||||
|
|
||||||
|
- **Mobile-first:** All elements must be responsive and designed for mobile first.
|
||||||
|
- **Consistency:** Interactive elements should match the style, behaviour, and intent of existing Telegram UI components.
|
||||||
|
- **Performance:** Animations should be smooth, ideally 60fps.
|
||||||
|
- **Accessibility:** Inputs and images must have labels.
|
||||||
|
- **Theme:** The app must use the dynamic theme-based colors provided by the API (Day/Night and custom themes).
|
||||||
|
- **Safe areas:** The interface must respect the **safe area** and **content safe area** so content does not overlap system or Telegram UI, especially in fullscreen.
|
||||||
|
- **Android:** On Android, use the extra User-Agent data (e.g. performance class) and reduce animations and effects on low-performance devices for smooth operation.
|
||||||
|
|
||||||
|
**Color schemes:** Mini Apps receive the user’s current **theme** in real time. Use the `ThemeParams` object and the CSS variables Telegram exposes (e.g. `--tg-theme-bg-color`, `--tg-theme-text-color`) so the UI adapts when the user switches Day/Night or custom themes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Theme and colors
|
||||||
|
|
||||||
|
### 2.1 Sources
|
||||||
|
|
||||||
|
Theme is resolved in this order:
|
||||||
|
|
||||||
|
1. Hash parameters: `tgWebAppColorScheme`, `tgWebAppThemeParams` (parsed in the inline script in `webapp-next/src/app/layout.tsx`).
|
||||||
|
2. At runtime: `Telegram.WebApp.colorScheme` and `Telegram.WebApp.themeParams` via `use-telegram-theme.ts` and `TelegramProvider`.
|
||||||
|
|
||||||
|
The inline script in the layout maps all Telegram theme keys to `--tg-theme-*` CSS variables on the document root. The hook sets `data-theme` (`light` / `dark`) and applies Mini App background/header colors.
|
||||||
|
|
||||||
|
### 2.2 Mapping (ThemeParams → app tokens)
|
||||||
|
|
||||||
|
In `webapp-next/src/app/globals.css`, `:root` and `[data-theme="light"]` / `[data-theme="dark"]` map Telegram’s ThemeParams to internal design tokens:
|
||||||
|
|
||||||
|
| Telegram ThemeParam (CSS var) | App token / usage |
|
||||||
|
|--------------------------------------|-------------------|
|
||||||
|
| `--tg-theme-bg-color` | `--bg` (background) |
|
||||||
|
| `--tg-theme-secondary-bg-color` | `--surface` (cards, panels) |
|
||||||
|
| `--tg-theme-text-color` | `--text` (primary text) |
|
||||||
|
| `--tg-theme-hint-color`, `--tg-theme-subtitle-text-color` | `--muted` (secondary text) |
|
||||||
|
| `--tg-theme-link-color` | `--accent` (links, secondary actions) |
|
||||||
|
| `--tg-theme-header-bg-color` | `--header-bg` |
|
||||||
|
| `--tg-theme-section-bg-color` | `--card` (sections) |
|
||||||
|
| `--tg-theme-section-header-text-color` | `--section-header` |
|
||||||
|
| `--tg-theme-section-separator-color` | `--border` |
|
||||||
|
| `--tg-theme-button-color` | `--primary` |
|
||||||
|
| `--tg-theme-button-text-color` | `--primary-foreground` |
|
||||||
|
| `--tg-theme-destructive-text-color` | `--error` |
|
||||||
|
| `--tg-theme-accent-text-color` | `--accent-text` |
|
||||||
|
|
||||||
|
Tailwind/shadcn semantic tokens are wired to these: `--background` → `--bg`, `--foreground` → `--text`, `--primary`, `--secondary` → `--surface`, etc.
|
||||||
|
|
||||||
|
### 2.3 Domain colors
|
||||||
|
|
||||||
|
Duty-specific semantics (fixed per theme, not from ThemeParams):
|
||||||
|
|
||||||
|
- `--duty` — duty shift (e.g. green).
|
||||||
|
- `--unavailable` — unavailable (e.g. amber).
|
||||||
|
- `--vacation` — vacation (e.g. blue).
|
||||||
|
- `--today` — “today” highlight; tied to `--tg-theme-accent-text-color` / `--tg-theme-link-color`.
|
||||||
|
|
||||||
|
Use Tailwind classes such as `bg-duty`, `border-l-unavailable`, `text-today`, `bg-today`, etc.
|
||||||
|
|
||||||
|
### 2.4 Derived tokens (color-mix)
|
||||||
|
|
||||||
|
Prefer these instead of ad-hoc color mixes:
|
||||||
|
|
||||||
|
- `--surface-hover`, `--surface-hover-10` — hover states for surfaces.
|
||||||
|
- `--surface-today-tint`, `--surface-muted-tint` — subtle tints.
|
||||||
|
- `--today-hover`, `--today-border`, `--today-border-selected`, `--today-gradient-end` — today state.
|
||||||
|
- `--muted-fade`, `--shadow-card`, etc.
|
||||||
|
|
||||||
|
Defined in `globals.css`; use via `var(--surface-hover)` in classes (e.g. `hover:bg-[var(--surface-hover)]`).
|
||||||
|
|
||||||
|
### 2.5 Rule for new work
|
||||||
|
|
||||||
|
Use **only** these tokens and Tailwind/shadcn aliases (`bg-background`, `text-muted`, `bg-surface`, `text-accent`, `border-l-duty`, `bg-today`, etc.). Do not hardcode hex or RGB in new components or screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Layout and safe areas
|
||||||
|
|
||||||
|
### 3.1 Width
|
||||||
|
|
||||||
|
- **Token:** `--max-width-app: 420px` (in `@theme` in `globals.css`).
|
||||||
|
- **Usage:** Page wrapper uses `max-w-[var(--max-width-app)]` (e.g. in `page.tsx`, `CalendarPage.tsx`, `admin/page.tsx`). Content is centred with `mx-auto`.
|
||||||
|
|
||||||
|
### 3.2 Height
|
||||||
|
|
||||||
|
- **Viewport:** Prefer `min-h-[var(--tg-viewport-stable-height,100vh)]` for the main content area so the Mini App fills the visible height correctly when expanded/collapsed. Fallback `100vh` when not in Telegram.
|
||||||
|
- **Body:** In `globals.css`, `body` already has `min-height: var(--tg-viewport-stable-height, 100vh)` and `background: var(--bg)`.
|
||||||
|
|
||||||
|
### 3.3 Safe area and content safe area
|
||||||
|
|
||||||
|
- **Class `.content-safe`** (in `globals.css`): Applies:
|
||||||
|
- `padding-top: var(--tg-viewport-content-safe-area-inset-top, env(safe-area-inset-top, 0))`
|
||||||
|
- `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, env(safe-area-inset-bottom, 0))`
|
||||||
|
- Use `.content-safe` on the **root container of each page** so content is not covered by the Telegram header or bottom bar (Bot API 8.0+).
|
||||||
|
- Lists that extend to the bottom should also account for bottom inset (e.g. `padding-bottom: var(--tg-viewport-content-safe-area-inset-bottom, 12px)` in `.container-app`).
|
||||||
|
|
||||||
|
### 3.4 Sheets and modals
|
||||||
|
|
||||||
|
Bottom sheets and modals that sit at the bottom of the screen must add safe area to their padding, e.g.:
|
||||||
|
|
||||||
|
`pb-[calc(24px+env(safe-area-inset-bottom,0px))]`
|
||||||
|
|
||||||
|
See `webapp-next/src/components/day-detail/DayDetail.tsx` for the Sheet content.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Typography and spacing
|
||||||
|
|
||||||
|
### 4.1 Font
|
||||||
|
|
||||||
|
- **Family:** `system-ui, -apple-system, sans-serif` (set in `globals.css` and Tailwind theme).
|
||||||
|
|
||||||
|
### 4.2 Patterns from the calendar and duty list
|
||||||
|
|
||||||
|
| Element | Classes / tokens |
|
||||||
|
|--------|-------------------|
|
||||||
|
| Month title | `text-[1.1rem]` / `sm:text-[1.25rem]`, `font-semibold` |
|
||||||
|
| Year (above month) | `text-xs`, `text-muted` |
|
||||||
|
| Nav buttons (prev/next month) | `size-10`, `rounded-[10px]` |
|
||||||
|
| Calendar day cell | `text-[0.85rem]`, `rounded-lg`, `p-1` |
|
||||||
|
| Duty timeline card | `px-2.5 py-2`, `rounded-lg` |
|
||||||
|
|
||||||
|
### 4.3 Page and block spacing
|
||||||
|
|
||||||
|
- Page container: `px-3 pb-6` in addition to `.content-safe`.
|
||||||
|
- Between sections: `mb-3`, `mb-4` as appropriate.
|
||||||
|
- Grids: `gap-1` for tight layouts (e.g. calendar grid), larger gaps where needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Component patterns
|
||||||
|
|
||||||
|
### 5.1 Buttons
|
||||||
|
|
||||||
|
- **Primary:** Use the default Button variant: `bg-primary text-primary-foreground` (from `webapp-next/src/components/ui/button.tsx`).
|
||||||
|
- **Secondary icon buttons** (e.g. calendar nav):
|
||||||
|
`bg-surface text-accent hover:bg-[var(--surface-hover)] focus-visible:outline-accent active:scale-95`
|
||||||
|
with `size-10` and `rounded-[10px]`.
|
||||||
|
- Keep focus visible (e.g. `focus-visible:outline-accent` or ring); do not remove outline without a visible replacement.
|
||||||
|
|
||||||
|
### 5.2 Cards
|
||||||
|
|
||||||
|
- **Background:** `bg-surface` or `bg-card` (both resolve to theme tokens).
|
||||||
|
- **Borders:** `border`, `--border` (section separator color).
|
||||||
|
- **Emphasis:** `var(--shadow-card)` for highlighted cards (e.g. current duty).
|
||||||
|
- **Left stripe by type:** `border-l-[3px]` with:
|
||||||
|
- `border-l-duty`, `border-l-unavailable`, `border-l-vacation` for event types;
|
||||||
|
- `border-l-today` for “current duty” (see `.border-l-today` in `globals.css`).
|
||||||
|
|
||||||
|
### 5.3 Calendar grid
|
||||||
|
|
||||||
|
- **Structure:** 7 columns × 6 rows; use `role="grid"` on the container and `role="gridcell"` on each cell.
|
||||||
|
- **Layout:** `min-h-[var(--calendar-grid-min-height)]`, cells `aspect-square` with `min-h-8`, `rounded-lg`, `gap-1`.
|
||||||
|
- **Today:** `bg-today text-[var(--bg)]`; hover `hover:bg-[var(--today-hover)]`.
|
||||||
|
- **Other month:** `opacity-40`, `pointer-events-none`, `bg-[var(--surface-muted-tint)]`.
|
||||||
|
|
||||||
|
### 5.4 Timeline list (duties)
|
||||||
|
|
||||||
|
- **Dates:** Horizontal line and vertical tick from shared CSS in `globals.css`:
|
||||||
|
`.duty-timeline-date`, `.duty-timeline-date--today` (with `::before` / `::after`).
|
||||||
|
- **Cards:** Same card rules as above; `border-l-[3px]` + type class; optional flip card for contacts (see `DutyTimelineCard.tsx`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Motion and performance
|
||||||
|
|
||||||
|
### 6.1 Timing
|
||||||
|
|
||||||
|
- **Tokens:** `--transition-fast: 0.15s`, `--transition-normal: 0.25s`, `--ease-out: cubic-bezier(0.32, 0.72, 0, 1)`.
|
||||||
|
- Use these for transitions and short animations so behaviour is consistent and predictable.
|
||||||
|
|
||||||
|
### 6.2 Reduced motion
|
||||||
|
|
||||||
|
- **Rule:** `@media (prefers-reduced-motion: reduce)` in `globals.css` shortens animation and transition durations globally. New animations should remain optional or short so they degrade gracefully when reduced.
|
||||||
|
|
||||||
|
### 6.3 Android low-performance devices
|
||||||
|
|
||||||
|
- **Detection:** `webapp-next/src/lib/telegram-android-perf.ts` reads Telegram’s User-Agent and sets `data-perf="low"` on the document root when the device performance class is LOW.
|
||||||
|
- **CSS:** `[data-perf="low"] *` in `globals.css` minimizes animation/transition duration. Avoid adding heavy or long animations without considering this; prefer simple or no animation on low-end devices.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Accessibility
|
||||||
|
|
||||||
|
- **Focus:** Use `focus-visible:outline-accent` (or equivalent ring) on interactive elements; do not remove focus outline without a visible alternative.
|
||||||
|
- **Calendar:** Use `role="grid"` and `role="gridcell"`, `aria-label` on nav buttons (e.g. “Previous month”), and a composite `aria-label` on each day cell (date + event types). See `webapp-next/src/components/calendar/CalendarDay.tsx`.
|
||||||
|
- **Images and inputs:** Always provide labels (per Telegram’s guidelines and WCAG).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Telegram integration
|
||||||
|
|
||||||
|
- **Header and background:** On init (layout script and `use-telegram-theme.ts`), call:
|
||||||
|
- `setBackgroundColor('bg_color')`
|
||||||
|
- `setHeaderColor('bg_color')`
|
||||||
|
- `setBottomBarColor('bottom_bar_bg_color')` when available (Bot API 7.10+).
|
||||||
|
- **Surface contrast:** When `--surface` equals `--bg` (e.g. some iOS OLED themes), `fixSurfaceContrast()` in `use-telegram-theme.ts` adjusts `--surface` using ThemeParams or a light color-mix so cards and panels remain visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Checklist for new screens and components
|
||||||
|
|
||||||
|
Use this for review when adding or changing UI:
|
||||||
|
|
||||||
|
- [ ] Use only design tokens from `globals.css` and Tailwind/shadcn aliases; no hardcoded colours.
|
||||||
|
- [ ] Page wrapper has `.content-safe`, `max-w-[var(--max-width-app)]`, and appropriate min-height (viewport-stable-height or `min-h-screen` with fallback).
|
||||||
|
- [ ] Buttons and cards follow the patterns above (variants, surfaces, border-l by type).
|
||||||
|
- [ ] Safe area is respected for bottom padding and for sheets/modals.
|
||||||
|
- [ ] Interactive elements and grids/lists have appropriate `aria-label`s and roles.
|
||||||
|
- [ ] New animations respect `prefers-reduced-motion` and `data-perf="low"` (short or minimal on low-end Android).
|
||||||
@@ -6,7 +6,7 @@ from datetime import date, timedelta
|
|||||||
|
|
||||||
import duty_teller.config as config
|
import duty_teller.config as config
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse, Response
|
from fastapi.responses import JSONResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -14,19 +14,34 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from duty_teller.api.calendar_ics import get_calendar_events
|
from duty_teller.api.calendar_ics import get_calendar_events
|
||||||
from duty_teller.api.dependencies import (
|
from duty_teller.api.dependencies import (
|
||||||
|
_lang_from_accept_language,
|
||||||
fetch_duties_response,
|
fetch_duties_response,
|
||||||
|
get_authenticated_telegram_id_dep,
|
||||||
get_db_session,
|
get_db_session,
|
||||||
get_validated_dates,
|
get_validated_dates,
|
||||||
|
require_admin_telegram_id,
|
||||||
require_miniapp_username,
|
require_miniapp_username,
|
||||||
)
|
)
|
||||||
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
|
from duty_teller.api.personal_calendar_ics import build_personal_ics, build_team_ics
|
||||||
from duty_teller.cache import ics_calendar_cache
|
from duty_teller.cache import invalidate_duty_related_caches, ics_calendar_cache
|
||||||
from duty_teller.db.repository import (
|
from duty_teller.db.repository import (
|
||||||
get_duties,
|
get_duties,
|
||||||
get_duties_for_user,
|
get_duties_for_user,
|
||||||
|
get_duty_by_id,
|
||||||
get_user_by_calendar_token,
|
get_user_by_calendar_token,
|
||||||
|
get_users_for_admin,
|
||||||
|
is_admin_for_telegram_user,
|
||||||
|
update_duty_user,
|
||||||
)
|
)
|
||||||
from duty_teller.db.schemas import CalendarEvent, DutyWithUser
|
from duty_teller.db.models import User
|
||||||
|
from duty_teller.db.schemas import (
|
||||||
|
AdminDutyReassignBody,
|
||||||
|
CalendarEvent,
|
||||||
|
DutyInDb,
|
||||||
|
DutyWithUser,
|
||||||
|
UserForAdmin,
|
||||||
|
)
|
||||||
|
from duty_teller.i18n import t
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -283,6 +298,98 @@ def get_personal_calendar_ical(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Admin API (initData + admin role required for GET /users and PATCH /duties) ---
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/admin/me",
|
||||||
|
summary="Check admin status",
|
||||||
|
description=(
|
||||||
|
"Returns is_admin for the authenticated Mini App user. "
|
||||||
|
"Requires valid initData (same as /api/duties)."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def admin_me(
|
||||||
|
_username: str = Depends(require_miniapp_username),
|
||||||
|
telegram_id: int = Depends(get_authenticated_telegram_id_dep),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> dict:
|
||||||
|
"""Return { is_admin: true } or { is_admin: false } for the current user."""
|
||||||
|
is_admin = is_admin_for_telegram_user(session, telegram_id)
|
||||||
|
return {"is_admin": is_admin}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get(
|
||||||
|
"/api/admin/users",
|
||||||
|
response_model=list[UserForAdmin],
|
||||||
|
summary="List users for admin dropdown",
|
||||||
|
description="Returns id, full_name, username for all users. Admin only.",
|
||||||
|
)
|
||||||
|
def admin_list_users(
|
||||||
|
_admin_telegram_id: int = Depends(require_admin_telegram_id),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> list[UserForAdmin]:
|
||||||
|
"""Return all users ordered by full_name for admin reassign dropdown."""
|
||||||
|
users = get_users_for_admin(session)
|
||||||
|
return [
|
||||||
|
UserForAdmin(
|
||||||
|
id=u.id,
|
||||||
|
full_name=u.full_name,
|
||||||
|
username=u.username,
|
||||||
|
role_id=u.role_id,
|
||||||
|
)
|
||||||
|
for u in users
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch(
|
||||||
|
"/api/admin/duties/{duty_id}",
|
||||||
|
response_model=DutyInDb,
|
||||||
|
summary="Reassign duty to another user",
|
||||||
|
description="Update duty's user_id. Admin only. Invalidates ICS and pin caches.",
|
||||||
|
)
|
||||||
|
def admin_reassign_duty(
|
||||||
|
duty_id: int,
|
||||||
|
body: AdminDutyReassignBody,
|
||||||
|
request: Request,
|
||||||
|
_admin_telegram_id: int = Depends(require_admin_telegram_id),
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> DutyInDb:
|
||||||
|
"""Reassign duty to another user; return updated duty or 404/400 with i18n detail."""
|
||||||
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||||
|
if duty_id <= 0 or body.user_id <= 0:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=t(lang, "api.bad_request"),
|
||||||
|
)
|
||||||
|
duty = get_duty_by_id(session, duty_id)
|
||||||
|
if duty is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=t(lang, "admin.duty_not_found"),
|
||||||
|
)
|
||||||
|
if session.get(User, body.user_id) is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=t(lang, "admin.user_not_found"),
|
||||||
|
)
|
||||||
|
updated = update_duty_user(
|
||||||
|
session, duty_id, body.user_id, commit=True
|
||||||
|
)
|
||||||
|
if updated is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=t(lang, "admin.duty_not_found"),
|
||||||
|
)
|
||||||
|
invalidate_duty_related_caches()
|
||||||
|
return DutyInDb(
|
||||||
|
id=updated.id,
|
||||||
|
user_id=updated.user_id,
|
||||||
|
start_at=updated.start_at,
|
||||||
|
end_at=updated.end_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
webapp_path = config.PROJECT_ROOT / "webapp-next" / "out"
|
||||||
if webapp_path.is_dir():
|
if webapp_path.is_dir():
|
||||||
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
app.mount("/app", StaticFiles(directory=str(webapp_path), html=True), name="webapp")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ from duty_teller.db.repository import (
|
|||||||
get_duties,
|
get_duties,
|
||||||
get_user_by_telegram_id,
|
get_user_by_telegram_id,
|
||||||
can_access_miniapp_for_telegram_user,
|
can_access_miniapp_for_telegram_user,
|
||||||
|
is_admin_for_telegram_user,
|
||||||
)
|
)
|
||||||
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
from duty_teller.db.schemas import DUTY_EVENT_TYPES, DutyWithUser
|
||||||
from duty_teller.db.session import session_scope
|
from duty_teller.db.session import session_scope
|
||||||
@@ -159,6 +160,103 @@ def get_authenticated_username(
|
|||||||
return username or (user.full_name or "") or f"id:{telegram_user_id}"
|
return username or (user.full_name or "") or f"id:{telegram_user_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def get_authenticated_telegram_id(
|
||||||
|
request: Request,
|
||||||
|
x_telegram_init_data: str | None,
|
||||||
|
session: Session,
|
||||||
|
) -> int:
|
||||||
|
"""Return Telegram user id for the authenticated miniapp user; 0 if skip-auth.
|
||||||
|
|
||||||
|
Same validation as get_authenticated_username. Used to check is_admin.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: FastAPI request (for Accept-Language in error messages).
|
||||||
|
x_telegram_init_data: Raw X-Telegram-Init-Data header value.
|
||||||
|
session: DB session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
telegram_user_id (int). When MINI_APP_SKIP_AUTH, returns 0 (no real user).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 403 if initData missing/invalid or user not in allowlist.
|
||||||
|
"""
|
||||||
|
if config.MINI_APP_SKIP_AUTH:
|
||||||
|
return 0
|
||||||
|
init_data = (x_telegram_init_data or "").strip()
|
||||||
|
if not init_data:
|
||||||
|
log.warning("no X-Telegram-Init-Data header")
|
||||||
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "api.open_from_telegram"))
|
||||||
|
max_age = config.INIT_DATA_MAX_AGE_SECONDS or None
|
||||||
|
telegram_user_id, username, auth_reason, lang = validate_init_data_with_reason(
|
||||||
|
init_data, config.BOT_TOKEN, max_age_seconds=max_age
|
||||||
|
)
|
||||||
|
if auth_reason != "ok":
|
||||||
|
log.warning("initData validation failed: %s", auth_reason)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403, detail=_auth_error_detail(auth_reason, lang)
|
||||||
|
)
|
||||||
|
if telegram_user_id is None:
|
||||||
|
log.warning("initData valid but telegram_user_id missing")
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||||
|
user = get_user_by_telegram_id(session, telegram_user_id)
|
||||||
|
if not user:
|
||||||
|
log.warning(
|
||||||
|
"user not in DB (username=%s, telegram_id=%s)",
|
||||||
|
username,
|
||||||
|
telegram_user_id,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||||
|
if not can_access_miniapp_for_telegram_user(session, telegram_user_id):
|
||||||
|
failed_phone = config.normalize_phone(user.phone) if user.phone else None
|
||||||
|
log.warning(
|
||||||
|
"access denied (username=%s, telegram_id=%s, phone=%s)",
|
||||||
|
username,
|
||||||
|
telegram_user_id,
|
||||||
|
failed_phone or "—",
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "api.access_denied"))
|
||||||
|
return telegram_user_id
|
||||||
|
|
||||||
|
|
||||||
|
def get_authenticated_telegram_id_dep(
|
||||||
|
request: Request,
|
||||||
|
x_telegram_init_data: Annotated[
|
||||||
|
str | None, Header(alias="X-Telegram-Init-Data")
|
||||||
|
] = None,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> int:
|
||||||
|
"""FastAPI dependency: return telegram_user_id for authenticated miniapp user (0 if skip-auth)."""
|
||||||
|
return get_authenticated_telegram_id(request, x_telegram_init_data, session)
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin_telegram_id(
|
||||||
|
request: Request,
|
||||||
|
x_telegram_init_data: Annotated[
|
||||||
|
str | None, Header(alias="X-Telegram-Init-Data")
|
||||||
|
] = None,
|
||||||
|
session: Session = Depends(get_db_session),
|
||||||
|
) -> int:
|
||||||
|
"""FastAPI dependency: require valid miniapp auth and admin role; return telegram_user_id.
|
||||||
|
|
||||||
|
When MINI_APP_SKIP_AUTH is True, admin routes are disabled (403).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 403 if initData missing/invalid, user not in allowlist, or not admin.
|
||||||
|
"""
|
||||||
|
if config.MINI_APP_SKIP_AUTH:
|
||||||
|
log.warning("Admin routes disabled when MINI_APP_SKIP_AUTH is set")
|
||||||
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
|
||||||
|
telegram_user_id = get_authenticated_telegram_id(
|
||||||
|
request, x_telegram_init_data, session
|
||||||
|
)
|
||||||
|
if not is_admin_for_telegram_user(session, telegram_user_id):
|
||||||
|
lang = _lang_from_accept_language(request.headers.get("Accept-Language"))
|
||||||
|
raise HTTPException(status_code=403, detail=t(lang, "import.admin_only"))
|
||||||
|
return telegram_user_id
|
||||||
|
|
||||||
|
|
||||||
def fetch_duties_response(
|
def fetch_duties_response(
|
||||||
session: Session, from_date: str, to_date: str
|
session: Session, from_date: str, to_date: str
|
||||||
) -> list[DutyWithUser]:
|
) -> list[DutyWithUser]:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from duty_teller.db.models import Base, User, Duty, Role
|
|||||||
from duty_teller.db.schemas import (
|
from duty_teller.db.schemas import (
|
||||||
UserCreate,
|
UserCreate,
|
||||||
UserInDb,
|
UserInDb,
|
||||||
|
UserForAdmin,
|
||||||
DutyCreate,
|
DutyCreate,
|
||||||
DutyInDb,
|
DutyInDb,
|
||||||
DutyWithUser,
|
DutyWithUser,
|
||||||
@@ -16,11 +17,14 @@ from duty_teller.db.session import (
|
|||||||
)
|
)
|
||||||
from duty_teller.db.repository import (
|
from duty_teller.db.repository import (
|
||||||
delete_duties_in_range,
|
delete_duties_in_range,
|
||||||
|
get_duties,
|
||||||
|
get_duty_by_id,
|
||||||
get_or_create_user,
|
get_or_create_user,
|
||||||
get_or_create_user_by_full_name,
|
get_or_create_user_by_full_name,
|
||||||
get_duties,
|
get_users_for_admin,
|
||||||
insert_duty,
|
insert_duty,
|
||||||
set_user_phone,
|
set_user_phone,
|
||||||
|
update_duty_user,
|
||||||
update_user_display_name,
|
update_user_display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +35,7 @@ __all__ = [
|
|||||||
"Role",
|
"Role",
|
||||||
"UserCreate",
|
"UserCreate",
|
||||||
"UserInDb",
|
"UserInDb",
|
||||||
|
"UserForAdmin",
|
||||||
"DutyCreate",
|
"DutyCreate",
|
||||||
"DutyInDb",
|
"DutyInDb",
|
||||||
"DutyWithUser",
|
"DutyWithUser",
|
||||||
@@ -39,11 +44,14 @@ __all__ = [
|
|||||||
"get_session",
|
"get_session",
|
||||||
"session_scope",
|
"session_scope",
|
||||||
"delete_duties_in_range",
|
"delete_duties_in_range",
|
||||||
|
"get_duties",
|
||||||
|
"get_duty_by_id",
|
||||||
"get_or_create_user",
|
"get_or_create_user",
|
||||||
"get_or_create_user_by_full_name",
|
"get_or_create_user_by_full_name",
|
||||||
"get_duties",
|
"get_users_for_admin",
|
||||||
"insert_duty",
|
"insert_duty",
|
||||||
"set_user_phone",
|
"set_user_phone",
|
||||||
|
"update_duty_user",
|
||||||
"update_user_display_name",
|
"update_user_display_name",
|
||||||
"init_db",
|
"init_db",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -322,6 +322,61 @@ def delete_duties_in_range(
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def get_duty_by_id(session: Session, duty_id: int) -> Duty | None:
|
||||||
|
"""Return duty by primary key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB session.
|
||||||
|
duty_id: Duty id (duties.id).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Duty or None if not found.
|
||||||
|
"""
|
||||||
|
return session.get(Duty, duty_id)
|
||||||
|
|
||||||
|
|
||||||
|
def update_duty_user(
|
||||||
|
session: Session,
|
||||||
|
duty_id: int,
|
||||||
|
new_user_id: int,
|
||||||
|
*,
|
||||||
|
commit: bool = True,
|
||||||
|
) -> Duty | None:
|
||||||
|
"""Update the assigned user of a duty.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB session.
|
||||||
|
duty_id: Duty id (duties.id).
|
||||||
|
new_user_id: New user id (users.id).
|
||||||
|
commit: If True, commit immediately. If False, caller commits.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Duty or None if duty not found.
|
||||||
|
"""
|
||||||
|
duty = session.get(Duty, duty_id)
|
||||||
|
if duty is None:
|
||||||
|
return None
|
||||||
|
duty.user_id = new_user_id
|
||||||
|
if commit:
|
||||||
|
session.commit()
|
||||||
|
session.refresh(duty)
|
||||||
|
else:
|
||||||
|
session.flush()
|
||||||
|
return duty
|
||||||
|
|
||||||
|
|
||||||
|
def get_users_for_admin(session: Session) -> list[User]:
|
||||||
|
"""Return all users ordered by full_name for admin dropdown (id, full_name, username).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session: DB session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of User instances ordered by full_name.
|
||||||
|
"""
|
||||||
|
return session.query(User).order_by(User.full_name).all()
|
||||||
|
|
||||||
|
|
||||||
def get_duties(
|
def get_duties(
|
||||||
session: Session,
|
session: Session,
|
||||||
from_date: str,
|
from_date: str,
|
||||||
|
|||||||
@@ -69,6 +69,21 @@ class DutyWithUser(DutyInDb):
|
|||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UserForAdmin(BaseModel):
|
||||||
|
"""User summary for admin dropdown: id, full_name, username, role_id."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
full_name: str
|
||||||
|
username: str | None = None
|
||||||
|
role_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminDutyReassignBody(BaseModel):
|
||||||
|
"""Request body for PATCH /api/admin/duties/:id — reassign duty to another user."""
|
||||||
|
|
||||||
|
user_id: int
|
||||||
|
|
||||||
|
|
||||||
class CalendarEvent(BaseModel):
|
class CalendarEvent(BaseModel):
|
||||||
"""External calendar event (e.g. holiday) for a single day."""
|
"""External calendar event (e.g. holiday) for a single day."""
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
),
|
),
|
||||||
"api.auth_invalid": "Invalid auth data",
|
"api.auth_invalid": "Invalid auth data",
|
||||||
"api.access_denied": "Access denied",
|
"api.access_denied": "Access denied",
|
||||||
|
"api.bad_request": "Bad request",
|
||||||
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
"dates.bad_format": "Parameters from and to must be in YYYY-MM-DD format",
|
||||||
"dates.from_after_to": "from date must not be after to",
|
"dates.from_after_to": "from date must not be after to",
|
||||||
"dates.range_too_large": "Date range is too large. Request a shorter period.",
|
"dates.range_too_large": "Date range is too large. Request a shorter period.",
|
||||||
@@ -98,6 +99,9 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"current_duty.shift": "Shift",
|
"current_duty.shift": "Shift",
|
||||||
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
"current_duty.remaining": "Remaining: {hours}h {minutes}min",
|
||||||
"current_duty.back": "Back to calendar",
|
"current_duty.back": "Back to calendar",
|
||||||
|
"admin.duty_not_found": "Duty not found",
|
||||||
|
"admin.user_not_found": "User not found",
|
||||||
|
"admin.reassign_success": "Duty reassigned successfully",
|
||||||
},
|
},
|
||||||
"ru": {
|
"ru": {
|
||||||
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
|
"start.greeting": "Привет! Я бот календаря дежурств. Используй /help для списка команд.",
|
||||||
@@ -174,6 +178,7 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"из которого открыт календарь (тот же бот, что в меню).",
|
"из которого открыт календарь (тот же бот, что в меню).",
|
||||||
"api.auth_invalid": "Неверные данные авторизации",
|
"api.auth_invalid": "Неверные данные авторизации",
|
||||||
"api.access_denied": "Доступ запрещён",
|
"api.access_denied": "Доступ запрещён",
|
||||||
|
"api.bad_request": "Неверный запрос",
|
||||||
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
"dates.bad_format": "Параметры from и to должны быть в формате YYYY-MM-DD",
|
||||||
"dates.from_after_to": "Дата from не должна быть позже to",
|
"dates.from_after_to": "Дата from не должна быть позже to",
|
||||||
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
|
"dates.range_too_large": "Диапазон дат слишком большой. Запросите более короткий период.",
|
||||||
@@ -184,5 +189,8 @@ MESSAGES: dict[str, dict[str, str]] = {
|
|||||||
"current_duty.shift": "Смена",
|
"current_duty.shift": "Смена",
|
||||||
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
"current_duty.remaining": "Осталось: {hours}ч {minutes}мин",
|
||||||
"current_duty.back": "Назад к календарю",
|
"current_duty.back": "Назад к календарю",
|
||||||
|
"admin.duty_not_found": "Дежурство не найдено",
|
||||||
|
"admin.user_not_found": "Пользователь не найден",
|
||||||
|
"admin.reassign_success": "Дежурство успешно переназначено",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
360
tests/test_admin_api.py
Normal file
360
tests/test_admin_api.py
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
"""Tests for admin API: GET /api/admin/me, GET /api/admin/users, PATCH /api/admin/duties/:id."""
|
||||||
|
|
||||||
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from duty_teller.api.app import app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
# --- GET /api/admin/me ---
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||||
|
def test_admin_me_skip_auth_returns_is_admin_false(client):
|
||||||
|
"""With MINI_APP_SKIP_AUTH, GET /api/admin/me returns is_admin: false (no real user)."""
|
||||||
|
r = client.get("/api/admin/me")
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"is_admin": False}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_me_returns_is_admin_true_when_admin(
|
||||||
|
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||||
|
):
|
||||||
|
"""When user is admin, GET /api/admin/me returns is_admin: true."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (100, "user", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
r = client.get("/api/admin/me", headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"is_admin": True}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_me_returns_is_admin_false_when_not_admin(
|
||||||
|
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||||
|
):
|
||||||
|
"""When user is not admin, GET /api/admin/me returns is_admin: false."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (200, "user", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="User", username="user")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = False
|
||||||
|
r = client.get("/api/admin/me", headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A200%7D&hash=x"})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json() == {"is_admin": False}
|
||||||
|
|
||||||
|
|
||||||
|
# --- GET /api/admin/users ---
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_users_403_without_init_data(client):
|
||||||
|
"""GET /api/admin/users without initData returns 403."""
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_users_403_when_not_admin(
|
||||||
|
mock_validate, mock_get_user, mock_can_access, mock_is_admin, client
|
||||||
|
):
|
||||||
|
"""GET /api/admin/users when not admin returns 403 with admin_only message."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (100, "u", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="U", username="u")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = False # not admin
|
||||||
|
r = client.get(
|
||||||
|
"/api/admin/users",
|
||||||
|
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A100%7D&hash=x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert "admin" in detail.lower() or "администратор" in detail or "only" in detail
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.get_users_for_admin")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_users_200_returns_list(
|
||||||
|
mock_validate, mock_get_user, mock_can_access, mock_is_admin, mock_get_users, client
|
||||||
|
):
|
||||||
|
"""GET /api/admin/users returns list of id, full_name, username, role_id."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
mock_get_users.return_value = [
|
||||||
|
SimpleNamespace(id=1, full_name="Alice", username="alice", role_id=1),
|
||||||
|
SimpleNamespace(id=2, full_name="Bob", username=None, role_id=2),
|
||||||
|
]
|
||||||
|
r = client.get(
|
||||||
|
"/api/admin/users",
|
||||||
|
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert len(data) == 2
|
||||||
|
assert data[0]["id"] == 1
|
||||||
|
assert data[0]["full_name"] == "Alice"
|
||||||
|
assert data[0]["username"] == "alice"
|
||||||
|
assert data[0]["role_id"] == 1
|
||||||
|
assert data[1]["id"] == 2
|
||||||
|
assert data[1]["full_name"] == "Bob"
|
||||||
|
assert data[1]["username"] is None
|
||||||
|
assert data[1]["role_id"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
# --- PATCH /api/admin/duties/:id ---
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_403_without_auth(client):
|
||||||
|
"""PATCH /api/admin/duties/1 without auth returns 403."""
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 2},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.require_admin_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_403_when_not_admin(mock_require_admin, client):
|
||||||
|
"""PATCH /api/admin/duties/1 when not admin returns 403."""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from duty_teller.i18n import t
|
||||||
|
|
||||||
|
mock_require_admin.side_effect = HTTPException(
|
||||||
|
status_code=403, detail=t("en", "import.admin_only")
|
||||||
|
)
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 2},
|
||||||
|
headers={"X-Telegram-Init-Data": "x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||||
|
@patch("duty_teller.api.app.update_duty_user")
|
||||||
|
@patch("duty_teller.api.app.get_duty_by_id")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_404_when_duty_missing(
|
||||||
|
mock_validate,
|
||||||
|
mock_get_user,
|
||||||
|
mock_can_access,
|
||||||
|
mock_is_admin,
|
||||||
|
mock_get_duty,
|
||||||
|
mock_update,
|
||||||
|
mock_invalidate,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
"""PATCH /api/admin/duties/999 returns 404 when duty not found."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
mock_get_duty.return_value = None
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/999",
|
||||||
|
json={"user_id": 2},
|
||||||
|
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
mock_update.assert_not_called()
|
||||||
|
mock_invalidate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||||
|
@patch("duty_teller.api.app.update_duty_user")
|
||||||
|
@patch("duty_teller.api.app.get_duty_by_id")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_400_when_user_not_found(
|
||||||
|
mock_validate,
|
||||||
|
mock_get_user,
|
||||||
|
mock_can_access,
|
||||||
|
mock_is_admin,
|
||||||
|
mock_get_duty,
|
||||||
|
mock_update,
|
||||||
|
mock_invalidate,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
"""PATCH /api/admin/duties/1 returns 400 when user_id does not exist."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
mock_get_duty.return_value = SimpleNamespace(
|
||||||
|
id=1, user_id=10, start_at="2026-01-15T09:00:00Z", end_at="2026-01-15T18:00:00Z"
|
||||||
|
)
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = None # User not found
|
||||||
|
with patch("duty_teller.api.app.get_db_session") as mock_db:
|
||||||
|
mock_db.return_value.__enter__.return_value = mock_session
|
||||||
|
mock_db.return_value.__exit__.return_value = None
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 999},
|
||||||
|
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
mock_update.assert_not_called()
|
||||||
|
mock_invalidate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.invalidate_duty_related_caches")
|
||||||
|
@patch("duty_teller.api.app.update_duty_user")
|
||||||
|
@patch("duty_teller.api.app.get_duty_by_id")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_200_updates_and_invalidates(
|
||||||
|
mock_validate,
|
||||||
|
mock_get_user,
|
||||||
|
mock_can_access,
|
||||||
|
mock_is_admin,
|
||||||
|
mock_get_duty,
|
||||||
|
mock_update_duty_user,
|
||||||
|
mock_invalidate,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
"""PATCH /api/admin/duties/1 with valid body returns 200 and invalidates caches."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
duty = SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
user_id=10,
|
||||||
|
start_at="2026-01-15T09:00:00Z",
|
||||||
|
end_at="2026-01-15T18:00:00Z",
|
||||||
|
)
|
||||||
|
updated_duty = SimpleNamespace(
|
||||||
|
id=1,
|
||||||
|
user_id=2,
|
||||||
|
start_at="2026-01-15T09:00:00Z",
|
||||||
|
end_at="2026-01-15T18:00:00Z",
|
||||||
|
)
|
||||||
|
mock_get_duty.return_value = duty
|
||||||
|
mock_update_duty_user.return_value = updated_duty
|
||||||
|
mock_session = MagicMock()
|
||||||
|
mock_session.get.return_value = SimpleNamespace(id=2) # User exists
|
||||||
|
with patch("duty_teller.api.app.get_db_session") as mock_db:
|
||||||
|
mock_db.return_value.__enter__.return_value = mock_session
|
||||||
|
mock_db.return_value.__exit__.return_value = None
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 2},
|
||||||
|
headers={"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.json()
|
||||||
|
assert data["id"] == 1
|
||||||
|
assert data["user_id"] == 2
|
||||||
|
assert data["start_at"] == "2026-01-15T09:00:00Z"
|
||||||
|
assert data["end_at"] == "2026-01-15T18:00:00Z"
|
||||||
|
mock_update_duty_user.assert_called_once_with(ANY, 1, 2, commit=True)
|
||||||
|
mock_invalidate.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||||
|
def test_admin_users_403_when_skip_auth(client):
|
||||||
|
"""GET /api/admin/users with MINI_APP_SKIP_AUTH returns 403 (admin routes disabled)."""
|
||||||
|
r = client.get("/api/admin/users")
|
||||||
|
assert r.status_code == 403
|
||||||
|
detail = r.json()["detail"]
|
||||||
|
assert "admin" in detail.lower() or "администратор" in detail
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", True)
|
||||||
|
def test_admin_reassign_403_when_skip_auth(client):
|
||||||
|
"""PATCH /api/admin/duties/1 with MINI_APP_SKIP_AUTH returns 403."""
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/1",
|
||||||
|
json={"user_id": 2},
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@patch("duty_teller.api.app.get_duty_by_id")
|
||||||
|
@patch("duty_teller.api.dependencies.is_admin_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.can_access_miniapp_for_telegram_user")
|
||||||
|
@patch("duty_teller.api.dependencies.get_user_by_telegram_id")
|
||||||
|
@patch("duty_teller.api.dependencies.validate_init_data_with_reason")
|
||||||
|
@patch("duty_teller.api.dependencies.config.MINI_APP_SKIP_AUTH", False)
|
||||||
|
def test_admin_reassign_404_uses_accept_language_for_detail(
|
||||||
|
mock_validate,
|
||||||
|
mock_get_user,
|
||||||
|
mock_can_access,
|
||||||
|
mock_is_admin,
|
||||||
|
mock_get_duty,
|
||||||
|
client,
|
||||||
|
):
|
||||||
|
"""PATCH with Accept-Language: ru returns 404 detail in Russian."""
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
mock_validate.return_value = (1, "admin", "ok", "en")
|
||||||
|
mock_get_user.return_value = SimpleNamespace(full_name="Admin", username="admin")
|
||||||
|
mock_can_access.return_value = True
|
||||||
|
mock_is_admin.return_value = True
|
||||||
|
mock_get_duty.return_value = None
|
||||||
|
with patch("duty_teller.api.app._lang_from_accept_language") as mock_lang:
|
||||||
|
mock_lang.return_value = "ru"
|
||||||
|
r = client.patch(
|
||||||
|
"/api/admin/duties/999",
|
||||||
|
json={"user_id": 2},
|
||||||
|
headers={
|
||||||
|
"X-Telegram-Init-Data": "auth_date=1&user=%7B%22id%22%3A1%7D&hash=x",
|
||||||
|
"Accept-Language": "ru",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert r.status_code == 404
|
||||||
|
assert r.json()["detail"] == "Дежурство не найдено"
|
||||||
@@ -9,9 +9,12 @@ from duty_teller.db.repository import (
|
|||||||
delete_duties_in_range,
|
delete_duties_in_range,
|
||||||
get_duties,
|
get_duties,
|
||||||
get_duties_for_user,
|
get_duties_for_user,
|
||||||
|
get_duty_by_id,
|
||||||
get_or_create_user,
|
get_or_create_user,
|
||||||
get_or_create_user_by_full_name,
|
get_or_create_user_by_full_name,
|
||||||
|
get_users_for_admin,
|
||||||
insert_duty,
|
insert_duty,
|
||||||
|
update_duty_user,
|
||||||
update_user_display_name,
|
update_user_display_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -217,6 +220,52 @@ def test_get_or_create_user_keeps_name_when_flag_true_updates_username(session):
|
|||||||
assert u2.username == "new_username"
|
assert u2.username == "new_username"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_duty_by_id_returns_duty(session, user_a):
|
||||||
|
"""get_duty_by_id returns the duty when it exists."""
|
||||||
|
duty = insert_duty(
|
||||||
|
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
|
||||||
|
)
|
||||||
|
found = get_duty_by_id(session, duty.id)
|
||||||
|
assert found is not None
|
||||||
|
assert found.id == duty.id
|
||||||
|
assert found.user_id == user_a.id
|
||||||
|
assert found.start_at == "2026-02-01T09:00:00Z"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_duty_by_id_returns_none_when_missing(session):
|
||||||
|
"""get_duty_by_id returns None for non-existent id."""
|
||||||
|
assert get_duty_by_id(session, 99999) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_duty_user_changes_user(session, user_a):
|
||||||
|
"""update_duty_user updates user_id and returns the duty."""
|
||||||
|
user_b = get_or_create_user_by_full_name(session, "User B")
|
||||||
|
duty = insert_duty(
|
||||||
|
session, user_a.id, "2026-02-01T09:00:00Z", "2026-02-01T18:00:00Z"
|
||||||
|
)
|
||||||
|
updated = update_duty_user(session, duty.id, user_b.id, commit=True)
|
||||||
|
assert updated is not None
|
||||||
|
assert updated.id == duty.id
|
||||||
|
assert updated.user_id == user_b.id
|
||||||
|
session.refresh(duty)
|
||||||
|
assert duty.user_id == user_b.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_duty_user_returns_none_when_duty_missing(session, user_a):
|
||||||
|
"""update_duty_user returns None when duty does not exist."""
|
||||||
|
assert update_duty_user(session, 99999, user_a.id, commit=True) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_users_for_admin_returns_all_ordered_by_full_name(session, user_a):
|
||||||
|
"""get_users_for_admin returns all users ordered by full_name."""
|
||||||
|
get_or_create_user_by_full_name(session, "Alice")
|
||||||
|
get_or_create_user_by_full_name(session, "Борис")
|
||||||
|
users = get_users_for_admin(session)
|
||||||
|
assert len(users) >= 3
|
||||||
|
full_names = [u.full_name for u in users]
|
||||||
|
assert full_names == sorted(full_names)
|
||||||
|
|
||||||
|
|
||||||
def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session):
|
def test_update_user_display_name_sets_flag_then_get_or_create_user_keeps_name(session):
|
||||||
"""update_user_display_name sets name and flag; get_or_create_user then does not overwrite name."""
|
"""update_user_display_name sets name and flag; get_or_create_user then does not overwrite name."""
|
||||||
get_or_create_user(
|
get_or_create_user(
|
||||||
|
|||||||
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>
|
</head>
|
||||||
<body className="antialiased">
|
<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">
|
<h1 className="text-xl font-semibold">
|
||||||
{translate(lang, "error_boundary.message")}
|
{translate(lang, "error_boundary.message")}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { useTranslation } from "@/i18n/use-translation";
|
|||||||
export default function NotFound() {
|
export default function NotFound() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
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>
|
<h1 className="text-xl font-semibold">{t("not_found.title")}</h1>
|
||||||
<p className="text-muted-foreground">{t("not_found.description")}</p>
|
<p className="text-muted-foreground">{t("not_found.description")}</p>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { useTelegramTheme } from "@/hooks/use-telegram-theme";
|
|||||||
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
import { useTelegramAuth } from "@/hooks/use-telegram-auth";
|
||||||
import { useAppInit } from "@/hooks/use-app-init";
|
import { useAppInit } from "@/hooks/use-app-init";
|
||||||
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
import { callMiniAppReadyOnce } from "@/lib/telegram-ready";
|
||||||
|
import { fetchAdminMe } from "@/lib/api";
|
||||||
|
import { getLang } from "@/i18n/messages";
|
||||||
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
import { AccessDeniedScreen } from "@/components/states/AccessDeniedScreen";
|
||||||
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
import { CurrentDutyView } from "@/components/current-duty/CurrentDutyView";
|
||||||
import { CalendarPage } from "@/components/CalendarPage";
|
import { CalendarPage } from "@/components/CalendarPage";
|
||||||
@@ -24,6 +26,15 @@ export default function Home() {
|
|||||||
|
|
||||||
useAppInit({ isAllowed, startParam });
|
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 } =
|
const { accessDenied, currentView, setCurrentView, setSelectedDay, appContentReady } =
|
||||||
useAppStore(
|
useAppStore(
|
||||||
useShallow((s: AppState) => ({
|
useShallow((s: AppState) => ({
|
||||||
@@ -50,7 +61,7 @@ export default function Home() {
|
|||||||
const content = accessDenied ? (
|
const content = accessDenied ? (
|
||||||
<AccessDeniedScreen primaryAction="reload" />
|
<AccessDeniedScreen primaryAction="reload" />
|
||||||
) : currentView === "currentDuty" ? (
|
) : 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
|
<CurrentDutyView
|
||||||
onBack={handleBackFromCurrentDuty}
|
onBack={handleBackFromCurrentDuty}
|
||||||
openedFromPin={startParam === "duty"}
|
openedFromPin={startParam === "duty"}
|
||||||
|
|||||||
@@ -6,12 +6,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState, useEffect, useCallback } from "react";
|
import { useRef, useState, useEffect, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import { useAppStore } from "@/store/app-store";
|
import { useAppStore } from "@/store/app-store";
|
||||||
import { useShallow } from "zustand/react/shallow";
|
import { useShallow } from "zustand/react/shallow";
|
||||||
import { useMonthData } from "@/hooks/use-month-data";
|
import { useMonthData } from "@/hooks/use-month-data";
|
||||||
import { useSwipe } from "@/hooks/use-swipe";
|
import { useSwipe } from "@/hooks/use-swipe";
|
||||||
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
import { useStickyScroll } from "@/hooks/use-sticky-scroll";
|
||||||
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
import { useAutoRefresh } from "@/hooks/use-auto-refresh";
|
||||||
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
import { CalendarHeader } from "@/components/calendar/CalendarHeader";
|
||||||
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
import { CalendarGrid } from "@/components/calendar/CalendarGrid";
|
||||||
import { DutyList } from "@/components/duty/DutyList";
|
import { DutyList } from "@/components/duty/DutyList";
|
||||||
@@ -53,6 +55,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
duties,
|
duties,
|
||||||
calendarEvents,
|
calendarEvents,
|
||||||
selectedDay,
|
selectedDay,
|
||||||
|
isAdmin,
|
||||||
nextMonth,
|
nextMonth,
|
||||||
prevMonth,
|
prevMonth,
|
||||||
setCurrentMonth,
|
setCurrentMonth,
|
||||||
@@ -68,6 +71,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
duties: s.duties,
|
duties: s.duties,
|
||||||
calendarEvents: s.calendarEvents,
|
calendarEvents: s.calendarEvents,
|
||||||
selectedDay: s.selectedDay,
|
selectedDay: s.selectedDay,
|
||||||
|
isAdmin: s.isAdmin,
|
||||||
nextMonth: s.nextMonth,
|
nextMonth: s.nextMonth,
|
||||||
prevMonth: s.prevMonth,
|
prevMonth: s.prevMonth,
|
||||||
setCurrentMonth: s.setCurrentMonth,
|
setCurrentMonth: s.setCurrentMonth,
|
||||||
@@ -76,6 +80,8 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { retry } = useMonthData({
|
const { retry } = useMonthData({
|
||||||
initDataRaw,
|
initDataRaw,
|
||||||
enabled: isAllowed,
|
enabled: isAllowed,
|
||||||
@@ -133,7 +139,7 @@ export function CalendarPage({ isAllowed, initDataRaw }: CalendarPageProps) {
|
|||||||
}, [loading, accessDenied, setAppContentReady]);
|
}, [loading, accessDenied, setAppContentReady]);
|
||||||
|
|
||||||
return (
|
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
|
<div
|
||||||
ref={calendarStickyRef}
|
ref={calendarStickyRef}
|
||||||
className="sticky top-0 z-10 min-h-[var(--calendar-block-min-height)] bg-background pb-2"
|
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}
|
disabled={navDisabled}
|
||||||
onPrevMonth={handlePrevMonth}
|
onPrevMonth={handlePrevMonth}
|
||||||
onNextMonth={handleNextMonth}
|
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
|
<CalendarGrid
|
||||||
currentMonth={currentMonth}
|
currentMonth={currentMonth}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useTranslation } from "@/i18n/use-translation";
|
import { useTranslation } from "@/i18n/use-translation";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -20,6 +21,8 @@ export interface CalendarHeaderProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onPrevMonth: () => void;
|
onPrevMonth: () => void;
|
||||||
onNextMonth: () => void;
|
onNextMonth: () => void;
|
||||||
|
/** Optional content shown above the nav row (e.g. Admin link). */
|
||||||
|
trailingContent?: ReactNode;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +31,7 @@ export function CalendarHeader({
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
onPrevMonth,
|
onPrevMonth,
|
||||||
onNextMonth,
|
onNextMonth,
|
||||||
|
trailingContent,
|
||||||
className,
|
className,
|
||||||
}: CalendarHeaderProps) {
|
}: CalendarHeaderProps) {
|
||||||
const { t, monthName, weekdayLabels } = useTranslation();
|
const { t, monthName, weekdayLabels } = useTranslation();
|
||||||
@@ -37,6 +41,9 @@ export function CalendarHeader({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className={cn("flex flex-col", className)}>
|
<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">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -204,7 +204,7 @@ export const DayDetail = React.forwardRef<DayDetailHandle, DayDetailProps>(
|
|||||||
<SheetContent
|
<SheetContent
|
||||||
side="bottom"
|
side="bottom"
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
overlayClassName="backdrop-blur-md"
|
overlayClassName="backdrop-blur-md"
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export function AccessDeniedScreen({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<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"
|
role="alert"
|
||||||
>
|
>
|
||||||
<h1 className="text-xl font-semibold">
|
<h1 className="text-xl font-semibold">
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ function SheetContent({
|
|||||||
side === "top" &&
|
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",
|
"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" &&
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ function TooltipContent({
|
|||||||
data-slot="tooltip-content"
|
data-slot="tooltip-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
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
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -82,6 +82,21 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"not_found.open_calendar": "Open calendar",
|
"not_found.open_calendar": "Open calendar",
|
||||||
"access_denied.hint": "Open the app again from Telegram.",
|
"access_denied.hint": "Open the app again from Telegram.",
|
||||||
"access_denied.reload": "Reload",
|
"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: {
|
ru: {
|
||||||
"app.title": "Календарь дежурств",
|
"app.title": "Календарь дежурств",
|
||||||
@@ -159,6 +174,21 @@ export const MESSAGES: Record<Lang, Record<string, string>> = {
|
|||||||
"not_found.open_calendar": "Открыть календарь",
|
"not_found.open_calendar": "Открыть календарь",
|
||||||
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
"access_denied.hint": "Откройте приложение снова из Telegram.",
|
||||||
"access_denied.reload": "Обновить",
|
"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 { 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", () => {
|
describe("fetchDuties", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
@@ -174,3 +181,160 @@ describe("fetchCalendarEvents", () => {
|
|||||||
).rejects.toMatchObject({ name: "AbortError" });
|
).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";
|
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). */
|
/** Minimal runtime check for a single duty item (required fields). */
|
||||||
function isDutyWithUser(x: unknown): x is DutyWithUser {
|
function isDutyWithUser(x: unknown): x is DutyWithUser {
|
||||||
if (!x || typeof x !== "object") return false;
|
if (!x || typeof x !== "object") return false;
|
||||||
@@ -207,3 +223,153 @@ export async function fetchCalendarEvents(
|
|||||||
opts.cleanup();
|
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;
|
selectedDay: string | null;
|
||||||
/** True when the first visible screen has finished loading; used to hide content until ready(). */
|
/** True when the first visible screen has finished loading; used to hide content until ready(). */
|
||||||
appContentReady: boolean;
|
appContentReady: boolean;
|
||||||
|
/** True when GET /api/admin/me returned is_admin: true; used to show Admin link. */
|
||||||
|
isAdmin: boolean;
|
||||||
|
|
||||||
setCurrentMonth: (d: Date) => void;
|
setCurrentMonth: (d: Date) => void;
|
||||||
nextMonth: () => void;
|
nextMonth: () => void;
|
||||||
@@ -44,8 +46,9 @@ export interface AppState {
|
|||||||
setCurrentView: (v: CurrentView) => void;
|
setCurrentView: (v: CurrentView) => void;
|
||||||
setSelectedDay: (key: string | null) => void;
|
setSelectedDay: (key: string | null) => void;
|
||||||
setAppContentReady: (v: boolean) => void;
|
setAppContentReady: (v: boolean) => void;
|
||||||
|
setIsAdmin: (v: boolean) => void;
|
||||||
/** Batch multiple state updates into a single re-render. */
|
/** 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();
|
const now = new Date();
|
||||||
@@ -71,6 +74,7 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
currentView: getInitialView(),
|
currentView: getInitialView(),
|
||||||
selectedDay: null,
|
selectedDay: null,
|
||||||
appContentReady: false,
|
appContentReady: false,
|
||||||
|
isAdmin: false,
|
||||||
|
|
||||||
setCurrentMonth: (d) => set({ currentMonth: d }),
|
setCurrentMonth: (d) => set({ currentMonth: d }),
|
||||||
nextMonth: () =>
|
nextMonth: () =>
|
||||||
@@ -91,5 +95,6 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
setCurrentView: (v) => set({ currentView: v }),
|
setCurrentView: (v) => set({ currentView: v }),
|
||||||
setSelectedDay: (key) => set({ selectedDay: key }),
|
setSelectedDay: (key) => set({ selectedDay: key }),
|
||||||
setAppContentReady: (v) => set({ appContentReady: v }),
|
setAppContentReady: (v) => set({ appContentReady: v }),
|
||||||
|
setIsAdmin: (v) => set({ isAdmin: v }),
|
||||||
batchUpdate: (partial) => set(partial),
|
batchUpdate: (partial) => set(partial),
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user