init-commit
This commit is contained in:
commit
78007b65e9
59
.env.example
Normal file
59
.env.example
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# Скопируйте в .env и поменяйте секреты.
|
||||||
|
COMPOSE_PROJECT_NAME=patrol
|
||||||
|
|
||||||
|
POSTGRES_DB=patrol
|
||||||
|
POSTGRES_USER=patrol
|
||||||
|
POSTGRES_PASSWORD=patrol
|
||||||
|
DB_PORT=5470
|
||||||
|
|
||||||
|
S3_ACCESS_KEY=patrol
|
||||||
|
S3_SECRET_KEY=patrol-s3-secret
|
||||||
|
S3_BUCKET=patrol-media
|
||||||
|
|
||||||
|
JWT_SECRET=change-me-to-a-long-random-string
|
||||||
|
|
||||||
|
# ── Паспорт Aspans: люди и пароли. Значения — как в .env symfony.
|
||||||
|
# Контейнер паспорта в docker-сети aspans (ASPANS_NETWORK).
|
||||||
|
PASSPORT_URL=http://aspans_passport_nginx
|
||||||
|
PLATFORM_CODE=patrol
|
||||||
|
# PLATFORM_SALT паспорта (в symfony — PASSPORT_PLATFORM_TOKEN)
|
||||||
|
PASSPORT_PLATFORM_TOKEN=
|
||||||
|
# Логины паспорта, которые входят владельцами платформы (через запятую)
|
||||||
|
PLATFORM_ADMIN_USERNAMES=admin
|
||||||
|
|
||||||
|
# ── SSO Aspans ID. client_id = PLATFORM_CODE.
|
||||||
|
ASPANS_ID_BASE_URL=https://id.aspans.com
|
||||||
|
# Секрет клиента patrol в Aspans ID (выдаёт тот, кто ведёт Aspans ID). Пусто — используется PLATFORM_CODE.
|
||||||
|
ASPANS_ID_CLIENT_SECRET=
|
||||||
|
# Адрес фронта для redirect_uri (если не удаётся взять из Origin браузера)
|
||||||
|
WEBSITE_URL=http://localhost:8090
|
||||||
|
|
||||||
|
# ── Синхронизация людей с паспортом через Kafka (docker compose --profile kafka up -d users_sync).
|
||||||
|
# Учётка: в проекте kafka — make service NAME=platform-patrol и права на общий префикс aspans.
|
||||||
|
# Пустой KAFKA_PASSWORD — шина выключена, правки копятся в users_sync_pending.
|
||||||
|
KAFKA_BROKERS=kafka-1:9092,kafka-2:9092,kafka-3:9092
|
||||||
|
KAFKA_USERNAME=platform-patrol
|
||||||
|
KAFKA_PASSWORD=
|
||||||
|
USERS_SYNC_TOPIC=aspans.users.changed
|
||||||
|
USERS_SYNC_SOURCE=platform-patrol
|
||||||
|
|
||||||
|
# Имена общих docker-сетей Aspans
|
||||||
|
ASPANS_NETWORK=aspans
|
||||||
|
KAFKA_NETWORK=kafka-net
|
||||||
|
|
||||||
|
# Порт, на котором открывается приложение (web + /api + /tiles)
|
||||||
|
WEB_PORT=8090
|
||||||
|
|
||||||
|
# Откуда фронт берёт подложку карты. Своё значение — свой тайл-сервер.
|
||||||
|
# Пока файла карты нет, можно временно поставить https://tile.openstreetmap.org/{z}/{x}/{y}.png
|
||||||
|
VITE_TILE_URL=/tiles/styles/basic-preview/256/{z}/{x}/{y}.png
|
||||||
|
|
||||||
|
# Реквизиты по БИН подтягиваются из pk.uchet.kz без токена (название, адрес, руководитель).
|
||||||
|
# Токен из личного кабинета pk.uchet.kz (раздел API-токены) добавляет телефон и email. Необязательно.
|
||||||
|
UCHET_KZ_TOKEN=
|
||||||
|
|
||||||
|
# Поиск адреса на карте — Nominatim (OpenStreetMap), не чаще 1 запроса в секунду.
|
||||||
|
# Email попадает в User-Agent, как просят правила Nominatim. Свой сервер — NOMINATIM_URL.
|
||||||
|
NOMINATIM_EMAIL=
|
||||||
|
# true, когда сервис открыт по HTTPS
|
||||||
|
COOKIE_SECURE=false
|
||||||
41
.env.production.example
Normal file
41
.env.production.example
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
# .env для сервера: cp .env.production.example .env и заполнить всё, где <...>.
|
||||||
|
# Случайные секреты: openssl rand -hex 32
|
||||||
|
COMPOSE_PROJECT_NAME=patrol
|
||||||
|
COMPOSE_FILE=docker-compose.yml:docker-compose.prod.yml
|
||||||
|
|
||||||
|
# Адрес сервиса. Без своего домена — sslip.io: IP 203.0.113.10 → 203-0-113-10.sslip.io
|
||||||
|
SITE_ADDRESS=<ip-через-дефисы>.sslip.io
|
||||||
|
WEBSITE_URL=https://<ip-через-дефисы>.sslip.io
|
||||||
|
COOKIE_SECURE=true
|
||||||
|
|
||||||
|
POSTGRES_DB=patrol
|
||||||
|
POSTGRES_USER=patrol
|
||||||
|
POSTGRES_PASSWORD=<openssl rand -hex 32>
|
||||||
|
# Только 127.0.0.1 — для psql и резервных копий с самого сервера
|
||||||
|
DB_BIND=127.0.0.1
|
||||||
|
DB_PORT=5470
|
||||||
|
|
||||||
|
S3_ACCESS_KEY=patrol
|
||||||
|
S3_SECRET_KEY=<openssl rand -hex 32>
|
||||||
|
S3_BUCKET=patrol-media
|
||||||
|
|
||||||
|
JWT_SECRET=<openssl rand -hex 32>
|
||||||
|
|
||||||
|
# Боевой паспорт Aspans
|
||||||
|
PASSPORT_URL=https://passport.aspans.com
|
||||||
|
PLATFORM_CODE=patrol
|
||||||
|
# PLATFORM_SALT боевого паспорта
|
||||||
|
PASSPORT_PLATFORM_TOKEN=<...>
|
||||||
|
PLATFORM_ADMIN_USERNAMES=admin
|
||||||
|
|
||||||
|
# Aspans ID: в клиенте patrol должен быть разрешён redirect_uri ${WEBSITE_URL}/auth/sso/callback
|
||||||
|
ASPANS_ID_BASE_URL=https://id.aspans.com
|
||||||
|
ASPANS_ID_CLIENT_SECRET=<...>
|
||||||
|
|
||||||
|
# Kafka на сервере пока не подключаем (users_sync не запускается)
|
||||||
|
KAFKA_PASSWORD=
|
||||||
|
|
||||||
|
VITE_TILE_URL=/tiles/styles/basic-preview/256/{z}/{x}/{y}.png
|
||||||
|
|
||||||
|
UCHET_KZ_TOKEN=
|
||||||
|
NOMINATIM_EMAIL=<email для User-Agent Nominatim>
|
||||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
*.log
|
||||||
|
tiles/*.mbtiles
|
||||||
|
tiles/planetiler/
|
||||||
|
.DS_Store
|
||||||
|
*.tsbuildinfo
|
||||||
81
README.md
Normal file
81
README.md
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
# Aspans · Обходы
|
||||||
|
|
||||||
|
Сервис обходов охраняемых объектов: периметры и маршруты на карте, график дежурств, мобильный экран сотрудника с офлайн-очередью, отчёты с фото, видео и геометками, вебхуки для интеграций.
|
||||||
|
|
||||||
|
Стек: React 18 + TypeScript + MUI (Vite, PWA), NestJS 11 + TypeORM, PostgreSQL 17 + PostGIS, SeaweedFS (S3 API) для медиа, tileserver-gl для своей карты Казахстана. Всё поднимается через docker compose.
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
./scripts/build-tiles.sh
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Откройте http://localhost:8090 и войдите владельцем платформы — учётной записью паспорта Aspans из `PLATFORM_ADMIN_USERNAMES` (по умолчанию `admin`, с его паролем из паспорта). Компанию можно зарегистрировать там же («Организации») или самостоятельно на странице входа → «Зарегистрировать».
|
||||||
|
|
||||||
|
Перед реальным использованием смените в `.env` `JWT_SECRET`, `POSTGRES_PASSWORD`, `S3_SECRET_KEY`.
|
||||||
|
|
||||||
|
## Паспорт Aspans, Aspans ID и Kafka
|
||||||
|
|
||||||
|
Люди и пароли живут в паспорте Aspans, «Обходы» — платформа `patrol` в нём. Организации, роли и доступ к объектам — свои, внутри «Обходов».
|
||||||
|
|
||||||
|
1. В паспорте через интерфейс «Платформы и группы» создать платформу `patrol` (без домена) и её группу `patrol_user`; выложить метод `v1/users/findUsers`.
|
||||||
|
2. В `.env`: `PASSPORT_URL`, `PASSPORT_PLATFORM_TOKEN` (= PLATFORM_SALT паспорта), `ASPANS_ID_BASE_URL`. Локально паспорт — контейнер в docker-сети `aspans` (см. `docker-compose.override.yml`), на сервере — `https://passport.aspans.com`.
|
||||||
|
3. Синхронизация через Kafka (как у symfony): в проекте kafka
|
||||||
|
`make service NAME=platform-patrol` и `./bin/service.sh grant-shared platform-patrol aspans.`,
|
||||||
|
пароль — в `KAFKA_PASSWORD`, затем `docker compose --profile kafka up -d users_sync`.
|
||||||
|
|
||||||
|
Как это работает:
|
||||||
|
- **Вход** — логином и паролем паспорта или через Aspans ID (SSO). Если человек уже вошёл в другой сервис Aspans, «Обходы» входят тихо (`prompt=none`). Пускают только тех, кто заведён в «Обходах» и состоит в организации с ролью; владельцы платформы — по `PLATFORM_ADMIN_USERNAMES`.
|
||||||
|
- **Новый сотрудник** заводится в паспорте (`createOrGetUserSimple`, группа `patrol_user`). **Существующий** — ищется в паспорте (`findUsers`, контакты замаскированы) и привязывается к платформе. Один человек — одна организация в «Обходах».
|
||||||
|
- **Профиль** (ФИО, email, телефон) правится в обе стороны через `aspans.users.changed`: правки из «Обходов» уходят событием `UserProfileUpsertedEvent` (очередь `users_sync_pending`), правки из паспорта применяются по версии (LWW). Пароль меняется сразу в паспорте.
|
||||||
|
- **Удаление**: убрали с платформы в паспорте (`UserRemovedFromPlatformEvent`) — доступ закрывается, сессии гаснут, человек пропадает из сотрудников; запись остаётся для отчётов. «Убрать из организации» в «Обходах» отвязывает человека от платформы в паспорте.
|
||||||
|
|
||||||
|
`build-tiles.sh` один раз скачивает OSM-данные Казахстана и собирает `tiles/kazakhstan.mbtiles` (planetiler, нужно ~4 ГБ RAM, несколько минут). Без этого файла сервис работает, но карта будет пустой. Повторный запуск обновляет карту, после него — `docker compose restart tiles`.
|
||||||
|
|
||||||
|
## Сервер
|
||||||
|
|
||||||
|
Без своего домена сервис открывается по `https://<ip-через-дефисы>.sslip.io`: sslip.io указывает на IP из имени, Caddy сам получает сертификат Let's Encrypt. HTTPS обязателен для телефонов охранников: без него браузер не даёт геолокацию и не включает офлайн-режим PWA.
|
||||||
|
|
||||||
|
1. Проверить сервер (только читает): `ssh user@IP 'bash -s' < scripts/server-check.sh`. Нужно: Docker с `docker compose` v2, свободные 80 и 443 (и открытые в файрволе), от 2 ГБ RAM (сборка фронта), от 10 ГБ свободного места плюс место под фото и видео.
|
||||||
|
2. Склонировать репозиторий, `cp .env.production.example .env`, заполнить.
|
||||||
|
3. Карту на сервере **не собирать**: скопировать готовый файл — `scp tiles/kazakhstan.mbtiles user@IP:<папка проекта>/tiles/` (~0,5 ГБ). Сборка planetiler требует ~4 ГБ RAM и качает исходные данные.
|
||||||
|
4. `docker compose up -d --build` (`COMPOSE_FILE` из `.env` сам подключит `docker-compose.prod.yml`).
|
||||||
|
5. В Aspans ID у клиента `patrol` разрешить redirect_uri `https://<ip-через-дефисы>.sslip.io/auth/sso/callback`.
|
||||||
|
|
||||||
|
Наружу открыты только 80 и 443 (Caddy). База слушает 127.0.0.1, api, tiles и s3 доступны только внутри docker.
|
||||||
|
|
||||||
|
## Порты
|
||||||
|
|
||||||
|
| Что | Адрес |
|
||||||
|
|---|---|
|
||||||
|
| Веб и API | http://localhost:8090 (`/api/…`, карта — `/tiles/…`) |
|
||||||
|
| PostgreSQL | localhost:5470 |
|
||||||
|
|
||||||
|
## Разработка без Docker для фронта
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
docker compose up -d db s3 tiles api web
|
||||||
|
npm run dev:web
|
||||||
|
```
|
||||||
|
|
||||||
|
Vite на http://localhost:5173 проксирует `/api` и `/tiles` на :8090.
|
||||||
|
|
||||||
|
## Устройство
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/api NestJS: auth, organizations, users/roles, sites, routes, schedules, rounds, media, reports, webhooks
|
||||||
|
apps/web React: панель управления и экран сотрудника /guard (PWA, офлайн-очередь в IndexedDB)
|
||||||
|
packages/shared права, статусы, флаги отметок, события вебхуков — общие для фронта и API
|
||||||
|
docker/ Dockerfile'ы и nginx
|
||||||
|
scripts/ сборка тайлов карты
|
||||||
|
```
|
||||||
|
|
||||||
|
- Обходы создаются из графиков на 7 дней вперёд (cron раз в час) для дней, где назначены дежурные. Начать обход может любой дежурный этого дня.
|
||||||
|
- Незакрытые к концу окна обходы становятся «Пропущен» или «Не полностью». Отметки, снятые офлайн до конца окна и пришедшие позже, пересчитывают статус.
|
||||||
|
- GPS только записывается: отметку «я тут» сотрудник ставит на карте, координаты телефона сохраняются рядом. Расхождения видны в отчёте флагами и ничего не блокируют.
|
||||||
|
- Медиа грузятся кусками по 8 МБ через API в SeaweedFS и докачиваются после обрыва связи.
|
||||||
|
- Вебхуки: `POST` с JSON, подпись `X-Patrol-Signature: sha256=<HMAC тела секретом>`; события `round.completed`, `round.partial`, `round.missed`, `visit.issue`.
|
||||||
|
- Реквизиты по БИН — из pk.uchet.kz: публичный поиск без токена даёт название, адрес и руководителя. С токеном из личного кабинета pk.uchet.kz (`UCHET_KZ_TOKEN` в `.env`) добавляются телефон и email. Выключить обращения к uchet.kz: `UCHET_KZ_DISABLED=true`.
|
||||||
40
apps/api/package.json
Normal file
40
apps/api/package.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"name": "@patrol/api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc -p tsconfig.build.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
|
"start": "node dist/main.js",
|
||||||
|
"start:dev": "tsc-watch -p tsconfig.build.json --onSuccess \"node dist/main.js\"",
|
||||||
|
"migration:run": "typeorm migration:run -d dist/database/data-source.js",
|
||||||
|
"migration:revert": "typeorm migration:revert -d dist/database/data-source.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.700.0",
|
||||||
|
"@confluentinc/kafka-javascript": "^1.11.0",
|
||||||
|
"@nestjs/common": "^11.1.0",
|
||||||
|
"@nestjs/config": "^4.0.4",
|
||||||
|
"@nestjs/core": "^11.1.0",
|
||||||
|
"@nestjs/jwt": "^11.0.2",
|
||||||
|
"@nestjs/platform-express": "^11.1.0",
|
||||||
|
"@nestjs/schedule": "^5.0.1",
|
||||||
|
"@nestjs/typeorm": "^11.0.3",
|
||||||
|
"@patrol/shared": "*",
|
||||||
|
"class-transformer": "^0.5.1",
|
||||||
|
"class-validator": "^0.14.1",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"pg": "^8.13.1",
|
||||||
|
"reflect-metadata": "^0.2.2",
|
||||||
|
"rxjs": "^7.8.1",
|
||||||
|
"typeorm": "0.3.31"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/cookie-parser": "^1.4.8",
|
||||||
|
"@types/express": "^5.0.0",
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"tsc-watch": "^6.2.1",
|
||||||
|
"typescript": "^5.9.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
87
apps/api/src/app.module.ts
Normal file
87
apps/api/src/app.module.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
|
||||||
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
import { ScheduleModule } from '@nestjs/schedule';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { config } from './config';
|
||||||
|
import { dataSourceOptions } from './database/data-source';
|
||||||
|
import { ENTITIES } from './entities';
|
||||||
|
import { AccessService } from './common/access.service';
|
||||||
|
import { AuditInterceptor } from './common/audit';
|
||||||
|
import { AuthGuard } from './common/auth';
|
||||||
|
import { HealthController } from './health.controller';
|
||||||
|
import { AuthController } from './modules/auth/auth.controller';
|
||||||
|
import { AuthService } from './modules/auth/auth.service';
|
||||||
|
import { CompanyRegistry } from './modules/organizations/company-registry';
|
||||||
|
import { OrganizationsService } from './modules/organizations/organizations.service';
|
||||||
|
import { PlatformController } from './modules/organizations/platform.controller';
|
||||||
|
import { UsersController } from './modules/users/users.controller';
|
||||||
|
import { UsersService } from './modules/users/users.service';
|
||||||
|
import { PeopleService } from './modules/users/people.service';
|
||||||
|
import { PassportClient } from './modules/passport/passport.client';
|
||||||
|
import { AspansIdClient } from './modules/passport/aspans-id.client';
|
||||||
|
import { SitesController } from './modules/sites/sites.controller';
|
||||||
|
import { SitesService } from './modules/sites/sites.service';
|
||||||
|
import { RoutesController } from './modules/routes/routes.controller';
|
||||||
|
import { RoutesService } from './modules/routes/routes.service';
|
||||||
|
import { SchedulesController } from './modules/schedules/schedules.controller';
|
||||||
|
import { SchedulesService } from './modules/schedules/schedules.service';
|
||||||
|
import { GeneratorService } from './modules/rounds/generator.service';
|
||||||
|
import { RoundsController } from './modules/rounds/rounds.controller';
|
||||||
|
import { RoundsService } from './modules/rounds/rounds.service';
|
||||||
|
import { MediaController } from './modules/media/media.controller';
|
||||||
|
import { MediaService } from './modules/media/media.service';
|
||||||
|
import { StorageService } from './modules/media/storage.service';
|
||||||
|
import { ReportsController } from './modules/reports/reports.controller';
|
||||||
|
import { ReportsService } from './modules/reports/reports.service';
|
||||||
|
import { WebhooksController } from './modules/webhooks/webhooks.controller';
|
||||||
|
import { WebhooksService } from './modules/webhooks/webhooks.service';
|
||||||
|
import { GeocodeController } from './modules/geocode/geocode.controller';
|
||||||
|
import { GeocodeService } from './modules/geocode/geocode.service';
|
||||||
|
|
||||||
|
// Один модуль на всё: сервис небольшой, а так проще следить за зависимостями.
|
||||||
|
@Module({
|
||||||
|
imports: [
|
||||||
|
TypeOrmModule.forRoot(dataSourceOptions),
|
||||||
|
TypeOrmModule.forFeature(ENTITIES),
|
||||||
|
JwtModule.register({ global: true, secret: config.jwtSecret }),
|
||||||
|
ScheduleModule.forRoot(),
|
||||||
|
],
|
||||||
|
controllers: [
|
||||||
|
HealthController,
|
||||||
|
AuthController,
|
||||||
|
PlatformController,
|
||||||
|
UsersController,
|
||||||
|
SitesController,
|
||||||
|
RoutesController,
|
||||||
|
SchedulesController,
|
||||||
|
RoundsController,
|
||||||
|
MediaController,
|
||||||
|
ReportsController,
|
||||||
|
WebhooksController,
|
||||||
|
GeocodeController,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: APP_GUARD, useClass: AuthGuard },
|
||||||
|
{ provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
|
||||||
|
AccessService,
|
||||||
|
AuthService,
|
||||||
|
CompanyRegistry,
|
||||||
|
OrganizationsService,
|
||||||
|
UsersService,
|
||||||
|
PeopleService,
|
||||||
|
PassportClient,
|
||||||
|
AspansIdClient,
|
||||||
|
SitesService,
|
||||||
|
RoutesService,
|
||||||
|
SchedulesService,
|
||||||
|
GeneratorService,
|
||||||
|
RoundsService,
|
||||||
|
StorageService,
|
||||||
|
MediaService,
|
||||||
|
ReportsService,
|
||||||
|
WebhooksService,
|
||||||
|
GeocodeService,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
40
apps/api/src/common/access.service.ts
Normal file
40
apps/api/src/common/access.service.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { In, IsNull, Repository } from 'typeorm';
|
||||||
|
import { Site, UserSite } from '../entities';
|
||||||
|
import { AuthUser, requireOrg } from './auth';
|
||||||
|
|
||||||
|
// Роль отвечает на «что можно», привязка user_sites — на «на каких объектах».
|
||||||
|
@Injectable()
|
||||||
|
export class AccessService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Site) private readonly sites: Repository<Site>,
|
||||||
|
@InjectRepository(UserSite) private readonly userSites: Repository<UserSite>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async siteIds(user: AuthUser): Promise<string[]> {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
if (user.allSites) {
|
||||||
|
const rows = await this.sites.find({ select: { id: true }, where: { organizationId: orgId, archivedAt: IsNull() } });
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
const links = await this.userSites.find({ where: { userId: user.id } });
|
||||||
|
if (!links.length) return [];
|
||||||
|
const rows = await this.sites.find({
|
||||||
|
select: { id: true },
|
||||||
|
where: { id: In(links.map((l) => l.siteId)), organizationId: orgId, archivedAt: IsNull() },
|
||||||
|
});
|
||||||
|
return rows.map((r) => r.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async site(user: AuthUser, siteId: string): Promise<Site> {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const site = await this.sites.findOne({ where: { id: siteId, organizationId: orgId } });
|
||||||
|
if (!site) throw new NotFoundException('Объект не найден');
|
||||||
|
if (!user.allSites) {
|
||||||
|
const link = await this.userSites.findOne({ where: { userId: user.id, siteId } });
|
||||||
|
if (!link) throw new ForbiddenException('Нет доступа к этому объекту');
|
||||||
|
}
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
}
|
||||||
50
apps/api/src/common/audit.ts
Normal file
50
apps/api/src/common/audit.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Observable, tap } from 'rxjs';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { AuditLog } from '../entities';
|
||||||
|
import type { AuthUser } from './auth';
|
||||||
|
|
||||||
|
const SECRET_KEYS = ['password', 'secret', 'token', 'refresh'];
|
||||||
|
|
||||||
|
function scrub(value: unknown, depth = 0): unknown {
|
||||||
|
if (depth > 4 || value === null || typeof value !== 'object') return value;
|
||||||
|
if (Array.isArray(value)) return value.length > 50 ? `[${value.length} элементов]` : value.map((v) => scrub(v, depth + 1));
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
||||||
|
out[k] = SECRET_KEYS.some((s) => k.toLowerCase().includes(s)) ? '***' : scrub(v, depth + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пишем в журнал все изменяющие запросы авторизованных пользователей, кроме загрузки кусков файлов.
|
||||||
|
@Injectable()
|
||||||
|
export class AuditInterceptor implements NestInterceptor {
|
||||||
|
constructor(@InjectRepository(AuditLog) private readonly audit: Repository<AuditLog>) {}
|
||||||
|
|
||||||
|
intercept(ctx: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||||
|
const req = ctx.switchToHttp().getRequest();
|
||||||
|
const method: string = req.method;
|
||||||
|
if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) || /\/media\/[^/]+\/parts\//.test(req.path)) {
|
||||||
|
return next.handle();
|
||||||
|
}
|
||||||
|
return next.handle().pipe(
|
||||||
|
tap((result: unknown) => {
|
||||||
|
const user: AuthUser | undefined = req.user;
|
||||||
|
if (!user) return;
|
||||||
|
const [, , entity, entityId] = String(req.route?.path || req.path).split('/');
|
||||||
|
const resultId = result && typeof result === 'object' && 'id' in result ? String((result as { id: unknown }).id) : null;
|
||||||
|
this.audit
|
||||||
|
.insert({
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
userId: user.id,
|
||||||
|
action: `${method} ${req.route?.path || req.path}`,
|
||||||
|
entity: entity || null,
|
||||||
|
entityId: req.params?.id || resultId || (entityId && !entityId.startsWith(':') ? entityId : null),
|
||||||
|
changes: scrub(req.body) as object,
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
123
apps/api/src/common/auth.ts
Normal file
123
apps/api/src/common/auth.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import {
|
||||||
|
CanActivate,
|
||||||
|
createParamDecorator,
|
||||||
|
ExecutionContext,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
SetMetadata,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Reflector } from '@nestjs/core';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import type { Permission } from '@patrol/shared';
|
||||||
|
import { Organization, User } from '../entities';
|
||||||
|
|
||||||
|
export type AuthUser = {
|
||||||
|
id: string;
|
||||||
|
login: string | null;
|
||||||
|
fullName: string;
|
||||||
|
organizationId: string | null;
|
||||||
|
isPlatformAdmin: boolean;
|
||||||
|
roleId: string | null;
|
||||||
|
roleName: string | null;
|
||||||
|
allSites: boolean;
|
||||||
|
permissions: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccessTokenPayload = { sub: string; typ: 'access' };
|
||||||
|
|
||||||
|
const PUBLIC_KEY = 'isPublic';
|
||||||
|
const PERMISSIONS_KEY = 'permissions';
|
||||||
|
const PLATFORM_KEY = 'platformOnly';
|
||||||
|
|
||||||
|
export const Public = () => SetMetadata(PUBLIC_KEY, true);
|
||||||
|
export const RequirePermissions = (...permissions: Permission[]) => SetMetadata(PERMISSIONS_KEY, permissions);
|
||||||
|
export const PlatformOnly = () => SetMetadata(PLATFORM_KEY, true);
|
||||||
|
|
||||||
|
export const CurrentUser = createParamDecorator((_: unknown, ctx: ExecutionContext): AuthUser => {
|
||||||
|
return ctx.switchToHttp().getRequest().user;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Один глобальный гард: проверяет токен, грузит пользователя с ролью и проверяет права метода.
|
||||||
|
@Injectable()
|
||||||
|
export class AuthGuard implements CanActivate {
|
||||||
|
constructor(
|
||||||
|
private readonly reflector: Reflector,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
@InjectRepository(User) private readonly users: Repository<User>,
|
||||||
|
@InjectRepository(Organization) private readonly orgs: Repository<Organization>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||||
|
const targets = [ctx.getHandler(), ctx.getClass()];
|
||||||
|
if (this.reflector.getAllAndOverride<boolean>(PUBLIC_KEY, targets)) return true;
|
||||||
|
|
||||||
|
const req = ctx.switchToHttp().getRequest();
|
||||||
|
const header: string | undefined = req.headers.authorization;
|
||||||
|
const token = header?.startsWith('Bearer ') ? header.slice(7) : undefined;
|
||||||
|
if (!token) throw new UnauthorizedException('Нужно войти');
|
||||||
|
|
||||||
|
let payload: AccessTokenPayload;
|
||||||
|
try {
|
||||||
|
payload = await this.jwt.verifyAsync<AccessTokenPayload>(token);
|
||||||
|
} catch {
|
||||||
|
throw new UnauthorizedException('Сессия закончилась');
|
||||||
|
}
|
||||||
|
if (payload.typ !== 'access') throw new UnauthorizedException('Неверный токен');
|
||||||
|
|
||||||
|
const user = await this.users.findOne({ where: { id: payload.sub }, relations: { role: true } });
|
||||||
|
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
||||||
|
const org = user.organizationId ? await this.orgs.findOne({ where: { id: user.organizationId } }) : null;
|
||||||
|
const problem = accessProblem(user, org);
|
||||||
|
if (problem) throw new UnauthorizedException(problem);
|
||||||
|
|
||||||
|
const authUser: AuthUser = {
|
||||||
|
id: user.id,
|
||||||
|
login: user.login,
|
||||||
|
fullName: user.fullName,
|
||||||
|
organizationId: user.organizationId,
|
||||||
|
isPlatformAdmin: user.isPlatformAdmin,
|
||||||
|
roleId: user.roleId,
|
||||||
|
roleName: user.role?.name ?? null,
|
||||||
|
allSites: !!user.role?.allSites,
|
||||||
|
permissions: new Set(user.role?.permissions ?? []),
|
||||||
|
};
|
||||||
|
req.user = authUser;
|
||||||
|
|
||||||
|
if (this.reflector.getAllAndOverride<boolean>(PLATFORM_KEY, targets) && !authUser.isPlatformAdmin) {
|
||||||
|
throw new ForbiddenException('Только для владельца платформы');
|
||||||
|
}
|
||||||
|
|
||||||
|
const required = this.reflector.getAllAndOverride<Permission[]>(PERMISSIONS_KEY, targets) ?? [];
|
||||||
|
if (required.length) {
|
||||||
|
if (!authUser.organizationId) throw new ForbiddenException('Нужна организация');
|
||||||
|
const missing = required.filter((p) => !authUser.permissions.has(p));
|
||||||
|
if (missing.length) throw new ForbiddenException(`Недостаточно прав: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Можно ли человеку в patrol прямо сейчас. Одно правило для входа по паролю, SSO и каждого запроса:
|
||||||
|
* быть на платформе (не убран в паспорте), не отключён в паспорте и здесь, и состоять в организации
|
||||||
|
* с ролью — либо быть владельцем платформы.
|
||||||
|
*/
|
||||||
|
export function accessProblem(user: User, org: Organization | null): string | null {
|
||||||
|
if (user.removedAt) return 'Вы больше не подключены к «Обходам»';
|
||||||
|
if (!user.passportEnabled) return 'Учётная запись отключена в Aspans ID';
|
||||||
|
if (!user.isActive) return 'Доступ отключён администратором организации';
|
||||||
|
if (user.isPlatformAdmin) return null;
|
||||||
|
if (!user.organizationId || !user.roleId || !org) {
|
||||||
|
return 'Вы не подключены ни к одной организации в «Обходах». Обратитесь к администратору своей организации';
|
||||||
|
}
|
||||||
|
if (org.status !== 'active') return 'Организация заблокирована';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireOrg(user: AuthUser): string {
|
||||||
|
if (!user.organizationId) throw new ForbiddenException('Действие доступно только сотрудникам организации');
|
||||||
|
return user.organizationId;
|
||||||
|
}
|
||||||
67
apps/api/src/common/geo.ts
Normal file
67
apps/api/src/common/geo.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import type { GeoJsonLineString, GeoJsonMultiPolygon, GeoJsonPoint, LatLng } from '@patrol/shared';
|
||||||
|
|
||||||
|
const R = 6371008.8;
|
||||||
|
|
||||||
|
export function distanceMeters(a: LatLng, b: LatLng): number {
|
||||||
|
const toRad = (d: number) => (d * Math.PI) / 180;
|
||||||
|
const dLat = toRad(b.lat - a.lat);
|
||||||
|
const dLng = toRad(b.lng - a.lng);
|
||||||
|
const h = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(a.lat)) * Math.cos(toRad(b.lat)) * Math.sin(dLng / 2) ** 2;
|
||||||
|
return 2 * R * Math.asin(Math.sqrt(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pointToLatLng(p: GeoJsonPoint): LatLng {
|
||||||
|
return { lng: p.coordinates[0], lat: p.coordinates[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function latLngToPoint(p: LatLng): GeoJsonPoint {
|
||||||
|
return { type: 'Point', coordinates: [p.lng, p.lat] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPosition(v: unknown): v is [number, number] {
|
||||||
|
return (
|
||||||
|
Array.isArray(v) &&
|
||||||
|
v.length >= 2 &&
|
||||||
|
typeof v[0] === 'number' &&
|
||||||
|
typeof v[1] === 'number' &&
|
||||||
|
Math.abs(v[0]) <= 180 &&
|
||||||
|
Math.abs(v[1]) <= 90
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertLatLng(v: unknown, field: string): LatLng {
|
||||||
|
const p = v as LatLng;
|
||||||
|
if (!p || typeof p.lat !== 'number' || typeof p.lng !== 'number' || Math.abs(p.lat) > 90 || Math.abs(p.lng) > 180) {
|
||||||
|
throw new BadRequestException(`${field}: нужны координаты { lat, lng }`);
|
||||||
|
}
|
||||||
|
return { lat: p.lat, lng: p.lng };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertPoint(v: unknown, field: string): GeoJsonPoint {
|
||||||
|
const g = v as GeoJsonPoint;
|
||||||
|
if (!g || g.type !== 'Point' || !isPosition(g.coordinates)) throw new BadRequestException(`${field}: нужен GeoJSON Point`);
|
||||||
|
return { type: 'Point', coordinates: [g.coordinates[0], g.coordinates[1]] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertLineString(v: unknown, field: string): GeoJsonLineString {
|
||||||
|
const g = v as GeoJsonLineString;
|
||||||
|
if (!g || g.type !== 'LineString' || !Array.isArray(g.coordinates) || g.coordinates.length < 2 || !g.coordinates.every(isPosition)) {
|
||||||
|
throw new BadRequestException(`${field}: нужна линия GeoJSON LineString минимум из 2 точек`);
|
||||||
|
}
|
||||||
|
return { type: 'LineString', coordinates: g.coordinates.map((c) => [c[0], c[1]]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фронт может прислать и Polygon, и MultiPolygon — храним всегда MultiPolygon.
|
||||||
|
export function assertMultiPolygon(v: unknown, field: string): GeoJsonMultiPolygon {
|
||||||
|
const g = v as { type?: string; coordinates?: unknown };
|
||||||
|
const ringOk = (ring: unknown) => Array.isArray(ring) && ring.length >= 4 && ring.every(isPosition);
|
||||||
|
const polyOk = (poly: unknown) => Array.isArray(poly) && poly.length >= 1 && poly.every(ringOk);
|
||||||
|
if (g?.type === 'Polygon' && polyOk(g.coordinates)) {
|
||||||
|
return { type: 'MultiPolygon', coordinates: [g.coordinates as [number, number][][]] };
|
||||||
|
}
|
||||||
|
if (g?.type === 'MultiPolygon' && Array.isArray(g.coordinates) && g.coordinates.length >= 1 && g.coordinates.every(polyOk)) {
|
||||||
|
return { type: 'MultiPolygon', coordinates: g.coordinates as [number, number][][][] };
|
||||||
|
}
|
||||||
|
throw new BadRequestException(`${field}: нужен GeoJSON Polygon или MultiPolygon`);
|
||||||
|
}
|
||||||
117
apps/api/src/common/input.ts
Normal file
117
apps/api/src/common/input.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
// Небольшие проверки входных данных: короче DTO-классов и с понятными русскими ошибками.
|
||||||
|
|
||||||
|
type Obj = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function obj(v: unknown, field = 'body'): Obj {
|
||||||
|
if (!v || typeof v !== 'object' || Array.isArray(v)) throw new BadRequestException(`${field}: нужен объект`);
|
||||||
|
return v as Obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function str(o: Obj, key: string, opts: { required?: boolean; max?: number; min?: number } = {}): string | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof v !== 'string') throw new BadRequestException(`Поле «${key}» должно быть строкой`);
|
||||||
|
const s = v.trim();
|
||||||
|
if (opts.required && !s) throw new BadRequestException(`Поле «${key}» обязательно`);
|
||||||
|
if (opts.min && s.length < opts.min) throw new BadRequestException(`Поле «${key}»: минимум ${opts.min} символов`);
|
||||||
|
if (s.length > (opts.max ?? 2000)) throw new BadRequestException(`Поле «${key}» слишком длинное`);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reqStr(o: Obj, key: string, opts: { max?: number; min?: number } = {}): string {
|
||||||
|
return str(o, key, { ...opts, required: true }) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function int(o: Obj, key: string, opts: { required?: boolean; min?: number; max?: number } = {}): number | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const n = typeof v === 'string' ? Number(v) : v;
|
||||||
|
if (typeof n !== 'number' || !Number.isInteger(n)) throw new BadRequestException(`Поле «${key}» должно быть целым числом`);
|
||||||
|
if (opts.min !== undefined && n < opts.min) throw new BadRequestException(`Поле «${key}»: не меньше ${opts.min}`);
|
||||||
|
if (opts.max !== undefined && n > opts.max) throw new BadRequestException(`Поле «${key}»: не больше ${opts.max}`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bool(o: Obj, key: string): boolean | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null) return undefined;
|
||||||
|
if (typeof v !== 'boolean') throw new BadRequestException(`Поле «${key}» должно быть true/false`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export function isUuid(v: unknown): v is string {
|
||||||
|
return typeof v === 'string' && UUID_RE.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuid(o: Obj, key: string, opts: { required?: boolean } = {}): string | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isUuid(v)) throw new BadRequestException(`Поле «${key}»: нужен uuid`);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function uuidArray(o: Obj, key: string): string[] | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null) return undefined;
|
||||||
|
if (!Array.isArray(v) || !v.every(isUuid)) throw new BadRequestException(`Поле «${key}»: нужен массив uuid`);
|
||||||
|
return Array.from(new Set(v));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
export function date(o: Obj, key: string, opts: { required?: boolean } = {}): string | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (typeof v !== 'string' || !DATE_RE.test(v) || Number.isNaN(Date.parse(`${v}T00:00:00Z`))) {
|
||||||
|
throw new BadRequestException(`Поле «${key}»: дата в формате ГГГГ-ММ-ДД`);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDateString(v: unknown): v is string {
|
||||||
|
return typeof v === 'string' && DATE_RE.test(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timestamp(o: Obj, key: string, opts: { required?: boolean } = {}): Date | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const d = new Date(String(v));
|
||||||
|
if (Number.isNaN(d.getTime())) throw new BadRequestException(`Поле «${key}»: неверная дата и время`);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function oneOf<T extends string>(o: Obj, key: string, values: readonly T[], opts: { required?: boolean } = {}): T | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
if (opts.required) throw new BadRequestException(`Поле «${key}» обязательно`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!values.includes(v as T)) throw new BadRequestException(`Поле «${key}»: одно из ${values.join(', ')}`);
|
||||||
|
return v as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function strArray(o: Obj, key: string): string[] | undefined {
|
||||||
|
const v = o[key];
|
||||||
|
if (v === undefined || v === null) return undefined;
|
||||||
|
if (!Array.isArray(v) || !v.every((x) => typeof x === 'string')) throw new BadRequestException(`Поле «${key}»: нужен массив строк`);
|
||||||
|
return v as string[];
|
||||||
|
}
|
||||||
68
apps/api/src/common/time.ts
Normal file
68
apps/api/src/common/time.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
// Перевод «дата + время в поясе объекта» в UTC без сторонних библиотек.
|
||||||
|
|
||||||
|
const formatters = new Map<string, Intl.DateTimeFormat>();
|
||||||
|
|
||||||
|
function formatter(tz: string): Intl.DateTimeFormat {
|
||||||
|
let f = formatters.get(tz);
|
||||||
|
if (!f) {
|
||||||
|
f = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: tz,
|
||||||
|
hourCycle: 'h23',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
formatters.set(tz, f);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parts(ms: number, tz: string): Record<string, number> {
|
||||||
|
const out: Record<string, number> = {};
|
||||||
|
for (const p of formatter(tz).formatToParts(new Date(ms))) {
|
||||||
|
if (p.type !== 'literal') out[p.type] = Number(p.value);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Смещение пояса от UTC в миллисекундах в момент ms.
|
||||||
|
function offsetMs(ms: number, tz: string): number {
|
||||||
|
const p = parts(ms, tz);
|
||||||
|
const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
|
||||||
|
return asUtc - Math.floor(ms / 1000) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidTimeZone(tz: string): boolean {
|
||||||
|
try {
|
||||||
|
formatter(tz);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function zonedToUtc(date: string, time: string, tz: string): Date {
|
||||||
|
const [y, m, d] = date.split('-').map(Number);
|
||||||
|
const [hh, mm] = time.split(':').map(Number);
|
||||||
|
const guess = Date.UTC(y, m - 1, d, hh, mm);
|
||||||
|
let result = guess - offsetMs(guess, tz);
|
||||||
|
const second = offsetMs(result, tz);
|
||||||
|
result = guess - second;
|
||||||
|
return new Date(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function localDate(ms: number, tz: string): string {
|
||||||
|
const p = parts(ms, tz);
|
||||||
|
return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: string, days: number): string {
|
||||||
|
const d = new Date(`${date}T00:00:00Z`);
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
67
apps/api/src/config.ts
Normal file
67
apps/api/src/config.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
// Все настройки — из переменных окружения (docker compose передаёт их из .env).
|
||||||
|
|
||||||
|
function required(name: string, fallback?: string): string {
|
||||||
|
const value = process.env[name] ?? fallback;
|
||||||
|
if (value === undefined || value === '') throw new Error(`Не задана переменная окружения ${name}`);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
port: Number(process.env.PORT || 3000),
|
||||||
|
db: {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: Number(process.env.DB_PORT || 5432),
|
||||||
|
database: process.env.POSTGRES_DB || 'patrol',
|
||||||
|
user: process.env.POSTGRES_USER || 'patrol',
|
||||||
|
password: process.env.POSTGRES_PASSWORD || 'patrol',
|
||||||
|
},
|
||||||
|
jwtSecret: required('JWT_SECRET', process.env.NODE_ENV === 'production' ? undefined : 'dev-secret'),
|
||||||
|
accessTtlSeconds: 15 * 60,
|
||||||
|
refreshTtlDays: 30,
|
||||||
|
mediaTokenTtlSeconds: 60 * 60,
|
||||||
|
s3: {
|
||||||
|
endpoint: process.env.S3_ENDPOINT || 'http://localhost:9000',
|
||||||
|
region: process.env.S3_REGION || 'us-east-1',
|
||||||
|
accessKey: process.env.S3_ACCESS_KEY || 'patrol',
|
||||||
|
secretKey: process.env.S3_SECRET_KEY || 'patrol-s3-secret',
|
||||||
|
bucket: process.env.S3_BUCKET || 'patrol-media',
|
||||||
|
},
|
||||||
|
// Паспорт Aspans — единственный источник людей и паролей. Платформенные вызовы подписываются
|
||||||
|
// md5(platform_code-timestamp-token), token = PLATFORM_SALT паспорта (как PASSPORT_PLATFORM_TOKEN в symfony).
|
||||||
|
passport: {
|
||||||
|
url: (process.env.PASSPORT_URL || '').replace(/\/+$/, ''),
|
||||||
|
platformCode: process.env.PLATFORM_CODE || 'patrol',
|
||||||
|
token: process.env.PASSPORT_PLATFORM_TOKEN || '',
|
||||||
|
// Единственная группа patrol в паспорте: без группы паспорт не пускает на платформу.
|
||||||
|
groupCode: process.env.PASSPORT_GROUP_CODE || 'patrol_user',
|
||||||
|
},
|
||||||
|
// SSO Aspans ID: OAuth2 + PKCE. client_id и секрет — код платформы (договорённость, как в symfony).
|
||||||
|
aspansId: {
|
||||||
|
baseUrl: (process.env.ASPANS_ID_BASE_URL || '').replace(/\/+$/, ''),
|
||||||
|
clientId: process.env.ASPANS_ID_CLIENT_ID || process.env.PLATFORM_CODE || 'patrol',
|
||||||
|
// Секрет OAuth-клиента в Aspans ID. Не задан — код платформы (так у symfony: клиент aspans, секрет aspans).
|
||||||
|
clientSecret: process.env.ASPANS_ID_CLIENT_SECRET || process.env.PLATFORM_CODE || 'patrol',
|
||||||
|
callbackPath: process.env.ASPANS_ID_CALLBACK_PATH || '/auth/sso/callback',
|
||||||
|
scope: process.env.ASPANS_ID_SCOPE || 'openid profile email groups platforms',
|
||||||
|
// Публичный адрес фронта — для redirect_uri, если Origin запроса не подходит.
|
||||||
|
websiteUrl: (process.env.WEBSITE_URL || '').replace(/\/+$/, ''),
|
||||||
|
},
|
||||||
|
// Владельцы платформы — логины паспорта. Им не нужна организация в patrol.
|
||||||
|
platformAdminUsernames: (process.env.PLATFORM_ADMIN_USERNAMES || 'admin')
|
||||||
|
.split(',')
|
||||||
|
.map((s) => s.trim().toLowerCase())
|
||||||
|
.filter(Boolean),
|
||||||
|
// Синхронизация пользователей с паспортом через Kafka (топик aspans.users.changed).
|
||||||
|
// Пустые KAFKA_BROKERS / KAFKA_USERNAME / KAFKA_PASSWORD — шина выключена, правки копятся в users_sync_pending.
|
||||||
|
kafka: {
|
||||||
|
brokers: process.env.KAFKA_BROKERS || '',
|
||||||
|
username: process.env.KAFKA_USERNAME || '',
|
||||||
|
password: process.env.KAFKA_PASSWORD || '',
|
||||||
|
topic: process.env.USERS_SYNC_TOPIC || 'aspans.users.changed',
|
||||||
|
source: process.env.USERS_SYNC_SOURCE || 'platform-patrol',
|
||||||
|
},
|
||||||
|
// Сколько дней вперёд генерировать обходы из графика.
|
||||||
|
generateDaysAhead: 7,
|
||||||
|
cookieSecure: process.env.COOKIE_SECURE === 'true',
|
||||||
|
disableCron: process.env.DISABLE_CRON === 'true',
|
||||||
|
};
|
||||||
22
apps/api/src/database/data-source.ts
Normal file
22
apps/api/src/database/data-source.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { DataSource, DataSourceOptions } from 'typeorm';
|
||||||
|
import { config } from '../config';
|
||||||
|
import { ENTITIES } from '../entities';
|
||||||
|
import { Init1761000000000 } from './migrations/1761000000000-Init';
|
||||||
|
import { Passport1761100000000 } from './migrations/1761100000000-Passport';
|
||||||
|
|
||||||
|
export const dataSourceOptions: DataSourceOptions = {
|
||||||
|
type: 'postgres',
|
||||||
|
host: config.db.host,
|
||||||
|
port: config.db.port,
|
||||||
|
database: config.db.database,
|
||||||
|
username: config.db.user,
|
||||||
|
password: config.db.password,
|
||||||
|
entities: ENTITIES,
|
||||||
|
migrations: [Init1761000000000, Passport1761100000000],
|
||||||
|
synchronize: false,
|
||||||
|
migrationsRun: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Для CLI typeorm (npm run migration:revert)
|
||||||
|
export default new DataSource(dataSourceOptions);
|
||||||
262
apps/api/src/database/migrations/1761000000000-Init.ts
Normal file
262
apps/api/src/database/migrations/1761000000000-Init.ts
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class Init1761000000000 implements MigrationInterface {
|
||||||
|
name = 'Init1761000000000';
|
||||||
|
|
||||||
|
public async up(q: QueryRunner): Promise<void> {
|
||||||
|
await q.query(`CREATE EXTENSION IF NOT EXISTS postgis`);
|
||||||
|
await q.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE organizations (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
bin char(12) NOT NULL UNIQUE,
|
||||||
|
name text NOT NULL,
|
||||||
|
legal_name text,
|
||||||
|
legal_address text,
|
||||||
|
director_name text,
|
||||||
|
phone text,
|
||||||
|
email text,
|
||||||
|
registry_data jsonb,
|
||||||
|
registry_synced_at timestamptz,
|
||||||
|
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'blocked')),
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE roles (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
code text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
is_system boolean NOT NULL DEFAULT false,
|
||||||
|
all_sites boolean NOT NULL DEFAULT false,
|
||||||
|
permissions text[] NOT NULL DEFAULT '{}',
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (organization_id, code)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE users (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
login text NOT NULL UNIQUE,
|
||||||
|
password_hash text NOT NULL,
|
||||||
|
full_name text NOT NULL,
|
||||||
|
phone text,
|
||||||
|
role_id uuid REFERENCES roles(id) ON DELETE SET NULL,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
is_platform_admin boolean NOT NULL DEFAULT false,
|
||||||
|
last_login_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
CHECK (is_platform_admin OR organization_id IS NOT NULL)
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX users_org_idx ON users (organization_id)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE user_sessions (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
refresh_hash text NOT NULL UNIQUE,
|
||||||
|
user_agent text,
|
||||||
|
expires_at timestamptz NOT NULL,
|
||||||
|
revoked_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE audit_log (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
action text NOT NULL,
|
||||||
|
entity text,
|
||||||
|
entity_id text,
|
||||||
|
changes jsonb,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX audit_log_org_created_idx ON audit_log (organization_id, created_at DESC)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE sites (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
address text,
|
||||||
|
description text,
|
||||||
|
perimeter geometry(MultiPolygon, 4326),
|
||||||
|
center geometry(Point, 4326),
|
||||||
|
timezone text NOT NULL DEFAULT 'Asia/Almaty',
|
||||||
|
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
archived_at timestamptz
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX sites_org_idx ON sites (organization_id)`);
|
||||||
|
await q.query(`CREATE INDEX sites_perimeter_gix ON sites USING gist (perimeter)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE user_sites (
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (user_id, site_id)
|
||||||
|
)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE routes (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
description text,
|
||||||
|
path geometry(LineString, 4326),
|
||||||
|
expected_minutes int,
|
||||||
|
strict_order boolean NOT NULL DEFAULT false,
|
||||||
|
version int NOT NULL DEFAULT 1,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
archived_at timestamptz
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX routes_site_idx ON routes (site_id)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE checkpoints (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
route_id uuid NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
||||||
|
position int NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
location geometry(Point, 4326) NOT NULL,
|
||||||
|
radius_m int NOT NULL DEFAULT 30 CHECK (radius_m > 0),
|
||||||
|
media_required text NOT NULL DEFAULT 'photo' CHECK (media_required IN ('none', 'photo', 'video', 'photo_or_video')),
|
||||||
|
instruction text,
|
||||||
|
archived_at timestamptz
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX checkpoints_route_idx ON checkpoints (route_id, position)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE patrol_schedules (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
route_id uuid NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
||||||
|
name text NOT NULL,
|
||||||
|
start_times text[] NOT NULL,
|
||||||
|
window_minutes int NOT NULL DEFAULT 60 CHECK (window_minutes > 0),
|
||||||
|
late_tolerance_minutes int NOT NULL DEFAULT 15 CHECK (late_tolerance_minutes >= 0),
|
||||||
|
valid_from date NOT NULL,
|
||||||
|
valid_to date,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX patrol_schedules_site_idx ON patrol_schedules (site_id)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE schedule_assignments (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
schedule_id uuid NOT NULL REFERENCES patrol_schedules(id) ON DELETE CASCADE,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
date date NOT NULL,
|
||||||
|
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (schedule_id, user_id, date)
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX schedule_assignments_user_date_idx ON schedule_assignments (user_id, date)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE rounds (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE,
|
||||||
|
route_id uuid NOT NULL REFERENCES routes(id) ON DELETE CASCADE,
|
||||||
|
schedule_id uuid REFERENCES patrol_schedules(id) ON DELETE SET NULL,
|
||||||
|
duty_date date,
|
||||||
|
performed_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
created_by uuid REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
planned_start timestamptz NOT NULL,
|
||||||
|
planned_end timestamptz NOT NULL,
|
||||||
|
late_tolerance_minutes int NOT NULL DEFAULT 15,
|
||||||
|
started_at timestamptz,
|
||||||
|
finished_at timestamptz,
|
||||||
|
status text NOT NULL DEFAULT 'planned'
|
||||||
|
CHECK (status IN ('planned', 'in_progress', 'completed', 'partial', 'missed', 'cancelled')),
|
||||||
|
route_snapshot jsonb NOT NULL,
|
||||||
|
checkpoints_total int NOT NULL DEFAULT 0,
|
||||||
|
checkpoints_done int NOT NULL DEFAULT 0,
|
||||||
|
comment text,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE UNIQUE INDEX rounds_schedule_start_uq ON rounds (schedule_id, planned_start) WHERE schedule_id IS NOT NULL`);
|
||||||
|
await q.query(`CREATE INDEX rounds_site_start_idx ON rounds (site_id, planned_start)`);
|
||||||
|
await q.query(`CREATE INDEX rounds_status_end_idx ON rounds (status, planned_end)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE checkpoint_visits (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
round_id uuid NOT NULL REFERENCES rounds(id) ON DELETE CASCADE,
|
||||||
|
checkpoint_id uuid REFERENCES checkpoints(id) ON DELETE SET NULL,
|
||||||
|
user_id uuid NOT NULL REFERENCES users(id),
|
||||||
|
marked_location geometry(Point, 4326) NOT NULL,
|
||||||
|
device_location geometry(Point, 4326),
|
||||||
|
device_accuracy_m real,
|
||||||
|
distance_to_checkpoint_m real,
|
||||||
|
gps_distance_m real,
|
||||||
|
flags text[] NOT NULL DEFAULT '{}',
|
||||||
|
note text,
|
||||||
|
is_issue boolean NOT NULL DEFAULT false,
|
||||||
|
client_created_at timestamptz NOT NULL,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX checkpoint_visits_round_idx ON checkpoint_visits (round_id)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE media (
|
||||||
|
id uuid PRIMARY KEY,
|
||||||
|
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
uploaded_by uuid NOT NULL REFERENCES users(id),
|
||||||
|
visit_id uuid REFERENCES checkpoint_visits(id) ON DELETE SET NULL,
|
||||||
|
kind text NOT NULL CHECK (kind IN ('photo', 'video')),
|
||||||
|
mime text NOT NULL,
|
||||||
|
size_bytes bigint NOT NULL,
|
||||||
|
filename text,
|
||||||
|
storage_key text NOT NULL,
|
||||||
|
upload_id text,
|
||||||
|
parts jsonb NOT NULL DEFAULT '[]',
|
||||||
|
status text NOT NULL DEFAULT 'uploading' CHECK (status IN ('uploading', 'ready')),
|
||||||
|
captured_at timestamptz,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX media_visit_idx ON media (visit_id)`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE webhooks (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
url text NOT NULL,
|
||||||
|
events text[] NOT NULL,
|
||||||
|
secret text NOT NULL,
|
||||||
|
is_active boolean NOT NULL DEFAULT true,
|
||||||
|
created_at timestamptz NOT NULL DEFAULT now()
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(q: QueryRunner): Promise<void> {
|
||||||
|
for (const table of [
|
||||||
|
'webhooks',
|
||||||
|
'media',
|
||||||
|
'checkpoint_visits',
|
||||||
|
'rounds',
|
||||||
|
'schedule_assignments',
|
||||||
|
'patrol_schedules',
|
||||||
|
'checkpoints',
|
||||||
|
'routes',
|
||||||
|
'user_sites',
|
||||||
|
'sites',
|
||||||
|
'audit_log',
|
||||||
|
'user_sessions',
|
||||||
|
'users',
|
||||||
|
'roles',
|
||||||
|
'organizations',
|
||||||
|
]) {
|
||||||
|
await q.query(`DROP TABLE IF EXISTS ${table}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
61
apps/api/src/database/migrations/1761100000000-Passport.ts
Normal file
61
apps/api/src/database/migrations/1761100000000-Passport.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Люди — из паспорта Aspans.
|
||||||
|
*
|
||||||
|
* Вход и пароли переезжают в паспорт (и Aspans ID), локальные пароли больше не нужны.
|
||||||
|
* Пользователь patrol = человек паспорта (passport_uuid) + организация, роль и объекты здесь.
|
||||||
|
*
|
||||||
|
* users_sync_pending / users_sync_state — та же схема, что groups_sync_* в паспорте и symfony:
|
||||||
|
* очередь «что отправить в aspans.users.changed» и версия последней правки (LWW) по (kind, ref).
|
||||||
|
*/
|
||||||
|
export class Passport1761100000000 implements MigrationInterface {
|
||||||
|
name = 'Passport1761100000000';
|
||||||
|
|
||||||
|
async up(q: QueryRunner): Promise<void> {
|
||||||
|
await q.query(`ALTER TABLE users DROP CONSTRAINT IF EXISTS users_check`);
|
||||||
|
await q.query(`ALTER TABLE users DROP CONSTRAINT IF EXISTS users_login_key`);
|
||||||
|
await q.query(`ALTER TABLE users ALTER COLUMN login DROP NOT NULL`);
|
||||||
|
await q.query(`ALTER TABLE users DROP COLUMN IF EXISTS password_hash`);
|
||||||
|
await q.query(`
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN passport_uuid uuid UNIQUE,
|
||||||
|
ADD COLUMN last_name text,
|
||||||
|
ADD COLUMN first_name text,
|
||||||
|
ADD COLUMN middle_name text,
|
||||||
|
ADD COLUMN email text,
|
||||||
|
ADD COLUMN passport_enabled boolean NOT NULL DEFAULT true,
|
||||||
|
ADD COLUMN removed_at timestamptz`);
|
||||||
|
// Локальные учётки без паспорта войти больше не смогут — помечаем их, чтобы не путали со своими.
|
||||||
|
await q.query(`UPDATE users SET removed_at = now(), is_active = false WHERE passport_uuid IS NULL`);
|
||||||
|
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE users_sync_pending (
|
||||||
|
kind text NOT NULL,
|
||||||
|
ref text NOT NULL,
|
||||||
|
changed_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||||
|
attempts int NOT NULL DEFAULT 0,
|
||||||
|
last_error text,
|
||||||
|
next_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (kind, ref)
|
||||||
|
)`);
|
||||||
|
await q.query(`CREATE INDEX users_sync_pending_next_idx ON users_sync_pending (next_at)`);
|
||||||
|
await q.query(`
|
||||||
|
CREATE TABLE users_sync_state (
|
||||||
|
kind text NOT NULL,
|
||||||
|
ref text NOT NULL,
|
||||||
|
version timestamptz NOT NULL,
|
||||||
|
PRIMARY KEY (kind, ref)
|
||||||
|
)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async down(q: QueryRunner): Promise<void> {
|
||||||
|
await q.query(`DROP TABLE users_sync_state`);
|
||||||
|
await q.query(`DROP TABLE users_sync_pending`);
|
||||||
|
await q.query(`
|
||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN passport_uuid, DROP COLUMN last_name, DROP COLUMN first_name, DROP COLUMN middle_name,
|
||||||
|
DROP COLUMN email, DROP COLUMN passport_enabled, DROP COLUMN removed_at`);
|
||||||
|
await q.query(`ALTER TABLE users ADD COLUMN password_hash text NOT NULL DEFAULT ''`);
|
||||||
|
}
|
||||||
|
}
|
||||||
293
apps/api/src/entities/index.ts
Normal file
293
apps/api/src/entities/index.ts
Normal file
@ -0,0 +1,293 @@
|
|||||||
|
import {
|
||||||
|
Column,
|
||||||
|
CreateDateColumn,
|
||||||
|
Entity,
|
||||||
|
Index,
|
||||||
|
JoinColumn,
|
||||||
|
ManyToOne,
|
||||||
|
PrimaryColumn,
|
||||||
|
PrimaryGeneratedColumn,
|
||||||
|
Unique,
|
||||||
|
UpdateDateColumn,
|
||||||
|
} from 'typeorm';
|
||||||
|
import type {
|
||||||
|
GeoJsonLineString,
|
||||||
|
GeoJsonMultiPolygon,
|
||||||
|
GeoJsonPoint,
|
||||||
|
MediaRequirement,
|
||||||
|
RoundStatus,
|
||||||
|
} from '@patrol/shared';
|
||||||
|
|
||||||
|
// TypeORM сам переводит geometry в GeoJSON при чтении и обратно при записи.
|
||||||
|
|
||||||
|
@Entity('organizations')
|
||||||
|
export class Organization {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'char', length: 12, unique: true }) bin: string;
|
||||||
|
@Column('text') name: string;
|
||||||
|
@Column({ type: 'text', name: 'legal_name', nullable: true }) legalName: string | null;
|
||||||
|
@Column({ type: 'text', name: 'legal_address', nullable: true }) legalAddress: string | null;
|
||||||
|
@Column({ type: 'text', name: 'director_name', nullable: true }) directorName: string | null;
|
||||||
|
@Column({ type: 'text', nullable: true }) phone: string | null;
|
||||||
|
@Column({ type: 'text', nullable: true }) email: string | null;
|
||||||
|
@Column({ type: 'jsonb', name: 'registry_data', nullable: true }) registryData: unknown | null;
|
||||||
|
@Column({ type: 'timestamptz', name: 'registry_synced_at', nullable: true }) registrySyncedAt: Date | null;
|
||||||
|
@Column({ type: 'text', default: 'active' }) status: 'active' | 'blocked';
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('roles')
|
||||||
|
export class Role {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||||||
|
@Column('text') code: string;
|
||||||
|
@Column('text') name: string;
|
||||||
|
@Column({ type: 'boolean', name: 'is_system', default: false }) isSystem: boolean;
|
||||||
|
@Column({ type: 'boolean', name: 'all_sites', default: false }) allSites: boolean;
|
||||||
|
@Column({ type: 'text', array: true, default: () => "'{}'" }) permissions: string[];
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('users')
|
||||||
|
export class User {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'organization_id', nullable: true }) organizationId: string | null;
|
||||||
|
// Человек из паспорта. Пароли и вход — только через паспорт / Aspans ID.
|
||||||
|
@Column({ type: 'uuid', name: 'passport_uuid', unique: true, nullable: true }) passportUuid: string | null;
|
||||||
|
// Логин паспорта (username) — для отображения.
|
||||||
|
@Column({ type: 'text', nullable: true }) login: string | null;
|
||||||
|
@Column({ type: 'text', name: 'last_name', nullable: true }) lastName: string | null;
|
||||||
|
@Column({ type: 'text', name: 'first_name', nullable: true }) firstName: string | null;
|
||||||
|
@Column({ type: 'text', name: 'middle_name', nullable: true }) middleName: string | null;
|
||||||
|
@Column({ type: 'text', name: 'full_name' }) fullName: string;
|
||||||
|
@Column({ type: 'text', nullable: true }) email: string | null;
|
||||||
|
@Column({ type: 'text', nullable: true }) phone: string | null;
|
||||||
|
@Column({ type: 'uuid', name: 'role_id', nullable: true }) roleId: string | null;
|
||||||
|
@ManyToOne(() => Role, { nullable: true })
|
||||||
|
@JoinColumn({ name: 'role_id' })
|
||||||
|
role: Role | null;
|
||||||
|
// Доступ в patrol (выключает администратор организации). Паспорт этим не управляет.
|
||||||
|
@Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean;
|
||||||
|
// is_enabled из паспорта: отключили человека в паспорте — не пускаем и сюда.
|
||||||
|
@Column({ type: 'boolean', name: 'passport_enabled', default: true }) passportEnabled: boolean;
|
||||||
|
// Убрали с платформы patrol в паспорте (или удалили из организации здесь). Строку не удаляем:
|
||||||
|
// на человека ссылаются обходы, отметки и фото в отчётах.
|
||||||
|
@Column({ type: 'timestamptz', name: 'removed_at', nullable: true }) removedAt: Date | null;
|
||||||
|
@Column({ type: 'boolean', name: 'is_platform_admin', default: false }) isPlatformAdmin: boolean;
|
||||||
|
@Column({ type: 'timestamptz', name: 'last_login_at', nullable: true }) lastLoginAt: Date | null;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('user_sites')
|
||||||
|
export class UserSite {
|
||||||
|
@PrimaryColumn({ type: 'uuid', name: 'user_id' }) userId: string;
|
||||||
|
@PrimaryColumn({ type: 'uuid', name: 'site_id' }) siteId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('user_sessions')
|
||||||
|
export class UserSession {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'user_id' }) userId: string;
|
||||||
|
@Column({ type: 'text', name: 'refresh_hash' }) refreshHash: string;
|
||||||
|
@Column({ type: 'text', name: 'user_agent', nullable: true }) userAgent: string | null;
|
||||||
|
@Column({ type: 'timestamptz', name: 'expires_at' }) expiresAt: Date;
|
||||||
|
@Column({ type: 'timestamptz', name: 'revoked_at', nullable: true }) revokedAt: Date | null;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('audit_log')
|
||||||
|
export class AuditLog {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'organization_id', nullable: true }) organizationId: string | null;
|
||||||
|
@Column({ type: 'uuid', name: 'user_id', nullable: true }) userId: string | null;
|
||||||
|
@Column('text') action: string;
|
||||||
|
@Column({ type: 'text', nullable: true }) entity: string | null;
|
||||||
|
@Column({ type: 'text', name: 'entity_id', nullable: true }) entityId: string | null;
|
||||||
|
@Column({ type: 'jsonb', nullable: true }) changes: unknown | null;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('sites')
|
||||||
|
export class Site {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||||||
|
@Column('text') name: string;
|
||||||
|
@Column({ type: 'text', nullable: true }) address: string | null;
|
||||||
|
@Column({ type: 'text', nullable: true }) description: string | null;
|
||||||
|
@Column({ type: 'geometry', spatialFeatureType: 'MultiPolygon', srid: 4326, nullable: true })
|
||||||
|
perimeter: GeoJsonMultiPolygon | null;
|
||||||
|
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
||||||
|
center: GeoJsonPoint | null;
|
||||||
|
@Column({ type: 'text', default: 'Asia/Almaty' }) timezone: string;
|
||||||
|
@Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||||||
|
@Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('routes')
|
||||||
|
export class Route {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'site_id' }) siteId: string;
|
||||||
|
@Column('text') name: string;
|
||||||
|
@Column({ type: 'text', nullable: true }) description: string | null;
|
||||||
|
@Column({ type: 'geometry', spatialFeatureType: 'LineString', srid: 4326, nullable: true })
|
||||||
|
path: GeoJsonLineString | null;
|
||||||
|
@Column({ type: 'int', name: 'expected_minutes', nullable: true }) expectedMinutes: number | null;
|
||||||
|
@Column({ type: 'boolean', name: 'strict_order', default: false }) strictOrder: boolean;
|
||||||
|
@Column({ type: 'int', default: 1 }) version: number;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||||||
|
@Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('checkpoints')
|
||||||
|
export class Checkpoint {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'route_id' }) routeId: string;
|
||||||
|
@Column('int') position: number;
|
||||||
|
@Column('text') name: string;
|
||||||
|
@Column({ type: 'geometry', spatialFeatureType: 'Point', srid: 4326 }) location: GeoJsonPoint;
|
||||||
|
@Column({ type: 'int', name: 'radius_m', default: 30 }) radiusM: number;
|
||||||
|
@Column({ type: 'text', name: 'media_required', default: 'photo' }) mediaRequired: MediaRequirement;
|
||||||
|
@Column({ type: 'text', nullable: true }) instruction: string | null;
|
||||||
|
@Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('patrol_schedules')
|
||||||
|
export class PatrolSchedule {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'site_id' }) siteId: string;
|
||||||
|
@Column({ type: 'uuid', name: 'route_id' }) routeId: string;
|
||||||
|
@Column('text') name: string;
|
||||||
|
// Время старта 'HH:MM' в часовом поясе объекта
|
||||||
|
@Column({ type: 'text', name: 'start_times', array: true }) startTimes: string[];
|
||||||
|
@Column({ type: 'int', name: 'window_minutes', default: 60 }) windowMinutes: number;
|
||||||
|
@Column({ type: 'int', name: 'late_tolerance_minutes', default: 15 }) lateToleranceMinutes: number;
|
||||||
|
@Column({ type: 'date', name: 'valid_from' }) validFrom: string;
|
||||||
|
@Column({ type: 'date', name: 'valid_to', nullable: true }) validTo: string | null;
|
||||||
|
@Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('schedule_assignments')
|
||||||
|
@Unique(['scheduleId', 'userId', 'date'])
|
||||||
|
export class ScheduleAssignment {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'schedule_id' }) scheduleId: string;
|
||||||
|
@Column({ type: 'uuid', name: 'user_id' }) userId: string;
|
||||||
|
@Column({ type: 'date' }) date: string;
|
||||||
|
@Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RouteSnapshot = {
|
||||||
|
version: number;
|
||||||
|
name: string;
|
||||||
|
strictOrder: boolean;
|
||||||
|
path: GeoJsonLineString | null;
|
||||||
|
checkpoints: {
|
||||||
|
id: string;
|
||||||
|
position: number;
|
||||||
|
name: string;
|
||||||
|
location: GeoJsonPoint;
|
||||||
|
radiusM: number;
|
||||||
|
mediaRequired: MediaRequirement;
|
||||||
|
instruction: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
@Entity('rounds')
|
||||||
|
@Index(['siteId', 'plannedStart'])
|
||||||
|
export class Round {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'site_id' }) siteId: string;
|
||||||
|
@Column({ type: 'uuid', name: 'route_id' }) routeId: string;
|
||||||
|
@Column({ type: 'uuid', name: 'schedule_id', nullable: true }) scheduleId: string | null;
|
||||||
|
// Дата графика (в поясе объекта), по ней ищем дежурных
|
||||||
|
@Column({ type: 'date', name: 'duty_date', nullable: true }) dutyDate: string | null;
|
||||||
|
@Column({ type: 'uuid', name: 'performed_by', nullable: true }) performedBy: string | null;
|
||||||
|
@Column({ type: 'uuid', name: 'created_by', nullable: true }) createdBy: string | null;
|
||||||
|
@Column({ type: 'timestamptz', name: 'planned_start' }) plannedStart: Date;
|
||||||
|
@Column({ type: 'timestamptz', name: 'planned_end' }) plannedEnd: Date;
|
||||||
|
@Column({ type: 'int', name: 'late_tolerance_minutes', default: 15 }) lateToleranceMinutes: number;
|
||||||
|
@Column({ type: 'timestamptz', name: 'started_at', nullable: true }) startedAt: Date | null;
|
||||||
|
@Column({ type: 'timestamptz', name: 'finished_at', nullable: true }) finishedAt: Date | null;
|
||||||
|
@Column({ type: 'text', default: 'planned' }) status: RoundStatus;
|
||||||
|
@Column({ type: 'jsonb', name: 'route_snapshot' }) routeSnapshot: RouteSnapshot;
|
||||||
|
@Column({ type: 'int', name: 'checkpoints_total', default: 0 }) checkpointsTotal: number;
|
||||||
|
@Column({ type: 'int', name: 'checkpoints_done', default: 0 }) checkpointsDone: number;
|
||||||
|
@Column({ type: 'text', nullable: true }) comment: string | null;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('checkpoint_visits')
|
||||||
|
export class CheckpointVisit {
|
||||||
|
// id генерирует телефон: повтор из офлайн-очереди не создаёт дубль
|
||||||
|
@PrimaryColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'round_id' }) roundId: string;
|
||||||
|
@Column({ type: 'uuid', name: 'checkpoint_id', nullable: true }) checkpointId: string | null;
|
||||||
|
@Column({ type: 'uuid', name: 'user_id' }) userId: string;
|
||||||
|
@Column({ type: 'geometry', name: 'marked_location', spatialFeatureType: 'Point', srid: 4326 })
|
||||||
|
markedLocation: GeoJsonPoint;
|
||||||
|
@Column({ type: 'geometry', name: 'device_location', spatialFeatureType: 'Point', srid: 4326, nullable: true })
|
||||||
|
deviceLocation: GeoJsonPoint | null;
|
||||||
|
@Column({ type: 'real', name: 'device_accuracy_m', nullable: true }) deviceAccuracyM: number | null;
|
||||||
|
@Column({ type: 'real', name: 'distance_to_checkpoint_m', nullable: true }) distanceToCheckpointM: number | null;
|
||||||
|
@Column({ type: 'real', name: 'gps_distance_m', nullable: true }) gpsDistanceM: number | null;
|
||||||
|
@Column({ type: 'text', array: true, default: () => "'{}'" }) flags: string[];
|
||||||
|
@Column({ type: 'text', nullable: true }) note: string | null;
|
||||||
|
@Column({ type: 'boolean', name: 'is_issue', default: false }) isIssue: boolean;
|
||||||
|
@Column({ type: 'timestamptz', name: 'client_created_at' }) clientCreatedAt: Date;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('media')
|
||||||
|
export class Media {
|
||||||
|
// id тоже генерирует телефон
|
||||||
|
@PrimaryColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||||||
|
@Column({ type: 'uuid', name: 'uploaded_by' }) uploadedBy: string;
|
||||||
|
@Column({ type: 'uuid', name: 'visit_id', nullable: true }) visitId: string | null;
|
||||||
|
@Column('text') kind: 'photo' | 'video';
|
||||||
|
@Column('text') mime: string;
|
||||||
|
@Column({ type: 'bigint', name: 'size_bytes' }) sizeBytes: string;
|
||||||
|
@Column({ type: 'text', nullable: true }) filename: string | null;
|
||||||
|
@Column({ type: 'text', name: 'storage_key' }) storageKey: string;
|
||||||
|
@Column({ type: 'text', name: 'upload_id', nullable: true }) uploadId: string | null;
|
||||||
|
@Column({ type: 'jsonb', default: () => "'[]'" }) parts: { partNumber: number; etag: string; size: number }[];
|
||||||
|
@Column({ type: 'text', default: 'uploading' }) status: 'uploading' | 'ready';
|
||||||
|
@Column({ type: 'timestamptz', name: 'captured_at', nullable: true }) capturedAt: Date | null;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Entity('webhooks')
|
||||||
|
export class Webhook {
|
||||||
|
@PrimaryGeneratedColumn('uuid') id: string;
|
||||||
|
@Column({ type: 'uuid', name: 'organization_id' }) organizationId: string;
|
||||||
|
@Column('text') url: string;
|
||||||
|
@Column({ type: 'text', array: true }) events: string[];
|
||||||
|
@Column('text') secret: string;
|
||||||
|
@Column({ type: 'boolean', name: 'is_active', default: true }) isActive: boolean;
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENTITIES = [
|
||||||
|
Organization,
|
||||||
|
Role,
|
||||||
|
User,
|
||||||
|
UserSite,
|
||||||
|
UserSession,
|
||||||
|
AuditLog,
|
||||||
|
Site,
|
||||||
|
Route,
|
||||||
|
Checkpoint,
|
||||||
|
PatrolSchedule,
|
||||||
|
ScheduleAssignment,
|
||||||
|
Round,
|
||||||
|
CheckpointVisit,
|
||||||
|
Media,
|
||||||
|
Webhook,
|
||||||
|
];
|
||||||
16
apps/api/src/health.controller.ts
Normal file
16
apps/api/src/health.controller.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { Controller, Get } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { Public } from './common/auth';
|
||||||
|
|
||||||
|
@Controller('health')
|
||||||
|
export class HealthController {
|
||||||
|
constructor(@InjectDataSource() private readonly db: DataSource) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get()
|
||||||
|
async health() {
|
||||||
|
await this.db.query('SELECT 1');
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
25
apps/api/src/main.ts
Normal file
25
apps/api/src/main.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import cookieParser from 'cookie-parser';
|
||||||
|
import express from 'express';
|
||||||
|
import { UPLOAD_PART_SIZE } from '@patrol/shared';
|
||||||
|
import { AppModule } from './app.module';
|
||||||
|
import { config } from './config';
|
||||||
|
|
||||||
|
async function bootstrap() {
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, { bodyParser: false });
|
||||||
|
app.setGlobalPrefix('api');
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
app.use(cookieParser());
|
||||||
|
// Куски файлов приходят сырыми байтами, всё остальное — JSON или форма.
|
||||||
|
app.use('/api/media', express.raw({ type: 'application/octet-stream', limit: UPLOAD_PART_SIZE + 1024 }));
|
||||||
|
app.use(express.json({ limit: '2mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
|
||||||
|
app.enableShutdownHooks();
|
||||||
|
await app.listen(config.port, '0.0.0.0');
|
||||||
|
Logger.log(`API слушает порт ${config.port}`, 'Bootstrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap();
|
||||||
108
apps/api/src/modules/auth/auth.controller.ts
Normal file
108
apps/api/src/modules/auth/auth.controller.ts
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, Param, Post, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { AuthUser, CurrentUser, Public } from '../../common/auth';
|
||||||
|
import { obj, reqStr, str } from '../../common/input';
|
||||||
|
import { OrganizationsService, parseRegisterInput } from '../organizations/organizations.service';
|
||||||
|
import { AuthService } from './auth.service';
|
||||||
|
|
||||||
|
const COOKIE = 'patrol_rt';
|
||||||
|
|
||||||
|
function setRefreshCookie(res: Response, token: string) {
|
||||||
|
res.cookie(COOKIE, token, {
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: 'lax',
|
||||||
|
secure: config.cookieSecure,
|
||||||
|
path: '/api/auth',
|
||||||
|
maxAge: config.refreshTtlDays * 864e5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Controller('auth')
|
||||||
|
export class AuthController {
|
||||||
|
constructor(
|
||||||
|
private readonly auth: AuthService,
|
||||||
|
private readonly orgs: OrganizationsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('login')
|
||||||
|
@HttpCode(200)
|
||||||
|
async login(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const o = obj(body);
|
||||||
|
const tokens = await this.auth.login(reqStr(o, 'login'), reqStr(o, 'password'), req.headers['user-agent']);
|
||||||
|
setRefreshCookie(res, tokens.refreshToken);
|
||||||
|
return { accessToken: tokens.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Что включено: вход через Aspans ID, адрес Aspans ID для проверки центральной сессии.
|
||||||
|
@Public()
|
||||||
|
@Get('config')
|
||||||
|
publicConfig() {
|
||||||
|
return this.auth.publicConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// SSO Aspans ID: начало — адрес авторизации и PKCE (state и verifier фронт держит у себя до возврата).
|
||||||
|
@Public()
|
||||||
|
@Post('sso/start')
|
||||||
|
@HttpCode(200)
|
||||||
|
ssoStart(@Body() body: unknown, @Req() req: Request) {
|
||||||
|
const o = obj(body ?? {});
|
||||||
|
const prompt = str(o, 'prompt', { max: 20 });
|
||||||
|
const origin = str(o, 'origin', { max: 300 }) ?? (req.headers.origin as string | undefined);
|
||||||
|
return this.auth.ssoStart(origin, prompt && ['none', 'login', 'consent', 'select_account'].includes(prompt) ? prompt : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('sso/callback')
|
||||||
|
@HttpCode(200)
|
||||||
|
async ssoCallback(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const o = obj(body);
|
||||||
|
const tokens = await this.auth.ssoCallback(
|
||||||
|
reqStr(o, 'code', { max: 2000 }),
|
||||||
|
reqStr(o, 'codeVerifier', { max: 300 }),
|
||||||
|
reqStr(o, 'redirectUri', { max: 500 }),
|
||||||
|
req.headers['user-agent'],
|
||||||
|
);
|
||||||
|
setRefreshCookie(res, tokens.refreshToken);
|
||||||
|
return { accessToken: tokens.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('refresh')
|
||||||
|
@HttpCode(200)
|
||||||
|
async refresh(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const tokens = await this.auth.refresh(req.cookies?.[COOKIE], req.headers['user-agent']);
|
||||||
|
setRefreshCookie(res, tokens.refreshToken);
|
||||||
|
return { accessToken: tokens.accessToken };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('logout')
|
||||||
|
@HttpCode(204)
|
||||||
|
async logout(@Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
await this.auth.logout(req.cookies?.[COOKIE]);
|
||||||
|
res.clearCookie(COOKIE, { path: '/api/auth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
me(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.auth.me(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Компания регистрируется сама: БИН → реквизиты из учет.kz → организация и первый администратор.
|
||||||
|
@Public()
|
||||||
|
@Get('company/:bin')
|
||||||
|
lookup(@Param('bin') bin: string) {
|
||||||
|
return this.orgs.lookup(bin.replace(/\s/g, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Post('register')
|
||||||
|
async register(@Body() body: unknown, @Req() req: Request, @Res({ passthrough: true }) res: Response) {
|
||||||
|
const { adminId, organization } = await this.orgs.register(parseRegisterInput(body, { allowExisting: false }), { trusted: false });
|
||||||
|
const tokens = await this.auth.issue(adminId, req.headers['user-agent']);
|
||||||
|
setRefreshCookie(res, tokens.refreshToken);
|
||||||
|
return { accessToken: tokens.accessToken, organization: { id: organization.id, name: organization.name } };
|
||||||
|
}
|
||||||
|
}
|
||||||
159
apps/api/src/modules/auth/auth.service.ts
Normal file
159
apps/api/src/modules/auth/auth.service.ts
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
import { ForbiddenException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import { IsNull, Repository } from 'typeorm';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { Organization, User, UserSession } from '../../entities';
|
||||||
|
import { accessProblem, type AccessTokenPayload, type AuthUser } from '../../common/auth';
|
||||||
|
import { AspansIdClient } from '../passport/aspans-id.client';
|
||||||
|
import { PassportClient, PassportPerson } from '../passport/passport.client';
|
||||||
|
import { composeFullName } from '../users-sync/profile';
|
||||||
|
|
||||||
|
function sha256(v: string): string {
|
||||||
|
return createHash('sha256').update(v).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход только через паспорт: по логину и паролю (паспорт проверяет пароль и привязку к patrol)
|
||||||
|
* или через Aspans ID (SSO). Здесь — проверка, что человек заведён в patrol, и свои токены сессии.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AuthService {
|
||||||
|
private readonly logger = new Logger(AuthService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
private readonly passport: PassportClient,
|
||||||
|
private readonly aspansId: AspansIdClient,
|
||||||
|
@InjectRepository(User) private readonly users: Repository<User>,
|
||||||
|
@InjectRepository(UserSession) private readonly sessions: Repository<UserSession>,
|
||||||
|
@InjectRepository(Organization) private readonly orgs: Repository<Organization>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
publicConfig() {
|
||||||
|
return {
|
||||||
|
passport: { enabled: this.passport.enabled },
|
||||||
|
aspansId: { enabled: this.aspansId.enabled, baseUrl: this.aspansId.baseUrl || null },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(login: string, password: string, userAgent?: string) {
|
||||||
|
const person = await this.passport.login(login.trim(), password);
|
||||||
|
const user = await this.resolve(person.uuid, person.username, person);
|
||||||
|
return this.issue(user.id, userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
ssoStart(origin: string | undefined, prompt?: string) {
|
||||||
|
return this.aspansId.start(origin, prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ssoCallback(code: string, codeVerifier: string, redirectUri: string, userAgent?: string) {
|
||||||
|
const info = await this.aspansId.exchange(code, codeVerifier, redirectUri);
|
||||||
|
const user = await this.resolve(info.uuid, info.username, null);
|
||||||
|
return this.issue(user.id, userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Человек паспорта → пользователь patrol. Не заведён здесь — не пускаем (кроме владельцев
|
||||||
|
* платформы из PLATFORM_ADMIN_USERNAMES: их запись создаётся при первом входе).
|
||||||
|
*/
|
||||||
|
private async resolve(uuid: string, username: string | null, person: PassportPerson | null) {
|
||||||
|
const isOwner = !!username && config.platformAdminUsernames.includes(username.toLowerCase());
|
||||||
|
let user = await this.users.findOneBy({ passportUuid: uuid });
|
||||||
|
|
||||||
|
if (!user && isOwner) {
|
||||||
|
const p = person ?? (await this.passport.getUser(uuid).catch(() => null));
|
||||||
|
user = await this.users.save({
|
||||||
|
passportUuid: uuid,
|
||||||
|
login: username,
|
||||||
|
firstName: p?.firstName ?? null,
|
||||||
|
lastName: p?.lastName ?? null,
|
||||||
|
middleName: p?.middleName ?? null,
|
||||||
|
email: p?.email ?? null,
|
||||||
|
phone: p?.phone ?? null,
|
||||||
|
fullName: composeFullName(p ?? {}, 'Владелец платформы'),
|
||||||
|
isPlatformAdmin: true,
|
||||||
|
organizationId: null,
|
||||||
|
});
|
||||||
|
this.logger.log(`Владелец платформы ${username} вошёл впервые — создана запись`);
|
||||||
|
}
|
||||||
|
if (!user) {
|
||||||
|
throw new ForbiddenException('Вы не подключены к «Обходам». Обратитесь к администратору своей организации');
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Partial<User> = { lastLoginAt: new Date() };
|
||||||
|
if (isOwner && !user.isPlatformAdmin) patch.isPlatformAdmin = true;
|
||||||
|
// Паспорт только что отдал свежий профиль — подтягиваем, даже если шина выключена.
|
||||||
|
if (person) {
|
||||||
|
Object.assign(patch, {
|
||||||
|
login: person.username,
|
||||||
|
firstName: person.firstName,
|
||||||
|
lastName: person.lastName,
|
||||||
|
middleName: person.middleName,
|
||||||
|
email: person.email,
|
||||||
|
phone: person.phone,
|
||||||
|
passportEnabled: person.isEnabled,
|
||||||
|
});
|
||||||
|
patch.fullName = composeFullName({ ...user, ...patch }, user.fullName);
|
||||||
|
}
|
||||||
|
const merged = { ...user, ...patch } as User;
|
||||||
|
const org = merged.organizationId ? await this.orgs.findOneBy({ id: merged.organizationId }) : null;
|
||||||
|
const problem = accessProblem(merged, org);
|
||||||
|
if (problem) throw new ForbiddenException(problem);
|
||||||
|
await this.users.update({ id: user.id }, patch);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
async issue(userId: string, userAgent?: string) {
|
||||||
|
const refresh = randomBytes(32).toString('base64url');
|
||||||
|
await this.sessions.save({
|
||||||
|
userId,
|
||||||
|
refreshHash: sha256(refresh),
|
||||||
|
userAgent: userAgent?.slice(0, 300) ?? null,
|
||||||
|
expiresAt: new Date(Date.now() + config.refreshTtlDays * 864e5),
|
||||||
|
});
|
||||||
|
return { accessToken: await this.accessToken(userId), refreshToken: refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken(userId: string) {
|
||||||
|
const payload: AccessTokenPayload = { sub: userId, typ: 'access' };
|
||||||
|
return this.jwt.signAsync(payload, { expiresIn: config.accessTtlSeconds });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ротация: старый refresh гасим, выдаём новый.
|
||||||
|
async refresh(refresh: string | undefined, userAgent?: string) {
|
||||||
|
if (!refresh) throw new UnauthorizedException('Нужно войти');
|
||||||
|
const session = await this.sessions.findOneBy({ refreshHash: sha256(refresh), revokedAt: IsNull() });
|
||||||
|
if (!session || session.expiresAt < new Date()) throw new UnauthorizedException('Сессия закончилась');
|
||||||
|
const user = await this.users.findOneBy({ id: session.userId });
|
||||||
|
if (!user) throw new UnauthorizedException('Пользователь не найден');
|
||||||
|
const org = user.organizationId ? await this.orgs.findOneBy({ id: user.organizationId }) : null;
|
||||||
|
const problem = accessProblem(user, org);
|
||||||
|
if (problem) throw new UnauthorizedException(problem);
|
||||||
|
await this.sessions.update({ id: session.id }, { revokedAt: new Date() });
|
||||||
|
return this.issue(user.id, userAgent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async logout(refresh: string | undefined) {
|
||||||
|
if (refresh) await this.sessions.update({ refreshHash: sha256(refresh) }, { revokedAt: new Date() });
|
||||||
|
}
|
||||||
|
|
||||||
|
async me(user: AuthUser) {
|
||||||
|
const org = user.organizationId ? await this.orgs.findOneBy({ id: user.organizationId }) : null;
|
||||||
|
const full = await this.users.findOneByOrFail({ id: user.id });
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
passportUuid: full.passportUuid,
|
||||||
|
login: user.login,
|
||||||
|
fullName: user.fullName,
|
||||||
|
email: full.email,
|
||||||
|
phone: full.phone,
|
||||||
|
isPlatformAdmin: user.isPlatformAdmin,
|
||||||
|
organization: org && { id: org.id, name: org.name, bin: org.bin },
|
||||||
|
role: user.roleId ? { id: user.roleId, name: user.roleName } : null,
|
||||||
|
allSites: user.allSites,
|
||||||
|
permissions: [...user.permissions],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/modules/geocode/geocode.controller.ts
Normal file
14
apps/api/src/modules/geocode/geocode.controller.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Controller, Get, Query } from '@nestjs/common';
|
||||||
|
import { RequirePermissions } from '../../common/auth';
|
||||||
|
import { GeocodeService } from './geocode.service';
|
||||||
|
|
||||||
|
@Controller('geocode')
|
||||||
|
export class GeocodeController {
|
||||||
|
constructor(private readonly geocode: GeocodeService) {}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.view')
|
||||||
|
@Get()
|
||||||
|
search(@Query('q') q?: string) {
|
||||||
|
return this.geocode.search(q ?? '');
|
||||||
|
}
|
||||||
|
}
|
||||||
95
apps/api/src/modules/geocode/geocode.service.ts
Normal file
95
apps/api/src/modules/geocode/geocode.service.ts
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import { BadGatewayException, BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type GeocodeHit = {
|
||||||
|
label: string;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
/** [south, west, north, east] */
|
||||||
|
bbox: [number, number, number, number] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NominatimRow = { display_name?: string; lat?: string; lon?: string; boundingbox?: string[] };
|
||||||
|
|
||||||
|
// Поиск адреса на карте — публичный Nominatim (OpenStreetMap), те же данные, что у подложки.
|
||||||
|
// Правила Nominatim: не чаще 1 запроса в секунду, свой User-Agent, кэш результатов.
|
||||||
|
// Поэтому браузер ходит не туда напрямую, а сюда: запросы всех пользователей идут по очереди.
|
||||||
|
@Injectable()
|
||||||
|
export class GeocodeService {
|
||||||
|
private readonly logger = new Logger(GeocodeService.name);
|
||||||
|
private readonly url = (process.env.NOMINATIM_URL || 'https://nominatim.openstreetmap.org').replace(/\/+$/, '');
|
||||||
|
private readonly countries = process.env.NOMINATIM_COUNTRIES ?? 'kz';
|
||||||
|
private readonly userAgent = `Aspans-Patrol/1.0${process.env.NOMINATIM_EMAIL ? ` (${process.env.NOMINATIM_EMAIL})` : ''}`;
|
||||||
|
private readonly cache = new Map<string, { at: number; hits: GeocodeHit[] }>();
|
||||||
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
private lastAt = 0;
|
||||||
|
|
||||||
|
get enabled() {
|
||||||
|
return process.env.GEOCODE_DISABLED !== 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async search(raw: string): Promise<GeocodeHit[]> {
|
||||||
|
const q = raw.trim().replace(/\s+/g, ' ');
|
||||||
|
if (q.length < 3) throw new BadRequestException('Введите хотя бы 3 символа');
|
||||||
|
if (q.length > 200) throw new BadRequestException('Слишком длинный запрос');
|
||||||
|
if (!this.enabled) return [];
|
||||||
|
|
||||||
|
const key = q.toLowerCase();
|
||||||
|
const hit = this.cache.get(key);
|
||||||
|
if (hit && Date.now() - hit.at < 24 * 3600_000) return hit.hits;
|
||||||
|
|
||||||
|
const hits = await this.throttled(() => this.fetch(q));
|
||||||
|
if (this.cache.size > 1000) this.cache.delete(this.cache.keys().next().value!);
|
||||||
|
this.cache.set(key, { at: Date.now(), hits });
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запросы строго друг за другом, между ними не меньше секунды.
|
||||||
|
private throttled<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const run = this.queue.then(async () => {
|
||||||
|
const wait = this.lastAt + 1100 - Date.now();
|
||||||
|
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
this.lastAt = Date.now();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.queue = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetch(q: string): Promise<GeocodeHit[]> {
|
||||||
|
const url = new URL(`${this.url}/search`);
|
||||||
|
url.searchParams.set('q', q);
|
||||||
|
url.searchParams.set('format', 'jsonv2');
|
||||||
|
url.searchParams.set('limit', '6');
|
||||||
|
url.searchParams.set('accept-language', 'ru');
|
||||||
|
if (this.countries) url.searchParams.set('countrycodes', this.countries);
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(url, { headers: { 'User-Agent': this.userAgent, Accept: 'application/json' }, signal: AbortSignal.timeout(10000) });
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`Nominatim недоступен: ${(e as Error).message}`);
|
||||||
|
throw new BadGatewayException('Поиск адреса сейчас недоступен');
|
||||||
|
}
|
||||||
|
const body = (await res.json().catch(() => null)) as NominatimRow[] | null;
|
||||||
|
if (!res.ok || !Array.isArray(body)) {
|
||||||
|
this.logger.warn(`Nominatim ответил ${res.status}`);
|
||||||
|
throw new BadGatewayException('Поиск адреса сейчас недоступен');
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
.map((r): GeocodeHit | null => {
|
||||||
|
const lat = Number(r.lat);
|
||||||
|
const lng = Number(r.lon);
|
||||||
|
if (!r.display_name || !Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||||
|
const b = r.boundingbox?.map(Number);
|
||||||
|
return {
|
||||||
|
label: r.display_name,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
bbox: b && b.length === 4 && b.every(Number.isFinite) ? [b[0], b[2], b[1], b[3]] : null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((h): h is GeocodeHit => !!h);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
apps/api/src/modules/media/media.controller.ts
Normal file
57
apps/api/src/modules/media/media.controller.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { Body, Controller, Get, Headers, Param, ParseIntPipe, ParseUUIDPipe, Post, Put, Query, Req, Res } from '@nestjs/common';
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import { AuthUser, CurrentUser, Public, RequirePermissions } from '../../common/auth';
|
||||||
|
import { MediaService } from './media.service';
|
||||||
|
|
||||||
|
@Controller('media')
|
||||||
|
export class MediaController {
|
||||||
|
constructor(private readonly media: MediaService) {}
|
||||||
|
|
||||||
|
@RequirePermissions('rounds.perform')
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.media.create(user, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('rounds.perform')
|
||||||
|
@Get(':id')
|
||||||
|
status(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.media.status(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тело — сырые байты куска (application/octet-stream), см. main.ts.
|
||||||
|
@RequirePermissions('rounds.perform')
|
||||||
|
@Put(':id/parts/:n')
|
||||||
|
part(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Param('n', ParseIntPipe) n: number,
|
||||||
|
@Req() req: Request,
|
||||||
|
) {
|
||||||
|
return this.media.part(user, id, n, req.body as Buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('rounds.perform')
|
||||||
|
@Post(':id/complete')
|
||||||
|
complete(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.media.complete(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Public()
|
||||||
|
@Get(':id/content')
|
||||||
|
async content(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Query('t') token: string,
|
||||||
|
@Headers('range') range: string | undefined,
|
||||||
|
@Res() res: Response,
|
||||||
|
) {
|
||||||
|
const { media, file } = await this.media.open(id, token, range);
|
||||||
|
res.status(file.partial ? 206 : 200);
|
||||||
|
res.setHeader('Content-Type', file.contentType || media.mime);
|
||||||
|
res.setHeader('Accept-Ranges', 'bytes');
|
||||||
|
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||||
|
if (file.contentLength !== undefined) res.setHeader('Content-Length', String(file.contentLength));
|
||||||
|
if (file.contentRange) res.setHeader('Content-Range', file.contentRange);
|
||||||
|
file.body.pipe(res);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
apps/api/src/modules/media/media.service.ts
Normal file
141
apps/api/src/modules/media/media.service.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { JwtService } from '@nestjs/jwt';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { UPLOAD_PART_SIZE } from '@patrol/shared';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { Media } from '../../entities';
|
||||||
|
import { AuthUser, requireOrg } from '../../common/auth';
|
||||||
|
import { obj, oneOf, reqStr, str, timestamp, uuid } from '../../common/input';
|
||||||
|
import { StorageService } from './storage.service';
|
||||||
|
|
||||||
|
const EXT: Record<string, string> = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/heic': 'heic',
|
||||||
|
'image/heif': 'heif',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/quicktime': 'mov',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
'video/3gpp': '3gp',
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaToken = { mid: string; typ: 'media' };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MediaService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(Media) private readonly media: Repository<Media>,
|
||||||
|
private readonly storage: StorageService,
|
||||||
|
private readonly jwt: JwtService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private view(m: Media) {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
kind: m.kind,
|
||||||
|
status: m.status,
|
||||||
|
sizeBytes: Number(m.sizeBytes),
|
||||||
|
partSize: UPLOAD_PART_SIZE,
|
||||||
|
uploadedParts: m.parts.map((p) => p.partNumber),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Начать загрузку. id приходит с телефона: повторный вызов из офлайн-очереди возвращает ту же загрузку.
|
||||||
|
async create(user: AuthUser, body: unknown) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const o = obj(body);
|
||||||
|
const id = uuid(o, 'id', { required: true })!;
|
||||||
|
const existing = await this.media.findOneBy({ id });
|
||||||
|
if (existing) {
|
||||||
|
if (existing.uploadedBy !== user.id) throw new ConflictException('Файл с таким id уже есть');
|
||||||
|
return this.view(existing);
|
||||||
|
}
|
||||||
|
const kind = oneOf(o, 'kind', ['photo', 'video'] as const, { required: true })!;
|
||||||
|
const mime = reqStr(o, 'mime', { max: 100 }).toLowerCase();
|
||||||
|
if (!mime.startsWith(kind === 'photo' ? 'image/' : 'video/')) throw new BadRequestException('Тип файла не совпадает с kind');
|
||||||
|
const size = Number(o.size);
|
||||||
|
if (!Number.isFinite(size) || size <= 0) throw new BadRequestException('Поле «size» обязательно');
|
||||||
|
const key = `org/${orgId}/${new Date().toISOString().slice(0, 7)}/${id}.${EXT[mime] || mime.split('/')[1] || 'bin'}`;
|
||||||
|
const uploadId = await this.storage.startUpload(key, mime);
|
||||||
|
const saved = await this.media.save({
|
||||||
|
id,
|
||||||
|
organizationId: orgId,
|
||||||
|
uploadedBy: user.id,
|
||||||
|
kind,
|
||||||
|
mime,
|
||||||
|
sizeBytes: String(size),
|
||||||
|
filename: str(o, 'filename', { max: 300 }) ?? null,
|
||||||
|
storageKey: key,
|
||||||
|
uploadId,
|
||||||
|
parts: [],
|
||||||
|
status: 'uploading',
|
||||||
|
capturedAt: timestamp(o, 'capturedAt') ?? null,
|
||||||
|
});
|
||||||
|
return this.view(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async own(user: AuthUser, id: string) {
|
||||||
|
const m = await this.media.findOneBy({ id });
|
||||||
|
if (!m || m.organizationId !== user.organizationId) throw new NotFoundException('Файл не найден');
|
||||||
|
if (m.uploadedBy !== user.id) throw new ForbiddenException('Это не ваш файл');
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
async status(user: AuthUser, id: string) {
|
||||||
|
return this.view(await this.own(user, id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async part(user: AuthUser, id: string, partNumber: number, body: Buffer) {
|
||||||
|
const m = await this.own(user, id);
|
||||||
|
if (m.status === 'ready') return this.view(m);
|
||||||
|
const total = Math.max(1, Math.ceil(Number(m.sizeBytes) / UPLOAD_PART_SIZE));
|
||||||
|
if (!Number.isInteger(partNumber) || partNumber < 1 || partNumber > total) throw new BadRequestException('Неверный номер куска');
|
||||||
|
if (!Buffer.isBuffer(body) || !body.length) throw new BadRequestException('Пустой кусок файла');
|
||||||
|
if (body.length > UPLOAD_PART_SIZE) throw new BadRequestException('Кусок больше допустимого');
|
||||||
|
const etag = await this.storage.uploadPart(m.storageKey, m.uploadId!, partNumber, body);
|
||||||
|
// jsonb_set по одному куску, чтобы параллельные запросы не затирали друг друга.
|
||||||
|
await this.media.query(
|
||||||
|
`UPDATE media SET parts = (
|
||||||
|
SELECT COALESCE(jsonb_agg(p ORDER BY (p->>'partNumber')::int), '[]'::jsonb)
|
||||||
|
FROM (SELECT p FROM jsonb_array_elements(parts) p WHERE (p->>'partNumber')::int <> $2
|
||||||
|
UNION ALL SELECT jsonb_build_object('partNumber', $2::int, 'etag', $3::text, 'size', $4::int)) t(p))
|
||||||
|
WHERE id = $1`,
|
||||||
|
[id, partNumber, etag, body.length],
|
||||||
|
);
|
||||||
|
return this.view(await this.media.findOneByOrFail({ id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(user: AuthUser, id: string) {
|
||||||
|
const m = await this.own(user, id);
|
||||||
|
if (m.status === 'ready') return this.view(m);
|
||||||
|
const total = Math.max(1, Math.ceil(Number(m.sizeBytes) / UPLOAD_PART_SIZE));
|
||||||
|
const have = new Set(m.parts.map((p) => p.partNumber));
|
||||||
|
const missing = Array.from({ length: total }, (_, i) => i + 1).filter((n) => !have.has(n));
|
||||||
|
if (missing.length) throw new BadRequestException(`Не загружены куски: ${missing.slice(0, 10).join(', ')}`);
|
||||||
|
await this.storage.completeUpload(m.storageKey, m.uploadId!, [...m.parts].sort((a, b) => a.partNumber - b.partNumber));
|
||||||
|
await this.media.update({ id }, { status: 'ready', uploadId: null });
|
||||||
|
return this.view(await this.media.findOneByOrFail({ id }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ссылка для <img>/<video>: браузер не отправит Bearer, поэтому выдаём короткоживущий токен на конкретный файл.
|
||||||
|
async url(id: string) {
|
||||||
|
const payload: MediaToken = { mid: id, typ: 'media' };
|
||||||
|
const t = await this.jwt.signAsync(payload, { expiresIn: config.mediaTokenTtlSeconds });
|
||||||
|
return `/api/media/${id}/content?t=${t}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async open(id: string, token: string | undefined, range?: string) {
|
||||||
|
let payload: MediaToken;
|
||||||
|
try {
|
||||||
|
payload = await this.jwt.verifyAsync<MediaToken>(token || '');
|
||||||
|
} catch {
|
||||||
|
throw new ForbiddenException('Ссылка на файл устарела — обновите страницу');
|
||||||
|
}
|
||||||
|
if (payload.typ !== 'media' || payload.mid !== id) throw new ForbiddenException('Неверная ссылка');
|
||||||
|
const m = await this.media.findOneBy({ id });
|
||||||
|
if (!m || m.status !== 'ready') throw new NotFoundException('Файл ещё загружается');
|
||||||
|
return { media: m, file: await this.storage.get(m.storageKey, range) };
|
||||||
|
}
|
||||||
|
}
|
||||||
99
apps/api/src/modules/media/storage.service.ts
Normal file
99
apps/api/src/modules/media/storage.service.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
AbortMultipartUploadCommand,
|
||||||
|
CompleteMultipartUploadCommand,
|
||||||
|
CreateBucketCommand,
|
||||||
|
CreateMultipartUploadCommand,
|
||||||
|
GetObjectCommand,
|
||||||
|
HeadBucketCommand,
|
||||||
|
S3Client,
|
||||||
|
UploadPartCommand,
|
||||||
|
} from '@aws-sdk/client-s3';
|
||||||
|
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
|
||||||
|
import type { Readable } from 'stream';
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
|
// Хранилище по S3 API (в compose — SeaweedFS). Файлы проходят через API кусками, поэтому браузеру не нужен прямой доступ к хранилищу.
|
||||||
|
@Injectable()
|
||||||
|
export class StorageService implements OnApplicationBootstrap {
|
||||||
|
private readonly logger = new Logger(StorageService.name);
|
||||||
|
readonly client = new S3Client({
|
||||||
|
endpoint: config.s3.endpoint,
|
||||||
|
region: config.s3.region,
|
||||||
|
forcePathStyle: true,
|
||||||
|
credentials: { accessKeyId: config.s3.accessKey, secretAccessKey: config.s3.secretKey },
|
||||||
|
});
|
||||||
|
private readonly bucket = config.s3.bucket;
|
||||||
|
private bucketReady: Promise<void> | null = null;
|
||||||
|
|
||||||
|
// Хранилище может подняться позже API — ждём его в фоне, старт API не блокируем.
|
||||||
|
onApplicationBootstrap() {
|
||||||
|
void this.ensureBucket().catch((e) => this.logger.error(`Хранилище недоступно: ${(e as Error).message}. Загрузка файлов пока не работает`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureBucket() {
|
||||||
|
if (!this.bucketReady) {
|
||||||
|
this.bucketReady = (async () => {
|
||||||
|
const deadline = Date.now() + 120_000;
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }));
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
const status = (e as { $metadata?: { httpStatusCode?: number } }).$metadata?.httpStatusCode;
|
||||||
|
if (status === 404) {
|
||||||
|
await this.client.send(new CreateBucketCommand({ Bucket: this.bucket }));
|
||||||
|
this.logger.log(`Создан бакет ${this.bucket}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) throw e;
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().catch((e) => {
|
||||||
|
// Следующая загрузка попробует снова.
|
||||||
|
this.bucketReady = null;
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.bucketReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startUpload(key: string, contentType: string) {
|
||||||
|
await this.ensureBucket();
|
||||||
|
const res = await this.client.send(new CreateMultipartUploadCommand({ Bucket: this.bucket, Key: key, ContentType: contentType }));
|
||||||
|
return res.UploadId!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadPart(key: string, uploadId: string, partNumber: number, body: Buffer) {
|
||||||
|
const res = await this.client.send(
|
||||||
|
new UploadPartCommand({ Bucket: this.bucket, Key: key, UploadId: uploadId, PartNumber: partNumber, Body: body }),
|
||||||
|
);
|
||||||
|
return res.ETag!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async completeUpload(key: string, uploadId: string, parts: { partNumber: number; etag: string }[]) {
|
||||||
|
await this.client.send(
|
||||||
|
new CompleteMultipartUploadCommand({
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Key: key,
|
||||||
|
UploadId: uploadId,
|
||||||
|
MultipartUpload: { Parts: parts.map((p) => ({ PartNumber: p.partNumber, ETag: p.etag })) },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async abortUpload(key: string, uploadId: string) {
|
||||||
|
await this.client.send(new AbortMultipartUploadCommand({ Bucket: this.bucket, Key: key, UploadId: uploadId })).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(key: string, range?: string) {
|
||||||
|
const res = await this.client.send(new GetObjectCommand({ Bucket: this.bucket, Key: key, Range: range }));
|
||||||
|
return {
|
||||||
|
body: res.Body as Readable,
|
||||||
|
contentLength: res.ContentLength,
|
||||||
|
contentRange: res.ContentRange,
|
||||||
|
contentType: res.ContentType,
|
||||||
|
partial: !!range && !!res.ContentRange,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
12
apps/api/src/modules/organizations/bin.ts
Normal file
12
apps/api/src/modules/organizations/bin.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
// Проверка контрольного разряда БИН/ИИН (12 цифр).
|
||||||
|
export function isValidBin(bin: string): boolean {
|
||||||
|
if (!/^\d{12}$/.test(bin)) return false;
|
||||||
|
const d = bin.split('').map(Number);
|
||||||
|
const sum = (weights: number[]) => weights.reduce((acc, w, i) => acc + w * d[i], 0) % 11;
|
||||||
|
let control = sum([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
|
||||||
|
if (control === 10) {
|
||||||
|
control = sum([3, 4, 5, 6, 7, 8, 9, 10, 11, 1, 2]);
|
||||||
|
if (control === 10) return false;
|
||||||
|
}
|
||||||
|
return control === d[11];
|
||||||
|
}
|
||||||
141
apps/api/src/modules/organizations/company-registry.ts
Normal file
141
apps/api/src/modules/organizations/company-registry.ts
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
export type CompanyInfo = {
|
||||||
|
name: string;
|
||||||
|
legalName: string | null;
|
||||||
|
legalAddress: string | null;
|
||||||
|
directorName: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
raw: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Публичный поиск pk.uchet.kz (без токена) — тот же, что в aspans_crm.
|
||||||
|
type PublicCompany = { name?: unknown; bin?: unknown; iin?: unknown; address?: unknown; head_fullname?: unknown; status?: unknown };
|
||||||
|
type PublicSearch = { results?: PublicCompany[] };
|
||||||
|
|
||||||
|
// Полная карточка get_bin_info (по токену из личного кабинета).
|
||||||
|
type FullInfo = {
|
||||||
|
bin?: unknown;
|
||||||
|
name_ru?: unknown;
|
||||||
|
address_ru?: unknown;
|
||||||
|
head_fullname?: unknown;
|
||||||
|
goszakup?: { full_name_ru?: unknown; phone?: unknown; email?: unknown } | null;
|
||||||
|
detail?: unknown;
|
||||||
|
error?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
// «Товарищество с ограниченной ответственностью "Щит"» → «ТОО "Щит"» — короткое название для интерфейса.
|
||||||
|
const LEGAL_FORMS: [RegExp, string][] = [
|
||||||
|
[/^Товарищество с ограниченной ответственностью\s+/i, 'ТОО '],
|
||||||
|
[/^Товарищество с дополнительной ответственностью\s+/i, 'ТДО '],
|
||||||
|
[/^Акционерное общество\s+/i, 'АО '],
|
||||||
|
[/^Общественное объединение\s+/i, 'ОО '],
|
||||||
|
[/^Производственный кооператив\s+/i, 'ПК '],
|
||||||
|
[/^Государственное учреждение\s+/i, 'ГУ '],
|
||||||
|
];
|
||||||
|
|
||||||
|
export function shortName(full: string) {
|
||||||
|
for (const [re, short] of LEGAL_FORMS) if (re.test(full)) return full.replace(re, short).trim();
|
||||||
|
return full.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ФИО в реестре заглавными: «БАРЫШЕВ МАКСИМ АНАТОЛЬЕВИЧ» → «Барышев Максим Анатольевич».
|
||||||
|
function titleCase(s: string) {
|
||||||
|
return s.toLowerCase().replace(/(^|[\s-])(\p{L})/gu, (_, sep: string, ch: string) => sep + ch.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||||
|
|
||||||
|
// Реквизиты по БИН из pk.uchet.kz.
|
||||||
|
// 1) Публичный поиск — работает без токена: название, адрес, руководитель.
|
||||||
|
// 2) Если задан UCHET_KZ_TOKEN — ещё полная карточка get_bin_info: плюс телефон и email; она важнее поиска.
|
||||||
|
@Injectable()
|
||||||
|
export class CompanyRegistry {
|
||||||
|
private readonly logger = new Logger(CompanyRegistry.name);
|
||||||
|
private readonly token = process.env.UCHET_KZ_TOKEN || '';
|
||||||
|
private readonly searchUrl = process.env.UCHET_KZ_SEARCH_URL || 'https://pk.uchet.kz/api/web/company/search/';
|
||||||
|
private readonly fullUrl = process.env.UCHET_KZ_API_URL || 'https://pk.uchet.kz/api/v2/get_bin_info/';
|
||||||
|
// Форма спрашивает БИН при вводе и ещё раз при регистрации — второй раз берём из кэша.
|
||||||
|
private readonly cache = new Map<string, { at: number; info: CompanyInfo | null }>();
|
||||||
|
|
||||||
|
get enabled(): boolean {
|
||||||
|
return process.env.UCHET_KZ_DISABLED !== 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookup(bin: string): Promise<CompanyInfo | null> {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
const hit = this.cache.get(bin);
|
||||||
|
if (hit && Date.now() - hit.at < 10 * 60_000) return hit.info;
|
||||||
|
|
||||||
|
const [found, full] = await Promise.all([this.searchPublic(bin), this.token ? this.getFull(bin) : Promise.resolve(null)]);
|
||||||
|
const info = full ?? found;
|
||||||
|
// Сетевую ошибку не кэшируем — следующий запрос попробует снова. «Не найдено» кэшируем.
|
||||||
|
if (info || found !== undefined || full !== undefined) this.cache.set(bin, { at: Date.now(), info: info ?? null });
|
||||||
|
return info ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// undefined — запрос не удался, null — компании нет.
|
||||||
|
private async searchPublic(bin: string): Promise<CompanyInfo | null | undefined> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.searchUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Accept: 'application/json', 'Accept-Language': 'ru', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ page: '1', size: 10, value: bin }),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
const body = (await res.json().catch(() => null)) as PublicSearch | null;
|
||||||
|
if (!res.ok || !body) {
|
||||||
|
this.logger.warn(`учет.kz (поиск) ответил ${res.status} по БИН ${bin}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const rows = Array.isArray(body.results) ? body.results : [];
|
||||||
|
const row = rows.find((r) => text(r.bin) === bin || text(r.iin) === bin);
|
||||||
|
const name = row && text(row.name);
|
||||||
|
if (!row || !name) return null;
|
||||||
|
const director = text(row.head_fullname);
|
||||||
|
return {
|
||||||
|
name: shortName(name),
|
||||||
|
legalName: name,
|
||||||
|
legalAddress: text(row.address),
|
||||||
|
directorName: director && titleCase(director),
|
||||||
|
phone: null,
|
||||||
|
email: null,
|
||||||
|
raw: row,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`учет.kz (поиск) недоступен по БИН ${bin}: ${(e as Error).message}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getFull(bin: string): Promise<CompanyInfo | null | undefined> {
|
||||||
|
const url = new URL(this.fullUrl);
|
||||||
|
url.searchParams.set('token', this.token);
|
||||||
|
url.searchParams.set('bin', bin);
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'application/json' }, signal: AbortSignal.timeout(10000) });
|
||||||
|
const body = (await res.json().catch(() => null)) as FullInfo | null;
|
||||||
|
if (!res.ok || !body || body.detail || body.error) {
|
||||||
|
// Токен в лог не пишем — только код ответа и ошибку сервиса.
|
||||||
|
this.logger.warn(`учет.kz (карточка) ответил ${res.status} по БИН ${bin}: ${JSON.stringify(body?.error ?? body?.detail ?? null)}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const legalName = text(body.name_ru) || text(body.goszakup?.full_name_ru);
|
||||||
|
if (!legalName) return null;
|
||||||
|
const director = text(body.head_fullname);
|
||||||
|
return {
|
||||||
|
name: shortName(legalName),
|
||||||
|
legalName,
|
||||||
|
legalAddress: text(body.address_ru),
|
||||||
|
directorName: director && titleCase(director),
|
||||||
|
phone: text(body.goszakup?.phone),
|
||||||
|
email: text(body.goszakup?.email),
|
||||||
|
raw: body,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.warn(`учет.kz (карточка) недоступна по БИН ${bin}: ${(e as Error).message}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
apps/api/src/modules/organizations/organizations.service.ts
Normal file
126
apps/api/src/modules/organizations/organizations.service.ts
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { ROLE_TEMPLATES } from '@patrol/shared';
|
||||||
|
import { Organization, Role } from '../../entities';
|
||||||
|
import { parsePerson, PeopleService, PersonInput } from '../users/people.service';
|
||||||
|
import { bool, obj, reqStr, str } from '../../common/input';
|
||||||
|
import { isValidBin } from './bin';
|
||||||
|
import { CompanyRegistry } from './company-registry';
|
||||||
|
|
||||||
|
export type RegisterInput = {
|
||||||
|
bin: string;
|
||||||
|
name: string;
|
||||||
|
legalName?: string;
|
||||||
|
legalAddress?: string;
|
||||||
|
directorName?: string;
|
||||||
|
phone?: string;
|
||||||
|
email?: string;
|
||||||
|
admin: PersonInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
// allowExisting — выбрать администратора из людей паспорта (только владелец платформы).
|
||||||
|
export function parseRegisterInput(body: unknown, opts: { allowExisting: boolean }): RegisterInput {
|
||||||
|
const o = obj(body);
|
||||||
|
const bin = reqStr(o, 'bin').replace(/\s/g, '');
|
||||||
|
if (!isValidBin(bin)) throw new BadRequestException('БИН должен состоять из 12 цифр с верным контрольным разрядом');
|
||||||
|
return {
|
||||||
|
bin,
|
||||||
|
name: str(o, 'name', { max: 300 }) ?? '',
|
||||||
|
legalName: str(o, 'legalName', { max: 500 }),
|
||||||
|
legalAddress: str(o, 'legalAddress', { max: 1000 }),
|
||||||
|
directorName: str(o, 'directorName', { max: 300 }),
|
||||||
|
phone: str(o, 'phone', { max: 50 }),
|
||||||
|
email: str(o, 'email', { max: 200 }),
|
||||||
|
admin: parsePerson(obj(o.admin, 'admin'), opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class OrganizationsService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly db: DataSource,
|
||||||
|
private readonly registry: CompanyRegistry,
|
||||||
|
private readonly people: PeopleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async lookup(bin: string) {
|
||||||
|
if (!isValidBin(bin)) throw new BadRequestException('Неверный БИН');
|
||||||
|
const exists = await this.db.getRepository(Organization).exist({ where: { bin } });
|
||||||
|
const info = await this.registry.lookup(bin);
|
||||||
|
return {
|
||||||
|
bin,
|
||||||
|
alreadyRegistered: exists,
|
||||||
|
registryEnabled: this.registry.enabled,
|
||||||
|
company: info && {
|
||||||
|
name: info.name,
|
||||||
|
legalName: info.legalName,
|
||||||
|
legalAddress: info.legalAddress,
|
||||||
|
directorName: info.directorName,
|
||||||
|
phone: info.phone,
|
||||||
|
email: info.email,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Компания, её роли из шаблонов и первый администратор. Администратор — человек паспорта:
|
||||||
|
* новый заводится там же, существующий привязывается к платформе patrol.
|
||||||
|
* trusted — регистрирует владелец платформы (иначе — сама компания, см. PeopleService.provision).
|
||||||
|
*/
|
||||||
|
async register(input: RegisterInput, opts: { trusted: boolean }) {
|
||||||
|
const info = await this.registry.lookup(input.bin);
|
||||||
|
const name = input.name || info?.name;
|
||||||
|
if (!name) throw new BadRequestException('Укажите название компании');
|
||||||
|
if (await this.db.getRepository(Organization).exist({ where: { bin: input.bin } })) {
|
||||||
|
throw new ConflictException('Компания с таким БИН уже зарегистрирована');
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = await this.people.provision(input.admin, opts);
|
||||||
|
|
||||||
|
return this.db.transaction(async (m) => {
|
||||||
|
if (await m.getRepository(Organization).exist({ where: { bin: input.bin } })) {
|
||||||
|
throw new ConflictException('Компания с таким БИН уже зарегистрирована');
|
||||||
|
}
|
||||||
|
const org = await m.getRepository(Organization).save({
|
||||||
|
bin: input.bin,
|
||||||
|
name,
|
||||||
|
legalName: input.legalName ?? info?.legalName ?? null,
|
||||||
|
legalAddress: input.legalAddress ?? info?.legalAddress ?? null,
|
||||||
|
directorName: input.directorName ?? info?.directorName ?? null,
|
||||||
|
phone: input.phone ?? info?.phone ?? null,
|
||||||
|
email: input.email ?? info?.email ?? null,
|
||||||
|
registryData: info?.raw ?? null,
|
||||||
|
registrySyncedAt: info ? new Date() : null,
|
||||||
|
});
|
||||||
|
const roles = await m.getRepository(Role).save(
|
||||||
|
ROLE_TEMPLATES.map((t) => ({
|
||||||
|
organizationId: org.id,
|
||||||
|
code: t.code,
|
||||||
|
name: t.name,
|
||||||
|
isSystem: true,
|
||||||
|
allSites: t.allSites,
|
||||||
|
permissions: [...t.permissions],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const adminRole = roles.find((r) => r.code === 'admin')!;
|
||||||
|
const adminId = await this.people.attach(m, person, org.id, adminRole.id);
|
||||||
|
return { organization: org, adminId };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
list() {
|
||||||
|
return this.db.query(`
|
||||||
|
SELECT o.id, o.bin, o.name, o.legal_name AS "legalName", o.status, o.created_at AS "createdAt",
|
||||||
|
(SELECT count(*)::int FROM users u WHERE u.organization_id = o.id AND u.removed_at IS NULL) AS "usersCount",
|
||||||
|
(SELECT count(*)::int FROM sites s WHERE s.organization_id = o.id AND s.archived_at IS NULL) AS "sitesCount"
|
||||||
|
FROM organizations o ORDER BY o.created_at DESC`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(id: string, body: unknown) {
|
||||||
|
const blocked = bool(obj(body), 'blocked');
|
||||||
|
if (blocked === undefined) throw new BadRequestException('Поле «blocked» обязательно');
|
||||||
|
await this.db.getRepository(Organization).update({ id }, { status: blocked ? 'blocked' : 'active' });
|
||||||
|
return this.db.getRepository(Organization).findOneByOrFail({ id });
|
||||||
|
}
|
||||||
|
}
|
||||||
36
apps/api/src/modules/organizations/platform.controller.ts
Normal file
36
apps/api/src/modules/organizations/platform.controller.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { PlatformOnly } from '../../common/auth';
|
||||||
|
import { UsersService } from '../users/users.service';
|
||||||
|
import { OrganizationsService, parseRegisterInput } from './organizations.service';
|
||||||
|
|
||||||
|
// Кабинет владельца платформы: компании и их блокировка. В отчёты компаний отсюда не попасть.
|
||||||
|
@PlatformOnly()
|
||||||
|
@Controller('platform/organizations')
|
||||||
|
export class PlatformController {
|
||||||
|
constructor(
|
||||||
|
private readonly orgs: OrganizationsService,
|
||||||
|
private readonly users: UsersService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Люди из паспорта — назначить администратором компании уже существующего человека.
|
||||||
|
@Get('passport-search')
|
||||||
|
searchPassport(@Query('q') q?: string) {
|
||||||
|
return this.users.searchPassportFor(null, q ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list() {
|
||||||
|
return this.orgs.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async create(@Body() body: unknown) {
|
||||||
|
const { organization } = await this.orgs.register(parseRegisterInput(body, { allowExisting: true }), { trusted: true });
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
setStatus(@Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.orgs.setStatus(id, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
96
apps/api/src/modules/passport/aspans-id.client.ts
Normal file
96
apps/api/src/modules/passport/aspans-id.client.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import { BadGatewayException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { createHash, randomBytes } from 'crypto';
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
|
const b64url = (buf: Buffer) => buf.toString('base64url');
|
||||||
|
|
||||||
|
export type AspansIdUser = { uuid: string; username: string | null; email: string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSO Aspans ID — тот же поток, что AspansIdStart / AspansIdCallback в symfony:
|
||||||
|
* authorization code + PKCE, client_id и client_secret = код платформы.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AspansIdClient {
|
||||||
|
private readonly logger = new Logger(AspansIdClient.name);
|
||||||
|
private readonly cfg = config.aspansId;
|
||||||
|
|
||||||
|
get enabled() {
|
||||||
|
return !!this.cfg.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
get baseUrl() {
|
||||||
|
return this.cfg.baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectUri(origin: string | undefined) {
|
||||||
|
const base = origin && /^https?:\/\/[^/]+$/.test(origin) ? origin : this.cfg.websiteUrl;
|
||||||
|
if (!base) throw new BadGatewayException('Не удалось определить адрес возврата (WEBSITE_URL)');
|
||||||
|
return base + this.cfg.callbackPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
start(origin: string | undefined, prompt?: string) {
|
||||||
|
if (!this.enabled) throw new BadGatewayException('Aspans ID не настроен (ASPANS_ID_BASE_URL)');
|
||||||
|
const redirectUri = this.redirectUri(origin);
|
||||||
|
const state = b64url(randomBytes(32));
|
||||||
|
const codeVerifier = b64url(randomBytes(64));
|
||||||
|
const codeChallenge = b64url(createHash('sha256').update(codeVerifier).digest());
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: this.cfg.clientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: this.cfg.scope,
|
||||||
|
state,
|
||||||
|
code_challenge: codeChallenge,
|
||||||
|
code_challenge_method: 'S256',
|
||||||
|
});
|
||||||
|
if (prompt) params.set('prompt', prompt);
|
||||||
|
return { redirectUrl: `${this.cfg.baseUrl}/oauth/authorize?${params}`, state, codeVerifier, redirectUri };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async call(path: string, init: RequestInit) {
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${this.cfg.baseUrl}${path}`, { ...init, signal: AbortSignal.timeout(20000) });
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Aspans ID недоступен (${path}): ${(e as Error).message}`);
|
||||||
|
throw new BadGatewayException('Aspans ID недоступен, попробуйте позже');
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
let body: Record<string, unknown> | null = null;
|
||||||
|
try {
|
||||||
|
body = text ? (JSON.parse(text) as Record<string, unknown>) : null;
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
if (!res.ok || !body) {
|
||||||
|
this.logger.warn(`Aspans ID ${path}: HTTP ${res.status} ${text.slice(0, 300)}`);
|
||||||
|
throw new UnauthorizedException('Не удалось войти через Aspans ID');
|
||||||
|
}
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Код → токен → userinfo → uuid человека в паспорте.
|
||||||
|
async exchange(code: string, codeVerifier: string, redirectUri: string): Promise<AspansIdUser> {
|
||||||
|
const token = await this.call('/oauth/token', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: this.cfg.clientId,
|
||||||
|
client_secret: this.cfg.clientSecret,
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const accessToken = token.access_token;
|
||||||
|
if (typeof accessToken !== 'string' || !accessToken) throw new UnauthorizedException('Aspans ID не выдал токен');
|
||||||
|
const info = await this.call('/oauth/userinfo', { headers: { Accept: 'application/json', Authorization: `Bearer ${accessToken}` } });
|
||||||
|
const user = (info.user && typeof info.user === 'object' ? info.user : info) as Record<string, unknown>;
|
||||||
|
const uuid = user.uuid ?? user.passport_uuid ?? info.passport_uuid;
|
||||||
|
if (typeof uuid !== 'string' || !uuid) throw new UnauthorizedException('Aspans ID не вернул uuid паспорта');
|
||||||
|
const s = (v: unknown) => (typeof v === 'string' && v ? v : null);
|
||||||
|
return { uuid: uuid.toLowerCase(), username: s(user.username), email: s(user.email) };
|
||||||
|
}
|
||||||
|
}
|
||||||
236
apps/api/src/modules/passport/passport.client.ts
Normal file
236
apps/api/src/modules/passport/passport.client.ts
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import { BadGatewayException, BadRequestException, ConflictException, Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
|
// Ответ /api/v1/*: { result } или { errors: [{ code, input_field, description }] }.
|
||||||
|
type V1Response<T> = { result?: T; errors?: { code: number; input_field?: string | null; description?: string | null }[] };
|
||||||
|
|
||||||
|
export type PassportUserFull = {
|
||||||
|
uuid: string;
|
||||||
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
middle_name: string | null;
|
||||||
|
is_enabled: boolean;
|
||||||
|
group_codes: string[];
|
||||||
|
user_in_platform: unknown | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PassportSearchItem = {
|
||||||
|
uuid: string;
|
||||||
|
first_name: string | null;
|
||||||
|
last_name: string | null;
|
||||||
|
middle_name: string | null;
|
||||||
|
email_masked: string | null;
|
||||||
|
phone_masked: string | null;
|
||||||
|
on_platform: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PassportPerson = {
|
||||||
|
uuid: string;
|
||||||
|
username: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
middleName: string | null;
|
||||||
|
isEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PassportApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly code: number,
|
||||||
|
readonly field: string | null,
|
||||||
|
message: string,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function person(u: { uuid: string } & Record<string, unknown>): PassportPerson {
|
||||||
|
const s = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : null);
|
||||||
|
return {
|
||||||
|
uuid: String(u.uuid).toLowerCase(),
|
||||||
|
username: s(u.username),
|
||||||
|
email: s(u.email),
|
||||||
|
phone: s(u.phone),
|
||||||
|
firstName: s(u.first_name),
|
||||||
|
lastName: s(u.last_name),
|
||||||
|
middleName: s(u.middle_name),
|
||||||
|
isEnabled: u.is_enabled === undefined ? true : !!u.is_enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Паспорт Aspans: люди, пароли, привязка к платформе patrol.
|
||||||
|
* Методы v1 с подписью платформы — как App\Aspans\PassportApi\Api в symfony.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PassportClient {
|
||||||
|
private readonly logger = new Logger(PassportClient.name);
|
||||||
|
private readonly cfg = config.passport;
|
||||||
|
|
||||||
|
get enabled() {
|
||||||
|
return !!this.cfg.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensure() {
|
||||||
|
if (!this.cfg.url) throw new BadGatewayException('Паспорт не настроен (PASSPORT_URL)');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async post(path: string, body: unknown): Promise<{ status: number; data: unknown }> {
|
||||||
|
this.ensure();
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${this.cfg.url}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(20000),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Паспорт недоступен (${path}): ${(e as Error).message}`);
|
||||||
|
throw new BadGatewayException('Паспорт Aspans недоступен, попробуйте позже');
|
||||||
|
}
|
||||||
|
const text = await res.text();
|
||||||
|
let data: unknown = null;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : null;
|
||||||
|
} catch {
|
||||||
|
this.logger.error(`Паспорт ответил не JSON (${path}, ${res.status}): ${text.slice(0, 300)}`);
|
||||||
|
throw new BadGatewayException('Паспорт Aspans ответил с ошибкой');
|
||||||
|
}
|
||||||
|
return { status: res.status, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подписанный вызов v1: platform_code, timestamp, request_hash = md5(code-ts-token).
|
||||||
|
private async v1<T>(method: string, params: Record<string, unknown>): Promise<T> {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
const body = {
|
||||||
|
...params,
|
||||||
|
platform_code: this.cfg.platformCode,
|
||||||
|
timestamp,
|
||||||
|
request_hash: createHash('md5').update(`${this.cfg.platformCode}-${timestamp}-${this.cfg.token}`).digest('hex'),
|
||||||
|
};
|
||||||
|
const { data } = await this.post(`/api/${method}`, body);
|
||||||
|
const r = data as V1Response<T>;
|
||||||
|
if (r?.errors?.length) {
|
||||||
|
const e = r.errors[0];
|
||||||
|
this.logger.warn(`Паспорт ${method}: ошибка ${e.code} ${e.input_field ?? ''} ${e.description ?? ''}`);
|
||||||
|
throw new PassportApiError(e.code, e.input_field ?? null, e.description || `Ошибка паспорта ${e.code}`);
|
||||||
|
}
|
||||||
|
if (!r || !('result' in r)) throw new BadGatewayException('Паспорт Aspans ответил без результата');
|
||||||
|
return r.result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Вход по логину (или email) и паролю. platform_code = patrol: паспорт сам проверит,
|
||||||
|
* что человек привязан к платформе (владелец admin проходит без привязки).
|
||||||
|
*/
|
||||||
|
async login(username: string, password: string): Promise<PassportPerson> {
|
||||||
|
const { status, data } = await this.post('/api/auth/login', {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
platform_code: this.cfg.platformCode,
|
||||||
|
});
|
||||||
|
const r = data as { status?: boolean; message?: string; content?: { user?: { uuid?: string } & Record<string, unknown> } };
|
||||||
|
if (r?.status && r.content?.user?.uuid) return person(r.content.user as { uuid: string });
|
||||||
|
if (r?.status && !r.content?.user) {
|
||||||
|
// Вход с подтверждением по SMS — его ведёт Aspans ID, здесь не поддерживаем.
|
||||||
|
throw new UnauthorizedException('Для этой учётной записи нужен вход через Aspans ID');
|
||||||
|
}
|
||||||
|
if (status === 403) throw new UnauthorizedException(r?.message || 'Нет доступа к «Обходам»');
|
||||||
|
throw new UnauthorizedException(r?.message || 'Неверный логин или пароль');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверка пароля без привязки к платформе (platform_code=all) — для саморегистрации существующего человека. */
|
||||||
|
async verifyPassword(username: string, password: string): Promise<PassportPerson | null> {
|
||||||
|
const { data } = await this.post('/api/auth/login', { username, password, platform_code: 'all' });
|
||||||
|
const r = data as { status?: boolean; content?: { user?: { uuid?: string } & Record<string, unknown> } };
|
||||||
|
return r?.status && r.content?.user?.uuid ? person(r.content.user as { uuid: string }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUser(uuid: string): Promise<PassportPerson & { onPlatform: boolean }> {
|
||||||
|
const u = await this.v1<PassportUserFull>('v1/users/getUserFullByUuid', { uuid });
|
||||||
|
return { ...person(u as unknown as { uuid: string }), onPlatform: !!u.user_in_platform };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Новый человек в паспорте сразу с привязкой к patrol и группой patrol_user.
|
||||||
|
* Если email или телефон уже есть в паспорте — паспорт вернёт существующего (existed),
|
||||||
|
* и привязывать его к patrol нужно отдельно (link).
|
||||||
|
*/
|
||||||
|
async createOrGet(p: { email: string; phone: string; password?: string; firstName: string; lastName: string; middleName?: string | null }) {
|
||||||
|
try {
|
||||||
|
const r = await this.v1<{ existed: boolean; user_full: PassportUserFull }>('v1/users/createOrGetUserSimple', {
|
||||||
|
email: p.email,
|
||||||
|
phone: p.phone,
|
||||||
|
password: p.password ?? null,
|
||||||
|
first_name: p.firstName,
|
||||||
|
last_name: p.lastName,
|
||||||
|
middle_name: p.middleName ?? null,
|
||||||
|
groups: [this.cfg.groupCode],
|
||||||
|
});
|
||||||
|
return { existed: r.existed, onPlatform: !!r.user_full.user_in_platform, person: person(r.user_full as unknown as { uuid: string }) };
|
||||||
|
} catch (e) {
|
||||||
|
throw this.humanize(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Привязать существующего человека к patrol (группа patrol_user). Профиль не меняем:
|
||||||
|
* передаём его же данные — метод паспорта требует ФИО и email. Паспорт сам пришлёт
|
||||||
|
* человеку письмо/WhatsApp о доступе.
|
||||||
|
*/
|
||||||
|
async link(uuid: string) {
|
||||||
|
const u = await this.getUser(uuid);
|
||||||
|
if (u.onPlatform) return u;
|
||||||
|
await this.v1('v1/users/linkUserGroupsOnPlatform', {
|
||||||
|
uuid,
|
||||||
|
platform: this.cfg.platformCode,
|
||||||
|
group_codes: [this.cfg.groupCode],
|
||||||
|
email: u.email,
|
||||||
|
phone: u.phone,
|
||||||
|
first_name: u.firstName ?? '',
|
||||||
|
last_name: u.lastName ?? '',
|
||||||
|
middle_name: u.middleName,
|
||||||
|
});
|
||||||
|
return { ...u, onPlatform: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Отвязать от patrol (в паспорте человек остаётся). */
|
||||||
|
async unlink(uuid: string) {
|
||||||
|
await this.v1('v1/users/deleteUser', { uuid, platform_codes: [this.cfg.platformCode] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async setPassword(uuid: string, password: string) {
|
||||||
|
try {
|
||||||
|
await this.v1('v1/users/editUser', { uuid, password });
|
||||||
|
} catch (e) {
|
||||||
|
throw this.humanize(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Свободен ли email для этого человека (профиль уходит в паспорт асинхронно — проверяем заранее, как symfony). */
|
||||||
|
async takenFields(uuid: string | null, email: string | null): Promise<string[]> {
|
||||||
|
if (!email) return [];
|
||||||
|
return this.v1<string[]>('v1/users/checkProfileUnique', { uuid, email });
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(query: string): Promise<PassportSearchItem[]> {
|
||||||
|
return this.v1<PassportSearchItem[]>('v1/users/findUsers', { query });
|
||||||
|
}
|
||||||
|
|
||||||
|
private humanize(e: unknown) {
|
||||||
|
if (!(e instanceof PassportApiError)) return e;
|
||||||
|
if (e.field === 'email' && /разным/.test(e.message)) {
|
||||||
|
return new ConflictException('Этот email и этот телефон принадлежат разным людям в Aspans ID');
|
||||||
|
}
|
||||||
|
if (e.field === 'email') return new BadRequestException(`Email: ${e.message}`);
|
||||||
|
if (e.field === 'phone') return new BadRequestException(`Телефон: ${e.message}`);
|
||||||
|
if (e.field === 'password') return new BadRequestException(`Пароль: ${e.message}`);
|
||||||
|
return new BadRequestException(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
apps/api/src/modules/reports/reports.controller.ts
Normal file
32
apps/api/src/modules/reports/reports.controller.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
|
||||||
|
import { AuthUser, CurrentUser, RequirePermissions } from '../../common/auth';
|
||||||
|
import { ReportsService } from './reports.service';
|
||||||
|
|
||||||
|
@Controller('reports')
|
||||||
|
export class ReportsController {
|
||||||
|
constructor(private readonly reports: ReportsService) {}
|
||||||
|
|
||||||
|
private check(user: AuthUser) {
|
||||||
|
if (!user.permissions.has('reports.view_all') && !user.permissions.has('reports.view_own')) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('rounds')
|
||||||
|
rounds(@CurrentUser() user: AuthUser, @Query() query: Record<string, string>) {
|
||||||
|
this.check(user);
|
||||||
|
return this.reports.rounds(user, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('summary')
|
||||||
|
summary(@CurrentUser() user: AuthUser, @Query() query: Record<string, string>) {
|
||||||
|
this.check(user);
|
||||||
|
return this.reports.summary(user, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('audit.view')
|
||||||
|
@Get('audit')
|
||||||
|
audit(@CurrentUser() user: AuthUser, @Query('page') page?: string) {
|
||||||
|
return this.reports.audit(user, Number(page) || 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
100
apps/api/src/modules/reports/reports.service.ts
Normal file
100
apps/api/src/modules/reports/reports.service.ts
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { ROUND_STATUSES } from '@patrol/shared';
|
||||||
|
import { AccessService } from '../../common/access.service';
|
||||||
|
import { AuthUser, requireOrg } from '../../common/auth';
|
||||||
|
import { isUuid } from '../../common/input';
|
||||||
|
|
||||||
|
type Filters = { siteId?: string; userId?: string; status?: string; from?: string; to?: string; page?: string; issuesOnly?: string };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ReportsService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly db: DataSource,
|
||||||
|
private readonly access: AccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async where(user: AuthUser, f: Filters) {
|
||||||
|
const siteIds = f.siteId && isUuid(f.siteId) ? [(await this.access.site(user, f.siteId)).id] : await this.access.siteIds(user);
|
||||||
|
const params: unknown[] = [siteIds];
|
||||||
|
const cond = ['r.site_id = ANY($1)'];
|
||||||
|
const add = (sql: string, value: unknown) => {
|
||||||
|
params.push(value);
|
||||||
|
cond.push(sql.replace('?', `$${params.length}`));
|
||||||
|
};
|
||||||
|
// Без права «отчёты всех» видны только свои обходы.
|
||||||
|
if (!user.permissions.has('reports.view_all')) add('r.performed_by = ?', user.id);
|
||||||
|
else if (f.userId && isUuid(f.userId)) add('r.performed_by = ?', f.userId);
|
||||||
|
if (f.status) {
|
||||||
|
const list = f.status.split(',').filter((s) => (ROUND_STATUSES as readonly string[]).includes(s));
|
||||||
|
if (list.length) add('r.status = ANY(?)', list);
|
||||||
|
} else {
|
||||||
|
cond.push(`r.status <> 'planned' OR r.planned_start < now()`);
|
||||||
|
}
|
||||||
|
const from = f.from ? new Date(f.from) : new Date(Date.now() - 7 * 864e5);
|
||||||
|
const to = f.to ? new Date(f.to) : new Date(Date.now() + 864e5);
|
||||||
|
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) throw new BadRequestException('Неверный период');
|
||||||
|
add('r.planned_start >= ?', from);
|
||||||
|
add('r.planned_start < ?', to);
|
||||||
|
if (f.issuesOnly === 'true') cond.push(`EXISTS (SELECT 1 FROM checkpoint_visits v WHERE v.round_id = r.id AND (v.is_issue OR cardinality(v.flags) > 0))`);
|
||||||
|
return { sql: cond.map((c) => `(${c})`).join(' AND '), params, empty: !siteIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
async rounds(user: AuthUser, f: Filters) {
|
||||||
|
requireOrg(user);
|
||||||
|
const w = await this.where(user, f);
|
||||||
|
if (w.empty) return { items: [], total: 0 };
|
||||||
|
const page = Math.max(1, Number(f.page) || 1);
|
||||||
|
const size = 50;
|
||||||
|
const [{ total }] = await this.db.query(`SELECT count(*)::int AS total FROM rounds r WHERE ${w.sql}`, w.params);
|
||||||
|
const items = await this.db.query(
|
||||||
|
`SELECT r.id, r.status, r.planned_start AS "plannedStart", r.planned_end AS "plannedEnd",
|
||||||
|
r.started_at AS "startedAt", r.finished_at AS "finishedAt",
|
||||||
|
r.checkpoints_done AS "checkpointsDone", r.checkpoints_total AS "checkpointsTotal",
|
||||||
|
r.site_id AS "siteId", s.name AS "siteName", s.timezone,
|
||||||
|
r.route_snapshot->>'name' AS "routeName", ps.name AS "scheduleName",
|
||||||
|
r.performed_by AS "performedBy", u.full_name AS "performerName",
|
||||||
|
(SELECT count(*)::int FROM checkpoint_visits v WHERE v.round_id = r.id AND cardinality(v.flags) > 0) AS "flaggedVisits",
|
||||||
|
(SELECT count(*)::int FROM checkpoint_visits v WHERE v.round_id = r.id AND v.is_issue) AS "issues",
|
||||||
|
(SELECT count(*)::int FROM media m JOIN checkpoint_visits v ON v.id = m.visit_id WHERE v.round_id = r.id) AS "mediaCount"
|
||||||
|
FROM rounds r
|
||||||
|
JOIN sites s ON s.id = r.site_id
|
||||||
|
LEFT JOIN users u ON u.id = r.performed_by
|
||||||
|
LEFT JOIN patrol_schedules ps ON ps.id = r.schedule_id
|
||||||
|
WHERE ${w.sql}
|
||||||
|
ORDER BY r.planned_start DESC
|
||||||
|
LIMIT ${size} OFFSET ${(page - 1) * size}`,
|
||||||
|
w.params,
|
||||||
|
);
|
||||||
|
return { items, total, page, pageSize: size };
|
||||||
|
}
|
||||||
|
|
||||||
|
async summary(user: AuthUser, f: Filters) {
|
||||||
|
requireOrg(user);
|
||||||
|
const w = await this.where(user, { ...f, status: undefined });
|
||||||
|
if (w.empty) return [];
|
||||||
|
return this.db.query(
|
||||||
|
`SELECT r.site_id AS "siteId", s.name AS "siteName",
|
||||||
|
count(*) FILTER (WHERE r.status = 'completed')::int AS completed,
|
||||||
|
count(*) FILTER (WHERE r.status = 'partial')::int AS partial,
|
||||||
|
count(*) FILTER (WHERE r.status = 'missed')::int AS missed,
|
||||||
|
count(*) FILTER (WHERE r.status IN ('planned', 'in_progress'))::int AS open,
|
||||||
|
count(*) FILTER (WHERE r.status <> 'cancelled')::int AS total
|
||||||
|
FROM rounds r JOIN sites s ON s.id = r.site_id
|
||||||
|
WHERE ${w.sql}
|
||||||
|
GROUP BY r.site_id, s.name ORDER BY s.name`,
|
||||||
|
w.params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
audit(user: AuthUser, page = 1) {
|
||||||
|
return this.db.query(
|
||||||
|
`SELECT a.id, a.action, a.entity, a.entity_id AS "entityId", a.changes, a.created_at AS "createdAt",
|
||||||
|
u.full_name AS "userName"
|
||||||
|
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
|
||||||
|
WHERE a.organization_id = $1 ORDER BY a.created_at DESC LIMIT 100 OFFSET $2`,
|
||||||
|
[requireOrg(user), (Math.max(1, page) - 1) * 100],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
117
apps/api/src/modules/rounds/generator.service.ts
Normal file
117
apps/api/src/modules/rounds/generator.service.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, IsNull } from 'typeorm';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { Checkpoint, PatrolSchedule, Route, Site } from '../../entities';
|
||||||
|
import { addDays, localDate, zonedToUtc } from '../../common/time';
|
||||||
|
import { snapshotOf } from '../routes/routes.service';
|
||||||
|
|
||||||
|
// Из графика (правило + назначения по дням) делает конкретные обходы на неделю вперёд.
|
||||||
|
@Injectable()
|
||||||
|
export class GeneratorService {
|
||||||
|
private readonly logger = new Logger(GeneratorService.name);
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(@InjectDataSource() private readonly db: DataSource) {}
|
||||||
|
|
||||||
|
@Cron(CronExpression.EVERY_HOUR)
|
||||||
|
async hourly() {
|
||||||
|
if (config.disableCron) return;
|
||||||
|
await this.generateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateAll() {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const schedules = await this.db.getRepository(PatrolSchedule).find({ where: { isActive: true } });
|
||||||
|
for (const s of schedules) await this.generate(s.id);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.error(`Генерация обходов: ${(e as Error).message}`);
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Время из списка, которое раньше первого, относится к следующему календарному дню (ночная смена).
|
||||||
|
static times(startTimes: string[]): { time: string; dayOffset: number }[] {
|
||||||
|
const first = startTimes[0];
|
||||||
|
return startTimes.map((time) => ({ time, dayOffset: time < first ? 1 : 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async generate(scheduleId: string): Promise<number> {
|
||||||
|
const schedule = await this.db.getRepository(PatrolSchedule).findOneBy({ id: scheduleId });
|
||||||
|
if (!schedule) return 0;
|
||||||
|
const site = await this.db.getRepository(Site).findOneBy({ id: schedule.siteId });
|
||||||
|
const route = await this.db.getRepository(Route).findOneBy({ id: schedule.routeId });
|
||||||
|
if (!site || site.archivedAt || !route || route.archivedAt) return 0;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const today = localDate(now, site.timezone);
|
||||||
|
const from = addDays(today, -1);
|
||||||
|
const to = addDays(today, config.generateDaysAhead);
|
||||||
|
|
||||||
|
// Дни, на которые есть хотя бы один дежурный.
|
||||||
|
const days: { date: string }[] = await this.db.query(
|
||||||
|
`SELECT DISTINCT to_char(date, 'YYYY-MM-DD') AS date FROM schedule_assignments
|
||||||
|
WHERE schedule_id = $1 AND date BETWEEN $2 AND $3`,
|
||||||
|
[schedule.id, from, to],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Дни без дежурных: ещё не начатые обходы отменяем.
|
||||||
|
await this.db.query(
|
||||||
|
`UPDATE rounds r SET status = 'cancelled', updated_at = now()
|
||||||
|
WHERE r.schedule_id = $1 AND r.status = 'planned' AND r.planned_start > now()
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM schedule_assignments a WHERE a.schedule_id = r.schedule_id AND a.date = r.duty_date)`,
|
||||||
|
[schedule.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!schedule.isActive || !days.length) return 0;
|
||||||
|
|
||||||
|
const checkpoints = await this.db.getRepository(Checkpoint).find({ where: { routeId: route.id, archivedAt: IsNull() } });
|
||||||
|
const snapshot = snapshotOf(route, checkpoints);
|
||||||
|
let created = 0;
|
||||||
|
|
||||||
|
for (const { date } of days) {
|
||||||
|
if (date < schedule.validFrom || (schedule.validTo && date > schedule.validTo)) continue;
|
||||||
|
for (const { time, dayOffset } of GeneratorService.times(schedule.startTimes)) {
|
||||||
|
const start = zonedToUtc(addDays(date, dayOffset), time, site.timezone);
|
||||||
|
const end = new Date(start.getTime() + schedule.windowMinutes * 60000);
|
||||||
|
if (end.getTime() <= now) continue;
|
||||||
|
const res: unknown[] = await this.db.query(
|
||||||
|
`INSERT INTO rounds (site_id, route_id, schedule_id, duty_date, planned_start, planned_end, late_tolerance_minutes,
|
||||||
|
status, route_snapshot, checkpoints_total)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, 'planned', $8, $9)
|
||||||
|
ON CONFLICT (schedule_id, planned_start) WHERE schedule_id IS NOT NULL
|
||||||
|
DO UPDATE SET status = 'planned', updated_at = now()
|
||||||
|
WHERE rounds.status = 'cancelled' AND rounds.planned_start > now()
|
||||||
|
RETURNING id`,
|
||||||
|
[
|
||||||
|
site.id,
|
||||||
|
route.id,
|
||||||
|
schedule.id,
|
||||||
|
date,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
schedule.lateToleranceMinutes,
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
snapshot.checkpoints.length,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
created += res.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Правило изменили или выключили: будущие не начатые обходы пересоздаём.
|
||||||
|
async rebuild(scheduleId: string) {
|
||||||
|
await this.db.query(
|
||||||
|
`DELETE FROM rounds WHERE schedule_id = $1 AND status IN ('planned', 'cancelled') AND planned_start > now()
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM checkpoint_visits v WHERE v.round_id = rounds.id)`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
return this.generate(scheduleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
apps/api/src/modules/rounds/rounds.controller.ts
Normal file
53
apps/api/src/modules/rounds/rounds.controller.ts
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { Body, Controller, Get, HttpCode, Param, ParseUUIDPipe, Post } from '@nestjs/common';
|
||||||
|
import { AuthUser, CurrentUser, RequirePermissions } from '../../common/auth';
|
||||||
|
import { RoundsService } from './rounds.service';
|
||||||
|
|
||||||
|
@Controller('rounds')
|
||||||
|
export class RoundsController {
|
||||||
|
constructor(private readonly rounds: RoundsService) {}
|
||||||
|
|
||||||
|
// Обходы сотрудника на ближайшие сутки (и незакрытые за прошедшие 12 часов).
|
||||||
|
@RequirePermissions('rounds.perform')
|
||||||
|
@Get('my')
|
||||||
|
my(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.rounds.my(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('rounds.adhoc')
|
||||||
|
@Post()
|
||||||
|
adhoc(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.rounds.adhoc(user, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
detail(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.rounds.detail(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('rounds.perform')
|
||||||
|
@Post(':id/start')
|
||||||
|
@HttpCode(200)
|
||||||
|
start(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.rounds.start(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('rounds.perform')
|
||||||
|
@Post(':id/visits')
|
||||||
|
visit(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.rounds.visit(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('rounds.perform')
|
||||||
|
@Post(':id/finish')
|
||||||
|
@HttpCode(200)
|
||||||
|
finish(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.rounds.finish(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('rounds.cancel')
|
||||||
|
@Post(':id/cancel')
|
||||||
|
@HttpCode(200)
|
||||||
|
cancel(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.rounds.cancel(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
372
apps/api/src/modules/rounds/rounds.service.ts
Normal file
372
apps/api/src/modules/rounds/rounds.service.ts
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||||
|
import { CLOCK_SKEW_MINUTES, GPS_FAR_METERS, RoundStatus, VisitFlag } from '@patrol/shared';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { Checkpoint, CheckpointVisit, Media, Route, Round, Site } from '../../entities';
|
||||||
|
import { AccessService } from '../../common/access.service';
|
||||||
|
import { AuthUser, requireOrg } from '../../common/auth';
|
||||||
|
import { assertLatLng, distanceMeters, latLngToPoint, pointToLatLng } from '../../common/geo';
|
||||||
|
import { bool, obj, str, timestamp, uuid, uuidArray } from '../../common/input';
|
||||||
|
import { MediaService } from '../media/media.service';
|
||||||
|
import { snapshotOf } from '../routes/routes.service';
|
||||||
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
||||||
|
|
||||||
|
const CLOSED: RoundStatus[] = ['completed', 'partial', 'missed', 'cancelled'];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoundsService {
|
||||||
|
private readonly logger = new Logger(RoundsService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly db: DataSource,
|
||||||
|
private readonly access: AccessService,
|
||||||
|
private readonly media: MediaService,
|
||||||
|
private readonly webhooks: WebhooksService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async orgOfSite(siteId: string) {
|
||||||
|
return (await this.db.getRepository(Site).findOneByOrFail({ id: siteId })).organizationId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- кто может проходить обход ----
|
||||||
|
|
||||||
|
private async isOnDuty(userId: string, round: Round) {
|
||||||
|
if (!round.scheduleId) return round.createdBy === userId || round.performedBy === userId;
|
||||||
|
const [row] = await this.db.query(
|
||||||
|
`SELECT 1 FROM schedule_assignments WHERE schedule_id = $1 AND user_id = $2 AND date = $3 LIMIT 1`,
|
||||||
|
[round.scheduleId, userId, round.dutyDate],
|
||||||
|
);
|
||||||
|
return !!row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async roundForPerformer(user: AuthUser, id: string) {
|
||||||
|
const round = await this.db.getRepository(Round).findOneBy({ id });
|
||||||
|
if (!round) throw new NotFoundException('Обход не найден');
|
||||||
|
await this.access.site(user, round.siteId);
|
||||||
|
if (round.performedBy && round.performedBy !== user.id) {
|
||||||
|
const [who] = await this.db.query(`SELECT full_name FROM users WHERE id = $1`, [round.performedBy]);
|
||||||
|
throw new ConflictException(`Этот обход уже проходит ${who?.full_name ?? 'другой сотрудник'}`);
|
||||||
|
}
|
||||||
|
if (!round.performedBy && !(await this.isOnDuty(user.id, round))) {
|
||||||
|
throw new ForbiddenException('Вы не дежурите по этому графику в этот день');
|
||||||
|
}
|
||||||
|
if (round.status === 'cancelled') throw new ConflictException('Обход отменён');
|
||||||
|
return round;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- экран сотрудника ----
|
||||||
|
|
||||||
|
async my(user: AuthUser) {
|
||||||
|
requireOrg(user);
|
||||||
|
const siteIds = await this.access.siteIds(user);
|
||||||
|
if (!siteIds.length) return [];
|
||||||
|
const rows: Round[] = await this.db
|
||||||
|
.getRepository(Round)
|
||||||
|
.createQueryBuilder('r')
|
||||||
|
.where('r.siteId IN (:...siteIds)', { siteIds })
|
||||||
|
.andWhere(`r.status <> 'cancelled'`)
|
||||||
|
.andWhere(`r.plannedEnd > now() - interval '12 hours' AND r.plannedStart < now() + interval '24 hours'`)
|
||||||
|
.andWhere(
|
||||||
|
`(r.performedBy = :uid
|
||||||
|
OR (r.performedBy IS NULL AND r.scheduleId IS NOT NULL AND EXISTS (
|
||||||
|
SELECT 1 FROM schedule_assignments a WHERE a.schedule_id = r.schedule_id AND a.user_id = :uid AND a.date = r.duty_date))
|
||||||
|
OR (r.scheduleId IS NULL AND r.createdBy = :uid))`,
|
||||||
|
{ uid: user.id },
|
||||||
|
)
|
||||||
|
.orderBy('r.plannedStart', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
return this.decorate(rows, { withVisits: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(user: AuthUser, id: string, body: unknown) {
|
||||||
|
const round = await this.roundForPerformer(user, id);
|
||||||
|
const at = timestamp(obj(body ?? {}), 'clientStartedAt') ?? new Date();
|
||||||
|
if (round.status === 'planned' || (round.status === 'in_progress' && !round.performedBy)) {
|
||||||
|
await this.db.getRepository(Round).update({ id }, { performedBy: user.id, startedAt: round.startedAt ?? at, status: 'in_progress' });
|
||||||
|
}
|
||||||
|
return this.detail(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Внеплановый обход. id может прийти с телефона (офлайн), тогда повтор не создаёт второй обход.
|
||||||
|
async adhoc(user: AuthUser, body: unknown) {
|
||||||
|
const o = obj(body);
|
||||||
|
const id = uuid(o, 'id');
|
||||||
|
if (id) {
|
||||||
|
const existing = await this.db.getRepository(Round).findOneBy({ id });
|
||||||
|
if (existing) {
|
||||||
|
if (existing.createdBy !== user.id) throw new ConflictException('Обход с таким id уже есть');
|
||||||
|
return this.detail(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const routeId = uuid(o, 'routeId', { required: true })!;
|
||||||
|
const route = await this.db.getRepository(Route).findOneBy({ id: routeId, archivedAt: IsNull() });
|
||||||
|
if (!route) throw new NotFoundException('Маршрут не найден');
|
||||||
|
await this.access.site(user, route.siteId);
|
||||||
|
const checkpoints = await this.db.getRepository(Checkpoint).find({ where: { routeId, archivedAt: IsNull() } });
|
||||||
|
const snapshot = snapshotOf(route, checkpoints);
|
||||||
|
const start = timestamp(o, 'clientStartedAt') ?? new Date();
|
||||||
|
const round = await this.db.getRepository(Round).save({
|
||||||
|
...(id ? { id } : {}),
|
||||||
|
siteId: route.siteId,
|
||||||
|
routeId,
|
||||||
|
scheduleId: null,
|
||||||
|
dutyDate: null,
|
||||||
|
performedBy: user.id,
|
||||||
|
createdBy: user.id,
|
||||||
|
plannedStart: start,
|
||||||
|
plannedEnd: new Date(start.getTime() + Math.max(route.expectedMinutes ?? 60, 15) * 2 * 60000),
|
||||||
|
startedAt: start,
|
||||||
|
status: 'in_progress',
|
||||||
|
routeSnapshot: snapshot,
|
||||||
|
checkpointsTotal: snapshot.checkpoints.length,
|
||||||
|
});
|
||||||
|
return this.detail(user, round.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async visit(user: AuthUser, roundId: string, body: unknown) {
|
||||||
|
const o = obj(body);
|
||||||
|
const id = uuid(o, 'id', { required: true })!;
|
||||||
|
const existing = await this.db.getRepository(CheckpointVisit).findOneBy({ id });
|
||||||
|
if (existing) {
|
||||||
|
if (existing.roundId !== roundId || existing.userId !== user.id) throw new ConflictException('Отметка с таким id уже есть');
|
||||||
|
return this.detail(user, roundId);
|
||||||
|
}
|
||||||
|
const round = await this.roundForPerformer(user, roundId);
|
||||||
|
const checkpointId = uuid(o, 'checkpointId') ?? null;
|
||||||
|
const cp = checkpointId ? round.routeSnapshot.checkpoints.find((c) => c.id === checkpointId) : undefined;
|
||||||
|
if (checkpointId && !cp) throw new BadRequestException('Этой точки нет в маршруте обхода');
|
||||||
|
|
||||||
|
const marked = assertLatLng(o.marked, 'marked');
|
||||||
|
const device = o.device ? assertLatLng(o.device, 'device') : null;
|
||||||
|
const accuracy = device && typeof (o.device as { accuracy?: unknown }).accuracy === 'number' ? (o.device as { accuracy: number }).accuracy : null;
|
||||||
|
const clientCreatedAt = timestamp(o, 'clientCreatedAt') ?? new Date();
|
||||||
|
const sentAt = timestamp(o, 'sentAt');
|
||||||
|
|
||||||
|
// GPS только логируем: флаги видны в отчёте и ничего не запрещают.
|
||||||
|
const flags: VisitFlag[] = [];
|
||||||
|
const distance = cp ? distanceMeters(marked, pointToLatLng(cp.location)) : null;
|
||||||
|
if (cp && distance! > cp.radiusM) flags.push('outside_radius');
|
||||||
|
const gpsDistance = device ? distanceMeters(marked, device) : null;
|
||||||
|
if (!device) flags.push('no_gps');
|
||||||
|
else if (gpsDistance! > GPS_FAR_METERS) flags.push('gps_far_from_mark');
|
||||||
|
if (clientCreatedAt > round.plannedEnd) flags.push('late');
|
||||||
|
if (sentAt && Math.abs(sentAt.getTime() - Date.now()) > CLOCK_SKEW_MINUTES * 60000) flags.push('clock_skew');
|
||||||
|
if (cp && round.routeSnapshot.strictOrder) {
|
||||||
|
const done: { checkpoint_id: string }[] = await this.db.query(
|
||||||
|
`SELECT DISTINCT checkpoint_id FROM checkpoint_visits WHERE round_id = $1 AND checkpoint_id IS NOT NULL`,
|
||||||
|
[roundId],
|
||||||
|
);
|
||||||
|
const doneIds = new Set(done.map((d) => d.checkpoint_id));
|
||||||
|
const skipped = round.routeSnapshot.checkpoints.some((c) => c.position < cp.position && !doneIds.has(c.id));
|
||||||
|
if (skipped) flags.push('out_of_order');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaIds = uuidArray(o, 'mediaIds') ?? [];
|
||||||
|
const isIssue = bool(o, 'isIssue') ?? false;
|
||||||
|
|
||||||
|
await this.db.transaction(async (m) => {
|
||||||
|
await m.getRepository(CheckpointVisit).insert({
|
||||||
|
id,
|
||||||
|
roundId,
|
||||||
|
checkpointId,
|
||||||
|
userId: user.id,
|
||||||
|
markedLocation: latLngToPoint(marked),
|
||||||
|
deviceLocation: device ? latLngToPoint(device) : null,
|
||||||
|
deviceAccuracyM: accuracy,
|
||||||
|
distanceToCheckpointM: distance,
|
||||||
|
gpsDistanceM: gpsDistance,
|
||||||
|
flags,
|
||||||
|
note: str(o, 'note', { max: 5000 }) ?? null,
|
||||||
|
isIssue,
|
||||||
|
clientCreatedAt,
|
||||||
|
});
|
||||||
|
if (mediaIds.length) {
|
||||||
|
await m.getRepository(Media).update({ id: In(mediaIds), uploadedBy: user.id, visitId: IsNull() }, { visitId: id });
|
||||||
|
}
|
||||||
|
await this.recount(m, roundId, user.id, clientCreatedAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isIssue) {
|
||||||
|
this.webhooks.emit(await this.orgOfSite(round.siteId), 'visit.issue', {
|
||||||
|
roundId,
|
||||||
|
visitId: id,
|
||||||
|
siteId: round.siteId,
|
||||||
|
checkpointId,
|
||||||
|
note: str(o, 'note', { max: 5000 }) ?? null,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.detail(user, roundId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пересчёт после отметки. Опоздавшая из офлайна отметка может поднять missed/partial до partial/completed.
|
||||||
|
private async recount(db: EntityManager, roundId: string, userId: string, at: Date) {
|
||||||
|
const round = await db.getRepository(Round).findOneByOrFail({ id: roundId });
|
||||||
|
const ids = round.routeSnapshot.checkpoints.map((c) => c.id);
|
||||||
|
const [{ done }] = await db.query(
|
||||||
|
`SELECT count(DISTINCT checkpoint_id)::int AS done FROM checkpoint_visits WHERE round_id = $1 AND checkpoint_id = ANY($2)`,
|
||||||
|
[roundId, ids],
|
||||||
|
);
|
||||||
|
const patch: Partial<Round> = { checkpointsDone: done, performedBy: round.performedBy ?? userId };
|
||||||
|
if (!round.startedAt || at < round.startedAt) patch.startedAt = at;
|
||||||
|
if (round.status === 'planned') patch.status = 'in_progress';
|
||||||
|
if ((round.status === 'missed' || round.status === 'partial') && at <= round.plannedEnd) {
|
||||||
|
patch.status = done >= round.checkpointsTotal ? 'completed' : 'partial';
|
||||||
|
}
|
||||||
|
await db.getRepository(Round).update({ id: roundId }, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
async finish(user: AuthUser, id: string, body: unknown) {
|
||||||
|
const round = await this.roundForPerformer(user, id);
|
||||||
|
const o = obj(body ?? {});
|
||||||
|
const at = timestamp(o, 'clientFinishedAt') ?? new Date();
|
||||||
|
if (!CLOSED.includes(round.status) || round.status === 'partial' || round.status === 'missed') {
|
||||||
|
const status: RoundStatus = round.checkpointsDone >= round.checkpointsTotal ? 'completed' : 'partial';
|
||||||
|
await this.db.getRepository(Round).update(
|
||||||
|
{ id },
|
||||||
|
{ status, finishedAt: at, comment: str(o, 'comment', { max: 5000 }) ?? round.comment, performedBy: round.performedBy ?? user.id },
|
||||||
|
);
|
||||||
|
this.webhooks.emit(await this.orgOfSite(round.siteId), status === 'completed' ? 'round.completed' : 'round.partial', {
|
||||||
|
roundId: id,
|
||||||
|
siteId: round.siteId,
|
||||||
|
status,
|
||||||
|
checkpointsDone: round.checkpointsDone,
|
||||||
|
checkpointsTotal: round.checkpointsTotal,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.detail(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(user: AuthUser, id: string) {
|
||||||
|
const round = await this.db.getRepository(Round).findOneBy({ id });
|
||||||
|
if (!round) throw new NotFoundException('Обход не найден');
|
||||||
|
await this.access.site(user, round.siteId);
|
||||||
|
if (round.status !== 'planned' && round.status !== 'in_progress') throw new ConflictException('Обход уже закрыт');
|
||||||
|
await this.db.getRepository(Round).update({ id }, { status: 'cancelled' });
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Раз в минуту закрываем обходы с истёкшим окном.
|
||||||
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
|
async closeExpired() {
|
||||||
|
if (config.disableCron) return;
|
||||||
|
const closed: { id: string; site_id: string; status: RoundStatus; organization_id: string; done: number; total: number }[] =
|
||||||
|
await this.db.query(
|
||||||
|
`UPDATE rounds r SET status = CASE
|
||||||
|
WHEN r.checkpoints_done = 0 THEN 'missed'
|
||||||
|
WHEN r.checkpoints_done >= r.checkpoints_total THEN 'completed'
|
||||||
|
ELSE 'partial' END,
|
||||||
|
updated_at = now()
|
||||||
|
FROM sites s
|
||||||
|
WHERE s.id = r.site_id AND r.status IN ('planned', 'in_progress') AND r.planned_end < now()
|
||||||
|
RETURNING r.id, r.site_id, r.status, s.organization_id, r.checkpoints_done AS done, r.checkpoints_total AS total`,
|
||||||
|
);
|
||||||
|
for (const r of closed) {
|
||||||
|
const event = r.status === 'missed' ? 'round.missed' : r.status === 'completed' ? 'round.completed' : 'round.partial';
|
||||||
|
this.webhooks.emit(r.organization_id, event, {
|
||||||
|
roundId: r.id,
|
||||||
|
siteId: r.site_id,
|
||||||
|
status: r.status,
|
||||||
|
checkpointsDone: r.done,
|
||||||
|
checkpointsTotal: r.total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (closed.length) this.logger.log(`Закрыто обходов по времени: ${closed.length}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- отчёт по одному обходу ----
|
||||||
|
|
||||||
|
async detail(user: AuthUser, id: string) {
|
||||||
|
const round = await this.db.getRepository(Round).findOneBy({ id });
|
||||||
|
if (!round) throw new NotFoundException('Обход не найден');
|
||||||
|
await this.access.site(user, round.siteId);
|
||||||
|
const onDuty = round.performedBy === user.id || (await this.isOnDuty(user.id, round));
|
||||||
|
if (!onDuty && !user.permissions.has('reports.view_all')) throw new ForbiddenException('Нет доступа к этому отчёту');
|
||||||
|
const [decorated] = await this.decorate([round], { withVisits: true });
|
||||||
|
return decorated;
|
||||||
|
}
|
||||||
|
|
||||||
|
async decorate(rounds: Round[], opts: { withVisits: boolean }) {
|
||||||
|
if (!rounds.length) return [];
|
||||||
|
const ids = rounds.map((r) => r.id);
|
||||||
|
const meta: Record<string, unknown>[] = await this.db.query(
|
||||||
|
`SELECT r.id, s.name AS "siteName", s.timezone, s.perimeter_json AS perimeter, u.full_name AS "performerName",
|
||||||
|
ps.name AS "scheduleName"
|
||||||
|
FROM rounds r
|
||||||
|
JOIN (SELECT id, name, timezone, ST_AsGeoJSON(perimeter)::json AS perimeter_json FROM sites) s ON s.id = r.site_id
|
||||||
|
LEFT JOIN users u ON u.id = r.performed_by
|
||||||
|
LEFT JOIN patrol_schedules ps ON ps.id = r.schedule_id
|
||||||
|
WHERE r.id = ANY($1)`,
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
const metaById = new Map(meta.map((m) => [m.id as string, m]));
|
||||||
|
let visitsByRound = new Map<string, unknown[]>();
|
||||||
|
if (opts.withVisits) {
|
||||||
|
const visits = await this.db.getRepository(CheckpointVisit).find({ where: { roundId: In(ids) }, order: { clientCreatedAt: 'ASC' } });
|
||||||
|
const media = visits.length
|
||||||
|
? await this.db.getRepository(Media).find({ where: { visitId: In(visits.map((v) => v.id)) }, order: { createdAt: 'ASC' } })
|
||||||
|
: [];
|
||||||
|
const mediaViews = await Promise.all(
|
||||||
|
media.map(async (m) => ({
|
||||||
|
id: m.id,
|
||||||
|
visitId: m.visitId,
|
||||||
|
kind: m.kind,
|
||||||
|
mime: m.mime,
|
||||||
|
status: m.status,
|
||||||
|
capturedAt: m.capturedAt,
|
||||||
|
url: m.status === 'ready' ? await this.media.url(m.id) : null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
visitsByRound = new Map(
|
||||||
|
ids.map((rid) => [
|
||||||
|
rid,
|
||||||
|
visits
|
||||||
|
.filter((v) => v.roundId === rid)
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.id,
|
||||||
|
checkpointId: v.checkpointId,
|
||||||
|
userId: v.userId,
|
||||||
|
marked: pointToLatLng(v.markedLocation),
|
||||||
|
device: v.deviceLocation ? { ...pointToLatLng(v.deviceLocation), accuracy: v.deviceAccuracyM } : null,
|
||||||
|
distanceToCheckpointM: v.distanceToCheckpointM,
|
||||||
|
gpsDistanceM: v.gpsDistanceM,
|
||||||
|
flags: v.flags,
|
||||||
|
note: v.note,
|
||||||
|
isIssue: v.isIssue,
|
||||||
|
clientCreatedAt: v.clientCreatedAt,
|
||||||
|
createdAt: v.createdAt,
|
||||||
|
media: mediaViews.filter((m) => m.visitId === v.id),
|
||||||
|
})),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return rounds.map((r) => {
|
||||||
|
const m = metaById.get(r.id) ?? {};
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
siteId: r.siteId,
|
||||||
|
siteName: m.siteName,
|
||||||
|
timezone: m.timezone,
|
||||||
|
perimeter: m.perimeter ?? null,
|
||||||
|
routeId: r.routeId,
|
||||||
|
scheduleId: r.scheduleId,
|
||||||
|
scheduleName: m.scheduleName ?? null,
|
||||||
|
dutyDate: r.dutyDate,
|
||||||
|
performedBy: r.performedBy,
|
||||||
|
performerName: m.performerName ?? null,
|
||||||
|
plannedStart: r.plannedStart,
|
||||||
|
plannedEnd: r.plannedEnd,
|
||||||
|
startedAt: r.startedAt,
|
||||||
|
finishedAt: r.finishedAt,
|
||||||
|
status: r.status,
|
||||||
|
route: r.routeSnapshot,
|
||||||
|
checkpointsTotal: r.checkpointsTotal,
|
||||||
|
checkpointsDone: r.checkpointsDone,
|
||||||
|
comment: r.comment,
|
||||||
|
...(opts.withVisits ? { visits: visitsByRound.get(r.id) ?? [] } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
39
apps/api/src/modules/routes/routes.controller.ts
Normal file
39
apps/api/src/modules/routes/routes.controller.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Put, Query } from '@nestjs/common';
|
||||||
|
import { AuthUser, CurrentUser, RequirePermissions } from '../../common/auth';
|
||||||
|
import { isUuid } from '../../common/input';
|
||||||
|
import { RoutesService } from './routes.service';
|
||||||
|
|
||||||
|
@Controller('routes')
|
||||||
|
export class RoutesController {
|
||||||
|
constructor(private readonly routes: RoutesService) {}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.view')
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: AuthUser, @Query('siteId') siteId?: string) {
|
||||||
|
return this.routes.list(user, isUuid(siteId) ? siteId : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.view')
|
||||||
|
@Get(':id')
|
||||||
|
get(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.routes.get(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('routes.manage')
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.routes.create(user, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('routes.manage')
|
||||||
|
@Put(':id')
|
||||||
|
update(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.routes.update(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('routes.manage')
|
||||||
|
@Post(':id/archive')
|
||||||
|
archive(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.routes.archive(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
202
apps/api/src/modules/routes/routes.service.ts
Normal file
202
apps/api/src/modules/routes/routes.service.ts
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager, In, IsNull } from 'typeorm';
|
||||||
|
import { MEDIA_REQUIREMENTS } from '@patrol/shared';
|
||||||
|
import { Checkpoint, Route, RouteSnapshot } from '../../entities';
|
||||||
|
import { AccessService } from '../../common/access.service';
|
||||||
|
import { AuthUser } from '../../common/auth';
|
||||||
|
import { assertLineString, assertPoint } from '../../common/geo';
|
||||||
|
import { bool, int, isUuid, obj, oneOf, reqStr, str, uuid } from '../../common/input';
|
||||||
|
|
||||||
|
type CheckpointInput = Omit<Checkpoint, 'routeId' | 'archivedAt' | 'id'> & { id?: string };
|
||||||
|
|
||||||
|
export function snapshotOf(route: Route, checkpoints: Checkpoint[]): RouteSnapshot {
|
||||||
|
return {
|
||||||
|
version: route.version,
|
||||||
|
name: route.name,
|
||||||
|
strictOrder: route.strictOrder,
|
||||||
|
path: route.path,
|
||||||
|
checkpoints: checkpoints
|
||||||
|
.filter((c) => !c.archivedAt)
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
position: c.position,
|
||||||
|
name: c.name,
|
||||||
|
location: c.location,
|
||||||
|
radiusM: c.radiusM,
|
||||||
|
mediaRequired: c.mediaRequired,
|
||||||
|
instruction: c.instruction,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RoutesService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly db: DataSource,
|
||||||
|
private readonly access: AccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async withCheckpoints(routes: Route[]) {
|
||||||
|
if (!routes.length) return [];
|
||||||
|
const cps = await this.db.getRepository(Checkpoint).find({
|
||||||
|
where: { routeId: In(routes.map((r) => r.id)), archivedAt: IsNull() },
|
||||||
|
order: { position: 'ASC' },
|
||||||
|
});
|
||||||
|
return routes.map((r) => ({ ...r, checkpoints: cps.filter((c) => c.routeId === r.id) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(user: AuthUser, siteId?: string) {
|
||||||
|
const siteIds = siteId ? [(await this.access.site(user, siteId)).id] : await this.access.siteIds(user);
|
||||||
|
if (!siteIds.length) return [];
|
||||||
|
const routes = await this.db.getRepository(Route).find({
|
||||||
|
where: { siteId: In(siteIds), archivedAt: IsNull() },
|
||||||
|
order: { name: 'ASC' },
|
||||||
|
});
|
||||||
|
return this.withCheckpoints(routes);
|
||||||
|
}
|
||||||
|
|
||||||
|
async route(user: AuthUser, id: string) {
|
||||||
|
const route = await this.db.getRepository(Route).findOneBy({ id });
|
||||||
|
if (!route) throw new NotFoundException('Маршрут не найден');
|
||||||
|
await this.access.site(user, route.siteId);
|
||||||
|
return route;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(user: AuthUser, id: string) {
|
||||||
|
const [full] = await this.withCheckpoints([await this.route(user, id)]);
|
||||||
|
return { ...full, warnings: await this.warnings(full.siteId, full.path, full.checkpoints) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private parse(o: Record<string, unknown>) {
|
||||||
|
const raw = o.checkpoints;
|
||||||
|
if (raw !== undefined && !Array.isArray(raw)) throw new BadRequestException('checkpoints: нужен массив');
|
||||||
|
const checkpoints: CheckpointInput[] | undefined = (raw as unknown[] | undefined)?.map((item, index) => {
|
||||||
|
const c = obj(item, `checkpoints[${index}]`);
|
||||||
|
return {
|
||||||
|
id: isUuid(c.id) ? c.id : undefined,
|
||||||
|
position: index + 1,
|
||||||
|
name: str(c, 'name', { max: 200 }) || `Точка ${index + 1}`,
|
||||||
|
location: assertPoint(c.location, `checkpoints[${index}].location`),
|
||||||
|
radiusM: int(c, 'radiusM', { min: 1, max: 5000 }) ?? 30,
|
||||||
|
mediaRequired: oneOf(c, 'mediaRequired', MEDIA_REQUIREMENTS) ?? 'photo',
|
||||||
|
instruction: str(c, 'instruction', { max: 2000 }) ?? null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: o.name !== undefined ? reqStr(o, 'name', { max: 300 }) : undefined,
|
||||||
|
description: 'description' in o ? str(o, 'description', { max: 5000 }) ?? null : undefined,
|
||||||
|
path: 'path' in o ? (o.path === null ? null : assertLineString(o.path, 'path')) : undefined,
|
||||||
|
expectedMinutes: 'expectedMinutes' in o ? int(o, 'expectedMinutes', { min: 1, max: 24 * 60 }) ?? null : undefined,
|
||||||
|
strictOrder: bool(o, 'strictOrder'),
|
||||||
|
checkpoints,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Точки и линия за пределами периметра — не ошибка, а подсказка руководителю.
|
||||||
|
private async warnings(siteId: string, path: unknown, checkpoints: { name: string; location: unknown }[]) {
|
||||||
|
const [row] = await this.db.query(
|
||||||
|
`SELECT perimeter IS NOT NULL AS has,
|
||||||
|
($2::text IS NULL OR perimeter IS NULL OR ST_Covers(perimeter, ST_SetSRID(ST_GeomFromGeoJSON($2), 4326))) AS path_ok,
|
||||||
|
COALESCE((SELECT array_agg(p.idx) FROM jsonb_array_elements($3::jsonb) WITH ORDINALITY AS p(g, idx)
|
||||||
|
WHERE perimeter IS NOT NULL AND NOT ST_Covers(perimeter, ST_SetSRID(ST_GeomFromGeoJSON(p.g::text), 4326))), '{}') AS outside
|
||||||
|
FROM sites WHERE id = $1`,
|
||||||
|
[siteId, path ? JSON.stringify(path) : null, JSON.stringify(checkpoints.map((c) => c.location))],
|
||||||
|
);
|
||||||
|
const out: string[] = [];
|
||||||
|
if (!row?.has) return out;
|
||||||
|
if (!row.path_ok) out.push('Линия маршрута выходит за периметр объекта');
|
||||||
|
for (const idx of row.outside as number[]) out.push(`Точка «${checkpoints[Number(idx) - 1]?.name}» за периметром объекта`);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async saveCheckpoints(m: EntityManager, routeId: string, input: CheckpointInput[]) {
|
||||||
|
const repo = m.getRepository(Checkpoint);
|
||||||
|
const existing = await repo.find({ where: { routeId, archivedAt: IsNull() } });
|
||||||
|
const keep = new Set(input.map((c) => c.id).filter(Boolean));
|
||||||
|
const removed = existing.filter((c) => !keep.has(c.id));
|
||||||
|
if (removed.length) {
|
||||||
|
// Точку с отметками только архивируем — на неё ссылаются отчёты.
|
||||||
|
const used: { checkpoint_id: string }[] = await m.query(
|
||||||
|
`SELECT DISTINCT checkpoint_id FROM checkpoint_visits WHERE checkpoint_id = ANY($1)`,
|
||||||
|
[removed.map((c) => c.id)],
|
||||||
|
);
|
||||||
|
const usedIds = new Set(used.map((u) => u.checkpoint_id));
|
||||||
|
const toArchive = removed.filter((c) => usedIds.has(c.id)).map((c) => c.id);
|
||||||
|
const toDelete = removed.filter((c) => !usedIds.has(c.id)).map((c) => c.id);
|
||||||
|
if (toArchive.length) await repo.update({ id: In(toArchive) }, { archivedAt: new Date() });
|
||||||
|
if (toDelete.length) await repo.delete({ id: In(toDelete) });
|
||||||
|
}
|
||||||
|
const known = new Set(existing.map((c) => c.id));
|
||||||
|
for (const c of input) {
|
||||||
|
const { id, ...data } = c;
|
||||||
|
if (id && known.has(id)) await repo.update({ id }, data);
|
||||||
|
else await repo.insert({ ...data, routeId });
|
||||||
|
}
|
||||||
|
return repo.find({ where: { routeId, archivedAt: IsNull() }, order: { position: 'ASC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ещё не начатые обходы получают новую версию маршрута, начатые и прошлые остаются как были.
|
||||||
|
private async refreshPlannedRounds(m: EntityManager, route: Route, checkpoints: Checkpoint[]) {
|
||||||
|
const snapshot = snapshotOf(route, checkpoints);
|
||||||
|
await m.query(
|
||||||
|
`UPDATE rounds SET route_snapshot = $2, checkpoints_total = $3, updated_at = now()
|
||||||
|
WHERE route_id = $1 AND status = 'planned' AND planned_start > now()`,
|
||||||
|
[route.id, JSON.stringify(snapshot), snapshot.checkpoints.length],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: AuthUser, body: unknown) {
|
||||||
|
const o = obj(body);
|
||||||
|
const siteId = uuid(o, 'siteId', { required: true })!;
|
||||||
|
await this.access.site(user, siteId);
|
||||||
|
const data = this.parse(o);
|
||||||
|
if (!data.name) throw new BadRequestException('Поле «name» обязательно');
|
||||||
|
const id = await this.db.transaction(async (m) => {
|
||||||
|
const route = await m.getRepository(Route).save({
|
||||||
|
siteId,
|
||||||
|
name: data.name!,
|
||||||
|
description: data.description ?? null,
|
||||||
|
path: data.path ?? null,
|
||||||
|
expectedMinutes: data.expectedMinutes ?? null,
|
||||||
|
strictOrder: data.strictOrder ?? false,
|
||||||
|
});
|
||||||
|
await this.saveCheckpoints(m, route.id, data.checkpoints ?? []);
|
||||||
|
return route.id;
|
||||||
|
});
|
||||||
|
return this.get(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(user: AuthUser, id: string, body: unknown) {
|
||||||
|
const current = await this.route(user, id);
|
||||||
|
const data = this.parse(obj(body));
|
||||||
|
await this.db.transaction(async (m) => {
|
||||||
|
const patch: Partial<Route> = {};
|
||||||
|
if (data.name !== undefined) patch.name = data.name;
|
||||||
|
if (data.description !== undefined) patch.description = data.description;
|
||||||
|
if (data.path !== undefined) patch.path = data.path;
|
||||||
|
if (data.expectedMinutes !== undefined) patch.expectedMinutes = data.expectedMinutes;
|
||||||
|
if (data.strictOrder !== undefined) patch.strictOrder = data.strictOrder;
|
||||||
|
const geometryChanged = data.path !== undefined || data.checkpoints !== undefined || data.strictOrder !== undefined;
|
||||||
|
if (geometryChanged) patch.version = current.version + 1;
|
||||||
|
if (Object.keys(patch).length) await m.getRepository(Route).update({ id }, patch);
|
||||||
|
const checkpoints = data.checkpoints
|
||||||
|
? await this.saveCheckpoints(m, id, data.checkpoints)
|
||||||
|
: await m.getRepository(Checkpoint).find({ where: { routeId: id, archivedAt: IsNull() } });
|
||||||
|
if (geometryChanged || data.name !== undefined) {
|
||||||
|
const route = await m.getRepository(Route).findOneByOrFail({ id });
|
||||||
|
await this.refreshPlannedRounds(m, route, checkpoints);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return this.get(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(user: AuthUser, id: string) {
|
||||||
|
await this.route(user, id);
|
||||||
|
await this.db.getRepository(Route).update({ id }, { archivedAt: new Date() });
|
||||||
|
await this.db.query(`UPDATE patrol_schedules SET is_active = false WHERE route_id = $1`, [id]);
|
||||||
|
await this.db.query(`UPDATE rounds SET status = 'cancelled', updated_at = now() WHERE route_id = $1 AND status = 'planned'`, [id]);
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
}
|
||||||
44
apps/api/src/modules/schedules/schedules.controller.ts
Normal file
44
apps/api/src/modules/schedules/schedules.controller.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { AuthUser, CurrentUser, RequirePermissions } from '../../common/auth';
|
||||||
|
import { isUuid } from '../../common/input';
|
||||||
|
import { SchedulesService } from './schedules.service';
|
||||||
|
|
||||||
|
@Controller('schedules')
|
||||||
|
export class SchedulesController {
|
||||||
|
constructor(private readonly schedules: SchedulesService) {}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.view')
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: AuthUser, @Query('siteId') siteId?: string) {
|
||||||
|
return this.schedules.list(user, isUuid(siteId) ? siteId : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('schedules.manage')
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.schedules.create(user, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('schedules.manage')
|
||||||
|
@Patch(':id')
|
||||||
|
update(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.schedules.update(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.view')
|
||||||
|
@Get('assignments/:siteId')
|
||||||
|
assignments(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('siteId', ParseUUIDPipe) siteId: string,
|
||||||
|
@Query('from') from: string,
|
||||||
|
@Query('to') to: string,
|
||||||
|
) {
|
||||||
|
return this.schedules.assignments(user, siteId, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('schedules.manage')
|
||||||
|
@Post('assignments')
|
||||||
|
bulk(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.schedules.bulk(user, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
145
apps/api/src/modules/schedules/schedules.service.ts
Normal file
145
apps/api/src/modules/schedules/schedules.service.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, IsNull } from 'typeorm';
|
||||||
|
import { PatrolSchedule, Route, ScheduleAssignment, User } from '../../entities';
|
||||||
|
import { AccessService } from '../../common/access.service';
|
||||||
|
import { AuthUser, requireOrg } from '../../common/auth';
|
||||||
|
import { bool, date, int, isDateString, isUuid, obj, reqStr, strArray, uuid } from '../../common/input';
|
||||||
|
import { TIME_RE } from '../../common/time';
|
||||||
|
import { GeneratorService } from '../rounds/generator.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SchedulesService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly db: DataSource,
|
||||||
|
private readonly access: AccessService,
|
||||||
|
private readonly generator: GeneratorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(user: AuthUser, siteId?: string) {
|
||||||
|
const siteIds = siteId ? [(await this.access.site(user, siteId)).id] : await this.access.siteIds(user);
|
||||||
|
if (!siteIds.length) return [];
|
||||||
|
return this.db.query(
|
||||||
|
`SELECT s.id, s.site_id AS "siteId", s.route_id AS "routeId", r.name AS "routeName", s.name,
|
||||||
|
s.start_times AS "startTimes", s.window_minutes AS "windowMinutes",
|
||||||
|
s.late_tolerance_minutes AS "lateToleranceMinutes",
|
||||||
|
to_char(s.valid_from, 'YYYY-MM-DD') AS "validFrom", to_char(s.valid_to, 'YYYY-MM-DD') AS "validTo",
|
||||||
|
s.is_active AS "isActive"
|
||||||
|
FROM patrol_schedules s JOIN routes r ON r.id = s.route_id
|
||||||
|
WHERE s.site_id = ANY($1) ORDER BY s.is_active DESC, s.name`,
|
||||||
|
[siteIds],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async schedule(user: AuthUser, id: string) {
|
||||||
|
const s = await this.db.getRepository(PatrolSchedule).findOneBy({ id });
|
||||||
|
if (!s) throw new NotFoundException('График не найден');
|
||||||
|
await this.access.site(user, s.siteId);
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
private parse(o: Record<string, unknown>) {
|
||||||
|
const startTimes = strArray(o, 'startTimes');
|
||||||
|
if (startTimes) {
|
||||||
|
if (!startTimes.length) throw new BadRequestException('Нужно хотя бы одно время начала');
|
||||||
|
if (!startTimes.every((t) => TIME_RE.test(t))) throw new BadRequestException('Время начала в формате ЧЧ:ММ');
|
||||||
|
if (new Set(startTimes).size !== startTimes.length) throw new BadRequestException('Время начала повторяется');
|
||||||
|
}
|
||||||
|
const validFrom = date(o, 'validFrom');
|
||||||
|
const validTo = 'validTo' in o ? date(o, 'validTo') ?? null : undefined;
|
||||||
|
if (validFrom && validTo && validTo < validFrom) throw new BadRequestException('Дата окончания раньше даты начала');
|
||||||
|
return {
|
||||||
|
name: o.name !== undefined ? reqStr(o, 'name', { max: 200 }) : undefined,
|
||||||
|
startTimes,
|
||||||
|
windowMinutes: int(o, 'windowMinutes', { min: 5, max: 24 * 60 }),
|
||||||
|
lateToleranceMinutes: int(o, 'lateToleranceMinutes', { min: 0, max: 24 * 60 }),
|
||||||
|
validFrom,
|
||||||
|
validTo,
|
||||||
|
isActive: bool(o, 'isActive'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: AuthUser, body: unknown) {
|
||||||
|
const o = obj(body);
|
||||||
|
const routeId = uuid(o, 'routeId', { required: true })!;
|
||||||
|
const route = await this.db.getRepository(Route).findOneBy({ id: routeId });
|
||||||
|
if (!route || route.archivedAt) throw new BadRequestException('Маршрут не найден');
|
||||||
|
await this.access.site(user, route.siteId);
|
||||||
|
const d = this.parse(o);
|
||||||
|
if (!d.name || !d.startTimes) throw new BadRequestException('Нужны название и время начала');
|
||||||
|
const s = await this.db.getRepository(PatrolSchedule).save({
|
||||||
|
siteId: route.siteId,
|
||||||
|
routeId,
|
||||||
|
name: d.name,
|
||||||
|
startTimes: d.startTimes,
|
||||||
|
windowMinutes: d.windowMinutes ?? 60,
|
||||||
|
lateToleranceMinutes: d.lateToleranceMinutes ?? 15,
|
||||||
|
validFrom: d.validFrom ?? new Date().toISOString().slice(0, 10),
|
||||||
|
validTo: d.validTo ?? null,
|
||||||
|
isActive: d.isActive ?? true,
|
||||||
|
});
|
||||||
|
return { id: s.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(user: AuthUser, id: string, body: unknown) {
|
||||||
|
await this.schedule(user, id);
|
||||||
|
const d = this.parse(obj(body));
|
||||||
|
const patch = Object.fromEntries(Object.entries(d).filter(([, v]) => v !== undefined)) as Partial<PatrolSchedule>;
|
||||||
|
if (Object.keys(patch).length) await this.db.getRepository(PatrolSchedule).update({ id }, patch);
|
||||||
|
await this.generator.rebuild(id);
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignments(user: AuthUser, siteId: string, from: string, to: string) {
|
||||||
|
await this.access.site(user, siteId);
|
||||||
|
if (!isDateString(from) || !isDateString(to)) throw new BadRequestException('from и to: даты ГГГГ-ММ-ДД');
|
||||||
|
return this.db.query(
|
||||||
|
`SELECT a.id, a.schedule_id AS "scheduleId", a.user_id AS "userId", to_char(a.date, 'YYYY-MM-DD') AS date
|
||||||
|
FROM schedule_assignments a JOIN patrol_schedules s ON s.id = a.schedule_id
|
||||||
|
WHERE s.site_id = $1 AND a.date BETWEEN $2 AND $3`,
|
||||||
|
[siteId, from, to],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Массовая правка календаря: add/remove — пары (scheduleId, userId, date).
|
||||||
|
async bulk(user: AuthUser, body: unknown) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const o = obj(body);
|
||||||
|
const parseItems = (key: string) => {
|
||||||
|
const raw = o[key];
|
||||||
|
if (raw === undefined) return [];
|
||||||
|
if (!Array.isArray(raw) || raw.length > 5000) throw new BadRequestException(`${key}: нужен массив до 5000 элементов`);
|
||||||
|
return raw.map((item, i) => {
|
||||||
|
const it = obj(item, `${key}[${i}]`);
|
||||||
|
if (!isUuid(it.scheduleId) || !isUuid(it.userId) || !isDateString(it.date)) {
|
||||||
|
throw new BadRequestException(`${key}[${i}]: нужны scheduleId, userId и date`);
|
||||||
|
}
|
||||||
|
return { scheduleId: it.scheduleId as string, userId: it.userId as string, date: it.date as string };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const add = parseItems('add');
|
||||||
|
const remove = parseItems('remove');
|
||||||
|
const scheduleIds = Array.from(new Set([...add, ...remove].map((i) => i.scheduleId)));
|
||||||
|
for (const id of scheduleIds) await this.schedule(user, id);
|
||||||
|
const userIds = Array.from(new Set(add.map((i) => i.userId)));
|
||||||
|
if (userIds.length) {
|
||||||
|
const count = await this.db.getRepository(User).count({ where: { id: In(userIds), organizationId: orgId, isActive: true, removedAt: IsNull() } });
|
||||||
|
if (count !== userIds.length) throw new BadRequestException('Часть сотрудников не найдена или отключена');
|
||||||
|
}
|
||||||
|
await this.db.transaction(async (m) => {
|
||||||
|
for (const r of remove) await m.getRepository(ScheduleAssignment).delete(r);
|
||||||
|
if (add.length) {
|
||||||
|
await m
|
||||||
|
.createQueryBuilder()
|
||||||
|
.insert()
|
||||||
|
.into(ScheduleAssignment)
|
||||||
|
.values(add.map((a) => ({ ...a, createdBy: user.id })))
|
||||||
|
.orIgnore()
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let created = 0;
|
||||||
|
for (const id of scheduleIds) created += await this.generator.generate(id);
|
||||||
|
return { added: add.length, removed: remove.length, roundsCreated: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
38
apps/api/src/modules/sites/sites.controller.ts
Normal file
38
apps/api/src/modules/sites/sites.controller.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||||
|
import { AuthUser, CurrentUser, RequirePermissions } from '../../common/auth';
|
||||||
|
import { SitesService } from './sites.service';
|
||||||
|
|
||||||
|
@Controller('sites')
|
||||||
|
export class SitesController {
|
||||||
|
constructor(private readonly sites: SitesService) {}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.view')
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.sites.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.view')
|
||||||
|
@Get(':id')
|
||||||
|
get(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.sites.get(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.manage')
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.sites.create(user, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.manage')
|
||||||
|
@Patch(':id')
|
||||||
|
update(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.sites.update(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('sites.manage')
|
||||||
|
@Post(':id/archive')
|
||||||
|
archive(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.sites.archive(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
93
apps/api/src/modules/sites/sites.service.ts
Normal file
93
apps/api/src/modules/sites/sites.service.ts
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In } from 'typeorm';
|
||||||
|
import { Site } from '../../entities';
|
||||||
|
import { AccessService } from '../../common/access.service';
|
||||||
|
import { AuthUser, requireOrg } from '../../common/auth';
|
||||||
|
import { assertMultiPolygon } from '../../common/geo';
|
||||||
|
import { obj, reqStr, str } from '../../common/input';
|
||||||
|
import { isValidTimeZone } from '../../common/time';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SitesService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly db: DataSource,
|
||||||
|
private readonly access: AccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(user: AuthUser) {
|
||||||
|
const ids = await this.access.siteIds(user);
|
||||||
|
if (!ids.length) return [];
|
||||||
|
const sites = await this.db.getRepository(Site).find({ where: { id: In(ids) }, order: { name: 'ASC' } });
|
||||||
|
const counts: { site_id: string; routes: number; rounds_today: number }[] = await this.db.query(
|
||||||
|
`SELECT s.id AS site_id,
|
||||||
|
(SELECT count(*)::int FROM routes r WHERE r.site_id = s.id AND r.archived_at IS NULL) AS routes,
|
||||||
|
(SELECT count(*)::int FROM rounds o WHERE o.site_id = s.id
|
||||||
|
AND o.planned_start >= date_trunc('day', now() AT TIME ZONE s.timezone) AT TIME ZONE s.timezone
|
||||||
|
AND o.planned_start < (date_trunc('day', now() AT TIME ZONE s.timezone) + interval '1 day') AT TIME ZONE s.timezone
|
||||||
|
) AS rounds_today
|
||||||
|
FROM sites s WHERE s.id = ANY($1)`,
|
||||||
|
[ids],
|
||||||
|
);
|
||||||
|
const byId = new Map(counts.map((c) => [c.site_id, c]));
|
||||||
|
return sites.map((s) => ({ ...s, routesCount: byId.get(s.id)?.routes ?? 0, roundsToday: byId.get(s.id)?.rounds_today ?? 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
get(user: AuthUser, id: string) {
|
||||||
|
return this.access.site(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем геометрию средствами PostGIS и считаем центр для карты.
|
||||||
|
private async geometry(perimeter: unknown) {
|
||||||
|
if (perimeter === null) return { perimeter: null, center: null };
|
||||||
|
const mp = assertMultiPolygon(perimeter, 'perimeter');
|
||||||
|
const [row] = await this.db.query(
|
||||||
|
`SELECT ST_IsValid(g) AS valid, ST_IsValidReason(g) AS reason, ST_AsGeoJSON(ST_PointOnSurface(g))::json AS center
|
||||||
|
FROM (SELECT ST_SetSRID(ST_GeomFromGeoJSON($1), 4326) AS g) t`,
|
||||||
|
[JSON.stringify(mp)],
|
||||||
|
);
|
||||||
|
if (!row.valid) throw new BadRequestException(`Периметр нарисован с ошибкой (${row.reason}). Проверьте, что стороны не пересекаются`);
|
||||||
|
return { perimeter: mp, center: row.center };
|
||||||
|
}
|
||||||
|
|
||||||
|
private fields(o: Record<string, unknown>) {
|
||||||
|
const timezone = str(o, 'timezone', { max: 64 });
|
||||||
|
if (timezone && !isValidTimeZone(timezone)) throw new BadRequestException('Неизвестный часовой пояс');
|
||||||
|
return {
|
||||||
|
...(o.name !== undefined ? { name: reqStr(o, 'name', { max: 300 }) } : {}),
|
||||||
|
...('address' in o ? { address: str(o, 'address', { max: 500 }) ?? null } : {}),
|
||||||
|
...('description' in o ? { description: str(o, 'description', { max: 5000 }) ?? null } : {}),
|
||||||
|
...(timezone ? { timezone } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: AuthUser, body: unknown) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const o = obj(body);
|
||||||
|
reqStr(o, 'name');
|
||||||
|
const geo = 'perimeter' in o ? await this.geometry(o.perimeter) : {};
|
||||||
|
const site = await this.db.getRepository(Site).save({
|
||||||
|
organizationId: orgId,
|
||||||
|
createdBy: user.id,
|
||||||
|
...this.fields(o),
|
||||||
|
...geo,
|
||||||
|
} as Partial<Site>);
|
||||||
|
return this.access.site(user, site.id).catch(() => site);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(user: AuthUser, id: string, body: unknown) {
|
||||||
|
await this.access.site(user, id);
|
||||||
|
const o = obj(body);
|
||||||
|
const geo = 'perimeter' in o ? await this.geometry(o.perimeter) : {};
|
||||||
|
await this.db.getRepository(Site).update({ id }, { ...this.fields(o), ...geo } as Partial<Site>);
|
||||||
|
return this.access.site(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async archive(user: AuthUser, id: string) {
|
||||||
|
await this.access.site(user, id);
|
||||||
|
await this.db.getRepository(Site).update({ id }, { archivedAt: new Date() });
|
||||||
|
// Будущие обходы архивного объекта не нужны.
|
||||||
|
await this.db.query(`UPDATE rounds SET status = 'cancelled', updated_at = now() WHERE site_id = $1 AND status = 'planned'`, [id]);
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
}
|
||||||
103
apps/api/src/modules/users-sync/applier.ts
Normal file
103
apps/api/src/modules/users-sync/applier.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, EntityManager, IsNull } from 'typeorm';
|
||||||
|
import { User, UserSession } from '../../entities';
|
||||||
|
import {
|
||||||
|
GROUP_DELETED,
|
||||||
|
GROUP_UPSERTED,
|
||||||
|
KIND_USER_PROFILE,
|
||||||
|
normalizeProfile,
|
||||||
|
SyncEvent,
|
||||||
|
USER_GROUPS_UPSERTED,
|
||||||
|
USER_PROFILE_UPSERTED,
|
||||||
|
USER_REMOVED_FROM_PLATFORM,
|
||||||
|
} from './event';
|
||||||
|
import { composeFullName, PROFILE_COLUMNS } from './profile';
|
||||||
|
import { UsersSyncQueue } from './queue';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Применение событий паспорта к людям patrol. Одна транзакция на событие, LWW по version
|
||||||
|
* (строгое сравнение: своё эхо и повторная доставка приходят с той же версией и не проходят).
|
||||||
|
*
|
||||||
|
* Применение входящего ничего не ставит в очередь отправки: в шину уходят только правки,
|
||||||
|
* сделанные в самом patrol.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class UsersSyncApplier {
|
||||||
|
constructor(@InjectDataSource() private readonly db: DataSource) {}
|
||||||
|
|
||||||
|
apply(event: SyncEvent): Promise<string> {
|
||||||
|
switch (event.event_type) {
|
||||||
|
case USER_PROFILE_UPSERTED:
|
||||||
|
return this.db.transaction((m) => this.applyProfile(m, event));
|
||||||
|
case USER_REMOVED_FROM_PLATFORM:
|
||||||
|
return this.db.transaction((m) => this.applyRemoved(m, event));
|
||||||
|
case GROUP_UPSERTED:
|
||||||
|
case GROUP_DELETED:
|
||||||
|
case USER_GROUPS_UPSERTED:
|
||||||
|
// Роли patrol свои, группы паспорта здесь не используются.
|
||||||
|
return Promise.resolve('skipped: groups are not used in patrol');
|
||||||
|
default:
|
||||||
|
return Promise.resolve(`skipped: ${event.event_type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyProfile(m: EntityManager, event: SyncEvent): Promise<string> {
|
||||||
|
const uuid = event.user_uuid!.toLowerCase();
|
||||||
|
if (!(await UsersSyncQueue.isNewer(m, KIND_USER_PROFILE, uuid, event.version))) return 'stale';
|
||||||
|
|
||||||
|
const profile = normalizeProfile(event.profile ?? {});
|
||||||
|
const repo = m.getRepository(User);
|
||||||
|
const user = await repo.findOneBy({ passportUuid: uuid });
|
||||||
|
|
||||||
|
const patch: Partial<User> = {};
|
||||||
|
for (const [field, column] of Object.entries(PROFILE_COLUMNS)) {
|
||||||
|
if (field in profile) (patch as Record<string, unknown>)[column] = profile[field as keyof typeof profile] ?? null;
|
||||||
|
}
|
||||||
|
if ('username' in profile) patch.login = profile.username ?? null;
|
||||||
|
if (typeof profile.is_enabled === 'boolean') patch.passportEnabled = profile.is_enabled;
|
||||||
|
|
||||||
|
let result: string;
|
||||||
|
if (user) {
|
||||||
|
const merged = { ...user, ...patch };
|
||||||
|
patch.fullName = composeFullName(merged, merged.login || merged.email || 'Без имени');
|
||||||
|
// Паспорт снова прислал профиль — человек на платформе (его вернули после удаления).
|
||||||
|
if (user.removedAt) patch.removedAt = null;
|
||||||
|
await repo.update({ id: user.id }, patch);
|
||||||
|
if (patch.passportEnabled === false && user.passportEnabled) await this.revoke(m, user.id);
|
||||||
|
result = 'applied';
|
||||||
|
} else {
|
||||||
|
// Человека добавили на платформу в паспорте. Без организации и роли он ничего не может,
|
||||||
|
// пока администратор организации не возьмёт его к себе.
|
||||||
|
await repo.insert({
|
||||||
|
passportUuid: uuid,
|
||||||
|
organizationId: null,
|
||||||
|
roleId: null,
|
||||||
|
...patch,
|
||||||
|
fullName: composeFullName(patch, patch.login || patch.email || 'Без имени'),
|
||||||
|
});
|
||||||
|
result = 'applied: person created';
|
||||||
|
}
|
||||||
|
|
||||||
|
await UsersSyncQueue.setVersion(m, KIND_USER_PROFILE, uuid, event.version);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Убрали с платформы patrol в паспорте (или удалили человека): закрываем доступ, строку оставляем для отчётов.
|
||||||
|
private async applyRemoved(m: EntityManager, event: SyncEvent): Promise<string> {
|
||||||
|
const uuid = event.user_uuid!.toLowerCase();
|
||||||
|
if (!(await UsersSyncQueue.isNewer(m, KIND_USER_PROFILE, uuid, event.version))) return 'stale';
|
||||||
|
|
||||||
|
const user = await m.getRepository(User).findOneBy({ passportUuid: uuid, removedAt: IsNull() });
|
||||||
|
if (user) {
|
||||||
|
await m.getRepository(User).update({ id: user.id }, { removedAt: new Date() });
|
||||||
|
await this.revoke(m, user.id);
|
||||||
|
}
|
||||||
|
await UsersSyncQueue.setVersion(m, KIND_USER_PROFILE, uuid, event.version);
|
||||||
|
return user ? 'applied' : 'skipped: user not found';
|
||||||
|
}
|
||||||
|
|
||||||
|
private async revoke(m: EntityManager, userId: string) {
|
||||||
|
await m.getRepository(UserSession).update({ userId, revokedAt: IsNull() }, { revokedAt: new Date() });
|
||||||
|
}
|
||||||
|
}
|
||||||
104
apps/api/src/modules/users-sync/event.ts
Normal file
104
apps/api/src/modules/users-sync/event.ts
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Контракт топика aspans.users.changed — тот же, что GroupsSyncEvent в паспорте и symfony.
|
||||||
|
*
|
||||||
|
* Конверт: event_id, event_type, occurred_at, idempotency_key, source, platform_code,
|
||||||
|
* version (момент правки, UTC, микросекунды; LWW). Ключ сообщения — platform_code.
|
||||||
|
*
|
||||||
|
* patrol использует только профиль: роли и организации живут здесь, группы паспорта
|
||||||
|
* (у patrol одна служебная patrol_user) не синхронизируем — их события пропускаем.
|
||||||
|
*/
|
||||||
|
export const KIND_USER_PROFILE = 'user_profile';
|
||||||
|
|
||||||
|
export const GROUP_UPSERTED = 'GroupUpsertedEvent';
|
||||||
|
export const GROUP_DELETED = 'GroupDeletedEvent';
|
||||||
|
export const USER_GROUPS_UPSERTED = 'UserGroupsUpsertedEvent';
|
||||||
|
export const USER_PROFILE_UPSERTED = 'UserProfileUpsertedEvent';
|
||||||
|
export const USER_REMOVED_FROM_PLATFORM = 'UserRemovedFromPlatformEvent';
|
||||||
|
|
||||||
|
const KNOWN = [GROUP_UPSERTED, GROUP_DELETED, USER_GROUPS_UPSERTED, USER_PROFILE_UPSERTED, USER_REMOVED_FROM_PLATFORM];
|
||||||
|
|
||||||
|
export const PROFILE_STRING_FIELDS = [
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'middle_name',
|
||||||
|
'email',
|
||||||
|
'phone',
|
||||||
|
'avatar',
|
||||||
|
'iin',
|
||||||
|
'birthday',
|
||||||
|
'gender',
|
||||||
|
'username',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type Profile = Partial<Record<(typeof PROFILE_STRING_FIELDS)[number], string | null>> & { is_enabled?: boolean };
|
||||||
|
|
||||||
|
export type SyncEvent = {
|
||||||
|
event_id: string;
|
||||||
|
event_type: string;
|
||||||
|
occurred_at: string;
|
||||||
|
idempotency_key: string;
|
||||||
|
source: string;
|
||||||
|
platform_code: string;
|
||||||
|
version: string;
|
||||||
|
user_uuid?: string;
|
||||||
|
profile?: Profile;
|
||||||
|
};
|
||||||
|
|
||||||
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function envelope(type: string, source: string, platformCode: string, version: string, idempotencyKey: string): SyncEvent {
|
||||||
|
return {
|
||||||
|
event_id: randomUUID(),
|
||||||
|
event_type: type,
|
||||||
|
occurred_at: new Date().toISOString().replace(/\.(\d{3})Z$/, '.$1000Z'),
|
||||||
|
idempotency_key: idempotencyKey,
|
||||||
|
source,
|
||||||
|
platform_code: platformCode,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userProfileUpserted(source: string, platformCode: string, version: string, userUuid: string, profile: Profile): SyncEvent {
|
||||||
|
return {
|
||||||
|
...envelope(USER_PROFILE_UPSERTED, source, platformCode, version, `user-profile-${platformCode}-${userUuid}-${version}`),
|
||||||
|
user_uuid: userUuid,
|
||||||
|
profile: normalizeProfile(profile),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Строки обрезаны, пустые → null, birthday — YYYY-MM-DD. Нет ключа — поле отправителю неизвестно. */
|
||||||
|
export function normalizeProfile(profile: Record<string, unknown>): Profile {
|
||||||
|
const result: Profile = {};
|
||||||
|
for (const field of PROFILE_STRING_FIELDS) {
|
||||||
|
if (!(field in profile)) continue;
|
||||||
|
const v = profile[field];
|
||||||
|
const s = v === null || v === undefined ? '' : typeof v === 'string' || typeof v === 'number' ? String(v).trim() : '';
|
||||||
|
result[field] = s === '' ? null : s;
|
||||||
|
}
|
||||||
|
if (result.birthday) result.birthday = /^\d{4}-\d{2}-\d{2}/.exec(result.birthday)?.[0] ?? null;
|
||||||
|
if (typeof profile.is_enabled === 'boolean') result.is_enabled = profile.is_enabled;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Проверка входящего сообщения: текст ошибки или null. */
|
||||||
|
export function validate(event: unknown): string | null {
|
||||||
|
if (!event || typeof event !== 'object' || Array.isArray(event)) return 'payload is not a JSON object';
|
||||||
|
const e = event as Record<string, unknown>;
|
||||||
|
for (const f of ['event_type', 'source', 'platform_code', 'version']) {
|
||||||
|
if (typeof e[f] !== 'string' || !e[f]) return `missing field ${f}`;
|
||||||
|
}
|
||||||
|
if (Number.isNaN(Date.parse(e.version as string))) return 'bad version';
|
||||||
|
if (!KNOWN.includes(e.event_type as string)) return `unknown event_type ${e.event_type}`;
|
||||||
|
if (e.event_type === USER_PROFILE_UPSERTED || e.event_type === USER_REMOVED_FROM_PLATFORM) {
|
||||||
|
if (typeof e.user_uuid !== 'string' || !UUID_RE.test(e.user_uuid)) return 'bad user_uuid';
|
||||||
|
}
|
||||||
|
if (e.event_type === USER_PROFILE_UPSERTED) {
|
||||||
|
if (!e.profile || typeof e.profile !== 'object') return 'bad profile';
|
||||||
|
const p = e.profile as Record<string, unknown>;
|
||||||
|
for (const f of PROFILE_STRING_FIELDS) if (p[f] !== undefined && p[f] !== null && typeof p[f] !== 'string') return `bad profile.${f}`;
|
||||||
|
if (p.is_enabled !== undefined && typeof p.is_enabled !== 'boolean') return 'bad profile.is_enabled';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
42
apps/api/src/modules/users-sync/kafka.ts
Normal file
42
apps/api/src/modules/users-sync/kafka.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { KafkaJS } from '@confluentinc/kafka-javascript';
|
||||||
|
import { config } from '../../config';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Подключение к общему кластеру Aspans: SASL_PLAINTEXT + SCRAM-SHA-512 внутри kafka-net —
|
||||||
|
* те же настройки, что KafkaFactory в паспорте и symfony (там ext-rdkafka, здесь та же librdkafka).
|
||||||
|
*/
|
||||||
|
function base() {
|
||||||
|
return {
|
||||||
|
'bootstrap.servers': config.kafka.brokers,
|
||||||
|
'security.protocol': 'sasl_plaintext' as const,
|
||||||
|
'sasl.mechanisms': 'SCRAM-SHA-512',
|
||||||
|
'sasl.username': config.kafka.username,
|
||||||
|
'sasl.password': config.kafka.password,
|
||||||
|
'client.id': `${config.kafka.username}-users-sync`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const kafkaEnabled = () => !!(config.kafka.brokers && config.kafka.username && config.kafka.password);
|
||||||
|
|
||||||
|
// Группа консьюмера обязана начинаться с имени учётки — так выданы ACL в кластере.
|
||||||
|
export const consumerGroup = () => `${config.kafka.username}.users-sync`;
|
||||||
|
|
||||||
|
export function createProducer() {
|
||||||
|
return new KafkaJS.Kafka({}).producer({
|
||||||
|
...base(),
|
||||||
|
acks: -1,
|
||||||
|
'enable.idempotence': true,
|
||||||
|
'max.in.flight.requests.per.connection': 5,
|
||||||
|
'compression.type': 'lz4',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createConsumer() {
|
||||||
|
return new KafkaJS.Kafka({}).consumer({
|
||||||
|
...base(),
|
||||||
|
'group.id': consumerGroup(),
|
||||||
|
'auto.offset.reset': 'earliest',
|
||||||
|
'enable.auto.commit': false,
|
||||||
|
'isolation.level': 'read_committed',
|
||||||
|
});
|
||||||
|
}
|
||||||
21
apps/api/src/modules/users-sync/profile.ts
Normal file
21
apps/api/src/modules/users-sync/profile.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import type { Profile } from './event';
|
||||||
|
|
||||||
|
/** «Фамилия Имя Отчество» — как показываем человека в patrol. */
|
||||||
|
export function composeFullName(p: { lastName?: string | null; firstName?: string | null; middleName?: string | null }, fallback = 'Без имени') {
|
||||||
|
return [p.lastName, p.firstName, p.middleName].filter((x) => x && x.trim()).join(' ').trim() || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поле события → колонка users. Чего здесь нет (аватар, ИИН, пол…), patrol не хранит и не отправляет. */
|
||||||
|
export const PROFILE_COLUMNS = {
|
||||||
|
first_name: 'firstName',
|
||||||
|
last_name: 'lastName',
|
||||||
|
middle_name: 'middleName',
|
||||||
|
email: 'email',
|
||||||
|
phone: 'phone',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type LocalProfile = { firstName: string | null; lastName: string | null; middleName: string | null; email: string | null; phone: string | null };
|
||||||
|
|
||||||
|
export function toEventProfile(u: LocalProfile): Profile {
|
||||||
|
return { first_name: u.firstName, last_name: u.lastName, middle_name: u.middleName, email: u.email, phone: u.phone };
|
||||||
|
}
|
||||||
86
apps/api/src/modules/users-sync/queue.ts
Normal file
86
apps/api/src/modules/users-sync/queue.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { DataSource, EntityManager } from 'typeorm';
|
||||||
|
|
||||||
|
type Db = DataSource | EntityManager;
|
||||||
|
|
||||||
|
// Версия в формате контракта: UTC, микросекунды.
|
||||||
|
const VERSION_SQL = (col: string) => `to_char(${col} AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')`;
|
||||||
|
|
||||||
|
export type PendingRow = { kind: string; ref: string; changed_at: string; attempts: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очередь «что отправить в шину» и версии правок (LWW) — та же схема, что
|
||||||
|
* GroupsSyncQueue в паспорте и symfony, только без platform_code: платформа здесь одна.
|
||||||
|
*/
|
||||||
|
export const UsersSyncQueue = {
|
||||||
|
/** Локальная правка: новая версия и строка в очередь отправки. Вызывать в той же транзакции, что и правку. */
|
||||||
|
async touch(db: Db, kind: string, ref: string) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO users_sync_state (kind, ref, version) VALUES ($1, $2, clock_timestamp())
|
||||||
|
ON CONFLICT (kind, ref) DO UPDATE SET version = EXCLUDED.version`,
|
||||||
|
[kind, ref],
|
||||||
|
);
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO users_sync_pending (kind, ref) VALUES ($1, $2)
|
||||||
|
ON CONFLICT (kind, ref) DO UPDATE SET changed_at = clock_timestamp(), attempts = 0, last_error = NULL, next_at = now()`,
|
||||||
|
[kind, ref],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async due(db: Db, limit: number): Promise<PendingRow[]> {
|
||||||
|
return db.query(
|
||||||
|
`SELECT kind, ref, changed_at::text AS changed_at, attempts FROM users_sync_pending
|
||||||
|
WHERE next_at <= now() ORDER BY changed_at LIMIT $1`,
|
||||||
|
[Math.max(1, limit)],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Удаляем, только если после отправки строку не тронули заново (иначе свежая правка потерялась бы).
|
||||||
|
async done(db: Db, row: PendingRow) {
|
||||||
|
await db.query(`DELETE FROM users_sync_pending WHERE kind = $1 AND ref = $2 AND changed_at = $3::timestamptz`, [
|
||||||
|
row.kind,
|
||||||
|
row.ref,
|
||||||
|
row.changed_at,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
async failed(db: Db, row: PendingRow, error: string) {
|
||||||
|
await db.query(
|
||||||
|
`UPDATE users_sync_pending
|
||||||
|
SET attempts = attempts + 1, last_error = $3,
|
||||||
|
next_at = now() + make_interval(secs => LEAST(600, 5 * power(2, LEAST(attempts, 10))))
|
||||||
|
WHERE kind = $1 AND ref = $2`,
|
||||||
|
[row.kind, row.ref, error.slice(0, 2000)],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async version(db: Db, kind: string, ref: string): Promise<string> {
|
||||||
|
const [row] = await db.query(
|
||||||
|
`INSERT INTO users_sync_state (kind, ref, version) VALUES ($1, $2, clock_timestamp())
|
||||||
|
ON CONFLICT (kind, ref) DO UPDATE SET version = users_sync_state.version
|
||||||
|
RETURNING ${VERSION_SQL('version')} AS v`,
|
||||||
|
[kind, ref],
|
||||||
|
);
|
||||||
|
return row.v;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Входящая версия новее нашей? Блокирует строку до конца транзакции — параллельный консьюмер подождёт. */
|
||||||
|
async isNewer(db: Db, kind: string, ref: string, version: string): Promise<boolean> {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO users_sync_state (kind, ref, version) VALUES ($1, $2, 'epoch') ON CONFLICT (kind, ref) DO NOTHING`,
|
||||||
|
[kind, ref],
|
||||||
|
);
|
||||||
|
const [row] = await db.query(
|
||||||
|
`SELECT $3::timestamptz > version AS newer FROM users_sync_state WHERE kind = $1 AND ref = $2 FOR UPDATE`,
|
||||||
|
[kind, ref, version],
|
||||||
|
);
|
||||||
|
return !!row?.newer;
|
||||||
|
},
|
||||||
|
|
||||||
|
async setVersion(db: Db, kind: string, ref: string, version: string) {
|
||||||
|
await db.query(
|
||||||
|
`INSERT INTO users_sync_state (kind, ref, version) VALUES ($1, $2, $3::timestamptz)
|
||||||
|
ON CONFLICT (kind, ref) DO UPDATE SET version = EXCLUDED.version`,
|
||||||
|
[kind, ref, version],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
23
apps/api/src/modules/users-sync/snapshot.ts
Normal file
23
apps/api/src/modules/users-sync/snapshot.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, IsNull } from 'typeorm';
|
||||||
|
import { config } from '../../config';
|
||||||
|
import { User } from '../../entities';
|
||||||
|
import { KIND_USER_PROFILE, SyncEvent, userProfileUpserted } from './event';
|
||||||
|
import { toEventProfile } from './profile';
|
||||||
|
import { UsersSyncQueue } from './queue';
|
||||||
|
|
||||||
|
/** Снимок для отправки: полное текущее состояние, а не дельта — потерянное сообщение лечится следующим. */
|
||||||
|
@Injectable()
|
||||||
|
export class UsersSyncSnapshots {
|
||||||
|
constructor(@InjectDataSource() private readonly db: DataSource) {}
|
||||||
|
|
||||||
|
/** null — отправлять нечего (человека на платформе уже нет), строку из очереди убрать. */
|
||||||
|
async build(kind: string, ref: string): Promise<SyncEvent | null> {
|
||||||
|
if (kind !== KIND_USER_PROFILE) return null;
|
||||||
|
const user = await this.db.getRepository(User).findOneBy({ passportUuid: ref, removedAt: IsNull() });
|
||||||
|
if (!user) return null;
|
||||||
|
const version = await UsersSyncQueue.version(this.db, kind, ref);
|
||||||
|
return userProfileUpserted(config.kafka.source, config.passport.platformCode, version, ref, toEventProfile(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/api/src/modules/users-sync/sync.module.ts
Normal file
14
apps/api/src/modules/users-sync/sync.module.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { dataSourceOptions } from '../../database/data-source';
|
||||||
|
import { ENTITIES } from '../../entities';
|
||||||
|
import { UsersSyncApplier } from './applier';
|
||||||
|
import { UsersSyncSnapshots } from './snapshot';
|
||||||
|
|
||||||
|
// Отдельный процесс синхронизации (как users_sync_relay / users_sync_consumer у паспорта и symfony):
|
||||||
|
// только база и логика синхронизации, без HTTP.
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forRoot({ ...dataSourceOptions, migrationsRun: false }), TypeOrmModule.forFeature(ENTITIES)],
|
||||||
|
providers: [UsersSyncApplier, UsersSyncSnapshots],
|
||||||
|
})
|
||||||
|
export class UsersSyncModule {}
|
||||||
125
apps/api/src/modules/users/people.service.ts
Normal file
125
apps/api/src/modules/users/people.service.ts
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
|
||||||
|
import { EntityManager } from 'typeorm';
|
||||||
|
import { Organization, User, UserSite } from '../../entities';
|
||||||
|
import { reqStr, str, uuid as uuidField } from '../../common/input';
|
||||||
|
import { PassportClient, PassportPerson } from '../passport/passport.client';
|
||||||
|
import { composeFullName } from '../users-sync/profile';
|
||||||
|
|
||||||
|
export type NewPersonInput = {
|
||||||
|
lastName: string;
|
||||||
|
firstName: string;
|
||||||
|
middleName: string | null;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PersonInput = { passportUuid: string } | NewPersonInput;
|
||||||
|
|
||||||
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
|
|
||||||
|
/** Телефон в виде паспорта: 11 цифр, 7XXXXXXXXXX. */
|
||||||
|
export function normalizePhone(raw: string): string {
|
||||||
|
let d = raw.replace(/\D/g, '');
|
||||||
|
if (d.length === 10) d = '7' + d;
|
||||||
|
if (d.length === 11 && d.startsWith('8')) d = '7' + d.slice(1);
|
||||||
|
if (!/^7\d{10}$/.test(d)) throw new BadRequestException('Телефон: 11 цифр, например +7 701 234 56 78');
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEmail(raw: string): string {
|
||||||
|
const v = raw.trim().toLowerCase();
|
||||||
|
if (!EMAIL_RE.test(v)) throw new BadRequestException('Неверный email');
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Человек в теле запроса: либо uuid существующего в паспорте, либо данные нового. */
|
||||||
|
export function parsePerson(o: Record<string, unknown>, opts: { allowExisting: boolean }): PersonInput {
|
||||||
|
const passportUuid = uuidField(o, 'passportUuid');
|
||||||
|
if (passportUuid) {
|
||||||
|
if (!opts.allowExisting) throw new BadRequestException('Выбор существующего человека здесь недоступен');
|
||||||
|
return { passportUuid: passportUuid.toLowerCase() };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
lastName: reqStr(o, 'lastName', { max: 100 }),
|
||||||
|
firstName: reqStr(o, 'firstName', { max: 100 }),
|
||||||
|
middleName: str(o, 'middleName', { max: 100 }) ?? null,
|
||||||
|
email: normalizeEmail(reqStr(o, 'email', { max: 200 })),
|
||||||
|
phone: normalizePhone(reqStr(o, 'phone', { max: 50 })),
|
||||||
|
password: reqStr(o, 'password', { min: 8, max: 200 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Люди patrol = люди паспорта. Сначала паспорт (завести или привязать к платформе patrol),
|
||||||
|
* потом своя запись: организация, роль, объекты. Паспортный шаг транзакцией не откатить, но он
|
||||||
|
* безопасен для повтора: привязанный человек без записи здесь просто ничего не может.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PeopleService {
|
||||||
|
constructor(private readonly passport: PassportClient) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* trusted — запрос от администратора (организации или платформы): человека с теми же email и
|
||||||
|
* телефоном, что уже есть в паспорте, привязываем. Без доверия (саморегистрация) — только
|
||||||
|
* если указан его пароль от Aspans ID.
|
||||||
|
*/
|
||||||
|
async provision(input: PersonInput, opts: { trusted: boolean }): Promise<PassportPerson> {
|
||||||
|
if ('passportUuid' in input) {
|
||||||
|
const u = await this.passport.link(input.passportUuid);
|
||||||
|
return u;
|
||||||
|
}
|
||||||
|
const r = await this.passport.createOrGet(input);
|
||||||
|
if (!r.existed) return r.person;
|
||||||
|
if (!opts.trusted) {
|
||||||
|
const own = await this.passport.verifyPassword(input.email, input.password);
|
||||||
|
if (!own || own.uuid !== r.person.uuid) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'Человек с таким email или телефоном уже есть в Aspans ID. Укажите пароль от Aspans ID или войдите через Aspans ID',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r.onPlatform ? r.person : this.passport.link(r.person.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Своя запись в организации. Один человек — одна организация в patrol. */
|
||||||
|
async attach(m: EntityManager, person: PassportPerson, orgId: string, roleId: string, siteIds: string[] = []) {
|
||||||
|
const repo = m.getRepository(User);
|
||||||
|
const existing = await repo.findOneBy({ passportUuid: person.uuid });
|
||||||
|
if (existing && !existing.removedAt && existing.organizationId && existing.organizationId !== orgId) {
|
||||||
|
const other = await m.getRepository(Organization).findOneBy({ id: existing.organizationId });
|
||||||
|
throw new ConflictException(`Этот человек уже работает в другой организации${other ? ` — «${other.name}»` : ''}`);
|
||||||
|
}
|
||||||
|
if (existing && !existing.removedAt && existing.organizationId === orgId) {
|
||||||
|
throw new ConflictException('Этот человек уже есть в вашей организации');
|
||||||
|
}
|
||||||
|
const data: Partial<User> = {
|
||||||
|
passportUuid: person.uuid,
|
||||||
|
organizationId: orgId,
|
||||||
|
roleId,
|
||||||
|
login: person.username,
|
||||||
|
firstName: person.firstName,
|
||||||
|
lastName: person.lastName,
|
||||||
|
middleName: person.middleName,
|
||||||
|
email: person.email,
|
||||||
|
phone: person.phone,
|
||||||
|
fullName: composeFullName(person, person.username || person.email || 'Без имени'),
|
||||||
|
passportEnabled: person.isEnabled,
|
||||||
|
isActive: true,
|
||||||
|
removedAt: null,
|
||||||
|
isPlatformAdmin: existing?.isPlatformAdmin ?? false,
|
||||||
|
};
|
||||||
|
let id: string;
|
||||||
|
if (existing) {
|
||||||
|
await repo.update({ id: existing.id }, data);
|
||||||
|
await m.getRepository(UserSite).delete({ userId: existing.id });
|
||||||
|
id = existing.id;
|
||||||
|
} else {
|
||||||
|
id = (await repo.save(data)).id;
|
||||||
|
}
|
||||||
|
if (siteIds.length) await m.getRepository(UserSite).insert(siteIds.map((siteId) => ({ userId: id, siteId })));
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
75
apps/api/src/modules/users/users.controller.ts
Normal file
75
apps/api/src/modules/users/users.controller.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Body, Controller, Delete, ForbiddenException, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
|
||||||
|
import { PERMISSION_LABELS, PERMISSIONS } from '@patrol/shared';
|
||||||
|
import { AuthUser, CurrentUser, RequirePermissions } from '../../common/auth';
|
||||||
|
import { isUuid } from '../../common/input';
|
||||||
|
import { UsersService } from './users.service';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class UsersController {
|
||||||
|
constructor(private readonly users: UsersService) {}
|
||||||
|
|
||||||
|
// Список нужен и для раздела «Сотрудники», и для календаря графика.
|
||||||
|
@Get('users')
|
||||||
|
list(@CurrentUser() user: AuthUser, @Query('siteId') siteId?: string) {
|
||||||
|
if (!user.permissions.has('users.manage') && !user.permissions.has('schedules.manage')) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав');
|
||||||
|
}
|
||||||
|
return this.users.list(user, isUuid(siteId) ? siteId : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Люди из паспорта — выбрать существующего вместо заведения дубля.
|
||||||
|
@RequirePermissions('users.manage')
|
||||||
|
@Get('users/passport-search')
|
||||||
|
searchPassport(@CurrentUser() user: AuthUser, @Query('q') q?: string) {
|
||||||
|
return this.users.searchPassport(user, q ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('users.manage')
|
||||||
|
@Post('users')
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.users.create(user, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('users.manage')
|
||||||
|
@Patch('users/:id')
|
||||||
|
update(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.users.update(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('users.manage')
|
||||||
|
@Delete('users/:id')
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.users.remove(user, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('roles')
|
||||||
|
roles(@CurrentUser() user: AuthUser) {
|
||||||
|
if (!user.permissions.has('users.manage') && !user.permissions.has('roles.manage')) {
|
||||||
|
throw new ForbiddenException('Недостаточно прав');
|
||||||
|
}
|
||||||
|
return this.users.roles(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('permissions')
|
||||||
|
permissions() {
|
||||||
|
return PERMISSIONS.map((code) => ({ code, label: PERMISSION_LABELS[code] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('roles.manage')
|
||||||
|
@Post('roles')
|
||||||
|
createRole(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.users.createRole(user, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('roles.manage')
|
||||||
|
@Patch('roles/:id')
|
||||||
|
updateRole(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.users.updateRole(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequirePermissions('roles.manage')
|
||||||
|
@Delete('roles/:id')
|
||||||
|
deleteRole(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.users.deleteRole(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
251
apps/api/src/modules/users/users.service.ts
Normal file
251
apps/api/src/modules/users/users.service.ts
Normal file
@ -0,0 +1,251 @@
|
|||||||
|
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectDataSource } from '@nestjs/typeorm';
|
||||||
|
import { DataSource, In, IsNull } from 'typeorm';
|
||||||
|
import { PERMISSIONS } from '@patrol/shared';
|
||||||
|
import { Role, Site, User, UserSession, UserSite } from '../../entities';
|
||||||
|
import { AuthUser, requireOrg } from '../../common/auth';
|
||||||
|
import { bool, obj, reqStr, str, strArray, uuid, uuidArray } from '../../common/input';
|
||||||
|
import { PassportClient } from '../passport/passport.client';
|
||||||
|
import { KIND_USER_PROFILE } from '../users-sync/event';
|
||||||
|
import { composeFullName } from '../users-sync/profile';
|
||||||
|
import { UsersSyncQueue } from '../users-sync/queue';
|
||||||
|
import { normalizeEmail, normalizePhone, parsePerson, PeopleService } from './people.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class UsersService {
|
||||||
|
constructor(
|
||||||
|
@InjectDataSource() private readonly db: DataSource,
|
||||||
|
private readonly passport: PassportClient,
|
||||||
|
private readonly people: PeopleService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(user: AuthUser, siteId?: string) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const params: unknown[] = [orgId];
|
||||||
|
let siteFilter = '';
|
||||||
|
if (siteId) {
|
||||||
|
params.push(siteId);
|
||||||
|
siteFilter = `AND (r.all_sites OR EXISTS (SELECT 1 FROM user_sites us2 WHERE us2.user_id = u.id AND us2.site_id = $2))`;
|
||||||
|
}
|
||||||
|
return this.db.query(
|
||||||
|
`SELECT u.id, u.passport_uuid AS "passportUuid", u.login, u.full_name AS "fullName",
|
||||||
|
u.last_name AS "lastName", u.first_name AS "firstName", u.middle_name AS "middleName",
|
||||||
|
u.email, u.phone, u.is_active AS "isActive", u.passport_enabled AS "passportEnabled",
|
||||||
|
u.last_login_at AS "lastLoginAt", u.created_at AS "createdAt",
|
||||||
|
r.id AS "roleId", r.name AS "roleName", COALESCE(r.all_sites, false) AS "allSites",
|
||||||
|
COALESCE(r.permissions @> ARRAY['rounds.perform'], false) AS "canPatrol",
|
||||||
|
COALESCE((SELECT array_agg(us.site_id) FROM user_sites us WHERE us.user_id = u.id), '{}') AS "siteIds"
|
||||||
|
FROM users u LEFT JOIN roles r ON r.id = u.role_id
|
||||||
|
WHERE u.organization_id = $1 AND u.removed_at IS NULL ${siteFilter}
|
||||||
|
ORDER BY u.full_name`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkRefs(orgId: string, roleId: string | undefined, siteIds: string[] | undefined) {
|
||||||
|
if (roleId && !(await this.db.getRepository(Role).exist({ where: { id: roleId, organizationId: orgId } }))) {
|
||||||
|
throw new BadRequestException('Роль не найдена');
|
||||||
|
}
|
||||||
|
if (siteIds?.length) {
|
||||||
|
const count = await this.db.getRepository(Site).count({ where: { id: In(siteIds), organizationId: orgId } });
|
||||||
|
if (count !== siteIds.length) throw new BadRequestException('Часть объектов не найдена');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async member(orgId: string, id: string) {
|
||||||
|
const target = await this.db.getRepository(User).findOneBy({ id, organizationId: orgId, removedAt: IsNull() });
|
||||||
|
if (!target) throw new NotFoundException('Сотрудник не найден');
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поиск людей в паспорте, чтобы взять в организацию уже существующего, а не заводить дубль.
|
||||||
|
* К каждому — где он в patrol сейчас: свободен, уже у нас, в другой организации.
|
||||||
|
*/
|
||||||
|
async searchPassport(user: AuthUser, query: string) {
|
||||||
|
return this.searchPassportFor(requireOrg(user), query);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** orgId = null — для владельца платформы: «у нас» не бывает, только свободен / в организации. */
|
||||||
|
async searchPassportFor(orgId: string | null, query: string) {
|
||||||
|
const q = (query ?? '').trim();
|
||||||
|
if (q.length < 3) return [];
|
||||||
|
const found = await this.passport.find(q);
|
||||||
|
if (!found.length) return [];
|
||||||
|
const local: { passport_uuid: string; organization_id: string | null; removed_at: Date | null; org_name: string | null }[] =
|
||||||
|
await this.db.query(
|
||||||
|
`SELECT u.passport_uuid, u.organization_id, u.removed_at, o.name AS org_name
|
||||||
|
FROM users u LEFT JOIN organizations o ON o.id = u.organization_id
|
||||||
|
WHERE u.passport_uuid = ANY($1::uuid[])`,
|
||||||
|
[found.map((f) => f.uuid)],
|
||||||
|
);
|
||||||
|
const byUuid = new Map(local.map((l) => [String(l.passport_uuid).toLowerCase(), l]));
|
||||||
|
return found.map((f) => {
|
||||||
|
const l = byUuid.get(f.uuid.toLowerCase());
|
||||||
|
const busy = l && !l.removed_at && l.organization_id;
|
||||||
|
return {
|
||||||
|
passportUuid: f.uuid,
|
||||||
|
fullName: composeFullName({ lastName: f.last_name, firstName: f.first_name, middleName: f.middle_name }),
|
||||||
|
emailMasked: f.email_masked,
|
||||||
|
phoneMasked: f.phone_masked,
|
||||||
|
status: !busy ? 'free' : l!.organization_id === orgId ? 'member' : 'other_org',
|
||||||
|
otherOrganization: busy && l!.organization_id !== orgId ? l!.org_name : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Новый человек (заводится в паспорте) или существующий (passportUuid) — в организацию с ролью и объектами.
|
||||||
|
async create(user: AuthUser, body: unknown) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const o = obj(body);
|
||||||
|
const person = parsePerson(o, { allowExisting: true });
|
||||||
|
const roleId = uuid(o, 'roleId', { required: true })!;
|
||||||
|
const siteIds = uuidArray(o, 'siteIds') ?? [];
|
||||||
|
await this.checkRefs(orgId, roleId, siteIds);
|
||||||
|
const p = await this.people.provision(person, { trusted: true });
|
||||||
|
const id = await this.db.transaction((m) => this.people.attach(m, p, orgId, roleId, siteIds));
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ФИО, email, телефон — профиль паспорта: сохраняем здесь и отправляем в паспорт через шину
|
||||||
|
* (как EditUserFull в symfony). Пароль — сразу в паспорт. Роль, объекты, доступ — только patrol.
|
||||||
|
*/
|
||||||
|
async update(user: AuthUser, id: string, body: unknown) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const target = await this.member(orgId, id);
|
||||||
|
const o = obj(body);
|
||||||
|
const roleId = uuid(o, 'roleId');
|
||||||
|
const siteIds = uuidArray(o, 'siteIds');
|
||||||
|
const isActive = bool(o, 'isActive');
|
||||||
|
const password = str(o, 'password', { min: 8, max: 200 });
|
||||||
|
await this.checkRefs(orgId, roleId, siteIds);
|
||||||
|
if (target.id === user.id && (isActive === false || (roleId && roleId !== target.roleId))) {
|
||||||
|
throw new ForbiddenException('Свою роль и доступ себе не меняют — попросите другого администратора');
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile: Partial<User> = {};
|
||||||
|
if (o.lastName !== undefined) profile.lastName = reqStr(o, 'lastName', { max: 100 });
|
||||||
|
if (o.firstName !== undefined) profile.firstName = reqStr(o, 'firstName', { max: 100 });
|
||||||
|
if ('middleName' in o) profile.middleName = str(o, 'middleName', { max: 100 }) ?? null;
|
||||||
|
if (o.email !== undefined) profile.email = normalizeEmail(reqStr(o, 'email', { max: 200 }));
|
||||||
|
if (o.phone !== undefined) profile.phone = normalizePhone(reqStr(o, 'phone', { max: 50 }));
|
||||||
|
const changed = (Object.keys(profile) as (keyof User)[]).filter((k) => profile[k] !== target[k]);
|
||||||
|
if (changed.length && !target.passportUuid) throw new BadRequestException('Сотрудник не связан с паспортом');
|
||||||
|
|
||||||
|
// Профиль уходит в паспорт асинхронно — занятый email узнаём заранее, а не после сохранения.
|
||||||
|
if (changed.includes('email') && profile.email) {
|
||||||
|
const taken = await this.passport.takenFields(target.passportUuid, profile.email);
|
||||||
|
if (taken.includes('email')) throw new ConflictException('Этот email уже занят другим человеком в Aspans ID');
|
||||||
|
}
|
||||||
|
if (password) {
|
||||||
|
if (!target.passportUuid) throw new BadRequestException('Сотрудник не связан с паспортом');
|
||||||
|
await this.passport.setPassword(target.passportUuid, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Partial<User> = {};
|
||||||
|
for (const k of changed) (patch as Record<string, unknown>)[k] = profile[k];
|
||||||
|
if (changed.length) patch.fullName = composeFullName({ ...target, ...patch }, target.fullName);
|
||||||
|
if (roleId) patch.roleId = roleId;
|
||||||
|
if (isActive !== undefined) patch.isActive = isActive;
|
||||||
|
|
||||||
|
await this.db.transaction(async (m) => {
|
||||||
|
if (Object.keys(patch).length) await m.getRepository(User).update({ id }, patch);
|
||||||
|
if (changed.length) await UsersSyncQueue.touch(m, KIND_USER_PROFILE, target.passportUuid!);
|
||||||
|
if (siteIds) {
|
||||||
|
await m.getRepository(UserSite).delete({ userId: id });
|
||||||
|
if (siteIds.length) await m.getRepository(UserSite).insert(siteIds.map((siteId) => ({ userId: id, siteId })));
|
||||||
|
}
|
||||||
|
// Новый пароль или отключение — выкидываем со всех устройств.
|
||||||
|
if (password || isActive === false) {
|
||||||
|
await m.getRepository(UserSession).update({ userId: id, revokedAt: IsNull() }, { revokedAt: new Date() });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Убрать из организации: отвязываем от платформы patrol в паспорте (сам человек в паспорте
|
||||||
|
* остаётся) и закрываем доступ здесь. Запись остаётся — на неё ссылаются отчёты.
|
||||||
|
*/
|
||||||
|
async remove(user: AuthUser, id: string) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const target = await this.member(orgId, id);
|
||||||
|
if (target.id === user.id) throw new ForbiddenException('Себя из организации не удаляют — попросите другого администратора');
|
||||||
|
if (target.passportUuid && !target.isPlatformAdmin) await this.passport.unlink(target.passportUuid);
|
||||||
|
await this.db.transaction(async (m) => {
|
||||||
|
await m.getRepository(User).update({ id }, { removedAt: new Date() });
|
||||||
|
await m.getRepository(UserSite).delete({ userId: id });
|
||||||
|
await m.query(`DELETE FROM schedule_assignments WHERE user_id = $1 AND date >= CURRENT_DATE`, [id]);
|
||||||
|
await m.getRepository(UserSession).update({ userId: id, revokedAt: IsNull() }, { revokedAt: new Date() });
|
||||||
|
});
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Роли
|
||||||
|
roles(user: AuthUser) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
return this.db.query(
|
||||||
|
`SELECT r.id, r.code, r.name, r.is_system AS "isSystem", r.all_sites AS "allSites", r.permissions,
|
||||||
|
(SELECT count(*)::int FROM users u WHERE u.role_id = r.id AND u.removed_at IS NULL) AS "usersCount"
|
||||||
|
FROM roles r WHERE r.organization_id = $1 ORDER BY r.is_system DESC, r.name`,
|
||||||
|
[orgId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parsePermissions(o: Record<string, unknown>) {
|
||||||
|
const permissions = strArray(o, 'permissions');
|
||||||
|
if (permissions && !permissions.every((p) => (PERMISSIONS as readonly string[]).includes(p))) {
|
||||||
|
throw new BadRequestException('Неизвестное право');
|
||||||
|
}
|
||||||
|
return permissions;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRole(user: AuthUser, body: unknown) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const o = obj(body);
|
||||||
|
const name = reqStr(o, 'name', { max: 100 });
|
||||||
|
const role = await this.db.getRepository(Role).save({
|
||||||
|
organizationId: orgId,
|
||||||
|
code: `custom_${Date.now().toString(36)}`,
|
||||||
|
name,
|
||||||
|
isSystem: false,
|
||||||
|
allSites: bool(o, 'allSites') ?? false,
|
||||||
|
permissions: this.parsePermissions(o) ?? [],
|
||||||
|
});
|
||||||
|
return { id: role.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateRole(user: AuthUser, id: string, body: unknown) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const role = await this.db.getRepository(Role).findOneBy({ id, organizationId: orgId });
|
||||||
|
if (!role) throw new NotFoundException('Роль не найдена');
|
||||||
|
const o = obj(body);
|
||||||
|
const permissions = this.parsePermissions(o);
|
||||||
|
// Администратор организации не должен случайно лишить себя управления ролями.
|
||||||
|
if (role.code === 'admin' && permissions && !permissions.includes('roles.manage')) {
|
||||||
|
throw new BadRequestException('У роли «Администратор» нельзя забрать право на роли');
|
||||||
|
}
|
||||||
|
await this.db.getRepository(Role).update(
|
||||||
|
{ id },
|
||||||
|
{
|
||||||
|
...(str(o, 'name') ? { name: str(o, 'name', { max: 100 }) } : {}),
|
||||||
|
...(bool(o, 'allSites') !== undefined ? { allSites: bool(o, 'allSites') } : {}),
|
||||||
|
...(permissions ? { permissions } : {}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRole(user: AuthUser, id: string) {
|
||||||
|
const orgId = requireOrg(user);
|
||||||
|
const role = await this.db.getRepository(Role).findOneBy({ id, organizationId: orgId });
|
||||||
|
if (!role) throw new NotFoundException('Роль не найдена');
|
||||||
|
if (role.isSystem) throw new BadRequestException('Встроенную роль удалить нельзя');
|
||||||
|
if (await this.db.getRepository(User).exist({ where: { roleId: id, removedAt: IsNull() } })) {
|
||||||
|
throw new ConflictException('Роль назначена сотрудникам — сначала смените им роль');
|
||||||
|
}
|
||||||
|
await this.db.getRepository(Role).delete({ id });
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
}
|
||||||
29
apps/api/src/modules/webhooks/webhooks.controller.ts
Normal file
29
apps/api/src/modules/webhooks/webhooks.controller.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Body, Controller, Delete, Get, Param, ParseUUIDPipe, Patch, Post } from '@nestjs/common';
|
||||||
|
import { AuthUser, CurrentUser, RequirePermissions } from '../../common/auth';
|
||||||
|
import { WebhooksService } from './webhooks.service';
|
||||||
|
|
||||||
|
@RequirePermissions('webhooks.manage')
|
||||||
|
@Controller('webhooks')
|
||||||
|
export class WebhooksController {
|
||||||
|
constructor(private readonly hooks: WebhooksService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
list(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.hooks.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
create(@CurrentUser() user: AuthUser, @Body() body: unknown) {
|
||||||
|
return this.hooks.create(user, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id')
|
||||||
|
update(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) {
|
||||||
|
return this.hooks.update(user, id, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) {
|
||||||
|
return this.hooks.remove(user, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
82
apps/api/src/modules/webhooks/webhooks.service.ts
Normal file
82
apps/api/src/modules/webhooks/webhooks.service.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { createHmac, randomBytes } from 'crypto';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { WEBHOOK_EVENTS, WebhookEvent } from '@patrol/shared';
|
||||||
|
import { Webhook } from '../../entities';
|
||||||
|
import { AuthUser, requireOrg } from '../../common/auth';
|
||||||
|
import { bool, obj, reqStr, strArray } from '../../common/input';
|
||||||
|
|
||||||
|
// Вебхуки — точка интеграции с задачником и другими сервисами.
|
||||||
|
// Тело — JSON, подпись в заголовке X-Patrol-Signature: sha256=<HMAC тела секретом вебхука>.
|
||||||
|
@Injectable()
|
||||||
|
export class WebhooksService {
|
||||||
|
private readonly logger = new Logger(WebhooksService.name);
|
||||||
|
|
||||||
|
constructor(@InjectRepository(Webhook) private readonly hooks: Repository<Webhook>) {}
|
||||||
|
|
||||||
|
emit(organizationId: string, event: WebhookEvent, data: unknown) {
|
||||||
|
this.deliver(organizationId, event, data).catch((e) => this.logger.warn(`Вебхук ${event}: ${(e as Error).message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliver(organizationId: string, event: WebhookEvent, data: unknown) {
|
||||||
|
const hooks = await this.hooks.find({ where: { organizationId, isActive: true } });
|
||||||
|
const body = JSON.stringify({ event, sentAt: new Date().toISOString(), data });
|
||||||
|
await Promise.all(
|
||||||
|
hooks
|
||||||
|
.filter((h) => h.events.includes(event))
|
||||||
|
.map(async (h) => {
|
||||||
|
const signature = createHmac('sha256', h.secret).update(body).digest('hex');
|
||||||
|
const res = await fetch(h.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-Patrol-Event': event, 'X-Patrol-Signature': `sha256=${signature}` },
|
||||||
|
body,
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
}).catch((e: Error) => ({ ok: false, status: e.message }));
|
||||||
|
if (!res.ok) this.logger.warn(`Вебхук ${h.url} (${event}) ответил ${res.status}`);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
list(user: AuthUser) {
|
||||||
|
return this.hooks.find({ where: { organizationId: requireOrg(user) }, order: { createdAt: 'DESC' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
private parse(o: Record<string, unknown>) {
|
||||||
|
const events = strArray(o, 'events');
|
||||||
|
if (events && !events.every((e) => (WEBHOOK_EVENTS as readonly string[]).includes(e))) {
|
||||||
|
throw new BadRequestException(`events: допустимы ${WEBHOOK_EVENTS.join(', ')}`);
|
||||||
|
}
|
||||||
|
const url = o.url !== undefined ? reqStr(o, 'url', { max: 1000 }) : undefined;
|
||||||
|
if (url && !/^https?:\/\//.test(url)) throw new BadRequestException('url должен начинаться с http:// или https://');
|
||||||
|
return { url, events, isActive: bool(o, 'isActive') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: AuthUser, body: unknown) {
|
||||||
|
const d = this.parse(obj(body));
|
||||||
|
if (!d.url || !d.events?.length) throw new BadRequestException('Нужны url и хотя бы одно событие');
|
||||||
|
return this.hooks.save({
|
||||||
|
organizationId: requireOrg(user),
|
||||||
|
url: d.url,
|
||||||
|
events: d.events,
|
||||||
|
isActive: d.isActive ?? true,
|
||||||
|
secret: randomBytes(24).toString('hex'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(user: AuthUser, id: string, body: unknown) {
|
||||||
|
const hook = await this.hooks.findOneBy({ id, organizationId: requireOrg(user) });
|
||||||
|
if (!hook) throw new NotFoundException('Вебхук не найден');
|
||||||
|
const d = this.parse(obj(body));
|
||||||
|
await this.hooks.update(
|
||||||
|
{ id },
|
||||||
|
{ ...(d.url ? { url: d.url } : {}), ...(d.events ? { events: d.events } : {}), ...(d.isActive !== undefined ? { isActive: d.isActive } : {}) },
|
||||||
|
);
|
||||||
|
return this.hooks.findOneByOrFail({ id });
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(user: AuthUser, id: string) {
|
||||||
|
await this.hooks.delete({ id, organizationId: requireOrg(user) });
|
||||||
|
return { id };
|
||||||
|
}
|
||||||
|
}
|
||||||
144
apps/api/src/sync.ts
Normal file
144
apps/api/src/sync.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import 'reflect-metadata';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { config } from './config';
|
||||||
|
import { UsersSyncApplier } from './modules/users-sync/applier';
|
||||||
|
import { SyncEvent, validate } from './modules/users-sync/event';
|
||||||
|
import { consumerGroup, createConsumer, createProducer, kafkaEnabled } from './modules/users-sync/kafka';
|
||||||
|
import { UsersSyncQueue } from './modules/users-sync/queue';
|
||||||
|
import { UsersSyncModule } from './modules/users-sync/sync.module';
|
||||||
|
import { UsersSyncSnapshots } from './modules/users-sync/snapshot';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синхронизация людей с паспортом через aspans.users.changed.
|
||||||
|
* relay — отправляет правки из users_sync_pending (ключ сообщения — platform_code);
|
||||||
|
* consumer — применяет события паспорта для platform_code = patrol.
|
||||||
|
* Запуск: node dist/sync.js [relay|consumer|all] (по умолчанию all).
|
||||||
|
*/
|
||||||
|
const log = new Logger('UsersSync');
|
||||||
|
const MAX_ATTEMPTS = 5;
|
||||||
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
let stopping = false;
|
||||||
|
|
||||||
|
async function relay(db: DataSource, snapshots: UsersSyncSnapshots) {
|
||||||
|
const producer = createProducer();
|
||||||
|
await producer.connect();
|
||||||
|
log.log(`relay: пишу в ${config.kafka.topic}`);
|
||||||
|
while (!stopping) {
|
||||||
|
const rows = await UsersSyncQueue.due(db, 50);
|
||||||
|
if (!rows.length) {
|
||||||
|
await sleep(1000);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const event = await snapshots.build(row.kind, row.ref);
|
||||||
|
if (event) {
|
||||||
|
// По одному сообщению и с ожиданием подтверждения: строгий порядок и точный результат на каждую строку.
|
||||||
|
await producer.send({
|
||||||
|
topic: config.kafka.topic,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
key: event.platform_code,
|
||||||
|
value: JSON.stringify(event),
|
||||||
|
headers: { event_type: event.event_type, idempotency_key: event.idempotency_key, source: event.source },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
log.log(`sent ${event.event_type} ${row.ref}`);
|
||||||
|
}
|
||||||
|
await UsersSyncQueue.done(db, row);
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`relay: ${row.kind}/${row.ref}: ${(e as Error).message}`);
|
||||||
|
await UsersSyncQueue.failed(db, row, (e as Error).message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await producer.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle(applier: UsersSyncApplier, payload: string): Promise<string> {
|
||||||
|
let event: SyncEvent;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
log.error(`consumer: bad JSON, skipped: ${payload.slice(0, 500)}`);
|
||||||
|
return 'skipped: bad json';
|
||||||
|
}
|
||||||
|
const error = validate(event);
|
||||||
|
if (error) {
|
||||||
|
log.error(`consumer: bad event (${error}), skipped: ${payload.slice(0, 500)}`);
|
||||||
|
return `skipped: ${error}`;
|
||||||
|
}
|
||||||
|
if (event.source === config.kafka.source) return 'skipped: own echo';
|
||||||
|
if (event.platform_code !== config.passport.platformCode) return 'skipped: other platform';
|
||||||
|
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await applier.apply(event);
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`consumer: apply failed (attempt ${attempt}) ${event.event_id}: ${(e as Error).message}`);
|
||||||
|
// «Отравленное» сообщение не должно останавливать всю синхронизацию.
|
||||||
|
if (attempt >= MAX_ATTEMPTS) {
|
||||||
|
log.error(`consumer: event dropped after retries: ${JSON.stringify(event)}`);
|
||||||
|
return `dropped: ${(e as Error).message}`;
|
||||||
|
}
|
||||||
|
await sleep(Math.min(30, 2 ** attempt) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function consumer(applier: UsersSyncApplier) {
|
||||||
|
const c = createConsumer();
|
||||||
|
await c.connect();
|
||||||
|
await c.subscribe({ topics: [config.kafka.topic] });
|
||||||
|
log.log(`consumer: слушаю ${config.kafka.topic} группой ${consumerGroup()}`);
|
||||||
|
await c.run({
|
||||||
|
eachMessage: async ({ topic, partition, message }) => {
|
||||||
|
const result = await handle(applier, message.value?.toString('utf8') ?? '');
|
||||||
|
if (!result.startsWith('skipped: other platform') && !result.startsWith('skipped: own echo')) {
|
||||||
|
log.log(`[${partition}:${message.offset}] ${result}`);
|
||||||
|
}
|
||||||
|
await c.commitOffsets([{ topic, partition, offset: (BigInt(message.offset) + 1n).toString() }]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const mode = process.argv[2] || 'all';
|
||||||
|
if (!kafkaEnabled()) {
|
||||||
|
// Шина выключена: правки копятся в users_sync_pending и уйдут после включения.
|
||||||
|
log.warn('KAFKA_BROKERS / KAFKA_USERNAME / KAFKA_PASSWORD не заданы — синхронизация выключена');
|
||||||
|
await new Promise(() => undefined);
|
||||||
|
}
|
||||||
|
const app = await NestFactory.createApplicationContext(UsersSyncModule, { logger: ['log', 'warn', 'error'] });
|
||||||
|
const db = app.get<DataSource>(getDataSourceToken());
|
||||||
|
const applier = app.get(UsersSyncApplier);
|
||||||
|
const snapshots = app.get(UsersSyncSnapshots);
|
||||||
|
|
||||||
|
const tasks: Promise<unknown>[] = [];
|
||||||
|
let c: Awaited<ReturnType<typeof consumer>> | null = null;
|
||||||
|
if (mode === 'all' || mode === 'consumer') c = await consumer(applier);
|
||||||
|
if (mode === 'all' || mode === 'relay') tasks.push(relay(db, snapshots));
|
||||||
|
|
||||||
|
const shutdown = async () => {
|
||||||
|
stopping = true;
|
||||||
|
await c?.disconnect().catch(() => undefined);
|
||||||
|
await Promise.allSettled(tasks);
|
||||||
|
await app.close();
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
log.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
export { handle as handleSyncMessage };
|
||||||
4
apps/api/tsconfig.build.json
Normal file
4
apps/api/tsconfig.build.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
|
||||||
|
}
|
||||||
18
apps/api/tsconfig.json
Normal file
18
apps/api/tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "commonjs",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"strictPropertyInitialization": false,
|
||||||
|
"emitDecoratorMetadata": true,
|
||||||
|
"experimentalDecorators": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
apps/web/index.html
Normal file
15
apps/web/index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<meta name="theme-color" content="#2563eb" />
|
||||||
|
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<title>Обходы</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
37
apps/web/package.json
Normal file
37
apps/web/package.json
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "@patrol/web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"typecheck": "tsc -b --noEmit",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@emotion/react": "^11.13.5",
|
||||||
|
"@emotion/styled": "^11.13.5",
|
||||||
|
"@geoman-io/leaflet-geoman-free": "2.17.0",
|
||||||
|
"@mui/icons-material": "^6.5.0",
|
||||||
|
"@mui/material": "^6.5.0",
|
||||||
|
"@patrol/shared": "*",
|
||||||
|
"@tanstack/react-query": "^5.62.0",
|
||||||
|
"exifr": "^7.1.3",
|
||||||
|
"idb-keyval": "^6.2.1",
|
||||||
|
"leaflet": "1.9.4",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-leaflet": "4.2.1",
|
||||||
|
"react-router-dom": "^6.28.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/leaflet": "^1.9.15",
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"vite-plugin-pwa": "^0.21.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
apps/web/public/icon-192.png
Normal file
BIN
apps/web/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/icon-512.png
Normal file
BIN
apps/web/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
1
apps/web/public/icon.svg
Normal file
1
apps/web/public/icon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.1 KiB |
83
apps/web/src/App.tsx
Normal file
83
apps/web/src/App.tsx
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import { CssBaseline, ThemeProvider } from '@mui/material';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
|
||||||
|
import { ApiError } from './api/client';
|
||||||
|
import { AuthProvider, useAuth } from './auth/AuthContext';
|
||||||
|
import { GuardApp } from './guard/GuardApp';
|
||||||
|
import { EmployeesPage } from './pages/EmployeesPage';
|
||||||
|
import { IntegrationsPage } from './pages/IntegrationsPage';
|
||||||
|
import { LoginPage } from './pages/LoginPage';
|
||||||
|
import { PlatformPage } from './pages/PlatformPage';
|
||||||
|
import { RegisterPage } from './pages/RegisterPage';
|
||||||
|
import { ReportsPage } from './pages/ReportsPage';
|
||||||
|
import { RoundDetailPage } from './pages/RoundDetailPage';
|
||||||
|
import { RoutesPage } from './pages/RoutesPage';
|
||||||
|
import { SchedulePage } from './pages/SchedulePage';
|
||||||
|
import { SiteEditPage } from './pages/SiteEditPage';
|
||||||
|
import { SitesPage } from './pages/SitesPage';
|
||||||
|
import { SsoCallbackPage } from './pages/SsoCallbackPage';
|
||||||
|
import { Shell, landingPath } from './shell/Shell';
|
||||||
|
import { LoadingState } from './ui/layout';
|
||||||
|
import { FeedbackProvider } from './ui/feedback';
|
||||||
|
import { theme } from './ui/theme';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 30_000,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
retry: (count, e) => !(e instanceof ApiError && e.status >= 400 && e.status < 500) && count < 2,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function Gate() {
|
||||||
|
const { status, me } = useAuth();
|
||||||
|
if (status === 'loading') return <LoadingState />;
|
||||||
|
if (status === 'guest' || !me) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route path="/auth/sso/callback" element={<SsoCallbackPage />} />
|
||||||
|
<Route path="*" element={<LoginPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path="/auth/sso/callback" element={<Navigate to={landingPath(me)} replace />} />
|
||||||
|
<Route path="/guard/*" element={<GuardApp />} />
|
||||||
|
<Route element={<Shell />}>
|
||||||
|
<Route path="/sites" element={<SitesPage />} />
|
||||||
|
<Route path="/sites/new" element={<SiteEditPage />} />
|
||||||
|
<Route path="/sites/:id" element={<SiteEditPage />} />
|
||||||
|
<Route path="/schedule" element={<SchedulePage />} />
|
||||||
|
<Route path="/routes" element={<RoutesPage />} />
|
||||||
|
<Route path="/routes/:routeId" element={<RoutesPage />} />
|
||||||
|
<Route path="/reports" element={<ReportsPage />} />
|
||||||
|
<Route path="/reports/:id" element={<RoundDetailPage />} />
|
||||||
|
<Route path="/employees" element={<EmployeesPage />} />
|
||||||
|
<Route path="/integrations" element={<IntegrationsPage />} />
|
||||||
|
<Route path="/platform" element={<PlatformPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to={landingPath(me)} replace />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={theme}>
|
||||||
|
<CssBaseline />
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<FeedbackProvider>
|
||||||
|
<BrowserRouter>
|
||||||
|
<AuthProvider>
|
||||||
|
<Gate />
|
||||||
|
</AuthProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</FeedbackProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/web/src/api/client.ts
Normal file
89
apps/web/src/api/client.ts
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
// Токен доступа живёт только в памяти, refresh — в httpOnly cookie. На 401 один раз обновляем и повторяем запрос.
|
||||||
|
|
||||||
|
let accessToken: string | null = null;
|
||||||
|
let refreshing: Promise<string | null> | null = null;
|
||||||
|
const unauthorizedListeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, readonly status: number, readonly data?: unknown) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAccessToken(token: string | null) {
|
||||||
|
accessToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasAccessToken() {
|
||||||
|
return !!accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onUnauthorized(fn: () => void) {
|
||||||
|
unauthorizedListeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
unauthorizedListeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccess(): Promise<string | null> {
|
||||||
|
if (!refreshing) {
|
||||||
|
refreshing = fetch('/api/auth/refresh', { method: 'POST', credentials: 'same-origin' })
|
||||||
|
.then(async (res) => {
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const data = (await res.json()) as { accessToken: string };
|
||||||
|
accessToken = data.accessToken;
|
||||||
|
return accessToken;
|
||||||
|
})
|
||||||
|
.catch(() => null)
|
||||||
|
.finally(() => {
|
||||||
|
refreshing = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return refreshing;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGES: Record<number, string> = {
|
||||||
|
0: 'Нет связи с сервером',
|
||||||
|
401: 'Сессия закончилась. Войдите снова.',
|
||||||
|
403: 'Недостаточно прав',
|
||||||
|
404: 'Не найдено',
|
||||||
|
413: 'Файл слишком большой',
|
||||||
|
};
|
||||||
|
|
||||||
|
type Options = { method?: string; body?: unknown; raw?: BodyInit; headers?: Record<string, string>; signal?: AbortSignal };
|
||||||
|
|
||||||
|
export async function api<T = unknown>(path: string, opts: Options = {}, retried = false): Promise<T> {
|
||||||
|
const headers: Record<string, string> = { ...(opts.headers || {}) };
|
||||||
|
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
|
||||||
|
let body: BodyInit | undefined = opts.raw;
|
||||||
|
if (opts.body !== undefined) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
body = JSON.stringify(opts.body);
|
||||||
|
}
|
||||||
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`/api${path}`, { method: opts.method || (body ? 'POST' : 'GET'), headers, body, credentials: 'same-origin', signal: opts.signal });
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name === 'AbortError') throw e;
|
||||||
|
throw new ApiError(MESSAGES[0], 0);
|
||||||
|
}
|
||||||
|
if (res.status === 401 && !retried && !path.startsWith('/auth/login')) {
|
||||||
|
const token = await refreshAccess();
|
||||||
|
if (token) return api<T>(path, opts, true);
|
||||||
|
unauthorizedListeners.forEach((fn) => fn());
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
const text = await res.text();
|
||||||
|
let data: unknown = undefined;
|
||||||
|
try {
|
||||||
|
data = text ? JSON.parse(text) : undefined;
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg = (data as { message?: string | string[] })?.message;
|
||||||
|
const message = Array.isArray(msg) ? msg.join('; ') : msg;
|
||||||
|
throw new ApiError(message || MESSAGES[res.status] || (res.status >= 500 ? 'Ошибка сервера, попробуйте ещё раз' : 'Не удалось выполнить запрос'), res.status, data);
|
||||||
|
}
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
184
apps/web/src/api/types.ts
Normal file
184
apps/web/src/api/types.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import type {
|
||||||
|
GeoJsonLineString,
|
||||||
|
GeoJsonMultiPolygon,
|
||||||
|
GeoJsonPoint,
|
||||||
|
LatLng,
|
||||||
|
MediaRequirement,
|
||||||
|
Permission,
|
||||||
|
RoundStatus,
|
||||||
|
} from '@patrol/shared';
|
||||||
|
|
||||||
|
export type Me = {
|
||||||
|
id: string;
|
||||||
|
passportUuid: string | null;
|
||||||
|
login: string | null;
|
||||||
|
fullName: string;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
isPlatformAdmin: boolean;
|
||||||
|
organization: { id: string; name: string; bin: string } | null;
|
||||||
|
role: { id: string; name: string } | null;
|
||||||
|
allSites: boolean;
|
||||||
|
permissions: Permission[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Site = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string | null;
|
||||||
|
description: string | null;
|
||||||
|
perimeter: GeoJsonMultiPolygon | null;
|
||||||
|
center: GeoJsonPoint | null;
|
||||||
|
timezone: string;
|
||||||
|
routesCount?: number;
|
||||||
|
roundsToday?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Checkpoint = {
|
||||||
|
id?: string;
|
||||||
|
position: number;
|
||||||
|
name: string;
|
||||||
|
location: GeoJsonPoint;
|
||||||
|
radiusM: number;
|
||||||
|
mediaRequired: MediaRequirement;
|
||||||
|
instruction: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Route = {
|
||||||
|
id: string;
|
||||||
|
siteId: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
path: GeoJsonLineString | null;
|
||||||
|
expectedMinutes: number | null;
|
||||||
|
strictOrder: boolean;
|
||||||
|
version: number;
|
||||||
|
checkpoints: Checkpoint[];
|
||||||
|
warnings?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Schedule = {
|
||||||
|
id: string;
|
||||||
|
siteId: string;
|
||||||
|
routeId: string;
|
||||||
|
routeName: string;
|
||||||
|
name: string;
|
||||||
|
startTimes: string[];
|
||||||
|
windowMinutes: number;
|
||||||
|
lateToleranceMinutes: number;
|
||||||
|
validFrom: string;
|
||||||
|
validTo: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Assignment = { id: string; scheduleId: string; userId: string; date: string };
|
||||||
|
|
||||||
|
export type Employee = {
|
||||||
|
id: string;
|
||||||
|
passportUuid: string | null;
|
||||||
|
login: string | null;
|
||||||
|
fullName: string;
|
||||||
|
lastName: string | null;
|
||||||
|
firstName: string | null;
|
||||||
|
middleName: string | null;
|
||||||
|
email: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
isActive: boolean;
|
||||||
|
passportEnabled: boolean;
|
||||||
|
lastLoginAt: string | null;
|
||||||
|
roleId: string | null;
|
||||||
|
roleName: string | null;
|
||||||
|
allSites: boolean;
|
||||||
|
canPatrol: boolean;
|
||||||
|
siteIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoleRow = {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
isSystem: boolean;
|
||||||
|
allSites: boolean;
|
||||||
|
permissions: Permission[];
|
||||||
|
usersCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaView = { id: string; kind: 'photo' | 'video'; mime: string; status: string; capturedAt: string | null; url: string | null };
|
||||||
|
|
||||||
|
export type Visit = {
|
||||||
|
id: string;
|
||||||
|
checkpointId: string | null;
|
||||||
|
userId: string;
|
||||||
|
marked: LatLng;
|
||||||
|
device: (LatLng & { accuracy: number | null }) | null;
|
||||||
|
distanceToCheckpointM: number | null;
|
||||||
|
gpsDistanceM: number | null;
|
||||||
|
flags: string[];
|
||||||
|
note: string | null;
|
||||||
|
isIssue: boolean;
|
||||||
|
clientCreatedAt: string;
|
||||||
|
createdAt: string;
|
||||||
|
media: MediaView[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoundDetail = {
|
||||||
|
id: string;
|
||||||
|
siteId: string;
|
||||||
|
siteName: string;
|
||||||
|
timezone: string;
|
||||||
|
perimeter: GeoJsonMultiPolygon | null;
|
||||||
|
routeId: string;
|
||||||
|
scheduleId: string | null;
|
||||||
|
scheduleName: string | null;
|
||||||
|
dutyDate: string | null;
|
||||||
|
performedBy: string | null;
|
||||||
|
performerName: string | null;
|
||||||
|
plannedStart: string;
|
||||||
|
plannedEnd: string;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
status: RoundStatus;
|
||||||
|
route: {
|
||||||
|
version: number;
|
||||||
|
name: string;
|
||||||
|
strictOrder: boolean;
|
||||||
|
path: GeoJsonLineString | null;
|
||||||
|
checkpoints: (Checkpoint & { id: string })[];
|
||||||
|
};
|
||||||
|
checkpointsTotal: number;
|
||||||
|
checkpointsDone: number;
|
||||||
|
comment: string | null;
|
||||||
|
visits: Visit[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RoundRow = {
|
||||||
|
id: string;
|
||||||
|
status: RoundStatus;
|
||||||
|
plannedStart: string;
|
||||||
|
plannedEnd: string;
|
||||||
|
startedAt: string | null;
|
||||||
|
finishedAt: string | null;
|
||||||
|
checkpointsDone: number;
|
||||||
|
checkpointsTotal: number;
|
||||||
|
siteId: string;
|
||||||
|
siteName: string;
|
||||||
|
timezone: string;
|
||||||
|
routeName: string;
|
||||||
|
scheduleName: string | null;
|
||||||
|
performedBy: string | null;
|
||||||
|
performerName: string | null;
|
||||||
|
flaggedVisits: number;
|
||||||
|
issues: number;
|
||||||
|
mediaCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PassportCandidate = {
|
||||||
|
passportUuid: string;
|
||||||
|
fullName: string;
|
||||||
|
emailMasked: string | null;
|
||||||
|
phoneMasked: string | null;
|
||||||
|
status: 'free' | 'member' | 'other_org';
|
||||||
|
otherOrganization: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AuthConfig = { passport: { enabled: boolean }; aspansId: { enabled: boolean; baseUrl: string | null } };
|
||||||
154
apps/web/src/auth/AuthContext.tsx
Normal file
154
apps/web/src/auth/AuthContext.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import type { Permission } from '@patrol/shared';
|
||||||
|
import { api, onUnauthorized, refreshAccess, setAccessToken } from '../api/client';
|
||||||
|
import type { Me } from '../api/types';
|
||||||
|
import { CALLBACK_PATH, installSsoWatcher, loadAuthConfig, markManualLogout, ssoLogoutRedirect, tryAutoSso } from './aspansId';
|
||||||
|
|
||||||
|
type AuthState = {
|
||||||
|
status: 'loading' | 'guest' | 'authed';
|
||||||
|
me: Me | null;
|
||||||
|
ssoEnabled: boolean;
|
||||||
|
can: (p: Permission) => boolean;
|
||||||
|
login: (login: string, password: string) => Promise<void>;
|
||||||
|
acceptToken: (token: string, opts?: { viaSso?: boolean }) => Promise<void>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
reload: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthState | null>(null);
|
||||||
|
|
||||||
|
const ME_CACHE = 'patrol.me';
|
||||||
|
// Вошли через Aspans ID — тогда выход там означает выход и здесь. Вход паролем центральную сессию не создаёт.
|
||||||
|
const VIA_SSO = 'patrol.via_sso';
|
||||||
|
|
||||||
|
const ls = {
|
||||||
|
get: (k: string) => {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(k);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (k: string, v: string) => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(k, v);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
del: (k: string) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(k);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Профиль кэшируем, чтобы экран сотрудника открывался без сети.
|
||||||
|
function cachedMe(): Me | null {
|
||||||
|
const raw = ls.get(ME_CACHE);
|
||||||
|
try {
|
||||||
|
return raw ? (JSON.parse(raw) as Me) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [status, setStatus] = useState<AuthState['status']>('loading');
|
||||||
|
const [me, setMe] = useState<Me | null>(null);
|
||||||
|
const [ssoEnabled, setSsoEnabled] = useState(false);
|
||||||
|
const meRef = useRef<Me | null>(null);
|
||||||
|
meRef.current = me;
|
||||||
|
|
||||||
|
const loadMe = useCallback(async () => {
|
||||||
|
const data = await api<Me>('/auth/me');
|
||||||
|
setMe(data);
|
||||||
|
setStatus('authed');
|
||||||
|
ls.set(ME_CACHE, JSON.stringify(data));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const signOutLocal = useCallback(() => {
|
||||||
|
setAccessToken(null);
|
||||||
|
setMe(null);
|
||||||
|
setStatus('guest');
|
||||||
|
ls.del(ME_CACHE);
|
||||||
|
ls.del(VIA_SSO);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
const cfg = await loadAuthConfig();
|
||||||
|
setSsoEnabled(cfg.aspansId.enabled);
|
||||||
|
// Страница возврата из Aspans ID сама обменяет код и войдёт.
|
||||||
|
if (window.location.pathname === CALLBACK_PATH) {
|
||||||
|
setStatus('guest');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const token = await refreshAccess();
|
||||||
|
if (token) {
|
||||||
|
await loadMe().catch(signOutLocal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Нет сети — пускаем по сохранённому профилю, очередь отправится, когда связь появится.
|
||||||
|
const cached = cachedMe();
|
||||||
|
if (!navigator.onLine && cached) {
|
||||||
|
setMe(cached);
|
||||||
|
setStatus('authed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('guest');
|
||||||
|
// Уже вошёл в другом сервисе Aspans — тихий вход через Aspans ID (один раз за вкладку).
|
||||||
|
void tryAutoSso();
|
||||||
|
})();
|
||||||
|
}, [loadMe, signOutLocal]);
|
||||||
|
|
||||||
|
useEffect(() => onUnauthorized(signOutLocal), [signOutLocal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ssoEnabled) return;
|
||||||
|
return installSsoWatcher({
|
||||||
|
localUuid: () => (ls.get(VIA_SSO) === '1' ? meRef.current?.passportUuid ?? null : meRef.current ? 'password-session' : null),
|
||||||
|
onLocalLogout: () => {
|
||||||
|
if (ls.get(VIA_SSO) !== '1' && meRef.current) return;
|
||||||
|
void api('/auth/logout', { method: 'POST' }).catch(() => undefined);
|
||||||
|
signOutLocal();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [ssoEnabled, signOutLocal]);
|
||||||
|
|
||||||
|
const value = useMemo<AuthState>(
|
||||||
|
() => ({
|
||||||
|
status,
|
||||||
|
me,
|
||||||
|
ssoEnabled,
|
||||||
|
can: (p) => !!me?.permissions.includes(p),
|
||||||
|
login: async (login, password) => {
|
||||||
|
const res = await api<{ accessToken: string }>('/auth/login', { body: { login, password } });
|
||||||
|
setAccessToken(res.accessToken);
|
||||||
|
ls.del(VIA_SSO);
|
||||||
|
await loadMe();
|
||||||
|
},
|
||||||
|
acceptToken: async (token, opts) => {
|
||||||
|
setAccessToken(token);
|
||||||
|
if (opts?.viaSso) ls.set(VIA_SSO, '1');
|
||||||
|
await loadMe();
|
||||||
|
},
|
||||||
|
logout: async () => {
|
||||||
|
const viaSso = ls.get(VIA_SSO) === '1';
|
||||||
|
await api('/auth/logout', { method: 'POST' }).catch(() => undefined);
|
||||||
|
signOutLocal();
|
||||||
|
// Вошли через Aspans ID — выходим и там, иначе тихий вход тут же вернёт сессию.
|
||||||
|
if (viaSso && ssoEnabled) ssoLogoutRedirect();
|
||||||
|
else markManualLogout();
|
||||||
|
},
|
||||||
|
reload: loadMe,
|
||||||
|
}),
|
||||||
|
[status, me, ssoEnabled, loadMe, signOutLocal],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuth = () => useContext(AuthContext)!;
|
||||||
199
apps/web/src/auth/aspansId.ts
Normal file
199
apps/web/src/auth/aspansId.ts
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import { api, hasAccessToken } from '../api/client';
|
||||||
|
import type { AuthConfig } from '../api/types';
|
||||||
|
|
||||||
|
// SSO Aspans ID — как aspans_front/components/aspansID в symfony: тихий вход, если человек уже вошёл
|
||||||
|
// в другом сервисе Aspans; слежение за центральной сессией; общий выход.
|
||||||
|
|
||||||
|
export const CALLBACK_PATH = '/auth/sso/callback';
|
||||||
|
const K = {
|
||||||
|
state: 'aspans_id_state',
|
||||||
|
verifier: 'aspans_id_code_verifier',
|
||||||
|
redirect: 'aspans_id_redirect_uri',
|
||||||
|
returnUrl: 'aspans_id_return_url',
|
||||||
|
silent: 'aspans_id_silent',
|
||||||
|
autoTried: 'aspans_id_autologin_tried',
|
||||||
|
triedSub: 'aspans_id_login_tried_sub',
|
||||||
|
consumed: 'aspans_id_consumed_code',
|
||||||
|
};
|
||||||
|
const LOGOUT_BROADCAST = 'patrol_logout';
|
||||||
|
const LOGGED_OUT_PARAM = 'aspans_logged_out';
|
||||||
|
const PING_INTERVAL = 60_000;
|
||||||
|
const PING_THROTTLE = 5_000;
|
||||||
|
|
||||||
|
const ss = {
|
||||||
|
get: (k: string) => {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(k);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
set: (k: string, v: string) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(k, v);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
del: (k: string) => {
|
||||||
|
try {
|
||||||
|
sessionStorage.removeItem(k);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let cfg: AuthConfig | null = null;
|
||||||
|
export async function loadAuthConfig(): Promise<AuthConfig> {
|
||||||
|
if (!cfg) cfg = await api<AuthConfig>('/auth/config').catch(() => ({ passport: { enabled: true }, aspansId: { enabled: false, baseUrl: null } }));
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
const baseUrl = () => cfg?.aspansId.baseUrl?.replace(/\/+$/, '') || '';
|
||||||
|
|
||||||
|
export async function startSso({ prompt, returnUrl }: { prompt?: 'none' | 'login'; returnUrl?: string } = {}) {
|
||||||
|
const data = await api<{ redirectUrl: string; state: string; codeVerifier: string; redirectUri: string }>('/auth/sso/start', {
|
||||||
|
body: { origin: window.location.origin, prompt },
|
||||||
|
});
|
||||||
|
ss.set(K.state, data.state);
|
||||||
|
ss.set(K.verifier, data.codeVerifier);
|
||||||
|
ss.set(K.redirect, data.redirectUri);
|
||||||
|
ss.set(K.returnUrl, returnUrl || window.location.pathname + window.location.search);
|
||||||
|
if (prompt === 'none') ss.set(K.silent, '1');
|
||||||
|
else ss.del(K.silent);
|
||||||
|
window.location.href = data.redirectUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Обработка возврата из Aspans ID. Возвращает куда идти дальше и, если вход удался, access token. */
|
||||||
|
export async function finishSso(): Promise<{ token: string | null; next: string; error: string | null }> {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const code = params.get('code');
|
||||||
|
const state = params.get('state');
|
||||||
|
const error = params.get('error');
|
||||||
|
const next = ss.get(K.returnUrl) || '/';
|
||||||
|
const silent = ss.get(K.silent) === '1';
|
||||||
|
const clear = () => [K.state, K.verifier, K.redirect, K.returnUrl, K.silent].forEach(ss.del);
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
clear();
|
||||||
|
// Тихая попытка: в Aspans ID никто не вошёл — просто остаёмся гостем.
|
||||||
|
if (silent) return { token: null, next, error: null };
|
||||||
|
return { token: null, next: '/', error: 'Вход через Aspans ID отменён' };
|
||||||
|
}
|
||||||
|
if (!code || !state || state !== ss.get(K.state) || !ss.get(K.verifier) || !ss.get(K.redirect)) {
|
||||||
|
clear();
|
||||||
|
return { token: null, next: '/', error: 'Сессия входа устарела, попробуйте ещё раз' };
|
||||||
|
}
|
||||||
|
// Защита от повторной обработки того же кода (StrictMode, двойной рендер).
|
||||||
|
if (ss.get(K.consumed) === code) return { token: null, next, error: null };
|
||||||
|
ss.set(K.consumed, code);
|
||||||
|
try {
|
||||||
|
const res = await api<{ accessToken: string }>('/auth/sso/callback', {
|
||||||
|
body: { code, codeVerifier: ss.get(K.verifier), redirectUri: ss.get(K.redirect) },
|
||||||
|
});
|
||||||
|
return { token: res.accessToken, next, error: null };
|
||||||
|
} catch (e) {
|
||||||
|
// Человек вошёл в Aspans ID, но в «Обходах» его нет — тихо остаёмся гостем, не крутим вход по кругу.
|
||||||
|
if (silent) {
|
||||||
|
ss.set(K.autoTried, '1');
|
||||||
|
return { token: null, next, error: null };
|
||||||
|
}
|
||||||
|
return { token: null, next: '/', error: (e as Error).message };
|
||||||
|
} finally {
|
||||||
|
clear();
|
||||||
|
ss.del(K.consumed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Один раз за вкладку: если в Aspans ID уже вошли (в symfony, CRM…) — войти тихо (prompt=none). */
|
||||||
|
export async function tryAutoSso() {
|
||||||
|
await loadAuthConfig();
|
||||||
|
if (!baseUrl() || hasAccessToken() || window.location.pathname === CALLBACK_PATH) return;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
if (params.get(LOGGED_OUT_PARAM) === '1') {
|
||||||
|
params.delete(LOGGED_OUT_PARAM);
|
||||||
|
const q = params.toString();
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname + (q ? `?${q}` : ''));
|
||||||
|
ss.set(K.autoTried, '1');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ss.get(K.autoTried) === '1') return;
|
||||||
|
ss.set(K.autoTried, '1');
|
||||||
|
await startSso({ prompt: 'none' }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Общий выход: из «Обходов», из других вкладок и из Aspans ID. */
|
||||||
|
export function ssoLogoutRedirect() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOGOUT_BROADCAST, String(Date.now()));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
ss.set(K.autoTried, '1');
|
||||||
|
ss.del(K.triedSub);
|
||||||
|
const back = `${window.location.origin}/?${LOGGED_OUT_PARAM}=1`;
|
||||||
|
if (!baseUrl()) {
|
||||||
|
window.location.assign(back);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.location.assign(`${baseUrl()}/logout?redirect_uri=${encodeURIComponent(back)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Слежение за центральной сессией Aspans ID: вышли там — выходим здесь; вошли там под другим
|
||||||
|
* человеком — перелогин; вошли там, а здесь гость — тихий вход.
|
||||||
|
*/
|
||||||
|
export function installSsoWatcher(opts: { localUuid: () => string | null; onLocalLogout: () => void }) {
|
||||||
|
let last = 0;
|
||||||
|
const check = async () => {
|
||||||
|
if (!baseUrl() || document.visibilityState === 'hidden' || window.location.pathname === CALLBACK_PATH) return;
|
||||||
|
if (Date.now() - last < PING_THROTTLE) return;
|
||||||
|
last = Date.now();
|
||||||
|
let data: { status?: boolean; authenticated?: boolean; sub?: string | number } | undefined;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${baseUrl()}/api/auth/session`, { credentials: 'include', cache: 'no-store', headers: { Accept: 'application/json' } });
|
||||||
|
if (!res.ok) return;
|
||||||
|
data = await res.json();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data || data.status === false) return;
|
||||||
|
const authed = data.authenticated === true;
|
||||||
|
const sub = data.sub ? String(data.sub).toLowerCase() : null;
|
||||||
|
const local = opts.localUuid();
|
||||||
|
if (!local) {
|
||||||
|
if (authed && sub && ss.get(K.triedSub) !== sub) {
|
||||||
|
ss.set(K.triedSub, sub);
|
||||||
|
startSso({ prompt: 'none' }).catch(() => undefined);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!authed || (sub && sub !== local.toLowerCase())) opts.onLocalLogout();
|
||||||
|
};
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key === LOGOUT_BROADCAST) opts.onLocalLogout();
|
||||||
|
};
|
||||||
|
window.addEventListener('storage', onStorage);
|
||||||
|
const onVisible = () => document.visibilityState === 'visible' && void check();
|
||||||
|
document.addEventListener('visibilitychange', onVisible);
|
||||||
|
window.addEventListener('focus', check);
|
||||||
|
const timer = window.setInterval(check, PING_INTERVAL);
|
||||||
|
void check();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', onStorage);
|
||||||
|
document.removeEventListener('visibilitychange', onVisible);
|
||||||
|
window.removeEventListener('focus', check);
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Выход без Aspans ID (вход был паролем): в этой вкладке не входить тихо сразу же снова. */
|
||||||
|
export function markManualLogout() {
|
||||||
|
ss.set(K.autoTried, '1');
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOGOUT_BROADCAST, String(Date.now()));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
269
apps/web/src/guard/GuardApp.tsx
Normal file
269
apps/web/src/guard/GuardApp.tsx
Normal file
@ -0,0 +1,269 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
IconButton,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
ListItemIcon,
|
||||||
|
Menu,
|
||||||
|
MenuItem,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import CloudOffIcon from '@mui/icons-material/CloudOff';
|
||||||
|
import CloudDoneIcon from '@mui/icons-material/CloudDone';
|
||||||
|
import SyncIcon from '@mui/icons-material/Sync';
|
||||||
|
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||||
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
|
import DashboardIcon from '@mui/icons-material/DashboardOutlined';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { fmtDate, fmtTime } from '../lib/format';
|
||||||
|
import { StatusChip } from '../lib/StatusChip';
|
||||||
|
import { BrandLogo } from '../ui/Brand';
|
||||||
|
import { EmptyState, ErrorState, LoadingState } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
import { LocalRound, overlay, useGuardRoutes, useGuardSites, useMyRounds } from './data';
|
||||||
|
import { dismissFailure, enqueue, onSynced, startAutoSync } from './queue';
|
||||||
|
import { RoundScreen } from './RoundScreen';
|
||||||
|
import { useQueue } from './useQueue';
|
||||||
|
|
||||||
|
export function useGuardRounds() {
|
||||||
|
const { me, can } = useAuth();
|
||||||
|
const q = useQueue();
|
||||||
|
const my = useMyRounds();
|
||||||
|
const sites = useGuardSites();
|
||||||
|
const routes = useGuardRoutes(can('rounds.adhoc'));
|
||||||
|
const rounds = useMemo(
|
||||||
|
() => (my.data ? overlay(my.data, q.ops, me!.id, { routes: routes.data, sites: sites.data }) : undefined),
|
||||||
|
[my.data, q.ops, me, routes.data, sites.data],
|
||||||
|
);
|
||||||
|
return { rounds, my, routes, sites };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SyncBadge() {
|
||||||
|
const q = useQueue();
|
||||||
|
if (q.syncing) return <Chip size="small" icon={<SyncIcon sx={{ animation: 'spin 1.2s linear infinite', '@keyframes spin': { to: { transform: 'rotate(360deg)' } } }} />} label={q.progress || `Отправка: ${q.ops.length}`} />;
|
||||||
|
if (!q.online) return <Chip size="small" color="warning" icon={<CloudOffIcon />} label={q.ops.length ? `Без сети · ждут ${q.ops.length}` : 'Без сети'} />;
|
||||||
|
if (q.ops.length) return <Chip size="small" color="warning" variant="outlined" icon={<SyncIcon />} label={`Ждут отправки: ${q.ops.length}`} />;
|
||||||
|
return <Chip size="small" variant="outlined" icon={<CloudDoneIcon />} label="Всё отправлено" sx={{ color: colors.success, borderColor: '#bbf7d0' }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopBar() {
|
||||||
|
const { me, logout, can } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
const q = useQueue();
|
||||||
|
const manager = can('sites.manage') || can('reports.view_all') || can('users.manage') || can('schedules.manage');
|
||||||
|
return (
|
||||||
|
<Box sx={{ position: 'sticky', top: 0, zIndex: 1000, bgcolor: '#fff', borderBottom: `1px solid ${colors.line}`, px: 2, py: 1.25, display: 'flex', alignItems: 'center', gap: 1, pt: 'max(10px, env(safe-area-inset-top))' }}>
|
||||||
|
<BrandLogo size={28} />
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<SyncBadge />
|
||||||
|
<IconButton onClick={(e) => setAnchor(e.currentTarget)} aria-label="Меню">
|
||||||
|
<MoreVertIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Menu anchorEl={anchor} open={!!anchor} onClose={() => setAnchor(null)}>
|
||||||
|
<MenuItem disabled>
|
||||||
|
<ListItemText primary={me?.fullName} secondary={me?.organization?.name} />
|
||||||
|
</MenuItem>
|
||||||
|
{manager && (
|
||||||
|
<MenuItem onClick={() => navigate('/sites')}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<DashboardIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
Панель управления
|
||||||
|
</MenuItem>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
onClick={() => {
|
||||||
|
if (q.ops.length && !window.confirm(`Не отправлено действий: ${q.ops.length}. После выхода они отправятся при следующем входе. Выйти?`)) return;
|
||||||
|
void logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LogoutIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
Выйти
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Failures() {
|
||||||
|
const q = useQueue();
|
||||||
|
if (!q.failures.length) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1, mb: 2 }}>
|
||||||
|
{q.failures.map((f) => (
|
||||||
|
<Alert key={f.opId} severity="error" onClose={() => dismissFailure(f.opId)} sx={{ borderRadius: '12px' }}>
|
||||||
|
Не принято сервером: {f.message}
|
||||||
|
</Alert>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOON_MS = 60 * 60000;
|
||||||
|
|
||||||
|
// ФИО пишут «Фамилия Имя Отчество» — здороваемся по имени.
|
||||||
|
const firstName = (full: string) => {
|
||||||
|
const parts = full.trim().split(/\s+/);
|
||||||
|
return parts[1] || parts[0] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
function RoundCard({ r, onOpen }: { r: LocalRound; onOpen: () => void }) {
|
||||||
|
const tz = r.timezone;
|
||||||
|
const now = Date.now();
|
||||||
|
const active = r.status === 'in_progress' || (r.status === 'planned' && new Date(r.plannedStart).getTime() - now < SOON_MS && new Date(r.plannedEnd).getTime() > now);
|
||||||
|
const pct = r.checkpointsTotal ? (r.checkpointsDone / r.checkpointsTotal) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
onClick={onOpen}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
bgcolor: '#fff',
|
||||||
|
borderRadius: '16px',
|
||||||
|
border: `1px solid ${active ? colors.brand : colors.line}`,
|
||||||
|
boxShadow: active ? '0 6px 20px rgba(37,99,235,0.12)' : 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
||||||
|
<Typography sx={{ fontWeight: 700, fontSize: 18 }}>
|
||||||
|
{fmtTime(r.plannedStart, tz)}–{fmtTime(r.plannedEnd, tz)}
|
||||||
|
</Typography>
|
||||||
|
<StatusChip status={r.status} />
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ fontWeight: 600 }} noWrap>
|
||||||
|
{r.route.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13.5 }} noWrap>
|
||||||
|
{r.siteName} · {fmtDate(r.plannedStart, tz)}
|
||||||
|
{r.scheduleId ? '' : ' · внеплановый'}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ mt: 1.25, height: 6, borderRadius: 3, bgcolor: colors.soft, overflow: 'hidden' }}>
|
||||||
|
<Box sx={{ height: '100%', width: `${pct}%`, bgcolor: r.checkpointsDone >= r.checkpointsTotal ? colors.success : colors.brand }} />
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 12.5, mt: 0.5 }}>
|
||||||
|
Точек {r.checkpointsDone} из {r.checkpointsTotal}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<ChevronRightIcon sx={{ color: colors.faint }} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Home() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { can, me } = useAuth();
|
||||||
|
const { rounds, my, routes, sites } = useGuardRounds();
|
||||||
|
const [picking, setPicking] = useState(false);
|
||||||
|
|
||||||
|
const startAdhoc = async (routeId: string) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
await enqueue([{ type: 'adhoc', opId: crypto.randomUUID(), id, routeId, clientStartedAt: new Date().toISOString() }]);
|
||||||
|
setPicking(false);
|
||||||
|
navigate(`/guard/round/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (my.isLoading) return <LoadingState />;
|
||||||
|
if (my.isError) return <ErrorState error={my.error} onRetry={() => my.refetch()} />;
|
||||||
|
|
||||||
|
const open = (rounds || []).filter((r) => r.status === 'planned' || r.status === 'in_progress');
|
||||||
|
const closed = (rounds || []).filter((r) => r.status !== 'planned' && r.status !== 'in_progress').reverse();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ px: 2, py: 2.5, maxWidth: 640, mx: 'auto' }}>
|
||||||
|
<Failures />
|
||||||
|
<Typography variant="h5" sx={{ mb: 0.5 }}>
|
||||||
|
Здравствуйте, {firstName(me?.fullName || '')}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, mb: 2.5 }}>{open.length ? 'Ваши обходы на ближайшие сутки' : 'Назначенных обходов сейчас нет'}</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{open.map((r) => (
|
||||||
|
<RoundCard key={r.id} r={r} onOpen={() => navigate(`/guard/round/${r.id}`)} />
|
||||||
|
))}
|
||||||
|
{!open.length && !closed.length && <EmptyState title="Обходов нет">Когда вас поставят в график, обходы появятся здесь.</EmptyState>}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{can('rounds.adhoc') && (
|
||||||
|
<Button fullWidth size="large" variant="outlined" startIcon={<AddIcon />} sx={{ mt: 2.5, borderRadius: '14px', bgcolor: '#fff' }} onClick={() => setPicking(true)}>
|
||||||
|
Внеплановый обход
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{closed.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Typography sx={{ fontWeight: 600, mt: 4, mb: 1.5, color: colors.muted, fontSize: 14, textTransform: 'uppercase', letterSpacing: '.04em' }}>Завершённые</Typography>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{closed.map((r) => (
|
||||||
|
<RoundCard key={r.id} r={r} onOpen={() => navigate(`/guard/round/${r.id}`)} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dialog open={picking} onClose={() => setPicking(false)} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle>Какой маршрут проходите?</DialogTitle>
|
||||||
|
<DialogContent sx={{ px: 1 }}>
|
||||||
|
{routes.isLoading ? (
|
||||||
|
<Box sx={{ display: 'grid', placeItems: 'center', py: 3 }}>
|
||||||
|
<CircularProgress size={24} />
|
||||||
|
</Box>
|
||||||
|
) : !routes.data?.length ? (
|
||||||
|
<Typography sx={{ color: colors.muted, px: 2, py: 2 }}>Маршрутов на ваших объектах нет.</Typography>
|
||||||
|
) : (
|
||||||
|
<List>
|
||||||
|
{routes.data.map((r) => (
|
||||||
|
<ListItemButton key={r.id} onClick={() => startAdhoc(r.id)} sx={{ borderRadius: '10px' }}>
|
||||||
|
<ListItemText primary={r.name} secondary={`${sites.data?.find((s) => s.id === r.siteId)?.name || ''} · точек ${r.checkpoints.length}`} />
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GuardApp() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { can } = useAuth();
|
||||||
|
useEffect(() => startAutoSync(), []);
|
||||||
|
useEffect(() => onSynced(() => qc.invalidateQueries({ queryKey: ['guard', 'my'] })), [qc]);
|
||||||
|
|
||||||
|
if (!can('rounds.perform')) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<EmptyState title="Нет права проходить обходы" />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Box sx={{ minHeight: '100vh', bgcolor: colors.page }}>
|
||||||
|
<TopBar />
|
||||||
|
<Routes>
|
||||||
|
<Route index element={<Home />} />
|
||||||
|
<Route path="round/:id" element={<RoundScreen />} />
|
||||||
|
</Routes>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
412
apps/web/src/guard/RoundScreen.tsx
Normal file
412
apps/web/src/guard/RoundScreen.tsx
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
import { useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import MyLocationIcon from '@mui/icons-material/MyLocation';
|
||||||
|
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
|
||||||
|
import VideocamIcon from '@mui/icons-material/Videocam';
|
||||||
|
import CollectionsIcon from '@mui/icons-material/Collections';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import ReportIcon from '@mui/icons-material/ReportGmailerrorred';
|
||||||
|
import FlagIcon from '@mui/icons-material/Flag';
|
||||||
|
import ScheduleIcon from '@mui/icons-material/Schedule';
|
||||||
|
import { Circle, GeoJSON, Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import exifr from 'exifr';
|
||||||
|
import { MEDIA_REQUIREMENT_LABELS } from '@patrol/shared';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { fmtTime } from '../lib/format';
|
||||||
|
import { StatusChip } from '../lib/StatusChip';
|
||||||
|
import { BaseMap, FitTo, PERIMETER_STYLE, ROUTE_STYLE, dotIcon, numberIcon, pinIcon } from '../map/base';
|
||||||
|
import { EmptyState, LoadingState } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
import type { LocalRound } from './data';
|
||||||
|
import { Fix, useGeolocation } from './geo';
|
||||||
|
import { useGuardRounds } from './GuardApp';
|
||||||
|
import { Op, enqueue } from './queue';
|
||||||
|
|
||||||
|
const ll = (c: [number, number]) => [c[1], c[0]] as [number, number];
|
||||||
|
type Cp = LocalRound['route']['checkpoints'][number];
|
||||||
|
|
||||||
|
function MeLayer({ fix }: { fix: Fix | null }) {
|
||||||
|
if (!fix) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{fix.accuracy && fix.accuracy < 500 && <Circle center={[fix.lat, fix.lng]} radius={fix.accuracy} pathOptions={{ color: '#0ea5e9', weight: 1, fillOpacity: 0.1 }} interactive={false} />}
|
||||||
|
<Marker position={[fix.lat, fix.lng]} icon={dotIcon('#0ea5e9', 16)} interactive={false} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CenterOnMe({ fix }: { fix: Fix | null }) {
|
||||||
|
const map = useMap();
|
||||||
|
if (!fix) return null;
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => map.setView([fix.lat, fix.lng], Math.max(map.getZoom(), 17))}
|
||||||
|
sx={{ position: 'absolute', right: 12, bottom: 32, zIndex: 1000, bgcolor: '#fff', boxShadow: '0 2px 8px rgba(15,23,42,.2)', '&:hover': { bgcolor: '#fff' } }}
|
||||||
|
aria-label="Где я"
|
||||||
|
>
|
||||||
|
<MyLocationIcon sx={{ color: colors.brand }} />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoundScreen() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { rounds, my } = useGuardRounds();
|
||||||
|
const { fix, error: geoError } = useGeolocation();
|
||||||
|
const [marking, setMarking] = useState<{ checkpointId: string | null; issue: boolean } | null>(null);
|
||||||
|
const [finishing, setFinishing] = useState(false);
|
||||||
|
|
||||||
|
const r = rounds?.find((x) => x.id === id);
|
||||||
|
const done = useMemo(() => new Set(r?.visits.map((v) => v.checkpointId).filter(Boolean)), [r]);
|
||||||
|
const cps = useMemo(() => [...(r?.route.checkpoints || [])].sort((a, b) => a.position - b.position), [r]);
|
||||||
|
const next = cps.find((c) => !done.has(c.id));
|
||||||
|
|
||||||
|
if (my.isLoading) return <LoadingState />;
|
||||||
|
if (!r) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 3 }}>
|
||||||
|
<EmptyState title="Обход не найден">
|
||||||
|
<Button onClick={() => navigate('/guard')}>К списку</Button>
|
||||||
|
</EmptyState>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tz = r.timezone;
|
||||||
|
const finished = !['planned', 'in_progress'].includes(r.status) || r.pendingFinish;
|
||||||
|
|
||||||
|
const start = () => enqueue([{ type: 'start', opId: crypto.randomUUID(), roundId: r.id, clientStartedAt: new Date().toISOString() }]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100dvh - 57px)' }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 1, bgcolor: '#fff', borderBottom: `1px solid ${colors.line}` }}>
|
||||||
|
<IconButton onClick={() => navigate('/guard')} aria-label="Назад">
|
||||||
|
<ArrowBackIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }} noWrap>
|
||||||
|
{r.route.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13 }} noWrap>
|
||||||
|
{fmtTime(r.plannedStart, tz)}–{fmtTime(r.plannedEnd, tz)} · {r.siteName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<StatusChip status={r.status} />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ flex: 1, position: 'relative', minHeight: 200 }}>
|
||||||
|
<BaseMap height="100%" style={{ borderRadius: 0, position: 'absolute', inset: 0 }} zoom={16}>
|
||||||
|
<FitTo geos={[r.perimeter, r.route.path, ...cps.map((c) => c.location)]} padding={24} />
|
||||||
|
{r.perimeter && <GeoJSON data={r.perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} interactive={false} />}
|
||||||
|
{r.route.path && <Polyline positions={r.route.path.coordinates.map(ll)} pathOptions={ROUTE_STYLE} interactive={false} />}
|
||||||
|
{cps.map((c) => (
|
||||||
|
<Marker
|
||||||
|
key={c.id}
|
||||||
|
position={ll(c.location.coordinates)}
|
||||||
|
icon={numberIcon(c.position, { done: done.has(c.id), active: next?.id === c.id, size: 32 })}
|
||||||
|
eventHandlers={{ click: () => !finished && setMarking({ checkpointId: c.id, issue: false }) }}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -16]}>
|
||||||
|
{c.name}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
{r.visits.map((v) => (
|
||||||
|
<Marker key={v.id} position={[v.marked.lat, v.marked.lng]} icon={pinIcon(v.isIssue ? colors.danger : colors.success)} interactive={false} />
|
||||||
|
))}
|
||||||
|
<MeLayer fix={fix} />
|
||||||
|
<CenterOnMe fix={fix} />
|
||||||
|
</BaseMap>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ bgcolor: '#fff', borderTop: `1px solid ${colors.line}`, px: 2, pt: 1.75, pb: 'max(16px, env(safe-area-inset-bottom))', boxShadow: '0 -8px 24px rgba(15,23,42,.06)' }}>
|
||||||
|
{geoError && !finished && (
|
||||||
|
<Typography sx={{ color: colors.warning, fontSize: 12.5, mb: 1 }}>{geoError}. Отметку ставьте нажатием на карту.</Typography>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>
|
||||||
|
Точек {r.checkpointsDone} из {r.checkpointsTotal}
|
||||||
|
</Typography>
|
||||||
|
{r.pendingVisitIds.size > 0 && <Chip size="small" icon={<ScheduleIcon />} label={`не отправлено: ${r.pendingVisitIds.size}`} />}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{finished ? (
|
||||||
|
<Alert severity={r.status === 'completed' ? 'success' : 'info'} sx={{ borderRadius: '12px' }}>
|
||||||
|
{r.pendingFinish ? 'Обход завершён, отчёт отправится при появлении сети.' : 'Обход закрыт.'}
|
||||||
|
</Alert>
|
||||||
|
) : r.status === 'planned' ? (
|
||||||
|
<Button fullWidth size="large" variant="contained" onClick={start} sx={{ py: 1.5, borderRadius: '14px', fontSize: 17 }}>
|
||||||
|
Начать обход
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{next ? (
|
||||||
|
<Box sx={{ p: 1.5, mb: 1.5, borderRadius: '12px', bgcolor: colors.brandSoft }}>
|
||||||
|
<Typography sx={{ fontSize: 13, color: colors.brand, fontWeight: 600 }}>Следующая точка · {next.position}</Typography>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>{next.name}</Typography>
|
||||||
|
{next.instruction && <Typography sx={{ fontSize: 14, mt: 0.25 }}>{next.instruction}</Typography>}
|
||||||
|
{next.mediaRequired !== 'none' && <Typography sx={{ fontSize: 12.5, color: colors.muted, mt: 0.5 }}>Нужно: {MEDIA_REQUIREMENT_LABELS[next.mediaRequired].toLowerCase()}</Typography>}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Alert severity="success" sx={{ mb: 1.5, borderRadius: '12px' }}>
|
||||||
|
Все точки отмечены — можно завершать.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button fullWidth size="large" variant="contained" startIcon={<FlagIcon />} onClick={() => setMarking({ checkpointId: next?.id ?? null, issue: false })} sx={{ py: 1.4, borderRadius: '14px' }} disabled={!next}>
|
||||||
|
Я на точке
|
||||||
|
</Button>
|
||||||
|
<IconButton onClick={() => setMarking({ checkpointId: null, issue: true })} sx={{ border: `1px solid #fecaca`, borderRadius: '14px', color: colors.danger, px: 1.75 }} aria-label="Сообщить о проблеме">
|
||||||
|
<ReportIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
<Button fullWidth onClick={() => setFinishing(true)} sx={{ mt: 1 }}>
|
||||||
|
Завершить обход
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{marking && <MarkDialog round={r} cps={cps} done={done} initial={marking} fix={fix} onClose={() => setMarking(null)} />}
|
||||||
|
{finishing && <FinishDialog round={r} onClose={() => setFinishing(false)} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TapToMark({ onMark }: { onMark: (p: { lat: number; lng: number }) => void }) {
|
||||||
|
useMapEvents({ click: (e) => onMark({ lat: e.latlng.lat, lng: e.latlng.lng }) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Picked = { id: string; file: File; kind: 'photo' | 'video'; url: string };
|
||||||
|
|
||||||
|
function MarkDialog({ round, cps, done, initial, fix, onClose }: {
|
||||||
|
round: LocalRound;
|
||||||
|
cps: Cp[];
|
||||||
|
done: Set<string | null>;
|
||||||
|
initial: { checkpointId: string | null; issue: boolean };
|
||||||
|
fix: Fix | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { me } = useAuth();
|
||||||
|
const [checkpointId, setCheckpointId] = useState<string | null>(initial.checkpointId);
|
||||||
|
const [marked, setMarked] = useState<{ lat: number; lng: number } | null>(fix ? { lat: fix.lat, lng: fix.lng } : null);
|
||||||
|
const [files, setFiles] = useState<Picked[]>([]);
|
||||||
|
const [note, setNote] = useState('');
|
||||||
|
const [issue, setIssue] = useState(initial.issue);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const photoRef = useRef<HTMLInputElement>(null);
|
||||||
|
const videoRef = useRef<HTMLInputElement>(null);
|
||||||
|
const galleryRef = useRef<HTMLInputElement>(null);
|
||||||
|
const cp = cps.find((c) => c.id === checkpointId);
|
||||||
|
|
||||||
|
const add = (list: FileList | null) => {
|
||||||
|
if (!list) return;
|
||||||
|
const picked: Picked[] = [];
|
||||||
|
for (const file of Array.from(list)) {
|
||||||
|
const kind = file.type.startsWith('video/') ? 'video' : 'photo';
|
||||||
|
picked.push({ id: crypto.randomUUID(), file, kind, url: URL.createObjectURL(file) });
|
||||||
|
}
|
||||||
|
setFiles((f) => [...f, ...picked]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = (id: string) =>
|
||||||
|
setFiles((f) => {
|
||||||
|
const p = f.find((x) => x.id === id);
|
||||||
|
if (p) URL.revokeObjectURL(p.url);
|
||||||
|
return f.filter((x) => x.id !== id);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasPhoto = files.some((f) => f.kind === 'photo');
|
||||||
|
const hasVideo = files.some((f) => f.kind === 'video');
|
||||||
|
const need = cp && !issue ? cp.mediaRequired : 'none';
|
||||||
|
const mediaOk = need === 'none' || (need === 'photo' && hasPhoto) || (need === 'video' && hasVideo) || (need === 'photo_or_video' && files.length > 0);
|
||||||
|
const canSave = !!marked && mediaOk && (!issue || note.trim() || files.length > 0) && !busy;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!marked || !me) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const ops: Op[] = [];
|
||||||
|
const blobs: { mediaId: string; blob: Blob }[] = [];
|
||||||
|
if (round.status === 'planned') ops.push({ type: 'start', opId: crypto.randomUUID(), roundId: round.id, clientStartedAt: new Date().toISOString() });
|
||||||
|
for (const f of files) {
|
||||||
|
let capturedAt: string | null = null;
|
||||||
|
if (f.kind === 'photo') {
|
||||||
|
const exif = await exifr.parse(f.file, ['DateTimeOriginal', 'CreateDate']).catch(() => null);
|
||||||
|
const d = exif?.DateTimeOriginal || exif?.CreateDate;
|
||||||
|
if (d instanceof Date && !Number.isNaN(d.getTime())) capturedAt = d.toISOString();
|
||||||
|
}
|
||||||
|
if (!capturedAt && f.file.lastModified) capturedAt = new Date(f.file.lastModified).toISOString();
|
||||||
|
const mime = f.file.type || (f.kind === 'photo' ? 'image/jpeg' : 'video/mp4');
|
||||||
|
ops.push({ type: 'media', opId: crypto.randomUUID(), id: f.id, kind: f.kind, mime, size: f.file.size, filename: f.file.name || `${f.kind}.${mime.split('/')[1]}`, capturedAt });
|
||||||
|
blobs.push({ mediaId: f.id, blob: f.file });
|
||||||
|
}
|
||||||
|
ops.push({
|
||||||
|
type: 'visit',
|
||||||
|
opId: crypto.randomUUID(),
|
||||||
|
roundId: round.id,
|
||||||
|
body: {
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
checkpointId: issue && !checkpointId ? null : checkpointId,
|
||||||
|
marked: { lat: Number(marked.lat.toFixed(7)), lng: Number(marked.lng.toFixed(7)) },
|
||||||
|
device: fix ? { lat: fix.lat, lng: fix.lng, accuracy: fix.accuracy } : null,
|
||||||
|
clientCreatedAt: new Date().toISOString(),
|
||||||
|
note: note.trim() || null,
|
||||||
|
isIssue: issue,
|
||||||
|
mediaIds: files.map((f) => f.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await enqueue(ops, blobs);
|
||||||
|
files.forEach((f) => URL.revokeObjectURL(f.url));
|
||||||
|
onClose();
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const center: [number, number] = cp ? ll(cp.location.coordinates) : fix ? [fix.lat, fix.lng] : round.route.checkpoints[0] ? ll(round.route.checkpoints[0].location.coordinates) : [43.2389, 76.8897];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} fullScreen>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 1, borderBottom: `1px solid ${colors.line}`, pt: 'max(8px, env(safe-area-inset-top))' }}>
|
||||||
|
<IconButton onClick={onClose} aria-label="Закрыть">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ fontWeight: 600, flex: 1 }}>{issue ? 'Сообщить о проблеме' : 'Отметка на точке'}</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ overflowY: 'auto', flex: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, overflowX: 'auto', px: 2, py: 1.5 }}>
|
||||||
|
{issue && <Chip label="Без точки" color={checkpointId === null ? 'error' : 'default'} onClick={() => setCheckpointId(null)} />}
|
||||||
|
{cps.map((c) => (
|
||||||
|
<Chip
|
||||||
|
key={c.id}
|
||||||
|
label={`${c.position}. ${c.name}`}
|
||||||
|
onClick={() => setCheckpointId(c.id)}
|
||||||
|
color={checkpointId === c.id ? 'primary' : 'default'}
|
||||||
|
variant={done.has(c.id) && checkpointId !== c.id ? 'outlined' : 'filled'}
|
||||||
|
icon={done.has(c.id) ? <FlagIcon /> : undefined}
|
||||||
|
sx={{ flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{cp?.instruction && (
|
||||||
|
<Typography sx={{ px: 2, pb: 1, fontSize: 14 }}>
|
||||||
|
<b>Проверить:</b> {cp.instruction}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box sx={{ position: 'relative', height: 280 }}>
|
||||||
|
<BaseMap height={280} style={{ borderRadius: 0 }} center={center} zoom={18}>
|
||||||
|
{round.perimeter && <GeoJSON data={round.perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} interactive={false} />}
|
||||||
|
{cp && <Circle center={ll(cp.location.coordinates)} radius={cp.radiusM} pathOptions={{ color: colors.brand, weight: 1, fillOpacity: 0.1 }} interactive={false} />}
|
||||||
|
{cp && <Marker position={ll(cp.location.coordinates)} icon={numberIcon(cp.position)} interactive={false} />}
|
||||||
|
<MeLayer fix={fix} />
|
||||||
|
{marked && <Marker position={[marked.lat, marked.lng]} icon={pinIcon(issue ? colors.danger : colors.success)} interactive={false} />}
|
||||||
|
<TapToMark onMark={setMarked} />
|
||||||
|
</BaseMap>
|
||||||
|
<Box sx={{ position: 'absolute', left: 8, right: 8, bottom: 8, zIndex: 1000, bgcolor: 'rgba(255,255,255,.95)', borderRadius: '10px', px: 1.5, py: 1, fontSize: 13.5, boxShadow: '0 2px 8px rgba(15,23,42,.15)' }}>
|
||||||
|
{marked ? 'Метка «я тут» поставлена. Нажмите на карту, чтобы поправить.' : 'Нажмите на карту там, где вы находитесь.'}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ px: 2, py: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 600, mb: 1 }}>
|
||||||
|
Фото и видео
|
||||||
|
{need !== 'none' && <Typography component="span" sx={{ color: mediaOk ? colors.success : colors.warning, fontSize: 13, ml: 1 }}>({MEDIA_REQUIREMENT_LABELS[need].toLowerCase()})</Typography>}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<Button variant="outlined" startIcon={<PhotoCameraIcon />} onClick={() => photoRef.current?.click()} sx={{ flex: 1 }}>
|
||||||
|
Фото
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<VideocamIcon />} onClick={() => videoRef.current?.click()} sx={{ flex: 1 }}>
|
||||||
|
Видео
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" startIcon={<CollectionsIcon />} onClick={() => galleryRef.current?.click()} sx={{ flex: 1 }}>
|
||||||
|
Галерея
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<input ref={photoRef} hidden type="file" accept="image/*" capture="environment" onChange={(e) => (add(e.target.files), (e.target.value = ''))} />
|
||||||
|
<input ref={videoRef} hidden type="file" accept="video/*" capture="environment" onChange={(e) => (add(e.target.files), (e.target.value = ''))} />
|
||||||
|
<input ref={galleryRef} hidden type="file" accept="image/*,video/*" multiple onChange={(e) => (add(e.target.files), (e.target.value = ''))} />
|
||||||
|
{files.length > 0 && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
|
||||||
|
{files.map((f) => (
|
||||||
|
<Box key={f.id} sx={{ position: 'relative', width: 84, height: 84, borderRadius: '10px', overflow: 'hidden', bgcolor: colors.soft }}>
|
||||||
|
{f.kind === 'photo' ? (
|
||||||
|
<img src={f.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<video src={f.url} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
)}
|
||||||
|
<IconButton size="small" onClick={() => remove(f.id)} sx={{ position: 'absolute', top: 2, right: 2, bgcolor: 'rgba(0,0,0,.5)', color: '#fff', p: 0.25, '&:hover': { bgcolor: 'rgba(0,0,0,.7)' } }}>
|
||||||
|
<CloseIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
{f.kind === 'video' && <VideocamIcon sx={{ position: 'absolute', left: 4, bottom: 4, color: '#fff', fontSize: 18 }} />}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<TextField label={issue ? 'Что случилось' : 'Комментарий'} multiline minRows={2} value={note} onChange={(e) => setNote(e.target.value)} required={issue && !files.length} />
|
||||||
|
<FormControlLabel control={<Switch checked={issue} onChange={(e) => setIssue(e.target.checked)} color="error" />} label="Это проблема — сообщить руководителю" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ px: 2, py: 1.5, borderTop: `1px solid ${colors.line}`, pb: 'max(12px, env(safe-area-inset-bottom))' }}>
|
||||||
|
{!marked && <Typography sx={{ color: colors.warning, fontSize: 13, mb: 1 }}>Поставьте метку на карте.</Typography>}
|
||||||
|
{marked && !mediaOk && <Typography sx={{ color: colors.warning, fontSize: 13, mb: 1 }}>Для этой точки нужно: {MEDIA_REQUIREMENT_LABELS[need].toLowerCase()}.</Typography>}
|
||||||
|
<Button fullWidth size="large" variant="contained" color={issue ? 'error' : 'primary'} disabled={!canSave} onClick={save} sx={{ py: 1.4, borderRadius: '14px' }}>
|
||||||
|
{busy ? 'Сохраняем…' : issue ? 'Отправить' : 'Отметить точку'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FinishDialog({ round, onClose }: { round: LocalRound; onClose: () => void }) {
|
||||||
|
const [comment, setComment] = useState('');
|
||||||
|
const missing = round.checkpointsTotal - round.checkpointsDone;
|
||||||
|
const finish = async () => {
|
||||||
|
await enqueue([{ type: 'finish', opId: crypto.randomUUID(), roundId: round.id, clientFinishedAt: new Date().toISOString(), comment: comment.trim() || null }]);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} fullWidth maxWidth="xs">
|
||||||
|
<DialogTitle>Завершить обход?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
{missing > 0 ? (
|
||||||
|
<Alert severity="warning" sx={{ mb: 2, borderRadius: '10px' }}>
|
||||||
|
Не отмечено точек: {missing}. Обход будет закрыт как «частично».
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ mb: 2, color: colors.muted }}>Все точки отмечены.</Typography>
|
||||||
|
)}
|
||||||
|
<TextField label="Комментарий" multiline minRows={2} value={comment} onChange={(e) => setComment(e.target.value)} placeholder={missing ? 'Почему не все точки' : 'Необязательно'} />
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Назад</Button>
|
||||||
|
<Button variant="contained" onClick={finish}>
|
||||||
|
Завершить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
110
apps/web/src/guard/data.ts
Normal file
110
apps/web/src/guard/data.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { get, set } from 'idb-keyval';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import type { RoundStatus } from '@patrol/shared';
|
||||||
|
import { ApiError, api } from '../api/client';
|
||||||
|
import type { Route, RoundDetail, Site, Visit } from '../api/types';
|
||||||
|
import type { Op } from './queue';
|
||||||
|
|
||||||
|
// Данные экрана сотрудника кэшируем в IndexedDB: без сети показываем последнюю копию.
|
||||||
|
async function cached<T>(key: string, load: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
const data = await load();
|
||||||
|
void set(key, data);
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ApiError && e.status !== 0) throw e;
|
||||||
|
const saved = await get<T>(key);
|
||||||
|
if (saved !== undefined) return saved;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useMyRounds = () =>
|
||||||
|
useQuery({ queryKey: ['guard', 'my'], queryFn: () => cached('patrol.cache.my', () => api<RoundDetail[]>('/rounds/my')), refetchInterval: 60000 });
|
||||||
|
|
||||||
|
export const useGuardSites = () => useQuery({ queryKey: ['guard', 'sites'], queryFn: () => cached('patrol.cache.sites', () => api<Site[]>('/sites')) });
|
||||||
|
|
||||||
|
export const useGuardRoutes = (enabled: boolean) =>
|
||||||
|
useQuery({ queryKey: ['guard', 'routes'], queryFn: () => cached('patrol.cache.routes', () => api<Route[]>('/routes')), enabled });
|
||||||
|
|
||||||
|
export type LocalRound = RoundDetail & { pendingVisitIds: Set<string>; pendingStart: boolean; pendingFinish: boolean; local: boolean };
|
||||||
|
|
||||||
|
// Обходы с сервера + то, что ещё лежит в очереди: начатые, отмеченные точки, завершённые, внеплановые.
|
||||||
|
export function overlay(rounds: RoundDetail[], ops: Op[], userId: string, extra: { routes?: Route[]; sites?: Site[] }): LocalRound[] {
|
||||||
|
const byId = new Map<string, LocalRound>(rounds.map((r) => [r.id, { ...r, visits: [...r.visits], pendingVisitIds: new Set(), pendingStart: false, pendingFinish: false, local: false }]));
|
||||||
|
|
||||||
|
for (const op of ops) {
|
||||||
|
if (op.type === 'adhoc' && !byId.has(op.id)) {
|
||||||
|
const route = extra.routes?.find((r) => r.id === op.routeId);
|
||||||
|
const site = route && extra.sites?.find((s) => s.id === route.siteId);
|
||||||
|
if (!route) continue;
|
||||||
|
const end = new Date(new Date(op.clientStartedAt).getTime() + Math.max(route.expectedMinutes ?? 60, 15) * 2 * 60000).toISOString();
|
||||||
|
const cps = route.checkpoints.filter((c): c is typeof c & { id: string } => !!c.id).sort((a, b) => a.position - b.position);
|
||||||
|
byId.set(op.id, {
|
||||||
|
id: op.id,
|
||||||
|
siteId: route.siteId,
|
||||||
|
siteName: site?.name || '',
|
||||||
|
timezone: site?.timezone || 'Asia/Almaty',
|
||||||
|
perimeter: site?.perimeter ?? null,
|
||||||
|
routeId: route.id,
|
||||||
|
scheduleId: null,
|
||||||
|
scheduleName: null,
|
||||||
|
dutyDate: null,
|
||||||
|
performedBy: userId,
|
||||||
|
performerName: null,
|
||||||
|
plannedStart: op.clientStartedAt,
|
||||||
|
plannedEnd: end,
|
||||||
|
startedAt: op.clientStartedAt,
|
||||||
|
finishedAt: null,
|
||||||
|
status: 'in_progress',
|
||||||
|
route: { version: route.version, name: route.name, strictOrder: route.strictOrder, path: route.path, checkpoints: cps },
|
||||||
|
checkpointsTotal: cps.length,
|
||||||
|
checkpointsDone: 0,
|
||||||
|
comment: null,
|
||||||
|
visits: [],
|
||||||
|
pendingVisitIds: new Set(),
|
||||||
|
pendingStart: true,
|
||||||
|
pendingFinish: false,
|
||||||
|
local: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const r = 'roundId' in op ? byId.get(op.roundId) : undefined;
|
||||||
|
if (!r) continue;
|
||||||
|
if (op.type === 'start' && r.status === 'planned') {
|
||||||
|
r.status = 'in_progress';
|
||||||
|
r.startedAt = op.clientStartedAt;
|
||||||
|
r.performedBy = userId;
|
||||||
|
r.pendingStart = true;
|
||||||
|
}
|
||||||
|
if (op.type === 'visit' && !r.visits.some((v) => v.id === op.body.id)) {
|
||||||
|
const visit: Visit = {
|
||||||
|
id: op.body.id,
|
||||||
|
checkpointId: op.body.checkpointId,
|
||||||
|
userId,
|
||||||
|
marked: op.body.marked,
|
||||||
|
device: op.body.device,
|
||||||
|
distanceToCheckpointM: null,
|
||||||
|
gpsDistanceM: null,
|
||||||
|
flags: [],
|
||||||
|
note: op.body.note,
|
||||||
|
isIssue: op.body.isIssue,
|
||||||
|
clientCreatedAt: op.body.clientCreatedAt,
|
||||||
|
createdAt: op.body.clientCreatedAt,
|
||||||
|
media: [],
|
||||||
|
};
|
||||||
|
r.visits.push(visit);
|
||||||
|
r.pendingVisitIds.add(visit.id);
|
||||||
|
}
|
||||||
|
if (op.type === 'finish' && (r.status === 'in_progress' || r.status === 'planned')) {
|
||||||
|
r.pendingFinish = true;
|
||||||
|
r.finishedAt = op.clientFinishedAt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const r of byId.values()) {
|
||||||
|
const done = new Set(r.visits.map((v) => v.checkpointId).filter(Boolean));
|
||||||
|
r.checkpointsDone = r.route.checkpoints.filter((c) => done.has(c.id)).length;
|
||||||
|
if (r.pendingFinish) r.status = (r.checkpointsDone >= r.checkpointsTotal ? 'completed' : 'partial') as RoundStatus;
|
||||||
|
}
|
||||||
|
return [...byId.values()].sort((a, b) => a.plannedStart.localeCompare(b.plannedStart));
|
||||||
|
}
|
||||||
27
apps/web/src/guard/geo.ts
Normal file
27
apps/web/src/guard/geo.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
export type Fix = { lat: number; lng: number; accuracy: number | null; at: number };
|
||||||
|
|
||||||
|
// GPS только для журнала: ничего не блокируем, если его нет.
|
||||||
|
export function useGeolocation(enabled = true) {
|
||||||
|
const [fix, setFix] = useState<Fix | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !('geolocation' in navigator)) {
|
||||||
|
if (enabled) setError('Геолокация недоступна на этом устройстве');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = navigator.geolocation.watchPosition(
|
||||||
|
(p) => {
|
||||||
|
setFix({ lat: p.coords.latitude, lng: p.coords.longitude, accuracy: p.coords.accuracy ?? null, at: Date.now() });
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
(e) => setError(e.code === e.PERMISSION_DENIED ? 'Нет доступа к геолокации' : 'Не удаётся определить местоположение'),
|
||||||
|
{ enableHighAccuracy: true, maximumAge: 10000, timeout: 30000 },
|
||||||
|
);
|
||||||
|
return () => navigator.geolocation.clearWatch(id);
|
||||||
|
}, [enabled]);
|
||||||
|
// Старше двух минут — уже не «где я сейчас».
|
||||||
|
const fresh = fix && Date.now() - fix.at < 120000 ? fix : null;
|
||||||
|
return { fix: fresh, error };
|
||||||
|
}
|
||||||
183
apps/web/src/guard/queue.ts
Normal file
183
apps/web/src/guard/queue.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import { del, get, set } from 'idb-keyval';
|
||||||
|
import { UPLOAD_PART_SIZE } from '@patrol/shared';
|
||||||
|
import { ApiError, api } from '../api/client';
|
||||||
|
|
||||||
|
// Офлайн-очередь сотрудника. Всё, что он делает на обходе, сначала ложится сюда (IndexedDB),
|
||||||
|
// потом по порядку уходит на сервер. Идентификаторы создаёт телефон, поэтому повтор безопасен.
|
||||||
|
|
||||||
|
export type VisitBody = {
|
||||||
|
id: string;
|
||||||
|
checkpointId: string | null;
|
||||||
|
marked: { lat: number; lng: number };
|
||||||
|
device: { lat: number; lng: number; accuracy: number | null } | null;
|
||||||
|
clientCreatedAt: string;
|
||||||
|
note: string | null;
|
||||||
|
isIssue: boolean;
|
||||||
|
mediaIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Op =
|
||||||
|
| { type: 'start'; opId: string; roundId: string; clientStartedAt: string }
|
||||||
|
| { type: 'adhoc'; opId: string; id: string; routeId: string; clientStartedAt: string }
|
||||||
|
| { type: 'media'; opId: string; id: string; kind: 'photo' | 'video'; mime: string; size: number; filename: string; capturedAt: string | null }
|
||||||
|
| { type: 'visit'; opId: string; roundId: string; body: VisitBody }
|
||||||
|
| { type: 'finish'; opId: string; roundId: string; clientFinishedAt: string; comment: string | null };
|
||||||
|
|
||||||
|
export type Failure = { opId: string; type: Op['type']; roundId?: string; message: string; at: string };
|
||||||
|
|
||||||
|
type State = { ops: Op[]; failures: Failure[]; syncing: boolean; progress: string | null; online: boolean };
|
||||||
|
|
||||||
|
const OPS_KEY = 'patrol.queue';
|
||||||
|
const FAIL_KEY = 'patrol.failures';
|
||||||
|
const blobKey = (mediaId: string) => `patrol.blob.${mediaId}`;
|
||||||
|
|
||||||
|
let state: State = { ops: [], failures: [], syncing: false, progress: null, online: typeof navigator === 'undefined' ? true : navigator.onLine };
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
let loaded: Promise<void> | null = null;
|
||||||
|
|
||||||
|
function emit(patch: Partial<State>) {
|
||||||
|
state = { ...state, ...patch };
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function subscribe(fn: () => void) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(fn);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getState = () => state;
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
if (!loaded) {
|
||||||
|
loaded = (async () => {
|
||||||
|
const [ops, failures] = await Promise.all([get<Op[]>(OPS_KEY), get<Failure[]>(FAIL_KEY)]);
|
||||||
|
emit({ ops: ops || [], failures: failures || [] });
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
return loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Записи в IndexedDB идут цепочкой, чтобы параллельные enqueue не затёрли друг друга.
|
||||||
|
let writeChain: Promise<unknown> = Promise.resolve();
|
||||||
|
function persist() {
|
||||||
|
const ops = state.ops;
|
||||||
|
const failures = state.failures;
|
||||||
|
writeChain = writeChain.then(() => Promise.all([set(OPS_KEY, ops), set(FAIL_KEY, failures)]));
|
||||||
|
return writeChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enqueue(items: Op[], blobs: { mediaId: string; blob: Blob }[] = []) {
|
||||||
|
await load();
|
||||||
|
for (const b of blobs) await set(blobKey(b.mediaId), b.blob);
|
||||||
|
emit({ ops: [...state.ops, ...items] });
|
||||||
|
await persist();
|
||||||
|
void sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function dismissFailure(opId: string) {
|
||||||
|
emit({ failures: state.failures.filter((f) => f.opId !== opId) });
|
||||||
|
await persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMedia(op: Extract<Op, { type: 'media' }>) {
|
||||||
|
const blob = await get<Blob>(blobKey(op.id));
|
||||||
|
const info = await api<{ status: string; uploadedParts: number[]; partSize: number }>('/media', {
|
||||||
|
body: { id: op.id, kind: op.kind, mime: op.mime, size: op.size, filename: op.filename, capturedAt: op.capturedAt },
|
||||||
|
});
|
||||||
|
if (info.status === 'ready') {
|
||||||
|
await del(blobKey(op.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!blob) throw new ApiError('Файл пропал из памяти телефона', 410);
|
||||||
|
const partSize = info.partSize || UPLOAD_PART_SIZE;
|
||||||
|
const total = Math.max(1, Math.ceil(blob.size / partSize));
|
||||||
|
const done = new Set(info.uploadedParts);
|
||||||
|
for (let n = 1; n <= total; n += 1) {
|
||||||
|
if (done.has(n)) continue;
|
||||||
|
emit({ progress: `Отправка ${op.kind === 'photo' ? 'фото' : 'видео'}: ${Math.round(((n - 1) / total) * 100)}%` });
|
||||||
|
const chunk = blob.slice((n - 1) * partSize, n * partSize);
|
||||||
|
await api(`/media/${op.id}/parts/${n}`, { method: 'PUT', raw: chunk, headers: { 'Content-Type': 'application/octet-stream' } });
|
||||||
|
}
|
||||||
|
await api(`/media/${op.id}/complete`, { method: 'POST' });
|
||||||
|
await del(blobKey(op.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function run(op: Op) {
|
||||||
|
switch (op.type) {
|
||||||
|
case 'start':
|
||||||
|
return api(`/rounds/${op.roundId}/start`, { body: { clientStartedAt: op.clientStartedAt } });
|
||||||
|
case 'adhoc':
|
||||||
|
return api('/rounds', { body: { id: op.id, routeId: op.routeId, clientStartedAt: op.clientStartedAt } });
|
||||||
|
case 'media':
|
||||||
|
return uploadMedia(op);
|
||||||
|
case 'visit':
|
||||||
|
return api(`/rounds/${op.roundId}/visits`, { body: { ...op.body, sentAt: new Date().toISOString() } });
|
||||||
|
case 'finish':
|
||||||
|
return api(`/rounds/${op.roundId}/finish`, { body: { clientFinishedAt: op.clientFinishedAt, comment: op.comment } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Временные ошибки (нет сети, 5xx, 401 без сессии) — оставляем операцию и пробуем позже.
|
||||||
|
// Ошибки по существу (409, 400, 403, 404) — убираем из очереди и показываем сотруднику.
|
||||||
|
const isTransient = (e: unknown) => !(e instanceof ApiError) || e.status === 0 || e.status === 401 || e.status === 408 || e.status === 429 || e.status >= 500;
|
||||||
|
|
||||||
|
let running: Promise<void> | null = null;
|
||||||
|
const syncListeners = new Set<() => void>();
|
||||||
|
export const onSynced = (fn: () => void) => {
|
||||||
|
syncListeners.add(fn);
|
||||||
|
return () => {
|
||||||
|
syncListeners.delete(fn);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sync() {
|
||||||
|
if (running) return running;
|
||||||
|
running = (async () => {
|
||||||
|
let sent = 0;
|
||||||
|
try {
|
||||||
|
await load();
|
||||||
|
if (!state.ops.length) return;
|
||||||
|
emit({ syncing: true });
|
||||||
|
while (state.ops.length) {
|
||||||
|
const op = state.ops[0];
|
||||||
|
try {
|
||||||
|
await run(op);
|
||||||
|
sent += 1;
|
||||||
|
} catch (e) {
|
||||||
|
if (isTransient(e)) break;
|
||||||
|
const roundId = 'roundId' in op ? op.roundId : op.type === 'adhoc' ? op.id : undefined;
|
||||||
|
emit({ failures: [...state.failures, { opId: op.opId, type: op.type, roundId, message: (e as Error).message, at: new Date().toISOString() }] });
|
||||||
|
if (op.type === 'media') await del(blobKey(op.id));
|
||||||
|
}
|
||||||
|
emit({ ops: state.ops.filter((o) => o.opId !== op.opId) });
|
||||||
|
await persist();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
emit({ syncing: false, progress: null });
|
||||||
|
running = null;
|
||||||
|
if (sent) syncListeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAutoSync() {
|
||||||
|
void load().then(sync);
|
||||||
|
const online = () => {
|
||||||
|
emit({ online: true });
|
||||||
|
void sync();
|
||||||
|
};
|
||||||
|
const offline = () => emit({ online: false });
|
||||||
|
window.addEventListener('online', online);
|
||||||
|
window.addEventListener('offline', offline);
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (navigator.onLine) void sync();
|
||||||
|
}, 20000);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('online', online);
|
||||||
|
window.removeEventListener('offline', offline);
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}
|
||||||
4
apps/web/src/guard/useQueue.ts
Normal file
4
apps/web/src/guard/useQueue.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
import { getState, subscribe } from './queue';
|
||||||
|
|
||||||
|
export const useQueue = () => useSyncExternalStore(subscribe, getState);
|
||||||
60
apps/web/src/lib/MediaGallery.tsx
Normal file
60
apps/web/src/lib/MediaGallery.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Box, Dialog, IconButton, Typography } from '@mui/material';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
import PlayCircleIcon from '@mui/icons-material/PlayCircleFilled';
|
||||||
|
import HourglassIcon from '@mui/icons-material/HourglassEmpty';
|
||||||
|
import type { MediaView } from '../api/types';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
export function MediaGallery({ media, size = 96 }: { media: MediaView[]; size?: number }) {
|
||||||
|
const [open, setOpen] = useState<MediaView | null>(null);
|
||||||
|
if (!media.length) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
{media.map((m) => (
|
||||||
|
<Box
|
||||||
|
key={m.id}
|
||||||
|
onClick={() => m.url && setOpen(m)}
|
||||||
|
sx={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '10px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
bgcolor: colors.soft,
|
||||||
|
position: 'relative',
|
||||||
|
cursor: m.url ? 'zoom-in' : 'default',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
border: `1px solid ${colors.line}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!m.url ? (
|
||||||
|
<Box sx={{ textAlign: 'center', color: colors.muted }}>
|
||||||
|
<HourglassIcon fontSize="small" />
|
||||||
|
<Typography sx={{ fontSize: 11 }}>загружается</Typography>
|
||||||
|
</Box>
|
||||||
|
) : m.kind === 'photo' ? (
|
||||||
|
<img src={m.url} alt="" loading="lazy" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<video src={`${m.url}#t=0.5`} preload="metadata" muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
||||||
|
<PlayCircleIcon sx={{ position: 'absolute', color: '#fff', fontSize: 36, filter: 'drop-shadow(0 1px 3px rgba(0,0,0,.5))' }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Dialog open={!!open} onClose={() => setOpen(null)} maxWidth="lg" slotProps={{ paper: { sx: { bgcolor: '#0b1220', borderRadius: '14px' } } }}>
|
||||||
|
<IconButton onClick={() => setOpen(null)} sx={{ position: 'absolute', right: 8, top: 8, color: '#fff', zIndex: 1, bgcolor: 'rgba(0,0,0,.35)' }}>
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
{open?.kind === 'photo' && <img src={open.url!} alt="" style={{ maxWidth: '90vw', maxHeight: '85vh', display: 'block' }} />}
|
||||||
|
{open?.kind === 'video' && <video src={open.url!} controls autoPlay style={{ maxWidth: '90vw', maxHeight: '85vh', display: 'block' }} />}
|
||||||
|
{open?.capturedAt && (
|
||||||
|
<Typography sx={{ color: '#cbd5e1', fontSize: 13, px: 2, py: 1 }}>Снято: {new Date(open.capturedAt).toLocaleString('ru-RU')}</Typography>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
apps/web/src/lib/PersonFields.tsx
Normal file
206
apps/web/src/lib/PersonFields.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
Grid2 as Grid,
|
||||||
|
InputAdornment,
|
||||||
|
List,
|
||||||
|
ListItemButton,
|
||||||
|
ListItemText,
|
||||||
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { PassportCandidate } from '../api/types';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
export type PersonForm = {
|
||||||
|
mode: 'new' | 'existing';
|
||||||
|
lastName: string;
|
||||||
|
firstName: string;
|
||||||
|
middleName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
passportUuid: string;
|
||||||
|
pickedLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyPerson = (): PersonForm => ({
|
||||||
|
mode: 'new',
|
||||||
|
lastName: '',
|
||||||
|
firstName: '',
|
||||||
|
middleName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
password: '',
|
||||||
|
passportUuid: '',
|
||||||
|
pickedLabel: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export function personPayload(p: PersonForm) {
|
||||||
|
if (p.mode === 'existing') return { passportUuid: p.passportUuid };
|
||||||
|
return {
|
||||||
|
lastName: p.lastName.trim(),
|
||||||
|
firstName: p.firstName.trim(),
|
||||||
|
middleName: p.middleName.trim() || null,
|
||||||
|
email: p.email.trim(),
|
||||||
|
phone: p.phone.trim(),
|
||||||
|
password: p.password,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function personReady(p: PersonForm) {
|
||||||
|
if (p.mode === 'existing') return !!p.passportUuid;
|
||||||
|
return !!(p.lastName.trim() && p.firstName.trim() && /\S+@\S+\.\S+/.test(p.email) && p.phone.replace(/\D/g, '').length >= 10 && p.password.length >= 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Поиск человека в паспорте Aspans: email, телефон, ИИН или ФИО. */
|
||||||
|
function PassportPicker({ searchUrl, value, onPick }: { searchUrl: string; value: PersonForm; onPick: (c: PassportCandidate | null) => void }) {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const [items, setItems] = useState<PassportCandidate[] | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const query = q.trim();
|
||||||
|
if (query.length < 3) {
|
||||||
|
setItems(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
setItems(await api<PassportCandidate[]>(`${searchUrl}?q=${encodeURIComponent(query)}`, { signal: ctrl.signal }));
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== 'AbortError') setError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
return () => {
|
||||||
|
ctrl.abort();
|
||||||
|
window.clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, [q, searchUrl]);
|
||||||
|
|
||||||
|
if (value.passportUuid) {
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.5, borderRadius: '12px', bgcolor: colors.brandSoft }}>
|
||||||
|
<CheckCircleIcon sx={{ color: colors.brand }} />
|
||||||
|
<Typography sx={{ fontWeight: 600, flex: 1 }}>{value.pickedLabel}</Typography>
|
||||||
|
<Chip size="small" label="Другой" onClick={() => onPick(null)} />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<TextField
|
||||||
|
label="Найти в Aspans ID"
|
||||||
|
placeholder="Email, телефон, ИИН или ФИО"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
startAdornment: (
|
||||||
|
<InputAdornment position="start">
|
||||||
|
<SearchIcon sx={{ color: colors.faint }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
endAdornment: loading ? <CircularProgress size={18} /> : undefined,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
helperText={error || 'Контакты в результатах скрыты — узнать человека можно по ФИО и последним цифрам'}
|
||||||
|
error={!!error}
|
||||||
|
/>
|
||||||
|
{items && (
|
||||||
|
<List dense sx={{ mt: 1, border: `1px solid ${colors.line}`, borderRadius: '12px', maxHeight: 260, overflowY: 'auto' }}>
|
||||||
|
{!items.length && <Typography sx={{ color: colors.muted, fontSize: 14, px: 2, py: 1.5 }}>Никого не нашли</Typography>}
|
||||||
|
{items.map((c) => {
|
||||||
|
const disabled = c.status !== 'free';
|
||||||
|
return (
|
||||||
|
<ListItemButton key={c.passportUuid} disabled={disabled} onClick={() => onPick(c)} sx={{ borderRadius: '10px' }}>
|
||||||
|
<ListItemText
|
||||||
|
primary={c.fullName}
|
||||||
|
secondary={[c.emailMasked, c.phoneMasked, c.status === 'member' ? 'уже в вашей организации' : c.status === 'other_org' ? `в организации «${c.otherOrganization}»` : null]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ')}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</List>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Человек: новый (заводится в паспорте) или существующий из паспорта. */
|
||||||
|
export function PersonFields({ value, onChange, searchUrl, allowExisting = true, passwordHint }: {
|
||||||
|
value: PersonForm;
|
||||||
|
onChange: (v: PersonForm) => void;
|
||||||
|
searchUrl?: string;
|
||||||
|
allowExisting?: boolean;
|
||||||
|
passwordHint?: string;
|
||||||
|
}) {
|
||||||
|
const set = (patch: Partial<PersonForm>) => onChange({ ...value, ...patch });
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{allowExisting && searchUrl && (
|
||||||
|
<Grid size={12}>
|
||||||
|
<ToggleButtonGroup size="small" exclusive value={value.mode} onChange={(_, v) => v && set({ mode: v })} fullWidth>
|
||||||
|
<ToggleButton value="new">Новый человек</ToggleButton>
|
||||||
|
<ToggleButton value="existing">Уже есть в Aspans ID</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
{value.mode === 'existing' && searchUrl ? (
|
||||||
|
<Grid size={12}>
|
||||||
|
<PassportPicker
|
||||||
|
searchUrl={searchUrl}
|
||||||
|
value={value}
|
||||||
|
onPick={(c) => set({ passportUuid: c?.passportUuid ?? '', pickedLabel: c?.fullName ?? '' })}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField label="Фамилия" value={value.lastName} onChange={(e) => set({ lastName: e.target.value })} required />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField label="Имя" value={value.firstName} onChange={(e) => set({ firstName: e.target.value })} required />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField label="Отчество" value={value.middleName} onChange={(e) => set({ middleName: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField label="Email" type="email" value={value.email} onChange={(e) => set({ email: e.target.value })} required autoComplete="off" />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField label="Телефон" value={value.phone} onChange={(e) => set({ phone: e.target.value })} required placeholder="+7 701 234 56 78" />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<TextField
|
||||||
|
label="Пароль Aspans ID"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={value.password}
|
||||||
|
onChange={(e) => set({ password: e.target.value })}
|
||||||
|
required
|
||||||
|
helperText={passwordHint ?? 'Не короче 8 символов. Если человек с этими email и телефоном уже есть в Aspans ID, его пароль не меняется'}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
22
apps/web/src/lib/SitePicker.tsx
Normal file
22
apps/web/src/lib/SitePicker.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { MenuItem, TextField } from '@mui/material';
|
||||||
|
import type { Site } from '../api/types';
|
||||||
|
|
||||||
|
export function SitePicker({ sites, value, onChange, allowAll, label = 'Объект', sx }: {
|
||||||
|
sites: Site[];
|
||||||
|
value: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
allowAll?: boolean;
|
||||||
|
label?: string;
|
||||||
|
sx?: object;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<TextField select size="small" label={label} value={value} onChange={(e) => onChange(e.target.value)} sx={{ minWidth: 240, ...sx }} fullWidth={false}>
|
||||||
|
{allowAll && <MenuItem value="">Все объекты</MenuItem>}
|
||||||
|
{sites.map((s) => (
|
||||||
|
<MenuItem key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
||||||
8
apps/web/src/lib/StatusChip.tsx
Normal file
8
apps/web/src/lib/StatusChip.tsx
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { Chip } from '@mui/material';
|
||||||
|
import type { RoundStatus } from '@patrol/shared';
|
||||||
|
import { STATUS_COLORS, statusLabel } from './format';
|
||||||
|
|
||||||
|
export function StatusChip({ status }: { status: RoundStatus }) {
|
||||||
|
const c = STATUS_COLORS[status];
|
||||||
|
return <Chip size="small" label={statusLabel(status)} sx={{ color: c.fg, bgcolor: c.bg }} />;
|
||||||
|
}
|
||||||
52
apps/web/src/lib/format.ts
Normal file
52
apps/web/src/lib/format.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import type { RoundStatus } from '@patrol/shared';
|
||||||
|
import { ROUND_STATUS_LABELS } from '@patrol/shared';
|
||||||
|
|
||||||
|
// Время показываем в часовом поясе объекта, а не браузера.
|
||||||
|
export function fmtTime(iso: string | null | undefined, tz?: string) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { hour: '2-digit', minute: '2-digit', timeZone: tz }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDate(iso: string | null | undefined, tz?: string) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'short', timeZone: tz }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDateTime(iso: string | null | undefined, tz?: string) {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: tz }).format(new Date(iso));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDayLabel(date: string) {
|
||||||
|
const d = new Date(`${date}T12:00:00`);
|
||||||
|
return new Intl.DateTimeFormat('ru-RU', { weekday: 'short', day: 'numeric', month: 'long' }).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ymd(d: Date) {
|
||||||
|
const p = (n: number) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(d: Date, n: number) {
|
||||||
|
const r = new Date(d);
|
||||||
|
r.setDate(r.getDate() + n);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATUS_COLORS: Record<RoundStatus, { fg: string; bg: string }> = {
|
||||||
|
planned: { fg: '#475569', bg: '#f1f5f9' },
|
||||||
|
in_progress: { fg: '#1d4ed8', bg: '#e0ecff' },
|
||||||
|
completed: { fg: '#15803d', bg: '#dcfce7' },
|
||||||
|
partial: { fg: '#b45309', bg: '#fef3c7' },
|
||||||
|
missed: { fg: '#b91c1c', bg: '#fee2e2' },
|
||||||
|
cancelled: { fg: '#64748b', bg: '#f1f5f9' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const statusLabel = (s: RoundStatus) => ROUND_STATUS_LABELS[s];
|
||||||
|
|
||||||
|
export function fmtDistance(m: number | null | undefined) {
|
||||||
|
if (m === null || m === undefined) return '—';
|
||||||
|
return m < 1000 ? `${Math.round(m)} м` : `${(m / 1000).toFixed(1)} км`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TIMEZONES = ['Asia/Almaty', 'Asia/Aqtobe', 'Asia/Aqtau', 'Asia/Atyrau', 'Asia/Oral', 'Asia/Qostanay', 'Asia/Qyzylorda'];
|
||||||
25
apps/web/src/lib/queries.ts
Normal file
25
apps/web/src/lib/queries.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Employee, Route, Schedule, Site } from '../api/types';
|
||||||
|
|
||||||
|
export const useSites = () => useQuery({ queryKey: ['sites'], queryFn: () => api<Site[]>('/sites') });
|
||||||
|
|
||||||
|
export const useRoutes = (siteId?: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['routes', siteId ?? 'all'],
|
||||||
|
queryFn: () => api<Route[]>(`/routes${siteId ? `?siteId=${siteId}` : ''}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useSchedules = (siteId?: string) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['schedules', siteId ?? 'all'],
|
||||||
|
queryFn: () => api<Schedule[]>(`/schedules${siteId ? `?siteId=${siteId}` : ''}`),
|
||||||
|
enabled: siteId !== '',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useEmployees = (siteId?: string, enabled = true) =>
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['users', siteId ?? 'all'],
|
||||||
|
queryFn: () => api<Employee[]>(`/users${siteId ? `?siteId=${siteId}` : ''}`),
|
||||||
|
enabled,
|
||||||
|
});
|
||||||
9
apps/web/src/main.tsx
Normal file
9
apps/web/src/main.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
132
apps/web/src/map/MapSearch.tsx
Normal file
132
apps/web/src/map/MapSearch.tsx
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import { Autocomplete, CircularProgress, TextField } from '@mui/material';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
|
||||||
|
export type GeocodeHit = { label: string; lat: number; lng: number; bbox: [number, number, number, number] | null };
|
||||||
|
|
||||||
|
// «10, проспект Абая, …, Алматы, 050000, Казахстан» → без страны: все адреса и так в Казахстане.
|
||||||
|
const shortLabel = (s: string) => s.replace(/,\s*Казахстан$/, '');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Поле адреса с подсказками из /api/geocode. Текст остаётся свободным: можно выбрать подсказку
|
||||||
|
* или оставить свой адрес, если поиск его не знает (место тогда находят на карте вручную).
|
||||||
|
*/
|
||||||
|
export function AddressAutocomplete({ value, onChange, onPick, disabled, label = 'Адрес' }: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
onPick: (hit: GeocodeHit) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
const [options, setOptions] = useState<GeocodeHit[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
// Ищем только когда человек печатает, а не при загрузке сохранённого адреса.
|
||||||
|
const typed = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const q = value.trim();
|
||||||
|
if (!typed.current || q.length < 3) {
|
||||||
|
setOptions([]);
|
||||||
|
setError('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = window.setTimeout(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const hits = await api<GeocodeHit[]>(`/geocode?q=${encodeURIComponent(q)}`, { signal: ctrl.signal });
|
||||||
|
setOptions(hits.map((h) => ({ ...h, label: shortLabel(h.label) })));
|
||||||
|
setError(hits.length ? '' : 'Адрес не найден — найдите место на карте вручную');
|
||||||
|
} catch (e) {
|
||||||
|
if ((e as Error).name !== 'AbortError') {
|
||||||
|
setOptions([]);
|
||||||
|
setError((e as Error).message);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!ctrl.signal.aborted) setLoading(false);
|
||||||
|
}
|
||||||
|
}, 600);
|
||||||
|
return () => {
|
||||||
|
ctrl.abort();
|
||||||
|
window.clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Autocomplete<GeocodeHit, false, false, true>
|
||||||
|
freeSolo
|
||||||
|
disabled={disabled}
|
||||||
|
open={open && value.trim().length >= 3 && (loading || options.length > 0)}
|
||||||
|
onOpen={() => setOpen(true)}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
options={options}
|
||||||
|
filterOptions={(x) => x}
|
||||||
|
getOptionLabel={(o) => (typeof o === 'string' ? o : o.label)}
|
||||||
|
inputValue={value}
|
||||||
|
onInputChange={(_, v, reason) => {
|
||||||
|
if (reason === 'input') typed.current = true;
|
||||||
|
if (reason !== 'reset') onChange(v);
|
||||||
|
}}
|
||||||
|
onChange={(_, v) => {
|
||||||
|
if (v && typeof v !== 'string') {
|
||||||
|
typed.current = false;
|
||||||
|
onChange(v.label);
|
||||||
|
onPick(v);
|
||||||
|
setOptions([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={loading}
|
||||||
|
loadingText="Ищем…"
|
||||||
|
renderOption={(props, o) => (
|
||||||
|
<li {...props} key={`${o.lat},${o.lng},${o.label}`} style={{ fontSize: 14 }}>
|
||||||
|
{o.label}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
renderInput={(params) => (
|
||||||
|
<TextField
|
||||||
|
{...params}
|
||||||
|
label={label}
|
||||||
|
helperText={error || 'Начните вводить — предложим адреса. Можно оставить свой'}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
...params.InputProps,
|
||||||
|
endAdornment: (
|
||||||
|
<>
|
||||||
|
{loading && <CircularProgress size={16} />}
|
||||||
|
{params.InputProps.endAdornment}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Перенести карту к найденному адресу и отметить его. Внутри BaseMap. */
|
||||||
|
export function MapFocus({ target }: { target: GeocodeHit | null }) {
|
||||||
|
const map = useMap();
|
||||||
|
const marker = useRef<L.CircleMarker | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!target) return;
|
||||||
|
if (target.bbox) map.fitBounds([[target.bbox[0], target.bbox[1]], [target.bbox[2], target.bbox[3]]], { maxZoom: 17, padding: [24, 24] });
|
||||||
|
else map.setView([target.lat, target.lng], 17);
|
||||||
|
marker.current?.remove();
|
||||||
|
marker.current = L.circleMarker([target.lat, target.lng], {
|
||||||
|
radius: 7,
|
||||||
|
color: '#dc2626',
|
||||||
|
weight: 2,
|
||||||
|
fillColor: '#ef4444',
|
||||||
|
fillOpacity: 0.9,
|
||||||
|
interactive: false,
|
||||||
|
pmIgnore: true,
|
||||||
|
} as L.CircleMarkerOptions).addTo(map);
|
||||||
|
}, [map, target]);
|
||||||
|
useEffect(() => () => void marker.current?.remove(), []);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
84
apps/web/src/map/PerimeterEditor.tsx
Normal file
84
apps/web/src/map/PerimeterEditor.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import '@geoman-io/leaflet-geoman-free';
|
||||||
|
import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css';
|
||||||
|
import type { GeoJsonMultiPolygon } from '@patrol/shared';
|
||||||
|
import { PERIMETER_STYLE } from './base';
|
||||||
|
|
||||||
|
// Рисование периметра: один или несколько многоугольников; результат — MultiPolygon.
|
||||||
|
export function PerimeterEditor({ value, onChange }: { value: GeoJsonMultiPolygon | null; onChange: (v: GeoJsonMultiPolygon | null) => void }) {
|
||||||
|
const map = useMap();
|
||||||
|
const group = useRef<L.FeatureGroup | null>(null);
|
||||||
|
const changeRef = useRef(onChange);
|
||||||
|
changeRef.current = onChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fg = L.featureGroup().addTo(map);
|
||||||
|
group.current = fg;
|
||||||
|
if (value) {
|
||||||
|
for (const poly of value.coordinates) {
|
||||||
|
const layer = L.polygon(poly.map((ring) => ring.map(([lng, lat]) => [lat, lng] as [number, number])), PERIMETER_STYLE);
|
||||||
|
fg.addLayer(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const collect = () => {
|
||||||
|
const polys: [number, number][][][] = [];
|
||||||
|
fg.eachLayer((l) => {
|
||||||
|
const geo = (l as L.Polygon).toGeoJSON().geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
|
||||||
|
if (geo.type === 'Polygon') polys.push(geo.coordinates as [number, number][][]);
|
||||||
|
else polys.push(...(geo.coordinates as [number, number][][][]));
|
||||||
|
});
|
||||||
|
changeRef.current(polys.length ? { type: 'MultiPolygon', coordinates: polys } : null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const watch = (l: L.Layer) => {
|
||||||
|
l.on('pm:edit pm:dragend pm:rotateend', collect);
|
||||||
|
};
|
||||||
|
fg.eachLayer(watch);
|
||||||
|
|
||||||
|
map.pm.setLang('ru');
|
||||||
|
map.pm.setGlobalOptions({ pathOptions: PERIMETER_STYLE, snappable: true, allowSelfIntersection: false });
|
||||||
|
map.pm.addControls({
|
||||||
|
position: 'topleft',
|
||||||
|
drawMarker: false,
|
||||||
|
drawCircleMarker: false,
|
||||||
|
drawPolyline: false,
|
||||||
|
drawCircle: false,
|
||||||
|
drawText: false,
|
||||||
|
drawRectangle: true,
|
||||||
|
drawPolygon: true,
|
||||||
|
cutPolygon: false,
|
||||||
|
rotateMode: false,
|
||||||
|
editMode: true,
|
||||||
|
dragMode: true,
|
||||||
|
removalMode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const onCreate = (e: { layer: L.Layer }) => {
|
||||||
|
map.removeLayer(e.layer);
|
||||||
|
fg.addLayer(e.layer);
|
||||||
|
watch(e.layer);
|
||||||
|
collect();
|
||||||
|
};
|
||||||
|
const onRemove = (e: { layer: L.Layer }) => {
|
||||||
|
fg.removeLayer(e.layer);
|
||||||
|
collect();
|
||||||
|
};
|
||||||
|
map.on('pm:create', onCreate);
|
||||||
|
map.on('pm:remove', onRemove);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
map.off('pm:create', onCreate);
|
||||||
|
map.off('pm:remove', onRemove);
|
||||||
|
map.pm.removeControls();
|
||||||
|
map.pm.disableDraw();
|
||||||
|
fg.remove();
|
||||||
|
};
|
||||||
|
// Перерисовываем только при смене объекта (key у компонента), а не на каждое изменение value.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
173
apps/web/src/map/RouteEditor.tsx
Normal file
173
apps/web/src/map/RouteEditor.tsx
Normal file
@ -0,0 +1,173 @@
|
|||||||
|
import { Fragment, useEffect, useRef } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import { Circle, GeoJSON, Marker, Tooltip, useMap, useMapEvents } from 'react-leaflet';
|
||||||
|
import '@geoman-io/leaflet-geoman-free';
|
||||||
|
import '@geoman-io/leaflet-geoman-free/dist/leaflet-geoman.css';
|
||||||
|
import type { GeoJsonLineString, GeoJsonMultiPolygon, GeoJsonPoint } from '@patrol/shared';
|
||||||
|
import { PERIMETER_STYLE, ROUTE_STYLE, numberIcon } from './base';
|
||||||
|
|
||||||
|
export type EditorCheckpoint = { key: string; name: string; location: GeoJsonPoint; radiusM: number };
|
||||||
|
export type EditorMode = 'path' | 'points' | 'view';
|
||||||
|
|
||||||
|
// Линия маршрута — через geoman: нарисовать, поправить вершины, удалить. Всегда одна линия.
|
||||||
|
function PathLayer({ value, onChange, mode }: { value: GeoJsonLineString | null; onChange: (v: GeoJsonLineString | null) => void; mode: EditorMode }) {
|
||||||
|
const map = useMap();
|
||||||
|
const line = useRef<L.Polyline | null>(null);
|
||||||
|
const changeRef = useRef(onChange);
|
||||||
|
changeRef.current = onChange;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const emit = () => {
|
||||||
|
const l = line.current;
|
||||||
|
if (!l) return changeRef.current(null);
|
||||||
|
const geo = l.toGeoJSON().geometry as GeoJSON.LineString;
|
||||||
|
changeRef.current(geo.coordinates.length >= 2 ? { type: 'LineString', coordinates: geo.coordinates as [number, number][] } : null);
|
||||||
|
};
|
||||||
|
const attach = (l: L.Polyline) => {
|
||||||
|
line.current = l;
|
||||||
|
l.setStyle(ROUTE_STYLE);
|
||||||
|
l.on('pm:edit pm:dragend', emit);
|
||||||
|
};
|
||||||
|
if (value) attach(L.polyline(value.coordinates.map(([lng, lat]) => [lat, lng] as [number, number])).addTo(map));
|
||||||
|
|
||||||
|
map.pm.setLang('ru');
|
||||||
|
map.pm.setGlobalOptions({ pathOptions: ROUTE_STYLE, snappable: true });
|
||||||
|
|
||||||
|
const onCreate = (e: { shape: string; layer: L.Layer }) => {
|
||||||
|
if (e.shape !== 'Line') return;
|
||||||
|
line.current?.remove();
|
||||||
|
attach(e.layer as L.Polyline);
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
const onRemove = (e: { layer: L.Layer }) => {
|
||||||
|
if (e.layer === line.current) {
|
||||||
|
line.current = null;
|
||||||
|
emit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
map.on('pm:create', onCreate);
|
||||||
|
map.on('pm:remove', onRemove);
|
||||||
|
return () => {
|
||||||
|
map.off('pm:create', onCreate);
|
||||||
|
map.off('pm:remove', onRemove);
|
||||||
|
map.pm.removeControls();
|
||||||
|
map.pm.disableDraw();
|
||||||
|
map.pm.disableGlobalEditMode();
|
||||||
|
line.current?.remove();
|
||||||
|
line.current = null;
|
||||||
|
};
|
||||||
|
// Инициализация один раз на маршрут (key у редактора).
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [map]);
|
||||||
|
|
||||||
|
// Панель geoman видна только в режиме «линия», чтобы клики в режиме «точки» не путались с рисованием.
|
||||||
|
useEffect(() => {
|
||||||
|
map.pm.removeControls();
|
||||||
|
map.pm.disableDraw();
|
||||||
|
map.pm.disableGlobalEditMode();
|
||||||
|
if (mode === 'path') {
|
||||||
|
map.pm.addControls({
|
||||||
|
position: 'topleft',
|
||||||
|
drawMarker: false,
|
||||||
|
drawCircleMarker: false,
|
||||||
|
drawPolygon: false,
|
||||||
|
drawRectangle: false,
|
||||||
|
drawCircle: false,
|
||||||
|
drawText: false,
|
||||||
|
drawPolyline: true,
|
||||||
|
cutPolygon: false,
|
||||||
|
rotateMode: false,
|
||||||
|
dragMode: false,
|
||||||
|
editMode: true,
|
||||||
|
removalMode: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (line.current) line.current.options.pmIgnore = mode !== 'path';
|
||||||
|
}, [map, mode]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ClickToAdd({ enabled, onAdd }: { enabled: boolean; onAdd: (p: GeoJsonPoint) => void }) {
|
||||||
|
const map = useMapEvents({
|
||||||
|
click(e) {
|
||||||
|
if (!enabled || map.pm.globalDrawModeEnabled()) return;
|
||||||
|
onAdd({ type: 'Point', coordinates: [Number(e.latlng.lng.toFixed(7)), Number(e.latlng.lat.toFixed(7))] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useEffect(() => {
|
||||||
|
const el = map.getContainer();
|
||||||
|
el.style.cursor = enabled ? 'crosshair' : '';
|
||||||
|
return () => {
|
||||||
|
el.style.cursor = '';
|
||||||
|
};
|
||||||
|
}, [map, enabled]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RouteEditor({
|
||||||
|
perimeter,
|
||||||
|
path,
|
||||||
|
onPathChange,
|
||||||
|
checkpoints,
|
||||||
|
onCheckpointsChange,
|
||||||
|
mode,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
perimeter: GeoJsonMultiPolygon | null;
|
||||||
|
path: GeoJsonLineString | null;
|
||||||
|
onPathChange: (v: GeoJsonLineString | null) => void;
|
||||||
|
checkpoints: EditorCheckpoint[];
|
||||||
|
onCheckpointsChange: (v: EditorCheckpoint[]) => void;
|
||||||
|
mode: EditorMode;
|
||||||
|
selected: number | null;
|
||||||
|
onSelect: (i: number | null) => void;
|
||||||
|
}) {
|
||||||
|
const add = (location: GeoJsonPoint) => {
|
||||||
|
const next = [...checkpoints, { key: crypto.randomUUID(), name: `Точка ${checkpoints.length + 1}`, location, radiusM: 30 }];
|
||||||
|
onCheckpointsChange(next);
|
||||||
|
onSelect(next.length - 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = (i: number, latlng: L.LatLng) => {
|
||||||
|
onCheckpointsChange(
|
||||||
|
checkpoints.map((c, j) => (j === i ? { ...c, location: { type: 'Point', coordinates: [Number(latlng.lng.toFixed(7)), Number(latlng.lat.toFixed(7))] } } : c)),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{perimeter && <GeoJSON data={perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} pmIgnore interactive={false} />}
|
||||||
|
<PathLayer value={path} onChange={onPathChange} mode={mode} />
|
||||||
|
<ClickToAdd enabled={mode === 'points'} onAdd={add} />
|
||||||
|
{checkpoints.map((c, i) => {
|
||||||
|
const pos: [number, number] = [c.location.coordinates[1], c.location.coordinates[0]];
|
||||||
|
return (
|
||||||
|
<Fragment key={c.key}>
|
||||||
|
<Circle center={pos} radius={c.radiusM} pathOptions={{ color: '#2563eb', weight: 1, fillOpacity: selected === i ? 0.18 : 0.08 }} pmIgnore interactive={false} />
|
||||||
|
<Marker
|
||||||
|
key={mode === 'path' ? 'passive' : 'active'}
|
||||||
|
interactive={mode !== 'path'}
|
||||||
|
position={pos}
|
||||||
|
icon={numberIcon(i + 1, { active: selected === i })}
|
||||||
|
draggable={mode === 'points'}
|
||||||
|
pmIgnore
|
||||||
|
eventHandlers={{
|
||||||
|
click: (e) => {
|
||||||
|
L.DomEvent.stopPropagation(e);
|
||||||
|
onSelect(i);
|
||||||
|
},
|
||||||
|
dragend: (e) => move(i, (e.target as L.Marker).getLatLng()),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip direction="top" offset={[0, -14]}>
|
||||||
|
{c.name}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
apps/web/src/map/base.tsx
Normal file
109
apps/web/src/map/base.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { ReactNode, useEffect } from 'react';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
||||||
|
import type { GeoJsonLineString, GeoJsonMultiPolygon, GeoJsonPoint } from '@patrol/shared';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Подложка — наш tileserver-gl (VITE_TILE_URL). Без переменной — публичный OSM, только для разработки.
|
||||||
|
export const TILE_URL: string = import.meta.env.VITE_TILE_URL || 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||||
|
export const ALMATY: [number, number] = [43.2389, 76.8897];
|
||||||
|
|
||||||
|
export function BaseMap({ children, height = 480, center, zoom = 15, style }: {
|
||||||
|
children?: ReactNode;
|
||||||
|
height?: number | string;
|
||||||
|
center?: [number, number];
|
||||||
|
zoom?: number;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<MapContainer
|
||||||
|
center={center || ALMATY}
|
||||||
|
zoom={zoom}
|
||||||
|
style={{ height, width: '100%', borderRadius: 12, ...style }}
|
||||||
|
scrollWheelZoom
|
||||||
|
>
|
||||||
|
<AutoResize />
|
||||||
|
<TileLayer url={TILE_URL} maxZoom={19} attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' />
|
||||||
|
{children}
|
||||||
|
</MapContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Карта в flex/absolute-контейнере получает размер позже монтирования — пересчитываем.
|
||||||
|
function AutoResize() {
|
||||||
|
const map = useMap();
|
||||||
|
useEffect(() => {
|
||||||
|
const el = map.getContainer();
|
||||||
|
const ro = new ResizeObserver(() => map.invalidateSize());
|
||||||
|
ro.observe(el);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [map]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Geo = GeoJsonMultiPolygon | GeoJsonLineString | GeoJsonPoint | null | undefined;
|
||||||
|
|
||||||
|
export function boundsOf(...geos: Geo[]): L.LatLngBounds | null {
|
||||||
|
let b: L.LatLngBounds | null = null;
|
||||||
|
for (const g of geos) {
|
||||||
|
if (!g) continue;
|
||||||
|
const layer = L.geoJSON(g as GeoJSON.GeoJsonObject);
|
||||||
|
const lb = layer.getBounds();
|
||||||
|
if (!lb.isValid()) continue;
|
||||||
|
b = b ? b.extend(lb) : lb;
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подогнать карту под геометрию один раз (или когда меняется key).
|
||||||
|
export function FitTo({ geos, padding = 32, maxZoom = 18 }: { geos: Geo[]; padding?: number; maxZoom?: number }) {
|
||||||
|
const map = useMap();
|
||||||
|
const key = JSON.stringify(geos.map((g) => (g ? JSON.stringify(g).length : 0)));
|
||||||
|
useEffect(() => {
|
||||||
|
const b = boundsOf(...geos);
|
||||||
|
if (!b) return;
|
||||||
|
const fit = () => {
|
||||||
|
map.invalidateSize();
|
||||||
|
map.fitBounds(b, { padding: [padding, padding], maxZoom });
|
||||||
|
};
|
||||||
|
fit();
|
||||||
|
// Если контейнер ещё не разложен, подгоняем ещё раз после раскладки.
|
||||||
|
const t = window.setTimeout(fit, 150);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [map, key]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function numberIcon(n: number | string, opts: { color?: string; done?: boolean; active?: boolean; size?: number } = {}) {
|
||||||
|
const size = opts.size ?? 28;
|
||||||
|
const color = opts.done ? '#16a34a' : opts.color || '#2563eb';
|
||||||
|
const ring = opts.active ? `box-shadow:0 0 0 4px ${color}55;` : 'box-shadow:0 2px 6px rgba(15,23,42,0.3);';
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'patrol-num-icon',
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${color};color:#fff;display:grid;place-items:center;font:600 ${Math.round(size * 0.46)}px Inter,system-ui,sans-serif;border:2px solid #fff;${ring}">${n}</div>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dotIcon(color: string, size = 14) {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'patrol-dot-icon',
|
||||||
|
iconSize: [size, size],
|
||||||
|
iconAnchor: [size / 2, size / 2],
|
||||||
|
html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:${color};border:2px solid #fff;box-shadow:0 1px 4px rgba(15,23,42,0.35)"></div>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pinIcon(color = '#dc2626') {
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'patrol-pin-icon',
|
||||||
|
iconSize: [30, 40],
|
||||||
|
iconAnchor: [15, 38],
|
||||||
|
html: `<svg width="30" height="40" viewBox="0 0 30 40"><path d="M15 1C7.3 1 1 7.2 1 14.9 1 25.3 15 39 15 39s14-13.7 14-24.1C29 7.2 22.7 1 15 1z" fill="${color}" stroke="#fff" stroke-width="2"/><circle cx="15" cy="15" r="5" fill="#fff"/></svg>`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PERIMETER_STYLE: L.PathOptions = { color: '#2563eb', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.08 };
|
||||||
|
export const ROUTE_STYLE: L.PathOptions = { color: '#7c3aed', weight: 4, opacity: 0.85, dashArray: '8 6' };
|
||||||
29
apps/web/src/pages/AuthLayout.tsx
Normal file
29
apps/web/src/pages/AuthLayout.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Box, Paper, Typography } from '@mui/material';
|
||||||
|
import { BrandLogo } from '../ui/Brand';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
export function AuthLayout({ title, subtitle, children, wide }: { title: string; subtitle?: string; children: ReactNode; wide?: boolean }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
minHeight: '100vh',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
px: 2,
|
||||||
|
py: 4,
|
||||||
|
background: `radial-gradient(1200px 600px at 10% -10%, ${colors.brandSofter}, transparent), radial-gradient(900px 500px at 110% 110%, #ede9fe, transparent), ${colors.page}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper sx={{ width: '100%', maxWidth: wide ? 640 : 420, p: { xs: 3, sm: 4.5 }, border: `1px solid ${colors.line}`, borderRadius: '20px', boxShadow: '0 20px 60px rgba(15,23,42,0.08)' }}>
|
||||||
|
<Box sx={{ mb: 3 }}>
|
||||||
|
<BrandLogo />
|
||||||
|
</Box>
|
||||||
|
<Typography variant="h5">{title}</Typography>
|
||||||
|
{subtitle && <Typography sx={{ color: colors.muted, mt: 0.75, mb: 1 }}>{subtitle}</Typography>}
|
||||||
|
<Box sx={{ mt: 2.5 }}>{children}</Box>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
450
apps/web/src/pages/EmployeesPage.tsx
Normal file
450
apps/web/src/pages/EmployeesPage.tsx
Normal file
@ -0,0 +1,450 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
Grid2 as Grid,
|
||||||
|
IconButton,
|
||||||
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
|
Switch,
|
||||||
|
Tab,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import EditIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import DeleteIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import GroupIcon from '@mui/icons-material/GroupOutlined';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { PERMISSIONS, PERMISSION_LABELS, type Permission } from '@patrol/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Employee, RoleRow } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { fmtDateTime } from '../lib/format';
|
||||||
|
import { useEmployees, useSites } from '../lib/queries';
|
||||||
|
import { emptyPerson, PersonFields, personPayload, personReady, type PersonForm } from '../lib/PersonFields';
|
||||||
|
import { useConfirm, useNotify } from '../ui/feedback';
|
||||||
|
import { Card, EmptyState, ErrorState, LoadingState, PageHeader, PersonAvatar } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
const formatPhone = (p: string) => {
|
||||||
|
const d = p.replace(/\D/g, '');
|
||||||
|
return d.length === 11 ? `+${d[0]} ${d.slice(1, 4)} ${d.slice(4, 7)} ${d.slice(7, 9)} ${d.slice(9)}` : p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const useRoles = () => useQuery({ queryKey: ['roles'], queryFn: () => api<RoleRow[]>('/roles') });
|
||||||
|
|
||||||
|
export function EmployeesPage() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const [tab, setTab] = useState(can('users.manage') ? 'users' : 'roles');
|
||||||
|
const [editUser, setEditUser] = useState<Employee | 'new' | null>(null);
|
||||||
|
const [editRole, setEditRole] = useState<RoleRow | 'new' | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Сотрудники"
|
||||||
|
subtitle="Учётные записи, роли и права, доступ к объектам"
|
||||||
|
actions={
|
||||||
|
tab === 'users'
|
||||||
|
? can('users.manage') && (
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setEditUser('new')}>
|
||||||
|
Добавить сотрудника
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
: can('roles.manage') && (
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setEditRole('new')}>
|
||||||
|
Новая роль
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2.5, borderBottom: `1px solid ${colors.line}` }}>
|
||||||
|
{can('users.manage') && <Tab value="users" label="Сотрудники" />}
|
||||||
|
<Tab value="roles" label="Роли и права" />
|
||||||
|
</Tabs>
|
||||||
|
{tab === 'users' ? <UsersTab onEdit={setEditUser} /> : <RolesTab onEdit={setEditRole} />}
|
||||||
|
{editUser && <UserDialog user={editUser === 'new' ? null : editUser} onClose={() => setEditUser(null)} />}
|
||||||
|
{editRole && <RoleDialog role={editRole === 'new' ? null : editRole} onClose={() => setEditRole(null)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UsersTab({ onEdit }: { onEdit: (u: Employee) => void }) {
|
||||||
|
const users = useEmployees();
|
||||||
|
const sites = useSites();
|
||||||
|
const siteName = (id: string) => sites.data?.find((s) => s.id === id)?.name;
|
||||||
|
if (users.isLoading) return <LoadingState />;
|
||||||
|
if (users.isError) return <ErrorState error={users.error} onRetry={() => users.refetch()} />;
|
||||||
|
if (!users.data?.length) return <Card><EmptyState icon={GroupIcon} title="Сотрудников пока нет" /></Card>;
|
||||||
|
return (
|
||||||
|
<Card flush>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Сотрудник</TableCell>
|
||||||
|
<TableCell>Роль</TableCell>
|
||||||
|
<TableCell>Объекты</TableCell>
|
||||||
|
<TableCell>Последний вход</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{users.data.map((u) => (
|
||||||
|
<TableRow key={u.id} hover sx={{ opacity: u.isActive ? 1 : 0.55, cursor: 'pointer' }} onClick={() => onEdit(u)}>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25 }}>
|
||||||
|
<PersonAvatar name={u.fullName} id={u.id} />
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{u.fullName}
|
||||||
|
{!u.isActive && <Chip size="small" label="Отключён" sx={{ ml: 1 }} />}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 12.5 }}>
|
||||||
|
{[u.email, u.phone && formatPhone(u.phone)].filter(Boolean).join(' · ') || u.login}
|
||||||
|
{!u.passportEnabled && ' · отключён в Aspans ID'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{u.roleName || '—'}</TableCell>
|
||||||
|
<TableCell sx={{ maxWidth: 320 }}>
|
||||||
|
{u.allSites ? (
|
||||||
|
<Chip size="small" label="Все объекты" sx={{ bgcolor: colors.brandSoft, color: colors.brand }} />
|
||||||
|
) : u.siteIds.length ? (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{u.siteIds.map((id) => (
|
||||||
|
<Chip key={id} size="small" label={siteName(id) || 'архив'} sx={{ bgcolor: colors.soft }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ color: colors.warning, fontSize: 13 }}>Нет доступа к объектам</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap', color: colors.muted }}>{u.lastLoginAt ? fmtDateTime(u.lastLoginAt) : 'не входил'}</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small">
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserDialog({ user, onClose }: { user: Employee | null; onClose: () => void }) {
|
||||||
|
const roles = useRoles();
|
||||||
|
const sites = useSites();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { me } = useAuth();
|
||||||
|
const [person, setPerson] = useState<PersonForm>(emptyPerson);
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
lastName: user?.lastName || '',
|
||||||
|
firstName: user?.firstName || '',
|
||||||
|
middleName: user?.middleName || '',
|
||||||
|
email: user?.email || '',
|
||||||
|
phone: user?.phone || '',
|
||||||
|
password: '',
|
||||||
|
roleId: user?.roleId || '',
|
||||||
|
siteIds: user?.siteIds || [],
|
||||||
|
isActive: user?.isActive ?? true,
|
||||||
|
});
|
||||||
|
const roleId = form.roleId || roles.data?.find((r) => r.code === 'guard')?.id || roles.data?.[0]?.id || '';
|
||||||
|
const role = roles.data?.find((r) => r.id === roleId);
|
||||||
|
const self = user?.id === me?.id;
|
||||||
|
|
||||||
|
const done = (msg: string) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['roles'] });
|
||||||
|
notify.success(msg);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
if (!user) return api('/users', { body: { ...personPayload(person), roleId, siteIds: form.siteIds } });
|
||||||
|
const body: Record<string, unknown> = { siteIds: form.siteIds };
|
||||||
|
// Профиль — только изменённые поля: он уходит в паспорт и разойдётся по всем сервисам Aspans.
|
||||||
|
const profile = { lastName: form.lastName.trim(), firstName: form.firstName.trim(), middleName: form.middleName.trim() || null, email: form.email.trim(), phone: form.phone.trim() };
|
||||||
|
if (profile.lastName !== (user.lastName || '')) body.lastName = profile.lastName;
|
||||||
|
if (profile.firstName !== (user.firstName || '')) body.firstName = profile.firstName;
|
||||||
|
if ((profile.middleName || '') !== (user.middleName || '')) body.middleName = profile.middleName;
|
||||||
|
if (profile.email !== (user.email || '')) body.email = profile.email;
|
||||||
|
if (profile.phone.replace(/\D/g, '') !== (user.phone || '').replace(/\D/g, '')) body.phone = profile.phone;
|
||||||
|
if (!self) {
|
||||||
|
body.roleId = roleId;
|
||||||
|
body.isActive = form.isActive;
|
||||||
|
}
|
||||||
|
if (form.password) body.password = form.password;
|
||||||
|
return api(`/users/${user.id}`, { method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
onSuccess: () => done('Сохранено'),
|
||||||
|
onError: notify.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!user) return;
|
||||||
|
const ok = await confirm({
|
||||||
|
title: `Убрать ${user.fullName} из организации?`,
|
||||||
|
message: 'Доступ к «Обходам» закроется, связь с платформой в Aspans ID снимется. Сама учётная запись Aspans ID и история обходов останутся.',
|
||||||
|
confirmText: 'Убрать',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api(`/users/${user.id}`, { method: 'DELETE' });
|
||||||
|
done('Сотрудник убран из организации');
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const ready = user
|
||||||
|
? !!(form.lastName.trim() && form.firstName.trim() && form.email.trim() && form.phone.trim()) && (!form.password || form.password.length >= 8)
|
||||||
|
: personReady(person);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{user ? user.fullName : 'Новый сотрудник'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Grid container spacing={2} sx={{ pt: 1 }}>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField label="Фамилия" value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} required />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField label="Имя" value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} required />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 4 }}>
|
||||||
|
<TextField label="Отчество" value={form.middleName} onChange={(e) => setForm({ ...form, middleName: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} required />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField label="Телефон" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} required />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13, mt: -1 }}>
|
||||||
|
ФИО, email и телефон — данные Aspans ID: изменения увидят и другие сервисы Aspans{user.login ? ` · логин ${user.login}` : ''}.
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<TextField
|
||||||
|
label="Новый пароль Aspans ID"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
helperText="Пусто — не менять. Пароль общий для всех сервисов Aspans, смена завершит сессии в «Обходах»"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Grid size={12}>
|
||||||
|
<PersonFields value={person} onChange={setPerson} searchUrl="/users/passport-search" />
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
<Grid size={12}>
|
||||||
|
<TextField select label="Роль" value={roleId} onChange={(e) => setForm({ ...form, roleId: e.target.value })} disabled={self} helperText={self ? 'Свою роль меняет другой администратор' : undefined}>
|
||||||
|
{(roles.data || []).map((r) => (
|
||||||
|
<MenuItem key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
{role?.allSites ? (
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 14 }}>У роли «{role.name}» доступ ко всем объектам.</Typography>
|
||||||
|
) : (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
label="Объекты"
|
||||||
|
value={form.siteIds}
|
||||||
|
onChange={(e) => setForm({ ...form, siteIds: (typeof e.target.value === 'string' ? e.target.value.split(',') : e.target.value) as string[] })}
|
||||||
|
slotProps={{
|
||||||
|
select: {
|
||||||
|
multiple: true,
|
||||||
|
renderValue: (v) => (
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{(v as string[]).map((id) => (
|
||||||
|
<Chip key={id} size="small" label={sites.data?.find((s) => s.id === id)?.name || id} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
helperText="Сотрудник увидит только эти объекты и их обходы"
|
||||||
|
>
|
||||||
|
{(sites.data || []).map((s) => (
|
||||||
|
<MenuItem key={s.id} value={s.id}>
|
||||||
|
<Checkbox checked={form.siteIds.includes(s.id)} size="small" />
|
||||||
|
<ListItemText primary={s.name} />
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
{user && !self && (
|
||||||
|
<Grid size={12}>
|
||||||
|
<FormControlLabel control={<Switch checked={form.isActive} onChange={(e) => setForm({ ...form, isActive: e.target.checked })} />} label="Доступ к «Обходам» включён" />
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
{user && !self && (
|
||||||
|
<Button color="error" onClick={remove} sx={{ mr: 'auto' }}>
|
||||||
|
Убрать из организации
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button onClick={onClose}>Отмена</Button>
|
||||||
|
<Button variant="contained" onClick={() => save.mutate()} disabled={!ready || save.isPending}>
|
||||||
|
{user ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RolesTab({ onEdit }: { onEdit: (r: RoleRow) => void }) {
|
||||||
|
const roles = useRoles();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotify();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
|
||||||
|
const remove = async (r: RoleRow) => {
|
||||||
|
if (!(await confirm({ title: `Удалить роль «${r.name}»?`, confirmText: 'Удалить', danger: true }))) return;
|
||||||
|
try {
|
||||||
|
await api(`/roles/${r.id}`, { method: 'DELETE' });
|
||||||
|
qc.invalidateQueries({ queryKey: ['roles'] });
|
||||||
|
notify.success('Роль удалена');
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (roles.isLoading) return <LoadingState />;
|
||||||
|
if (roles.isError) return <ErrorState error={roles.error} onRetry={() => roles.refetch()} />;
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{(roles.data || []).map((r) => (
|
||||||
|
<Grid key={r.id} size={{ xs: 12, md: 6, xl: 4 }}>
|
||||||
|
<Card
|
||||||
|
title={r.name}
|
||||||
|
subtitle={`${r.usersCount} сотр. · ${r.allSites ? 'все объекты' : 'только назначенные объекты'}${r.isSystem ? ' · встроенная' : ''}`}
|
||||||
|
action={
|
||||||
|
can('roles.manage') && (
|
||||||
|
<Box sx={{ display: 'flex' }}>
|
||||||
|
<IconButton size="small" onClick={() => onEdit(r)}>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
{!r.isSystem && (
|
||||||
|
<IconButton size="small" onClick={() => remove(r)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
sx={{ height: '100%' }}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{r.permissions.map((p) => (
|
||||||
|
<Chip key={p} size="small" label={PERMISSION_LABELS[p] || p} sx={{ bgcolor: colors.soft, fontWeight: 500 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUPS: { title: string; items: Permission[] }[] = [
|
||||||
|
{ title: 'Объекты и маршруты', items: ['sites.view', 'sites.manage', 'routes.manage', 'schedules.manage'] },
|
||||||
|
{ title: 'Обходы', items: ['rounds.perform', 'rounds.adhoc', 'rounds.cancel'] },
|
||||||
|
{ title: 'Отчёты', items: ['reports.view_own', 'reports.view_all', 'audit.view'] },
|
||||||
|
{ title: 'Администрирование', items: ['users.manage', 'roles.manage', 'webhooks.manage'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function RoleDialog({ role, onClose }: { role: RoleRow | null; onClose: () => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const [form, setForm] = useState({ name: role?.name || '', allSites: role?.allSites ?? false, permissions: new Set<Permission>(role?.permissions || ['sites.view']) });
|
||||||
|
|
||||||
|
const toggle = (p: Permission) => {
|
||||||
|
const next = new Set(form.permissions);
|
||||||
|
if (next.has(p)) next.delete(p);
|
||||||
|
else next.add(p);
|
||||||
|
setForm({ ...form, permissions: next });
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body = { name: form.name.trim(), allSites: form.allSites, permissions: PERMISSIONS.filter((p) => form.permissions.has(p)) };
|
||||||
|
return role ? api(`/roles/${role.id}`, { method: 'PATCH', body }) : api('/roles', { body });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['roles'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['users'] });
|
||||||
|
notify.success('Роль сохранена');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: notify.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{role ? `Роль «${role.name}»` : 'Новая роль'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||||
|
<TextField label="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
<FormControlLabel control={<Switch checked={form.allSites} onChange={(e) => setForm({ ...form, allSites: e.target.checked })} />} label="Доступ ко всем объектам организации" />
|
||||||
|
{GROUPS.map((g) => (
|
||||||
|
<Box key={g.title}>
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: 14, mb: 0.5 }}>{g.title}</Typography>
|
||||||
|
{g.items.map((p) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={p}
|
||||||
|
sx={{ display: 'flex', ml: 0 }}
|
||||||
|
control={<Checkbox size="small" checked={form.permissions.has(p)} onChange={() => toggle(p)} />}
|
||||||
|
label={<Typography sx={{ fontSize: 14 }}>{PERMISSION_LABELS[p]}</Typography>}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Отмена</Button>
|
||||||
|
<Button variant="contained" onClick={() => save.mutate()} disabled={!form.name.trim() || save.isPending}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
281
apps/web/src/pages/IntegrationsPage.tsx
Normal file
281
apps/web/src/pages/IntegrationsPage.tsx
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
IconButton,
|
||||||
|
Switch,
|
||||||
|
Tab,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
Tabs,
|
||||||
|
TextField,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import DeleteIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
|
||||||
|
import WebhookIcon from '@mui/icons-material/WebhookOutlined';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { WEBHOOK_EVENTS, type WebhookEvent } from '@patrol/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { fmtDateTime } from '../lib/format';
|
||||||
|
import { useConfirm, useNotify } from '../ui/feedback';
|
||||||
|
import { Card, EmptyState, ErrorState, LoadingState, PageHeader } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
type Hook = { id: string; url: string; events: WebhookEvent[]; secret: string; isActive: boolean; createdAt: string };
|
||||||
|
type AuditRow = { id: string; action: string; entity: string | null; entityId: string | null; changes: unknown; createdAt: string; userName: string | null };
|
||||||
|
|
||||||
|
const EVENT_LABELS: Record<WebhookEvent, string> = {
|
||||||
|
'round.completed': 'Обход выполнен',
|
||||||
|
'round.partial': 'Обход выполнен частично',
|
||||||
|
'round.missed': 'Обход пропущен',
|
||||||
|
'visit.issue': 'Сотрудник отметил проблему',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function IntegrationsPage() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const [tab, setTab] = useState(can('webhooks.manage') ? 'hooks' : 'audit');
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Интеграции"
|
||||||
|
subtitle="Вебхуки для задачника и других сервисов, журнал изменений"
|
||||||
|
actions={
|
||||||
|
tab === 'hooks' &&
|
||||||
|
can('webhooks.manage') && (
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreating(true)}>
|
||||||
|
Добавить вебхук
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2.5, borderBottom: `1px solid ${colors.line}` }}>
|
||||||
|
{can('webhooks.manage') && <Tab value="hooks" label="Вебхуки" />}
|
||||||
|
{can('audit.view') && <Tab value="audit" label="Журнал изменений" />}
|
||||||
|
</Tabs>
|
||||||
|
{tab === 'hooks' ? <Hooks /> : <Audit />}
|
||||||
|
{creating && <HookDialog onClose={() => setCreating(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Hooks() {
|
||||||
|
const hooks = useQuery({ queryKey: ['webhooks'], queryFn: () => api<Hook[]>('/webhooks') });
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const patch = async (h: Hook, body: Partial<Hook>) => {
|
||||||
|
try {
|
||||||
|
await api(`/webhooks/${h.id}`, { method: 'PATCH', body });
|
||||||
|
qc.invalidateQueries({ queryKey: ['webhooks'] });
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const remove = async (h: Hook) => {
|
||||||
|
if (!(await confirm({ title: 'Удалить вебхук?', message: h.url, confirmText: 'Удалить', danger: true }))) return;
|
||||||
|
try {
|
||||||
|
await api(`/webhooks/${h.id}`, { method: 'DELETE' });
|
||||||
|
qc.invalidateQueries({ queryKey: ['webhooks'] });
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const copy = (text: string) => navigator.clipboard?.writeText(text).then(() => notify.success('Скопировано'));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
<Card flush>
|
||||||
|
{hooks.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : hooks.isError ? (
|
||||||
|
<ErrorState error={hooks.error} onRetry={() => hooks.refetch()} />
|
||||||
|
) : !hooks.data?.length ? (
|
||||||
|
<EmptyState icon={WebhookIcon} title="Вебхуков нет">
|
||||||
|
Добавьте адрес — мы будем отправлять туда события обходов.
|
||||||
|
</EmptyState>
|
||||||
|
) : (
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Адрес</TableCell>
|
||||||
|
<TableCell>События</TableCell>
|
||||||
|
<TableCell>Секрет</TableCell>
|
||||||
|
<TableCell>Вкл.</TableCell>
|
||||||
|
<TableCell />
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{hooks.data.map((h) => (
|
||||||
|
<TableRow key={h.id}>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}>{h.url}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{h.events.map((e) => (
|
||||||
|
<Chip key={e} size="small" label={e} sx={{ bgcolor: colors.soft, fontFamily: 'monospace', fontWeight: 500 }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
<Typography component="span" sx={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||||
|
{h.secret.slice(0, 6)}…
|
||||||
|
</Typography>
|
||||||
|
<Tooltip title="Скопировать секрет">
|
||||||
|
<IconButton size="small" onClick={() => copy(h.secret)}>
|
||||||
|
<ContentCopyIcon sx={{ fontSize: 16 }} />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch size="small" checked={h.isActive} onChange={(e) => patch(h, { isActive: e.target.checked })} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<IconButton size="small" onClick={() => remove(h)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
<Card title="Формат запроса" subtitle="POST с JSON-телом; подпись — HMAC-SHA256 тела секретом вебхука">
|
||||||
|
<Box component="pre" sx={{ m: 0, p: 2, bgcolor: '#0f172a', color: '#e2e8f0', borderRadius: '10px', fontSize: 12.5, overflowX: 'auto' }}>
|
||||||
|
{`POST <ваш адрес>
|
||||||
|
Content-Type: application/json
|
||||||
|
X-Patrol-Event: round.missed
|
||||||
|
X-Patrol-Signature: sha256=<hex HMAC-SHA256(тело, секрет)>
|
||||||
|
|
||||||
|
{
|
||||||
|
"event": "round.missed",
|
||||||
|
"sentAt": "2026-10-20T19:05:00.000Z",
|
||||||
|
"data": { "roundId": "…", "siteId": "…", "status": "missed", "checkpointsDone": 2, "checkpointsTotal": 5 }
|
||||||
|
}`}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ mt: 1.5, display: 'flex', flexDirection: 'column', gap: 0.5 }}>
|
||||||
|
{WEBHOOK_EVENTS.map((e) => (
|
||||||
|
<Typography key={e} sx={{ fontSize: 14 }}>
|
||||||
|
<Box component="code" sx={{ bgcolor: colors.soft, px: 0.75, py: 0.25, borderRadius: '6px', mr: 1 }}>
|
||||||
|
{e}
|
||||||
|
</Box>
|
||||||
|
{EVENT_LABELS[e]}
|
||||||
|
</Typography>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HookDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [events, setEvents] = useState<Set<WebhookEvent>>(new Set(WEBHOOK_EVENTS));
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api('/webhooks', { body: { url: url.trim(), events: [...events] } }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['webhooks'] });
|
||||||
|
notify.success('Вебхук добавлен');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: notify.error,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Новый вебхук</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||||
|
<TextField label="URL" placeholder="https://tasks.example.kz/hooks/patrol" value={url} onChange={(e) => setUrl(e.target.value)} />
|
||||||
|
<Box>
|
||||||
|
{WEBHOOK_EVENTS.map((e) => (
|
||||||
|
<FormControlLabel
|
||||||
|
key={e}
|
||||||
|
sx={{ display: 'flex' }}
|
||||||
|
control={
|
||||||
|
<Checkbox
|
||||||
|
size="small"
|
||||||
|
checked={events.has(e)}
|
||||||
|
onChange={() => {
|
||||||
|
const next = new Set(events);
|
||||||
|
if (next.has(e)) next.delete(e);
|
||||||
|
else next.add(e);
|
||||||
|
setEvents(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={
|
||||||
|
<Typography sx={{ fontSize: 14 }}>
|
||||||
|
{EVENT_LABELS[e]} <span style={{ color: colors.muted, fontFamily: 'monospace' }}>{e}</span>
|
||||||
|
</Typography>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Отмена</Button>
|
||||||
|
<Button variant="contained" disabled={!/^https?:\/\//.test(url.trim()) || !events.size || save.isPending} onClick={() => save.mutate()}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Audit() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const audit = useQuery({ queryKey: ['audit', page], queryFn: () => api<AuditRow[]>(`/reports/audit?page=${page}`) });
|
||||||
|
if (audit.isLoading) return <LoadingState />;
|
||||||
|
if (audit.isError) return <ErrorState error={audit.error} onRetry={() => audit.refetch()} />;
|
||||||
|
return (
|
||||||
|
<Card flush>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Когда</TableCell>
|
||||||
|
<TableCell>Кто</TableCell>
|
||||||
|
<TableCell>Действие</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{(audit.data || []).map((a) => (
|
||||||
|
<TableRow key={a.id}>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap', color: colors.muted }}>{fmtDateTime(a.createdAt)}</TableCell>
|
||||||
|
<TableCell>{a.userName || 'система'}</TableCell>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace', fontSize: 13 }}>
|
||||||
|
{a.action}
|
||||||
|
{a.entityId ? ` · ${a.entityId.slice(0, 8)}` : ''}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, py: 1.5 }}>
|
||||||
|
<Button disabled={page === 1} onClick={() => setPage(page - 1)}>
|
||||||
|
Новее
|
||||||
|
</Button>
|
||||||
|
<Button disabled={(audit.data?.length || 0) < 100} onClick={() => setPage(page + 1)}>
|
||||||
|
Старше
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
apps/web/src/pages/LoginPage.tsx
Normal file
63
apps/web/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { Alert, Box, Button, Link, TextField, Typography } from '@mui/material';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { startSso } from '../auth/aspansId';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
|
||||||
|
// Как форма входа aspans_front (symfony): логин и пароль, «Или», «Войти через Aspans ID».
|
||||||
|
export function LoginPage() {
|
||||||
|
const { login } = useAuth();
|
||||||
|
const [form, setForm] = useState({ login: '', password: '' });
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState<'password' | 'sso' | null>(null);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy('password');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await login(form.login.trim(), form.password);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Без prompt: если в Aspans ID уже вошли (в symfony, CRM…), вход пройдёт без пароля.
|
||||||
|
const sso = async () => {
|
||||||
|
setBusy('sso');
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await startSso({ returnUrl: '/' });
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message || 'Не удалось начать вход через Aspans ID');
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout title="Вход" subtitle="Учётная запись Aspans ID — та же, что в других сервисах Aspans">
|
||||||
|
<Box component="form" onSubmit={submit} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
|
<TextField label="Логин или email" autoComplete="username" value={form.login} onChange={(e) => setForm({ ...form, login: e.target.value })} required />
|
||||||
|
<TextField label="Пароль" type="password" autoComplete="current-password" value={form.password} onChange={(e) => setForm({ ...form, password: e.target.value })} required />
|
||||||
|
<Button type="submit" variant="contained" size="large" disabled={!!busy}>
|
||||||
|
{busy === 'password' ? 'Входим…' : 'Войти'}
|
||||||
|
</Button>
|
||||||
|
<Typography sx={{ textAlign: 'center', color: colors.muted, fontSize: 14 }}>или</Typography>
|
||||||
|
<Button variant="outlined" size="large" onClick={sso} disabled={!!busy}>
|
||||||
|
{busy === 'sso' ? 'Переходим в Aspans ID…' : 'Войти через Aspans ID'}
|
||||||
|
</Button>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 14, textAlign: 'center', mt: 1 }}>
|
||||||
|
Компании ещё нет в «Обходах»?{' '}
|
||||||
|
<Link component={RouterLink} to="/register" sx={{ fontWeight: 600 }}>
|
||||||
|
Зарегистрировать
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
apps/web/src/pages/PlatformPage.tsx
Normal file
122
apps/web/src/pages/PlatformPage.tsx
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Switch, Table, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import BusinessIcon from '@mui/icons-material/BusinessOutlined';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { fmtDateTime } from '../lib/format';
|
||||||
|
import { useConfirm, useNotify } from '../ui/feedback';
|
||||||
|
import { Card, EmptyState, ErrorState, LoadingState, PageHeader } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
import { personReady } from '../lib/PersonFields';
|
||||||
|
import { CompanyFields, companyPayload, emptyCompany } from './RegisterPage';
|
||||||
|
|
||||||
|
type Org = { id: string; bin: string; name: string; legalName: string | null; status: 'active' | 'blocked'; createdAt: string; usersCount: number; sitesCount: number };
|
||||||
|
|
||||||
|
export function PlatformPage() {
|
||||||
|
const orgs = useQuery({ queryKey: ['platform-orgs'], queryFn: () => api<Org[]>('/platform/organizations') });
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const setBlocked = async (o: Org, blocked: boolean) => {
|
||||||
|
if (blocked && !(await confirm({ title: `Заблокировать «${o.name}»?`, message: 'Сотрудники компании не смогут войти.', confirmText: 'Заблокировать', danger: true }))) return;
|
||||||
|
try {
|
||||||
|
await api(`/platform/organizations/${o.id}`, { method: 'PATCH', body: { blocked } });
|
||||||
|
qc.invalidateQueries({ queryKey: ['platform-orgs'] });
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Организации"
|
||||||
|
subtitle="Компании на платформе"
|
||||||
|
actions={
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setCreating(true)}>
|
||||||
|
Зарегистрировать компанию
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Card flush>
|
||||||
|
{orgs.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : orgs.isError ? (
|
||||||
|
<ErrorState error={orgs.error} onRetry={() => orgs.refetch()} />
|
||||||
|
) : !orgs.data?.length ? (
|
||||||
|
<EmptyState icon={BusinessIcon} title="Компаний пока нет" />
|
||||||
|
) : (
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Компания</TableCell>
|
||||||
|
<TableCell>БИН</TableCell>
|
||||||
|
<TableCell>Сотрудников</TableCell>
|
||||||
|
<TableCell>Объектов</TableCell>
|
||||||
|
<TableCell>Зарегистрирована</TableCell>
|
||||||
|
<TableCell>Активна</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{orgs.data.map((o) => (
|
||||||
|
<TableRow key={o.id}>
|
||||||
|
<TableCell>
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{o.name}
|
||||||
|
{o.status === 'blocked' && <Chip size="small" color="error" label="Заблокирована" sx={{ ml: 1 }} />}
|
||||||
|
</Typography>
|
||||||
|
{o.legalName && <Typography sx={{ color: colors.muted, fontSize: 12.5 }}>{o.legalName}</Typography>}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ fontFamily: 'monospace' }}>{o.bin}</TableCell>
|
||||||
|
<TableCell>{o.usersCount}</TableCell>
|
||||||
|
<TableCell>{o.sitesCount}</TableCell>
|
||||||
|
<TableCell sx={{ color: colors.muted, whiteSpace: 'nowrap' }}>{fmtDateTime(o.createdAt)}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Switch size="small" checked={o.status === 'active'} onChange={(e) => setBlocked(o, !e.target.checked)} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
{creating && <CreateOrgDialog onClose={() => setCreating(false)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateOrgDialog({ onClose }: { onClose: () => void }) {
|
||||||
|
const [form, setForm] = useState(emptyCompany);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => api('/platform/organizations', { body: companyPayload(form) }),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['platform-orgs'] });
|
||||||
|
notify.success('Компания зарегистрирована');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: notify.error,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>Регистрация компании</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ pt: 1 }}>
|
||||||
|
<CompanyFields value={form} onChange={setForm} adminSearchUrl="/platform/organizations/passport-search" />
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Отмена</Button>
|
||||||
|
<Button variant="contained" onClick={() => save.mutate()} disabled={save.isPending || form.bin.length !== 12 || !form.name.trim() || !personReady(form.admin)}>
|
||||||
|
Зарегистрировать
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
206
apps/web/src/pages/RegisterPage.tsx
Normal file
206
apps/web/src/pages/RegisterPage.tsx
Normal file
@ -0,0 +1,206 @@
|
|||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
|
import { Alert, Box, Button, Divider, Grid2 as Grid, InputAdornment, Link, TextField, Typography } from '@mui/material';
|
||||||
|
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { emptyPerson, PersonFields, personPayload, type PersonForm } from '../lib/PersonFields';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
|
||||||
|
type Lookup = {
|
||||||
|
bin: string;
|
||||||
|
alreadyRegistered: boolean;
|
||||||
|
registryEnabled: boolean;
|
||||||
|
company: { name: string; legalName: string | null; legalAddress: string | null; directorName: string | null; phone: string | null; email: string | null } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompanyForm = {
|
||||||
|
bin: string;
|
||||||
|
name: string;
|
||||||
|
legalName: string;
|
||||||
|
legalAddress: string;
|
||||||
|
directorName: string;
|
||||||
|
phone: string;
|
||||||
|
email: string;
|
||||||
|
admin: PersonForm;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const emptyCompany = (): CompanyForm => ({
|
||||||
|
bin: '',
|
||||||
|
name: '',
|
||||||
|
legalName: '',
|
||||||
|
legalAddress: '',
|
||||||
|
directorName: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
admin: emptyPerson(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Пустые строки не отправляем — сервер считает их отсутствующими полями.
|
||||||
|
export function companyPayload(f: CompanyForm) {
|
||||||
|
const clean = (o: Record<string, string>) => Object.fromEntries(Object.entries(o).filter(([, v]) => v.trim() !== '').map(([k, v]) => [k, v.trim()]));
|
||||||
|
const { admin, ...rest } = f;
|
||||||
|
return { ...clean(rest), admin: personPayload(admin) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форма компании: БИН → реквизиты из учет.kz (если подключено) → первый администратор.
|
||||||
|
export function CompanyFields({ value, onChange, adminSearchUrl, passwordHint }: {
|
||||||
|
value: CompanyForm;
|
||||||
|
onChange: (v: CompanyForm) => void;
|
||||||
|
adminSearchUrl?: string;
|
||||||
|
passwordHint?: string;
|
||||||
|
}) {
|
||||||
|
const [lookup, setLookup] = useState<Lookup | null>(null);
|
||||||
|
const [lookupError, setLookupError] = useState('');
|
||||||
|
const [checking, setChecking] = useState(false);
|
||||||
|
const set = (patch: Partial<CompanyForm>) => onChange({ ...value, ...patch });
|
||||||
|
|
||||||
|
const check = async (bin: string) => {
|
||||||
|
setChecking(true);
|
||||||
|
setLookupError('');
|
||||||
|
try {
|
||||||
|
const res = await api<Lookup>(`/auth/company/${bin}`);
|
||||||
|
setLookup(res);
|
||||||
|
if (res.company) {
|
||||||
|
onChange({
|
||||||
|
...value,
|
||||||
|
bin,
|
||||||
|
name: value.name || res.company.name || '',
|
||||||
|
legalName: value.legalName || res.company.legalName || '',
|
||||||
|
legalAddress: value.legalAddress || res.company.legalAddress || '',
|
||||||
|
directorName: value.directorName || res.company.directorName || '',
|
||||||
|
phone: value.phone || res.company.phone || '',
|
||||||
|
email: value.email || res.company.email || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
setLookup(null);
|
||||||
|
setLookupError((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setChecking(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBin = (raw: string) => {
|
||||||
|
const bin = raw.replace(/\D/g, '').slice(0, 12);
|
||||||
|
set({ bin });
|
||||||
|
setLookup(null);
|
||||||
|
setLookupError('');
|
||||||
|
if (bin.length === 12) check(bin);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={12}>
|
||||||
|
<TextField
|
||||||
|
label="БИН"
|
||||||
|
value={value.bin}
|
||||||
|
onChange={(e) => onBin(e.target.value)}
|
||||||
|
required
|
||||||
|
inputMode="numeric"
|
||||||
|
error={!!lookupError || !!lookup?.alreadyRegistered}
|
||||||
|
helperText={
|
||||||
|
lookupError ||
|
||||||
|
(lookup?.alreadyRegistered
|
||||||
|
? 'Компания с этим БИН уже зарегистрирована'
|
||||||
|
: lookup?.company
|
||||||
|
? 'Реквизиты подставлены из учет.kz — проверьте'
|
||||||
|
: lookup && !lookup.registryEnabled
|
||||||
|
? 'БИН корректный. Реквизиты заполните вручную'
|
||||||
|
: lookup
|
||||||
|
? 'В реестре не нашли — заполните вручную'
|
||||||
|
: '12 цифр')
|
||||||
|
}
|
||||||
|
slotProps={{
|
||||||
|
input: {
|
||||||
|
endAdornment: lookup && !lookup.alreadyRegistered && (
|
||||||
|
<InputAdornment position="end">
|
||||||
|
<CheckCircleIcon sx={{ color: colors.success }} />
|
||||||
|
</InputAdornment>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
disabled={checking}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<TextField label="Название компании" value={value.name} onChange={(e) => set({ name: e.target.value })} required />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField label="Юридическое название" value={value.legalName} onChange={(e) => set({ legalName: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField label="Руководитель" value={value.directorName} onChange={(e) => set({ directorName: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<TextField label="Юридический адрес" value={value.legalAddress} onChange={(e) => set({ legalAddress: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField label="Телефон компании" value={value.phone} onChange={(e) => set({ phone: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, sm: 6 }}>
|
||||||
|
<TextField label="Email" type="email" value={value.email} onChange={(e) => set({ email: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<Divider sx={{ my: 0.5 }} />
|
||||||
|
<Typography sx={{ fontWeight: 600, mt: 2 }}>Администратор компании</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 14 }}>
|
||||||
|
Он заведёт объекты, маршруты и сотрудников. Входит в «Обходы» учётной записью Aspans ID.
|
||||||
|
</Typography>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<PersonFields
|
||||||
|
value={value.admin}
|
||||||
|
onChange={(admin) => onChange({ ...value, admin })}
|
||||||
|
searchUrl={adminSearchUrl}
|
||||||
|
allowExisting={!!adminSearchUrl}
|
||||||
|
passwordHint={passwordHint}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegisterPage() {
|
||||||
|
const { acceptToken } = useAuth();
|
||||||
|
const [form, setForm] = useState<CompanyForm>(emptyCompany);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const res = await api<{ accessToken: string }>('/auth/register', { body: companyPayload(form) });
|
||||||
|
await acceptToken(res.accessToken);
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout wide title="Регистрация компании" subtitle="Введите БИН — реквизиты подтянем из учет.kz">
|
||||||
|
<Box component="form" onSubmit={submit} sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
||||||
|
{error && <Alert severity="error">{error}</Alert>}
|
||||||
|
<CompanyFields
|
||||||
|
value={form}
|
||||||
|
onChange={setForm}
|
||||||
|
passwordHint="Не короче 8 символов. Если вы уже есть в Aspans ID — укажите свой пароль от него"
|
||||||
|
/>
|
||||||
|
<Button type="submit" variant="contained" size="large" disabled={busy}>
|
||||||
|
{busy ? 'Регистрируем…' : 'Зарегистрировать компанию'}
|
||||||
|
</Button>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 14, textAlign: 'center' }}>
|
||||||
|
Уже есть доступ?{' '}
|
||||||
|
<Link component={RouterLink} to="/" sx={{ fontWeight: 600 }}>
|
||||||
|
Войти
|
||||||
|
</Link>
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
apps/web/src/pages/ReportsPage.tsx
Normal file
209
apps/web/src/pages/ReportsPage.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
FormControlLabel,
|
||||||
|
Grid2 as Grid,
|
||||||
|
MenuItem,
|
||||||
|
Pagination,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableRow,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AssessmentIcon from '@mui/icons-material/AssessmentOutlined';
|
||||||
|
import PhotoCameraIcon from '@mui/icons-material/PhotoCameraOutlined';
|
||||||
|
import WarningAmberIcon from '@mui/icons-material/WarningAmber';
|
||||||
|
import ReportIcon from '@mui/icons-material/ReportGmailerrorredOutlined';
|
||||||
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
||||||
|
import { ROUND_STATUSES, ROUND_STATUS_LABELS } from '@patrol/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { RoundRow } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { addDays, fmtDate, fmtTime, ymd } from '../lib/format';
|
||||||
|
import { SitePicker } from '../lib/SitePicker';
|
||||||
|
import { StatusChip } from '../lib/StatusChip';
|
||||||
|
import { useEmployees, useSites } from '../lib/queries';
|
||||||
|
import { Card, EmptyState, ErrorState, LoadingState, PageHeader, PersonAvatar } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
type Summary = { siteId: string; siteName: string; completed: number; partial: number; missed: number; open: number; total: number };
|
||||||
|
|
||||||
|
export function ReportsPage() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const sites = useSites();
|
||||||
|
const viewAll = can('reports.view_all');
|
||||||
|
const users = useEmployees(undefined, viewAll && (can('users.manage') || can('schedules.manage')));
|
||||||
|
|
||||||
|
const f = {
|
||||||
|
siteId: params.get('site') || '',
|
||||||
|
userId: params.get('user') || '',
|
||||||
|
status: params.get('status') || '',
|
||||||
|
from: params.get('from') || ymd(addDays(new Date(), -7)),
|
||||||
|
to: params.get('to') || ymd(new Date()),
|
||||||
|
issuesOnly: params.get('issues') === '1',
|
||||||
|
page: Number(params.get('page')) || 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
const set = (patch: Record<string, string | null>) => {
|
||||||
|
const next = new URLSearchParams(params);
|
||||||
|
for (const [k, v] of Object.entries(patch)) {
|
||||||
|
if (v === null || v === '') next.delete(k);
|
||||||
|
else next.set(k, v);
|
||||||
|
}
|
||||||
|
if (!('page' in patch)) next.delete('page');
|
||||||
|
setParams(next, { replace: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Конец периода включительно: до начала следующего дня.
|
||||||
|
const query = useMemo(() => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (f.siteId) q.set('siteId', f.siteId);
|
||||||
|
if (f.userId) q.set('userId', f.userId);
|
||||||
|
if (f.status) q.set('status', f.status);
|
||||||
|
q.set('from', new Date(`${f.from}T00:00:00`).toISOString());
|
||||||
|
q.set('to', addDays(new Date(`${f.to}T00:00:00`), 1).toISOString());
|
||||||
|
if (f.issuesOnly) q.set('issuesOnly', 'true');
|
||||||
|
return q;
|
||||||
|
}, [f.siteId, f.userId, f.status, f.from, f.to, f.issuesOnly]);
|
||||||
|
|
||||||
|
const rounds = useQuery({
|
||||||
|
queryKey: ['reports', query.toString(), f.page],
|
||||||
|
queryFn: () => api<{ items: RoundRow[]; total: number; pageSize: number }>(`/reports/rounds?${query}&page=${f.page}`),
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
const summary = useQuery({ queryKey: ['summary', query.toString()], queryFn: () => api<Summary[]>(`/reports/summary?${query}`) });
|
||||||
|
|
||||||
|
const totals = (summary.data || []).reduce(
|
||||||
|
(acc, s) => ({ completed: acc.completed + s.completed, partial: acc.partial + s.partial, missed: acc.missed + s.missed, open: acc.open + s.open, total: acc.total + s.total }),
|
||||||
|
{ completed: 0, partial: 0, missed: 0, open: 0, total: 0 },
|
||||||
|
);
|
||||||
|
const closed = totals.completed + totals.partial + totals.missed;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Отчёты" subtitle={viewAll ? 'Кто, когда и где прошёл обход — с фото, видео и геометками' : 'Ваши обходы'} />
|
||||||
|
|
||||||
|
<Grid container spacing={2} sx={{ mb: 3 }}>
|
||||||
|
{[
|
||||||
|
{ label: 'Выполнено', value: totals.completed, color: colors.success },
|
||||||
|
{ label: 'Частично', value: totals.partial, color: colors.warning },
|
||||||
|
{ label: 'Пропущено', value: totals.missed, color: colors.danger },
|
||||||
|
{ label: 'Выполнение', value: closed ? `${Math.round((totals.completed / closed) * 100)}%` : '—', color: colors.brand },
|
||||||
|
].map((t) => (
|
||||||
|
<Grid key={t.label} size={{ xs: 6, md: 3 }}>
|
||||||
|
<Box sx={{ p: 2.25, bgcolor: '#fff', border: `1px solid ${colors.line}`, borderRadius: '14px' }}>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13 }}>{t.label}</Typography>
|
||||||
|
<Typography sx={{ fontSize: 28, fontWeight: 700, color: t.color, lineHeight: 1.3 }}>{summary.isLoading ? '…' : t.value}</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Card flush>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1.5, flexWrap: 'wrap', alignItems: 'center', px: 2.5, py: 2, borderBottom: `1px solid ${colors.line}` }}>
|
||||||
|
<SitePicker sites={sites.data || []} value={f.siteId} onChange={(v) => set({ site: v })} allowAll sx={{ minWidth: 200 }} />
|
||||||
|
{viewAll && users.data && (
|
||||||
|
<TextField select size="small" label="Сотрудник" value={f.userId} onChange={(e) => set({ user: e.target.value })} sx={{ minWidth: 200 }} fullWidth={false}>
|
||||||
|
<MenuItem value="">Все</MenuItem>
|
||||||
|
{users.data.map((u) => (
|
||||||
|
<MenuItem key={u.id} value={u.id}>
|
||||||
|
{u.fullName}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
)}
|
||||||
|
<TextField select size="small" label="Статус" value={f.status} onChange={(e) => set({ status: e.target.value })} sx={{ minWidth: 160 }} fullWidth={false}>
|
||||||
|
<MenuItem value="">Все</MenuItem>
|
||||||
|
{ROUND_STATUSES.map((s) => (
|
||||||
|
<MenuItem key={s} value={s}>
|
||||||
|
{ROUND_STATUS_LABELS[s]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField size="small" type="date" label="С" value={f.from} onChange={(e) => set({ from: e.target.value })} slotProps={{ inputLabel: { shrink: true } }} fullWidth={false} />
|
||||||
|
<TextField size="small" type="date" label="По" value={f.to} onChange={(e) => set({ to: e.target.value })} slotProps={{ inputLabel: { shrink: true } }} fullWidth={false} />
|
||||||
|
<FormControlLabel control={<Switch checked={f.issuesOnly} onChange={(e) => set({ issues: e.target.checked ? '1' : null })} />} label="Только с замечаниями" />
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{rounds.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : rounds.isError ? (
|
||||||
|
<ErrorState error={rounds.error} onRetry={() => rounds.refetch()} />
|
||||||
|
) : !rounds.data?.items.length ? (
|
||||||
|
<EmptyState icon={AssessmentIcon} title="Обходов за период нет" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box sx={{ overflowX: 'auto' }}>
|
||||||
|
<Table size="small">
|
||||||
|
<TableHead>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>Когда</TableCell>
|
||||||
|
<TableCell>Объект и маршрут</TableCell>
|
||||||
|
<TableCell>Сотрудник</TableCell>
|
||||||
|
<TableCell>Точки</TableCell>
|
||||||
|
<TableCell>Статус</TableCell>
|
||||||
|
<TableCell align="right">Детали</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rounds.data.items.map((r) => (
|
||||||
|
<TableRow key={r.id} hover sx={{ cursor: 'pointer' }} onClick={() => navigate(`/reports/${r.id}`)}>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: 14 }}>
|
||||||
|
{fmtDate(r.plannedStart, r.timezone)}, {fmtTime(r.plannedStart, r.timezone)}–{fmtTime(r.plannedEnd, r.timezone)}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 12.5 }}>
|
||||||
|
{r.startedAt ? `факт ${fmtTime(r.startedAt, r.timezone)}–${fmtTime(r.finishedAt, r.timezone)}` : r.scheduleName || 'Внеплановый'}
|
||||||
|
</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Typography sx={{ fontSize: 14, fontWeight: 500 }}>{r.siteName}</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 12.5 }}>{r.routeName}</Typography>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{r.performerName ? (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<PersonAvatar name={r.performerName} id={r.performedBy!} size={26} />
|
||||||
|
<Typography sx={{ fontSize: 14 }}>{r.performerName}</Typography>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Typography sx={{ color: colors.faint, fontSize: 14 }}>—</Typography>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell sx={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{r.checkpointsDone} / {r.checkpointsTotal}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<StatusChip status={r.status} />
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right">
|
||||||
|
<Box sx={{ display: 'inline-flex', gap: 0.75 }}>
|
||||||
|
{r.mediaCount > 0 && <Chip size="small" icon={<PhotoCameraIcon />} label={r.mediaCount} variant="outlined" />}
|
||||||
|
{r.flaggedVisits > 0 && <Chip size="small" icon={<WarningAmberIcon />} label={r.flaggedVisits} color="warning" variant="outlined" />}
|
||||||
|
{r.issues > 0 && <Chip size="small" icon={<ReportIcon />} label={r.issues} color="error" variant="outlined" />}
|
||||||
|
</Box>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</Box>
|
||||||
|
{rounds.data.total > rounds.data.pageSize && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||||
|
<Pagination count={Math.ceil(rounds.data.total / rounds.data.pageSize)} page={f.page} onChange={(_, p) => set({ page: String(p) })} color="primary" />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
191
apps/web/src/pages/RoundDetailPage.tsx
Normal file
191
apps/web/src/pages/RoundDetailPage.tsx
Normal file
@ -0,0 +1,191 @@
|
|||||||
|
import { Fragment } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { Alert, Box, Button, Chip, Grid2 as Grid, Typography } from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import { GeoJSON, Marker, Polyline, Tooltip } from 'react-leaflet';
|
||||||
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { VISIT_FLAG_LABELS, type VisitFlag } from '@patrol/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { RoundDetail } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { fmtDateTime, fmtDistance, fmtTime } from '../lib/format';
|
||||||
|
import { MediaGallery } from '../lib/MediaGallery';
|
||||||
|
import { StatusChip } from '../lib/StatusChip';
|
||||||
|
import { BaseMap, FitTo, PERIMETER_STYLE, ROUTE_STYLE, dotIcon, numberIcon, pinIcon } from '../map/base';
|
||||||
|
import { useConfirm, useNotify } from '../ui/feedback';
|
||||||
|
import { Card, ErrorState, LoadingState, PageHeader } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
const ll = (c: [number, number]) => [c[1], c[0]] as [number, number];
|
||||||
|
|
||||||
|
export function RoundDetailPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const notify = useNotify();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const round = useQuery({ queryKey: ['round', id], queryFn: () => api<RoundDetail>(`/rounds/${id}`) });
|
||||||
|
|
||||||
|
if (round.isLoading) return <LoadingState />;
|
||||||
|
if (round.isError || !round.data) return <ErrorState error={round.error} onRetry={() => round.refetch()} />;
|
||||||
|
const r = round.data;
|
||||||
|
const tz = r.timezone;
|
||||||
|
const doneIds = new Set(r.visits.map((v) => v.checkpointId).filter(Boolean));
|
||||||
|
const cpName = (cid: string | null) => {
|
||||||
|
if (!cid) return 'Вне точки';
|
||||||
|
const cp = r.route.checkpoints.find((c) => c.id === cid);
|
||||||
|
return cp ? `${cp.position}. ${cp.name}` : 'Точка удалена';
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = async () => {
|
||||||
|
if (!(await confirm({ title: 'Отменить обход?', message: 'Он не попадёт в пропущенные.', confirmText: 'Отменить обход', danger: true }))) return;
|
||||||
|
try {
|
||||||
|
await api(`/rounds/${r.id}/cancel`, { method: 'POST' });
|
||||||
|
qc.invalidateQueries({ queryKey: ['round', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['reports'] });
|
||||||
|
notify.success('Обход отменён');
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const facts: [string, string][] = [
|
||||||
|
['Объект', r.siteName],
|
||||||
|
['Маршрут', `${r.route.name} (версия ${r.route.version})`],
|
||||||
|
['График', r.scheduleName || 'Внеплановый обход'],
|
||||||
|
['Сотрудник', r.performerName || '—'],
|
||||||
|
['План', `${fmtDateTime(r.plannedStart, tz)} – ${fmtTime(r.plannedEnd, tz)}`],
|
||||||
|
['Факт', r.startedAt ? `${fmtDateTime(r.startedAt, tz)} – ${r.finishedAt ? fmtTime(r.finishedAt, tz) : '…'}` : '—'],
|
||||||
|
['Точки', `${r.checkpointsDone} из ${r.checkpointsTotal}`],
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(-1)} sx={{ mb: 1.5, ml: -1 }}>
|
||||||
|
Назад
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={
|
||||||
|
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.5, flexWrap: 'wrap' }}>
|
||||||
|
Обход {fmtDateTime(r.plannedStart, tz)}
|
||||||
|
<StatusChip status={r.status} />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
subtitle={`${r.siteName} · ${r.route.name}`}
|
||||||
|
actions={
|
||||||
|
can('rounds.cancel') &&
|
||||||
|
(r.status === 'planned' || r.status === 'in_progress') && (
|
||||||
|
<Button color="error" variant="outlined" onClick={cancel}>
|
||||||
|
Отменить обход
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, lg: 7 }}>
|
||||||
|
<Card flush>
|
||||||
|
<BaseMap height={520} style={{ borderRadius: 0 }}>
|
||||||
|
<FitTo geos={[r.perimeter, r.route.path, ...r.route.checkpoints.map((c) => c.location)]} />
|
||||||
|
{r.perimeter && <GeoJSON data={r.perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} interactive={false} />}
|
||||||
|
{r.route.path && <Polyline positions={r.route.path.coordinates.map(ll)} pathOptions={ROUTE_STYLE} />}
|
||||||
|
{r.route.checkpoints.map((c) => (
|
||||||
|
<Marker key={c.id} position={ll(c.location.coordinates)} icon={numberIcon(c.position, { done: doneIds.has(c.id) })}>
|
||||||
|
<Tooltip direction="top" offset={[0, -14]}>
|
||||||
|
{c.name}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
{r.visits.map((v) => (
|
||||||
|
<Fragment key={v.id}>
|
||||||
|
<Marker position={[v.marked.lat, v.marked.lng]} icon={pinIcon(v.isIssue ? colors.danger : v.flags.length ? colors.warning : colors.success)}>
|
||||||
|
<Tooltip direction="top" offset={[0, -36]}>
|
||||||
|
{cpName(v.checkpointId)} · {fmtTime(v.clientCreatedAt, tz)}
|
||||||
|
</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
{v.device && (
|
||||||
|
<>
|
||||||
|
<Marker position={[v.device.lat, v.device.lng]} icon={dotIcon('#0ea5e9', 12)}>
|
||||||
|
<Tooltip>GPS телефона{v.device.accuracy ? ` ±${Math.round(v.device.accuracy)} м` : ''}</Tooltip>
|
||||||
|
</Marker>
|
||||||
|
<Polyline positions={[[v.marked.lat, v.marked.lng], [v.device.lat, v.device.lng]]} pathOptions={{ color: '#0ea5e9', weight: 1.5, dashArray: '3 4' }} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</BaseMap>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2.5, flexWrap: 'wrap', px: 2.5, py: 1.5, borderTop: `1px solid ${colors.line}`, fontSize: 13, color: colors.muted }}>
|
||||||
|
{[
|
||||||
|
[colors.success, 'отметка без замечаний'],
|
||||||
|
[colors.warning, 'с флагами'],
|
||||||
|
[colors.danger, 'проблема'],
|
||||||
|
['#0ea5e9', 'GPS телефона'],
|
||||||
|
].map(([c, label]) => (
|
||||||
|
<Box key={label} component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75 }}>
|
||||||
|
<Box component="span" sx={{ width: 10, height: 10, borderRadius: '50%', bgcolor: c }} />
|
||||||
|
{label}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, lg: 5 }}>
|
||||||
|
<Card title="Сводка">
|
||||||
|
<Box component="dl" sx={{ m: 0, display: 'grid', gridTemplateColumns: 'auto 1fr', columnGap: 2, rowGap: 1 }}>
|
||||||
|
{facts.map(([k, v]) => (
|
||||||
|
<Fragment key={k}>
|
||||||
|
<Box component="dt" sx={{ color: colors.muted, fontSize: 14 }}>
|
||||||
|
{k}
|
||||||
|
</Box>
|
||||||
|
<Box component="dd" sx={{ m: 0, fontSize: 14, fontWeight: 500 }}>
|
||||||
|
{v}
|
||||||
|
</Box>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
{r.comment && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2, borderRadius: '10px' }}>
|
||||||
|
{r.comment}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<Card title="Отметки" subtitle={r.visits.length ? `${r.visits.length} шт., время — по часовому поясу объекта (${tz})` : 'Отметок нет'} flush>
|
||||||
|
<Box sx={{ px: 2.5, pb: 2.5, pt: 1.5, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{r.visits.map((v) => (
|
||||||
|
<Box key={v.id} sx={{ p: 2, border: `1px solid ${v.isIssue ? '#fecaca' : colors.line}`, bgcolor: v.isIssue ? '#fef2f2' : '#fff', borderRadius: '12px' }}>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>
|
||||||
|
{cpName(v.checkpointId)}
|
||||||
|
{v.isIssue && <Chip size="small" color="error" label="Проблема" sx={{ ml: 1 }} />}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13 }}>
|
||||||
|
Отмечено {fmtDateTime(v.clientCreatedAt, tz)}
|
||||||
|
{Math.abs(new Date(v.createdAt).getTime() - new Date(v.clientCreatedAt).getTime()) > 120000 && ` · отправлено ${fmtDateTime(v.createdAt, tz)}`}
|
||||||
|
{v.distanceToCheckpointM !== null && ` · до точки ${fmtDistance(v.distanceToCheckpointM)}`}
|
||||||
|
{v.gpsDistanceM !== null && ` · GPS в ${fmtDistance(v.gpsDistanceM)} от отметки`}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{v.flags.map((f) => (
|
||||||
|
<Chip key={f} size="small" label={VISIT_FLAG_LABELS[f as VisitFlag] || f} color="warning" variant="outlined" />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{v.note && <Typography sx={{ mt: 1, fontSize: 14 }}>{v.note}</Typography>}
|
||||||
|
{v.media.length > 0 && (
|
||||||
|
<Box sx={{ mt: 1.5 }}>
|
||||||
|
<MediaGallery media={v.media} />
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
440
apps/web/src/pages/RoutesPage.tsx
Normal file
440
apps/web/src/pages/RoutesPage.tsx
Normal file
@ -0,0 +1,440 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
FormControlLabel,
|
||||||
|
Grid2 as Grid,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
ToggleButton,
|
||||||
|
ToggleButtonGroup,
|
||||||
|
Tooltip,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import RouteIcon from '@mui/icons-material/RouteOutlined';
|
||||||
|
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||||
|
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||||
|
import DeleteIcon from '@mui/icons-material/DeleteOutline';
|
||||||
|
import TimelineIcon from '@mui/icons-material/Timeline';
|
||||||
|
import PlaceIcon from '@mui/icons-material/PlaceOutlined';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { MEDIA_REQUIREMENTS, MEDIA_REQUIREMENT_LABELS, type GeoJsonLineString, type MediaRequirement } from '@patrol/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Route } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { SitePicker } from '../lib/SitePicker';
|
||||||
|
import { useRoutes, useSites } from '../lib/queries';
|
||||||
|
import { BaseMap, FitTo } from '../map/base';
|
||||||
|
import { EditorMode, RouteEditor } from '../map/RouteEditor';
|
||||||
|
import { useConfirm, useNotify } from '../ui/feedback';
|
||||||
|
import { Card, EmptyState, ErrorState, LoadingState, PageHeader } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
type CpForm = {
|
||||||
|
key: string;
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
location: { type: 'Point'; coordinates: [number, number] };
|
||||||
|
radiusM: number;
|
||||||
|
mediaRequired: MediaRequirement;
|
||||||
|
instruction: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Form = { name: string; description: string; expectedMinutes: string; strictOrder: boolean; path: GeoJsonLineString | null; checkpoints: CpForm[] };
|
||||||
|
|
||||||
|
const emptyForm = (): Form => ({ name: '', description: '', expectedMinutes: '', strictOrder: false, path: null, checkpoints: [] });
|
||||||
|
|
||||||
|
function toForm(r: Route): Form {
|
||||||
|
return {
|
||||||
|
name: r.name,
|
||||||
|
description: r.description || '',
|
||||||
|
expectedMinutes: r.expectedMinutes ? String(r.expectedMinutes) : '',
|
||||||
|
strictOrder: r.strictOrder,
|
||||||
|
path: r.path,
|
||||||
|
checkpoints: [...r.checkpoints]
|
||||||
|
.sort((a, b) => a.position - b.position)
|
||||||
|
.map((c) => ({
|
||||||
|
key: c.id || crypto.randomUUID(),
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
location: c.location,
|
||||||
|
radiusM: c.radiusM,
|
||||||
|
mediaRequired: c.mediaRequired,
|
||||||
|
instruction: c.instruction || '',
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutesPage() {
|
||||||
|
const { routeId } = useParams();
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const sites = useSites();
|
||||||
|
const siteId = params.get('site') || sites.data?.[0]?.id || '';
|
||||||
|
const routes = useRoutes(siteId || undefined);
|
||||||
|
const { can } = useAuth();
|
||||||
|
|
||||||
|
const pickSite = (id: string) => {
|
||||||
|
setParams({ site: id });
|
||||||
|
if (routeId) navigate(`/routes?site=${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (sites.isLoading) return <LoadingState />;
|
||||||
|
if (sites.isError) return <ErrorState error={sites.error} onRetry={() => sites.refetch()} />;
|
||||||
|
if (!sites.data?.length) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Маршруты" />
|
||||||
|
<Card>
|
||||||
|
<EmptyState icon={RouteIcon} title="Сначала добавьте объект">
|
||||||
|
Маршрут рисуется внутри периметра объекта.
|
||||||
|
</EmptyState>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const site = sites.data.find((s) => s.id === siteId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Маршруты"
|
||||||
|
subtitle="Линия обхода и контрольные точки на карте"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<SitePicker sites={sites.data} value={siteId} onChange={pickSite} />
|
||||||
|
{can('routes.manage') && (
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate(`/routes/new?site=${siteId}`)}>
|
||||||
|
Новый маршрут
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, md: 3 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{routes.isLoading && <LoadingState />}
|
||||||
|
{routes.data?.length === 0 && routeId !== 'new' && <Typography sx={{ color: colors.muted, px: 1 }}>На объекте пока нет маршрутов.</Typography>}
|
||||||
|
{routes.data?.map((r) => (
|
||||||
|
<Box
|
||||||
|
key={r.id}
|
||||||
|
onClick={() => navigate(`/routes/${r.id}?site=${siteId}`)}
|
||||||
|
sx={{
|
||||||
|
p: 1.75,
|
||||||
|
borderRadius: '12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
bgcolor: '#fff',
|
||||||
|
border: `1px solid ${routeId === r.id ? colors.brand : colors.line}`,
|
||||||
|
boxShadow: routeId === r.id ? `0 0 0 3px ${colors.brandSoft}` : 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>{r.name}</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13 }}>
|
||||||
|
Точек: {r.checkpoints.length} · версия {r.version}
|
||||||
|
{r.strictOrder ? ' · строгий порядок' : ''}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, md: 9 }}>
|
||||||
|
{routeId ? (
|
||||||
|
<RouteForm key={`${routeId}-${siteId}`} routeId={routeId} siteId={siteId} perimeter={site?.perimeter ?? null} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<EmptyState icon={RouteIcon} title="Выберите маршрут">
|
||||||
|
{can('routes.manage') ? 'или создайте новый для этого объекта' : null}
|
||||||
|
</EmptyState>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RouteForm({ routeId, siteId, perimeter }: { routeId: string; siteId: string; perimeter: import('@patrol/shared').GeoJsonMultiPolygon | null }) {
|
||||||
|
const isNew = routeId === 'new';
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const editable = can('routes.manage');
|
||||||
|
|
||||||
|
const route = useQuery({ queryKey: ['route', routeId], queryFn: () => api<Route>(`/routes/${routeId}`), enabled: !isNew });
|
||||||
|
const [form, setForm] = useState<Form>(emptyForm);
|
||||||
|
const [loadedKey, setLoadedKey] = useState(isNew ? 'new' : '');
|
||||||
|
const [mode, setMode] = useState<EditorMode>('points');
|
||||||
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
const [warnings, setWarnings] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.data) {
|
||||||
|
setForm(toForm(route.data));
|
||||||
|
setWarnings(route.data.warnings || []);
|
||||||
|
setLoadedKey(`${route.data.id}-${route.data.version}`);
|
||||||
|
}
|
||||||
|
}, [route.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body = {
|
||||||
|
siteId,
|
||||||
|
name: form.name.trim(),
|
||||||
|
description: form.description.trim() || null,
|
||||||
|
expectedMinutes: form.expectedMinutes ? Number(form.expectedMinutes) : null,
|
||||||
|
strictOrder: form.strictOrder,
|
||||||
|
path: form.path,
|
||||||
|
checkpoints: form.checkpoints.map((c) => ({
|
||||||
|
...(c.id ? { id: c.id } : {}),
|
||||||
|
name: c.name.trim(),
|
||||||
|
location: c.location,
|
||||||
|
radiusM: c.radiusM,
|
||||||
|
mediaRequired: c.mediaRequired,
|
||||||
|
instruction: c.instruction.trim() || null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
return isNew ? api<Route>('/routes', { body }) : api<Route>(`/routes/${routeId}`, { method: 'PUT', body });
|
||||||
|
},
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['routes'] });
|
||||||
|
qc.setQueryData(['route', saved.id], saved);
|
||||||
|
setWarnings(saved.warnings || []);
|
||||||
|
notify.success(saved.warnings?.length ? 'Маршрут сохранён, есть предупреждения' : 'Маршрут сохранён');
|
||||||
|
if (isNew) navigate(`/routes/${saved.id}?site=${siteId}`, { replace: true });
|
||||||
|
},
|
||||||
|
onError: notify.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const archive = async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Убрать маршрут в архив?',
|
||||||
|
message: 'Графики по нему отключатся, запланированные обходы отменятся. Отчёты сохранятся.',
|
||||||
|
confirmText: 'В архив',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api(`/routes/${routeId}/archive`, { method: 'POST' });
|
||||||
|
qc.invalidateQueries({ queryKey: ['routes'] });
|
||||||
|
notify.success('Маршрут в архиве');
|
||||||
|
navigate(`/routes?site=${siteId}`);
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCp = (i: number, patch: Partial<CpForm>) => setForm((f) => ({ ...f, checkpoints: f.checkpoints.map((c, j) => (j === i ? { ...c, ...patch } : c)) }));
|
||||||
|
const moveCp = (i: number, dir: -1 | 1) =>
|
||||||
|
setForm((f) => {
|
||||||
|
const list = [...f.checkpoints];
|
||||||
|
const j = i + dir;
|
||||||
|
if (j < 0 || j >= list.length) return f;
|
||||||
|
[list[i], list[j]] = [list[j], list[i]];
|
||||||
|
setSelected(j);
|
||||||
|
return { ...f, checkpoints: list };
|
||||||
|
});
|
||||||
|
const removeCp = (i: number) => {
|
||||||
|
setForm((f) => ({ ...f, checkpoints: f.checkpoints.filter((_, j) => j !== i) }));
|
||||||
|
setSelected(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitGeos = useMemo(
|
||||||
|
() => (isNew ? [perimeter] : [perimeter, route.data?.path ?? null, ...(route.data?.checkpoints || []).map((c) => c.location)]),
|
||||||
|
[isNew, perimeter, route.data],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isNew && route.isLoading) return <LoadingState />;
|
||||||
|
if (!isNew && route.isError) return <ErrorState error={route.error} onRetry={() => route.refetch()} />;
|
||||||
|
if (!loadedKey) return <LoadingState />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
{warnings.length > 0 && (
|
||||||
|
<Alert severity="warning" sx={{ borderRadius: '12px' }}>
|
||||||
|
{warnings.map((w) => (
|
||||||
|
<div key={w}>{w}</div>
|
||||||
|
))}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<Card flush>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap', px: 2, py: 1.5, borderBottom: `1px solid ${colors.line}` }}>
|
||||||
|
{editable ? (
|
||||||
|
<ToggleButtonGroup size="small" exclusive value={mode} onChange={(_, v) => v && setMode(v)}>
|
||||||
|
<ToggleButton value="points">
|
||||||
|
<PlaceIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
||||||
|
Точки
|
||||||
|
</ToggleButton>
|
||||||
|
<ToggleButton value="path">
|
||||||
|
<TimelineIcon sx={{ fontSize: 18, mr: 0.75 }} />
|
||||||
|
Линия маршрута
|
||||||
|
</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
) : (
|
||||||
|
<span />
|
||||||
|
)}
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13 }}>
|
||||||
|
{!editable
|
||||||
|
? 'Только просмотр'
|
||||||
|
: mode === 'points'
|
||||||
|
? 'Кликните по карте, чтобы добавить точку. Точку можно перетащить.'
|
||||||
|
: 'Нарисуйте линию инструментом слева, двойной клик — закончить.'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<BaseMap height={520} style={{ borderRadius: 0 }}>
|
||||||
|
<FitTo geos={fitGeos} />
|
||||||
|
<RouteEditor
|
||||||
|
key={loadedKey}
|
||||||
|
perimeter={perimeter}
|
||||||
|
path={form.path}
|
||||||
|
onPathChange={(path) => setForm((f) => ({ ...f, path }))}
|
||||||
|
checkpoints={form.checkpoints}
|
||||||
|
onCheckpointsChange={(list) =>
|
||||||
|
setForm((f) => ({
|
||||||
|
...f,
|
||||||
|
checkpoints: list.map((c) => {
|
||||||
|
const prev = f.checkpoints.find((p) => p.key === c.key);
|
||||||
|
return prev ? { ...prev, location: c.location } : { ...c, mediaRequired: 'photo', instruction: '' } as CpForm;
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
mode={editable ? mode : 'view'}
|
||||||
|
selected={selected}
|
||||||
|
onSelect={setSelected}
|
||||||
|
/>
|
||||||
|
</BaseMap>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={{ xs: 12, lg: 5 }}>
|
||||||
|
<Card title="Маршрут">
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField label="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required disabled={!editable} />
|
||||||
|
<TextField label="Описание" multiline minRows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} disabled={!editable} />
|
||||||
|
<TextField
|
||||||
|
label="Ожидаемая длительность, мин"
|
||||||
|
type="number"
|
||||||
|
value={form.expectedMinutes}
|
||||||
|
onChange={(e) => setForm({ ...form, expectedMinutes: e.target.value })}
|
||||||
|
disabled={!editable}
|
||||||
|
slotProps={{ htmlInput: { min: 1, max: 1440 } }}
|
||||||
|
/>
|
||||||
|
<FormControlLabel
|
||||||
|
control={<Switch checked={form.strictOrder} onChange={(e) => setForm({ ...form, strictOrder: e.target.checked })} disabled={!editable} />}
|
||||||
|
label="Строгий порядок точек"
|
||||||
|
/>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13, mt: -1.5 }}>
|
||||||
|
Отметка не по порядку не блокируется, а помечается в отчёте.
|
||||||
|
</Typography>
|
||||||
|
{editable && (
|
||||||
|
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
|
||||||
|
{!isNew && (
|
||||||
|
<Button color="error" onClick={archive}>
|
||||||
|
В архив
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="contained" onClick={() => save.mutate()} disabled={!form.name.trim() || save.isPending}>
|
||||||
|
{save.isPending ? 'Сохраняем…' : 'Сохранить маршрут'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, lg: 7 }}>
|
||||||
|
<Card title="Контрольные точки" subtitle={`${form.checkpoints.length} шт.`} flush>
|
||||||
|
<Box sx={{ px: 2, pb: 2, pt: 1.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||||
|
{form.checkpoints.length === 0 && (
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 14, px: 1, py: 2 }}>Точек нет. В режиме «Точки» кликните по карте.</Typography>
|
||||||
|
)}
|
||||||
|
{form.checkpoints.map((c, i) => (
|
||||||
|
<Box
|
||||||
|
key={c.key}
|
||||||
|
onClick={() => setSelected(i)}
|
||||||
|
sx={{
|
||||||
|
p: 1.5,
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: `1px solid ${selected === i ? colors.brand : colors.line}`,
|
||||||
|
bgcolor: selected === i ? colors.brandSoft : '#fff',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<Chip size="small" label={i + 1} color="primary" sx={{ minWidth: 30 }} />
|
||||||
|
<TextField size="small" value={c.name} onChange={(e) => setCp(i, { name: e.target.value })} disabled={!editable} placeholder="Название точки" />
|
||||||
|
{editable && (
|
||||||
|
<Box sx={{ display: 'flex', flexShrink: 0 }}>
|
||||||
|
<Tooltip title="Выше">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={() => moveCp(i, -1)} disabled={i === 0}>
|
||||||
|
<ArrowUpwardIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Ниже">
|
||||||
|
<span>
|
||||||
|
<IconButton size="small" onClick={() => moveCp(i, 1)} disabled={i === form.checkpoints.length - 1}>
|
||||||
|
<ArrowDownwardIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip title="Удалить">
|
||||||
|
<IconButton size="small" onClick={() => removeCp(i)}>
|
||||||
|
<DeleteIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{selected === i && (
|
||||||
|
<Grid container spacing={1.5} sx={{ mt: 1 }}>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Радиус, м"
|
||||||
|
type="number"
|
||||||
|
value={c.radiusM}
|
||||||
|
onChange={(e) => setCp(i, { radiusM: Math.max(1, Number(e.target.value) || 1) })}
|
||||||
|
disabled={!editable}
|
||||||
|
slotProps={{ htmlInput: { min: 1, max: 5000 } }}
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 6 }}>
|
||||||
|
<TextField select size="small" label="Медиа" value={c.mediaRequired} onChange={(e) => setCp(i, { mediaRequired: e.target.value as MediaRequirement })} disabled={!editable}>
|
||||||
|
{MEDIA_REQUIREMENTS.map((m) => (
|
||||||
|
<MenuItem key={m} value={m}>
|
||||||
|
{MEDIA_REQUIREMENT_LABELS[m]}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={12}>
|
||||||
|
<TextField
|
||||||
|
size="small"
|
||||||
|
label="Что проверить"
|
||||||
|
multiline
|
||||||
|
value={c.instruction}
|
||||||
|
onChange={(e) => setCp(i, { instruction: e.target.value })}
|
||||||
|
disabled={!editable}
|
||||||
|
placeholder="Например: дверь склада закрыта, пломба цела"
|
||||||
|
/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
469
apps/web/src/pages/SchedulePage.tsx
Normal file
469
apps/web/src/pages/SchedulePage.tsx
Normal file
@ -0,0 +1,469 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Chip,
|
||||||
|
Dialog,
|
||||||
|
DialogActions,
|
||||||
|
DialogContent,
|
||||||
|
DialogTitle,
|
||||||
|
FormControlLabel,
|
||||||
|
Grid2 as Grid,
|
||||||
|
IconButton,
|
||||||
|
MenuItem,
|
||||||
|
Switch,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
|
||||||
|
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||||
|
import EditIcon from '@mui/icons-material/EditOutlined';
|
||||||
|
import CalendarMonthIcon from '@mui/icons-material/CalendarMonthOutlined';
|
||||||
|
import CheckIcon from '@mui/icons-material/Check';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Assignment, Schedule } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { ymd } from '../lib/format';
|
||||||
|
import { SitePicker } from '../lib/SitePicker';
|
||||||
|
import { useEmployees, useRoutes, useSchedules, useSites } from '../lib/queries';
|
||||||
|
import { useNotify } from '../ui/feedback';
|
||||||
|
import { Card, EmptyState, ErrorState, LoadingState, PageHeader, PersonAvatar } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
const WEEKDAYS = ['вс', 'пн', 'вт', 'ср', 'чт', 'пт', 'сб'];
|
||||||
|
|
||||||
|
export function SchedulePage() {
|
||||||
|
const [params, setParams] = useSearchParams();
|
||||||
|
const sites = useSites();
|
||||||
|
const siteId = params.get('site') || sites.data?.[0]?.id || '';
|
||||||
|
const schedules = useSchedules(siteId || '');
|
||||||
|
const { can } = useAuth();
|
||||||
|
const [editing, setEditing] = useState<Schedule | 'new' | null>(null);
|
||||||
|
const [activeId, setActiveId] = useState<string>('');
|
||||||
|
|
||||||
|
const active = schedules.data?.find((s) => s.id === activeId) || schedules.data?.find((s) => s.isActive) || schedules.data?.[0];
|
||||||
|
|
||||||
|
if (sites.isLoading) return <LoadingState />;
|
||||||
|
if (sites.isError) return <ErrorState error={sites.error} onRetry={() => sites.refetch()} />;
|
||||||
|
if (!sites.data?.length) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="График обходов" />
|
||||||
|
<Card>
|
||||||
|
<EmptyState icon={CalendarMonthIcon} title="Сначала добавьте объект и маршрут" />
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="График обходов"
|
||||||
|
subtitle="Когда проходить маршрут и кто дежурит в какие дни"
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
<SitePicker sites={sites.data} value={siteId} onChange={(id) => setParams({ site: id })} />
|
||||||
|
{can('schedules.manage') && (
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setEditing('new')}>
|
||||||
|
Новый график
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{schedules.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : !schedules.data?.length ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState icon={CalendarMonthIcon} title="Графиков пока нет">
|
||||||
|
График задаёт маршрут, время начала обходов и окно на прохождение.
|
||||||
|
</EmptyState>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
{schedules.data.map((s) => (
|
||||||
|
<Grid key={s.id} size={{ xs: 12, sm: 6, xl: 4 }}>
|
||||||
|
<Box
|
||||||
|
onClick={() => setActiveId(s.id)}
|
||||||
|
sx={{
|
||||||
|
p: 2,
|
||||||
|
height: '100%',
|
||||||
|
bgcolor: '#fff',
|
||||||
|
borderRadius: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: `1px solid ${active?.id === s.id ? colors.brand : colors.line}`,
|
||||||
|
boxShadow: active?.id === s.id ? `0 0 0 3px ${colors.brandSoft}` : 'none',
|
||||||
|
opacity: s.isActive ? 1 : 0.6,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: 1 }}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>{s.name}</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13 }}>Маршрут: {s.routeName}</Typography>
|
||||||
|
</Box>
|
||||||
|
{can('schedules.manage') && (
|
||||||
|
<IconButton
|
||||||
|
size="small"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setEditing(s);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EditIcon fontSize="small" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 1.25 }}>
|
||||||
|
{s.startTimes.map((t) => (
|
||||||
|
<Chip key={t} size="small" label={t} sx={{ bgcolor: colors.soft }} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 12.5, mt: 1.25 }}>
|
||||||
|
Окно {s.windowMinutes} мин · допуск {s.lateToleranceMinutes} мин · с {s.validFrom}
|
||||||
|
{s.validTo ? ` по ${s.validTo}` : ''}
|
||||||
|
{!s.isActive && ' · выключен'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
))}
|
||||||
|
</Grid>
|
||||||
|
{active && <DutyCalendar key={active.id} siteId={siteId} schedule={active} />}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
{editing && <ScheduleDialog siteId={siteId} schedule={editing === 'new' ? null : editing} onClose={() => setEditing(null)} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyOf = (userId: string, date: string) => `${userId}|${date}`;
|
||||||
|
|
||||||
|
// Календарь дежурств: строки — сотрудники с правом обхода, столбцы — дни месяца.
|
||||||
|
function DutyCalendar({ siteId, schedule }: { siteId: string; schedule: Schedule }) {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const editable = can('schedules.manage');
|
||||||
|
const notify = useNotify();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [month, setMonth] = useState(() => {
|
||||||
|
const d = new Date();
|
||||||
|
return new Date(d.getFullYear(), d.getMonth(), 1);
|
||||||
|
});
|
||||||
|
const [pending, setPending] = useState<Map<string, boolean>>(new Map());
|
||||||
|
|
||||||
|
const days = useMemo(() => {
|
||||||
|
const list: Date[] = [];
|
||||||
|
const d = new Date(month);
|
||||||
|
while (d.getMonth() === month.getMonth()) {
|
||||||
|
list.push(new Date(d));
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [month]);
|
||||||
|
const from = ymd(days[0]);
|
||||||
|
const to = ymd(days[days.length - 1]);
|
||||||
|
const today = ymd(new Date());
|
||||||
|
|
||||||
|
const users = useEmployees(siteId, editable || can('users.manage'));
|
||||||
|
const assignments = useQuery({
|
||||||
|
queryKey: ['assignments', siteId, from, to],
|
||||||
|
queryFn: () => api<Assignment[]>(`/schedules/assignments/${siteId}?from=${from}&to=${to}`),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = useMemo(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const a of assignments.data || []) if (a.scheduleId === schedule.id) set.add(keyOf(a.userId, a.date));
|
||||||
|
return set;
|
||||||
|
}, [assignments.data, schedule.id]);
|
||||||
|
|
||||||
|
const isOn = (k: string) => (pending.has(k) ? pending.get(k)! : saved.has(k));
|
||||||
|
|
||||||
|
const toggle = (userId: string, date: string) => {
|
||||||
|
if (!editable || date < today) return;
|
||||||
|
const k = keyOf(userId, date);
|
||||||
|
setPending((p) => {
|
||||||
|
const next = new Map(p);
|
||||||
|
const target = !isOn(k);
|
||||||
|
if (target === saved.has(k)) next.delete(k);
|
||||||
|
else next.set(k, target);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const fillRow = (userId: string, weekdays: number[] | 'all' | 'clear') => {
|
||||||
|
if (!editable) return;
|
||||||
|
setPending((p) => {
|
||||||
|
const next = new Map(p);
|
||||||
|
for (const d of days) {
|
||||||
|
const date = ymd(d);
|
||||||
|
if (date < today) continue;
|
||||||
|
const k = keyOf(userId, date);
|
||||||
|
const target = weekdays === 'clear' ? false : weekdays === 'all' ? true : weekdays.includes(d.getDay());
|
||||||
|
if (weekdays !== 'clear' && weekdays !== 'all' && !target) continue;
|
||||||
|
if (target === saved.has(k)) next.delete(k);
|
||||||
|
else next.set(k, target);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const add: { scheduleId: string; userId: string; date: string }[] = [];
|
||||||
|
const remove: typeof add = [];
|
||||||
|
pending.forEach((on, k) => {
|
||||||
|
const [userId, date] = k.split('|');
|
||||||
|
(on ? add : remove).push({ scheduleId: schedule.id, userId, date });
|
||||||
|
});
|
||||||
|
return api<{ added: number; removed: number; roundsCreated: number }>('/schedules/assignments', { body: { add, remove } });
|
||||||
|
},
|
||||||
|
onSuccess: (res) => {
|
||||||
|
setPending(new Map());
|
||||||
|
qc.invalidateQueries({ queryKey: ['assignments', siteId] });
|
||||||
|
notify.success(`График сохранён${res.roundsCreated ? `, создано обходов: ${res.roundsCreated}` : ''}`);
|
||||||
|
},
|
||||||
|
onError: notify.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const guards = (users.data || []).filter((u) => u.isActive && u.canPatrol);
|
||||||
|
const monthLabel = new Intl.DateTimeFormat('ru-RU', { month: 'long', year: 'numeric' }).format(month);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
title={`Дежурства: ${schedule.name}`}
|
||||||
|
subtitle="Кликните по клетке, чтобы назначить или снять дежурство. Обход может взять любой из дежурных."
|
||||||
|
action={
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||||
|
<IconButton size="small" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}>
|
||||||
|
<ChevronLeftIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Typography sx={{ fontWeight: 600, minWidth: 130, textAlign: 'center', textTransform: 'capitalize' }}>{monthLabel}</Typography>
|
||||||
|
<IconButton size="small" onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}>
|
||||||
|
<ChevronRightIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
flush
|
||||||
|
>
|
||||||
|
{users.isLoading || assignments.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : users.isError ? (
|
||||||
|
<ErrorState error={users.error} />
|
||||||
|
) : !guards.length ? (
|
||||||
|
<EmptyState title="Нет сотрудников с правом обхода на этом объекте">Добавьте их в разделе «Сотрудники» и откройте доступ к объекту.</EmptyState>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Box sx={{ overflowX: 'auto', px: 1, pt: 2 }}>
|
||||||
|
<Box component="table" sx={{ borderCollapse: 'separate', borderSpacing: '3px', minWidth: '100%' }}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<Box component="th" sx={{ position: 'sticky', left: 0, bgcolor: '#fff', zIndex: 1, minWidth: 220 }} />
|
||||||
|
{days.map((d) => {
|
||||||
|
const weekend = d.getDay() === 0 || d.getDay() === 6;
|
||||||
|
const isToday = ymd(d) === today;
|
||||||
|
return (
|
||||||
|
<Box component="th" key={d.getDate()} sx={{ fontWeight: 600, fontSize: 12, color: isToday ? colors.brand : weekend ? colors.danger : colors.muted, minWidth: 32 }}>
|
||||||
|
<div>{d.getDate()}</div>
|
||||||
|
<div style={{ fontWeight: 400 }}>{WEEKDAYS[d.getDay()]}</div>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{guards.map((u) => (
|
||||||
|
<tr key={u.id}>
|
||||||
|
<Box component="td" sx={{ position: 'sticky', left: 0, bgcolor: '#fff', zIndex: 1, pr: 1 }}>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||||
|
<PersonAvatar name={u.fullName} id={u.id} size={28} />
|
||||||
|
<Box sx={{ minWidth: 0, flex: 1 }}>
|
||||||
|
<Typography sx={{ fontSize: 14, fontWeight: 500 }} noWrap>
|
||||||
|
{u.fullName}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{editable && <RowMenu onFill={(w) => fillRow(u.id, w)} />}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{days.map((d) => {
|
||||||
|
const date = ymd(d);
|
||||||
|
const k = keyOf(u.id, date);
|
||||||
|
const on = isOn(k);
|
||||||
|
const changed = pending.has(k);
|
||||||
|
const past = date < today;
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="td"
|
||||||
|
key={date}
|
||||||
|
onClick={() => toggle(u.id, date)}
|
||||||
|
title={past ? 'Прошедший день' : undefined}
|
||||||
|
sx={{
|
||||||
|
height: 32,
|
||||||
|
borderRadius: '7px',
|
||||||
|
textAlign: 'center',
|
||||||
|
cursor: editable && !past ? 'pointer' : 'default',
|
||||||
|
bgcolor: on ? (past ? '#93c5fd' : colors.brand) : past ? '#f8fafc' : colors.soft,
|
||||||
|
color: '#fff',
|
||||||
|
outline: changed ? `2px solid ${colors.warning}` : 'none',
|
||||||
|
outlineOffset: -2,
|
||||||
|
transition: 'background-color .1s',
|
||||||
|
'&:hover': editable && !past ? { bgcolor: on ? colors.brandDark : colors.brandSofter } : {},
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{on && <CheckIcon sx={{ fontSize: 16, verticalAlign: 'middle' }} />}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{editable && (
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 1.5, px: 3, py: 2, borderTop: `1px solid ${colors.line}`, mt: 2 }}>
|
||||||
|
{pending.size > 0 && <Typography sx={{ color: colors.muted, fontSize: 14 }}>Изменений: {pending.size}</Typography>}
|
||||||
|
<Button disabled={!pending.size} onClick={() => setPending(new Map())}>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
<Button variant="contained" disabled={!pending.size || save.isPending} onClick={() => save.mutate()}>
|
||||||
|
{save.isPending ? 'Сохраняем…' : 'Сохранить график'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RowMenu({ onFill }: { onFill: (w: number[] | 'all' | 'clear') => void }) {
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
return (
|
||||||
|
<TextField
|
||||||
|
select
|
||||||
|
size="small"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value;
|
||||||
|
setValue('');
|
||||||
|
onFill(v === 'all' ? 'all' : v === 'clear' ? 'clear' : v === 'weekdays' ? [1, 2, 3, 4, 5] : v === 'weekend' ? [0, 6] : v === 'odd' ? [1, 3, 5] : [2, 4]);
|
||||||
|
}}
|
||||||
|
fullWidth={false}
|
||||||
|
sx={{ width: 44, '& .MuiSelect-select': { py: 0.5, pr: '20px !important', pl: 0.5 }, '& fieldset': { border: 'none' } }}
|
||||||
|
slotProps={{ select: { displayEmpty: true, renderValue: () => '' } }}
|
||||||
|
>
|
||||||
|
<MenuItem value="all">Весь месяц</MenuItem>
|
||||||
|
<MenuItem value="weekdays">По будням</MenuItem>
|
||||||
|
<MenuItem value="weekend">По выходным</MenuItem>
|
||||||
|
<MenuItem value="odd">Пн, ср, пт</MenuItem>
|
||||||
|
<MenuItem value="even">Вт, чт</MenuItem>
|
||||||
|
<MenuItem value="clear">Очистить месяц</MenuItem>
|
||||||
|
</TextField>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
function ScheduleDialog({ siteId, schedule, onClose }: { siteId: string; schedule: Schedule | null; onClose: () => void }) {
|
||||||
|
const routes = useRoutes(siteId);
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const [form, setForm] = useState(() => ({
|
||||||
|
name: schedule?.name || '',
|
||||||
|
routeId: schedule?.routeId || '',
|
||||||
|
startTimes: schedule?.startTimes || ['22:00', '02:00'],
|
||||||
|
windowMinutes: String(schedule?.windowMinutes ?? 60),
|
||||||
|
lateToleranceMinutes: String(schedule?.lateToleranceMinutes ?? 10),
|
||||||
|
validFrom: schedule?.validFrom || ymd(new Date()),
|
||||||
|
validTo: schedule?.validTo || '',
|
||||||
|
isActive: schedule?.isActive ?? true,
|
||||||
|
}));
|
||||||
|
const [time, setTime] = useState('');
|
||||||
|
const routeId = form.routeId || routes.data?.[0]?.id || '';
|
||||||
|
|
||||||
|
const addTime = () => {
|
||||||
|
if (!TIME_RE.test(time) || form.startTimes.includes(time)) return;
|
||||||
|
setForm({ ...form, startTimes: [...form.startTimes, time] });
|
||||||
|
setTime('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
startTimes: form.startTimes,
|
||||||
|
windowMinutes: Number(form.windowMinutes),
|
||||||
|
lateToleranceMinutes: Number(form.lateToleranceMinutes),
|
||||||
|
validFrom: form.validFrom,
|
||||||
|
validTo: form.validTo || null,
|
||||||
|
isActive: form.isActive,
|
||||||
|
};
|
||||||
|
return schedule ? api(`/schedules/${schedule.id}`, { method: 'PATCH', body }) : api('/schedules', { body: { ...body, routeId } });
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['schedules'] });
|
||||||
|
notify.success('График сохранён');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: notify.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open onClose={onClose} maxWidth="sm" fullWidth>
|
||||||
|
<DialogTitle>{schedule ? 'График обходов' : 'Новый график'}</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 1 }}>
|
||||||
|
<TextField label="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required placeholder="Ночные обходы" />
|
||||||
|
<TextField select label="Маршрут" value={routeId} onChange={(e) => setForm({ ...form, routeId: e.target.value })} disabled={!!schedule} helperText={schedule ? 'Маршрут графика не меняется — создайте новый график' : undefined}>
|
||||||
|
{(routes.data || []).map((r) => (
|
||||||
|
<MenuItem key={r.id} value={r.id}>
|
||||||
|
{r.name}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<Box>
|
||||||
|
<Typography sx={{ fontSize: 14, fontWeight: 600, mb: 1 }}>Время начала обходов</Typography>
|
||||||
|
<Box sx={{ display: 'flex', gap: 0.75, flexWrap: 'wrap', mb: 1.25 }}>
|
||||||
|
{[...form.startTimes].sort().map((t) => (
|
||||||
|
<Chip key={t} label={t} onDelete={() => setForm({ ...form, startTimes: form.startTimes.filter((x) => x !== t) })} />
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||||
|
<TextField size="small" type="time" value={time} onChange={(e) => setTime(e.target.value)} sx={{ maxWidth: 160 }} onKeyDown={(e) => e.key === 'Enter' && (e.preventDefault(), addTime())} />
|
||||||
|
<Button onClick={addTime} disabled={!TIME_RE.test(time)}>
|
||||||
|
Добавить
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 12.5, mt: 0.75 }}>
|
||||||
|
Время раньше первого считается следующими сутками: 22:00 и 02:00 — одна смена.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Grid container spacing={2}>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField label="Окно на обход, мин" type="number" value={form.windowMinutes} onChange={(e) => setForm({ ...form, windowMinutes: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField label="Допуск опоздания, мин" type="number" value={form.lateToleranceMinutes} onChange={(e) => setForm({ ...form, lateToleranceMinutes: e.target.value })} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField label="Действует с" type="date" value={form.validFrom} onChange={(e) => setForm({ ...form, validFrom: e.target.value })} slotProps={{ inputLabel: { shrink: true } }} />
|
||||||
|
</Grid>
|
||||||
|
<Grid size={6}>
|
||||||
|
<TextField label="по" type="date" value={form.validTo} onChange={(e) => setForm({ ...form, validTo: e.target.value })} slotProps={{ inputLabel: { shrink: true } }} helperText="Пусто — бессрочно" />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
<FormControlLabel control={<Switch checked={form.isActive} onChange={(e) => setForm({ ...form, isActive: e.target.checked })} />} label="График включён" />
|
||||||
|
</Box>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={onClose}>Отмена</Button>
|
||||||
|
<Button variant="contained" onClick={() => save.mutate()} disabled={!form.name.trim() || !routeId || !form.startTimes.length || save.isPending}>
|
||||||
|
Сохранить
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
149
apps/web/src/pages/SiteEditPage.tsx
Normal file
149
apps/web/src/pages/SiteEditPage.tsx
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { Alert, Box, Button, Grid2 as Grid, MenuItem, TextField, Typography } from '@mui/material';
|
||||||
|
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import type { GeoJsonMultiPolygon } from '@patrol/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Site } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { TIMEZONES } from '../lib/format';
|
||||||
|
import { BaseMap, FitTo } from '../map/base';
|
||||||
|
import { PerimeterEditor } from '../map/PerimeterEditor';
|
||||||
|
import { AddressAutocomplete, MapFocus, type GeocodeHit } from '../map/MapSearch';
|
||||||
|
import { useConfirm, useNotify } from '../ui/feedback';
|
||||||
|
import { Card, ErrorState, LoadingState, PageHeader } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
import { GeoJSON } from 'react-leaflet';
|
||||||
|
import { PERIMETER_STYLE } from '../map/base';
|
||||||
|
|
||||||
|
type Form = { name: string; address: string; description: string; timezone: string; perimeter: GeoJsonMultiPolygon | null };
|
||||||
|
|
||||||
|
export function SiteEditPage() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const isNew = !id;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const notify = useNotify();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { can } = useAuth();
|
||||||
|
const editable = can('sites.manage');
|
||||||
|
|
||||||
|
const site = useQuery({ queryKey: ['site', id], queryFn: () => api<Site>(`/sites/${id}`), enabled: !isNew });
|
||||||
|
const [focus, setFocus] = useState<GeocodeHit | null>(null);
|
||||||
|
const [form, setForm] = useState<Form>({ name: '', address: '', description: '', timezone: 'Asia/Almaty', perimeter: null });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (site.data) {
|
||||||
|
setForm({
|
||||||
|
name: site.data.name,
|
||||||
|
address: site.data.address || '',
|
||||||
|
description: site.data.description || '',
|
||||||
|
timezone: site.data.timezone,
|
||||||
|
perimeter: site.data.perimeter,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [site.data]);
|
||||||
|
|
||||||
|
const save = useMutation({
|
||||||
|
mutationFn: () => {
|
||||||
|
const body = { name: form.name.trim(), address: form.address.trim() || null, description: form.description.trim() || null, timezone: form.timezone, perimeter: form.perimeter };
|
||||||
|
return isNew ? api<Site>('/sites', { body }) : api<Site>(`/sites/${id}`, { method: 'PATCH', body });
|
||||||
|
},
|
||||||
|
onSuccess: (saved) => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['sites'] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['site', saved.id] });
|
||||||
|
notify.success('Объект сохранён');
|
||||||
|
if (isNew) navigate(`/sites/${saved.id}`, { replace: true });
|
||||||
|
},
|
||||||
|
onError: notify.error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const archive = async () => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: 'Убрать объект в архив?',
|
||||||
|
message: 'Запланированные обходы по нему отменятся. История и отчёты сохранятся.',
|
||||||
|
confirmText: 'В архив',
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await api(`/sites/${id}/archive`, { method: 'POST' });
|
||||||
|
qc.invalidateQueries({ queryKey: ['sites'] });
|
||||||
|
notify.success('Объект в архиве');
|
||||||
|
navigate('/sites');
|
||||||
|
} catch (e) {
|
||||||
|
notify.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isNew && site.isLoading) return <LoadingState />;
|
||||||
|
if (!isNew && site.isError) return <ErrorState error={site.error} onRetry={() => site.refetch()} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate('/sites')} sx={{ mb: 1.5, ml: -1 }}>
|
||||||
|
Все объекты
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
title={isNew ? 'Новый объект' : form.name || 'Объект'}
|
||||||
|
subtitle={editable ? 'Нарисуйте периметр — один или несколько участков' : undefined}
|
||||||
|
actions={
|
||||||
|
editable && (
|
||||||
|
<>
|
||||||
|
{!isNew && (
|
||||||
|
<Button color="error" onClick={archive}>
|
||||||
|
В архив
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="contained" onClick={() => save.mutate()} disabled={!form.name.trim() || save.isPending}>
|
||||||
|
{save.isPending ? 'Сохраняем…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, lg: 4 }}>
|
||||||
|
<Card title="Данные объекта">
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<TextField label="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required disabled={!editable} />
|
||||||
|
<AddressAutocomplete value={form.address} onChange={(address) => setForm((f) => ({ ...f, address }))} onPick={setFocus} disabled={!editable} />
|
||||||
|
<TextField select label="Часовой пояс" value={form.timezone} onChange={(e) => setForm({ ...form, timezone: e.target.value })} disabled={!editable} helperText="По нему считается время обходов">
|
||||||
|
{TIMEZONES.map((tz) => (
|
||||||
|
<MenuItem key={tz} value={tz}>
|
||||||
|
{tz}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</TextField>
|
||||||
|
<TextField label="Описание" multiline minRows={3} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} disabled={!editable} />
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
{editable && (
|
||||||
|
<Alert severity="info" sx={{ mt: 2, borderRadius: '12px' }}>
|
||||||
|
Инструменты слева на карте: многоугольник, прямоугольник, правка вершин, перемещение и удаление участка.
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, lg: 8 }}>
|
||||||
|
<Card flush>
|
||||||
|
<BaseMap height={600} style={{ borderRadius: 0 }}>
|
||||||
|
<FitTo geos={[site.data?.perimeter]} />
|
||||||
|
<MapFocus target={focus} />
|
||||||
|
{editable ? (
|
||||||
|
<PerimeterEditor key={site.data?.id || 'new'} value={site.data?.perimeter ?? null} onChange={(perimeter) => setForm((f) => ({ ...f, perimeter }))} />
|
||||||
|
) : (
|
||||||
|
site.data?.perimeter && <GeoJSON data={site.data.perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} />
|
||||||
|
)}
|
||||||
|
</BaseMap>
|
||||||
|
<Box sx={{ px: 2.5, py: 1.5, borderTop: `1px solid ${colors.line}` }}>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 13 }}>
|
||||||
|
{form.perimeter ? `Участков периметра: ${form.perimeter.coordinates.length}` : 'Периметр не задан'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
90
apps/web/src/pages/SitesPage.tsx
Normal file
90
apps/web/src/pages/SitesPage.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Box, Button, Chip, Grid2 as Grid, Typography } from '@mui/material';
|
||||||
|
import AddIcon from '@mui/icons-material/Add';
|
||||||
|
import ApartmentIcon from '@mui/icons-material/ApartmentOutlined';
|
||||||
|
import { GeoJSON, Tooltip } from 'react-leaflet';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { useSites } from '../lib/queries';
|
||||||
|
import { BaseMap, FitTo, PERIMETER_STYLE } from '../map/base';
|
||||||
|
import { Card, EmptyState, ErrorState, LoadingState, PageHeader } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
export function SitesPage() {
|
||||||
|
const { can } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const sites = useSites();
|
||||||
|
|
||||||
|
const add = can('sites.manage') && (
|
||||||
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/sites/new')}>
|
||||||
|
Новый объект
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title="Мои объекты" subtitle="Охраняемые объекты и их периметры на карте" actions={add} />
|
||||||
|
{sites.isLoading ? (
|
||||||
|
<LoadingState />
|
||||||
|
) : sites.isError ? (
|
||||||
|
<ErrorState error={sites.error} onRetry={() => sites.refetch()} />
|
||||||
|
) : !sites.data?.length ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState icon={ApartmentIcon} title="Объектов пока нет">
|
||||||
|
{can('sites.manage') ? 'Добавьте первый объект и нарисуйте его периметр на карте.' : 'Вам ещё не назначили объекты.'}
|
||||||
|
</EmptyState>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid size={{ xs: 12, lg: 5 }}>
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
{sites.data.map((s) => (
|
||||||
|
<Box
|
||||||
|
key={s.id}
|
||||||
|
onClick={() => navigate(`/sites/${s.id}`)}
|
||||||
|
sx={{
|
||||||
|
p: 2.25,
|
||||||
|
bgcolor: '#fff',
|
||||||
|
border: `1px solid ${colors.line}`,
|
||||||
|
borderRadius: '14px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'border-color .15s, box-shadow .15s',
|
||||||
|
'&:hover': { borderColor: colors.brand, boxShadow: '0 6px 20px rgba(37,99,235,0.08)' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', gap: 1, alignItems: 'flex-start' }}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600 }}>{s.name}</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 14 }} noWrap>
|
||||||
|
{s.address || 'Адрес не указан'}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
{!s.perimeter && <Chip size="small" label="Нет периметра" color="warning" variant="outlined" />}
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: 'flex', gap: 2, mt: 1.5, color: colors.muted, fontSize: 13 }}>
|
||||||
|
<span>Маршрутов: {s.routesCount ?? 0}</span>
|
||||||
|
<span>Обходов сегодня: {s.roundsToday ?? 0}</span>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
<Grid size={{ xs: 12, lg: 7 }}>
|
||||||
|
<Card flush>
|
||||||
|
<BaseMap height={560} style={{ borderRadius: 0 }}>
|
||||||
|
<FitTo geos={sites.data.map((s) => s.perimeter)} />
|
||||||
|
{sites.data.map(
|
||||||
|
(s) =>
|
||||||
|
s.perimeter && (
|
||||||
|
<GeoJSON key={s.id} data={s.perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} eventHandlers={{ click: () => navigate(`/sites/${s.id}`) }}>
|
||||||
|
<Tooltip sticky>{s.name}</Tooltip>
|
||||||
|
</GeoJSON>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</BaseMap>
|
||||||
|
</Card>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/web/src/pages/SsoCallbackPage.tsx
Normal file
49
apps/web/src/pages/SsoCallbackPage.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { Alert, Box, Button, CircularProgress, Typography } from '@mui/material';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { finishSso, ssoLogoutRedirect } from '../auth/aspansId';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
import { AuthLayout } from './AuthLayout';
|
||||||
|
|
||||||
|
// Возврат из Aspans ID: обмен кода на сессию «Обходов».
|
||||||
|
export function SsoCallbackPage() {
|
||||||
|
const { acceptToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const started = useRef(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (started.current) return;
|
||||||
|
started.current = true;
|
||||||
|
(async () => {
|
||||||
|
const r = await finishSso();
|
||||||
|
if (r.token) {
|
||||||
|
await acceptToken(r.token, { viaSso: true });
|
||||||
|
navigate(r.next.startsWith('/auth/sso') ? '/' : r.next, { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (r.error) setError(r.error);
|
||||||
|
else navigate(r.next.startsWith('/auth/sso') ? '/' : r.next, { replace: true });
|
||||||
|
})();
|
||||||
|
}, [acceptToken, navigate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthLayout title="Вход через Aspans ID">
|
||||||
|
{error ? (
|
||||||
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||||
|
<Alert severity="error">{error}</Alert>
|
||||||
|
{/* Выходим и из Aspans ID: иначе следующий «Войти через Aspans ID» молча вернёт тот же аккаунт. */}
|
||||||
|
<Button variant="contained" onClick={() => ssoLogoutRedirect()}>
|
||||||
|
На страницу входа
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5, color: colors.muted }}>
|
||||||
|
<CircularProgress size={22} />
|
||||||
|
<Typography>Выполняем вход…</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
214
apps/web/src/shell/Shell.tsx
Normal file
214
apps/web/src/shell/Shell.tsx
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { NavLink, Navigate, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { Box, Divider, Drawer, IconButton, ListItemIcon, Menu, MenuItem, Typography, useMediaQuery } from '@mui/material';
|
||||||
|
import type { SvgIconComponent } from '@mui/icons-material';
|
||||||
|
import ApartmentIcon from '@mui/icons-material/ApartmentOutlined';
|
||||||
|
import CalendarMonthIcon from '@mui/icons-material/CalendarMonthOutlined';
|
||||||
|
import RouteIcon from '@mui/icons-material/RouteOutlined';
|
||||||
|
import AssessmentIcon from '@mui/icons-material/AssessmentOutlined';
|
||||||
|
import GroupIcon from '@mui/icons-material/GroupOutlined';
|
||||||
|
import WebhookIcon from '@mui/icons-material/WebhookOutlined';
|
||||||
|
import BusinessIcon from '@mui/icons-material/BusinessOutlined';
|
||||||
|
import DirectionsWalkIcon from '@mui/icons-material/DirectionsWalk';
|
||||||
|
import LogoutIcon from '@mui/icons-material/Logout';
|
||||||
|
import MenuIcon from '@mui/icons-material/Menu';
|
||||||
|
import type { Permission } from '@patrol/shared';
|
||||||
|
import type { Me } from '../api/types';
|
||||||
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
import { BrandLogo } from '../ui/Brand';
|
||||||
|
import { PersonAvatar } from '../ui/layout';
|
||||||
|
import { colors } from '../ui/theme';
|
||||||
|
|
||||||
|
type NavItem = { to: string; label: string; icon: SvgIconComponent; allowed: (me: Me, can: (p: Permission) => boolean) => boolean };
|
||||||
|
|
||||||
|
const NAV: NavItem[] = [
|
||||||
|
{ to: '/sites', label: 'Мои объекты', icon: ApartmentIcon, allowed: (me, can) => !!me.organization && can('sites.view') },
|
||||||
|
{ to: '/schedule', label: 'График обходов', icon: CalendarMonthIcon, allowed: (me, can) => !!me.organization && can('sites.view') },
|
||||||
|
{ to: '/routes', label: 'Маршруты', icon: RouteIcon, allowed: (me, can) => !!me.organization && can('sites.view') },
|
||||||
|
{
|
||||||
|
to: '/reports',
|
||||||
|
label: 'Отчёты',
|
||||||
|
icon: AssessmentIcon,
|
||||||
|
allowed: (me, can) => !!me.organization && (can('reports.view_all') || can('reports.view_own')),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
to: '/employees',
|
||||||
|
label: 'Сотрудники',
|
||||||
|
icon: GroupIcon,
|
||||||
|
allowed: (me, can) => !!me.organization && (can('users.manage') || can('roles.manage')),
|
||||||
|
},
|
||||||
|
{ to: '/integrations', label: 'Интеграции', icon: WebhookIcon, allowed: (me, can) => !!me.organization && (can('webhooks.manage') || can('audit.view')) },
|
||||||
|
{ to: '/platform', label: 'Организации', icon: BusinessIcon, allowed: (me) => me.isPlatformAdmin },
|
||||||
|
];
|
||||||
|
|
||||||
|
const can = (me: Me) => (p: Permission) => me.permissions.includes(p);
|
||||||
|
|
||||||
|
// Охранник без управленческих прав сразу попадает в мобильный экран обходов.
|
||||||
|
export function landingPath(me: Me) {
|
||||||
|
const items = NAV.filter((n) => n.allowed(me, can(me)));
|
||||||
|
const manager = can(me)('sites.manage') || can(me)('reports.view_all') || can(me)('users.manage') || can(me)('schedules.manage');
|
||||||
|
if (can(me)('rounds.perform') && !manager) return '/guard';
|
||||||
|
return items[0]?.to || (can(me)('rounds.perform') ? '/guard' : '/sites');
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIDEBAR_W = 256;
|
||||||
|
|
||||||
|
function Sidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||||
|
const { me, can: allowed } = useAuth();
|
||||||
|
const items = NAV.filter((n) => n.allowed(me!, allowed));
|
||||||
|
return (
|
||||||
|
<Box sx={{ width: SIDEBAR_W, height: '100%', display: 'flex', flexDirection: 'column', bgcolor: '#fff', borderRight: `1px solid ${colors.line}` }}>
|
||||||
|
<Box sx={{ px: 2.5, py: 2.5 }}>
|
||||||
|
<BrandLogo />
|
||||||
|
</Box>
|
||||||
|
{me?.organization && (
|
||||||
|
<Box sx={{ mx: 2, mb: 1.5, px: 1.5, py: 1.25, borderRadius: '12px', bgcolor: colors.soft }}>
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: 14, lineHeight: 1.3 }} noWrap title={me.organization.name}>
|
||||||
|
{me.organization.name}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 12 }}>БИН {me.organization.bin}</Typography>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box component="nav" sx={{ px: 1.5, display: 'flex', flexDirection: 'column', gap: 0.25, flex: 1, overflowY: 'auto' }}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Box
|
||||||
|
key={item.to}
|
||||||
|
component={NavLink}
|
||||||
|
to={item.to}
|
||||||
|
onClick={onNavigate}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.1,
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: colors.muted,
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 500,
|
||||||
|
fontSize: 14.5,
|
||||||
|
transition: 'background-color .15s, color .15s',
|
||||||
|
'&:hover': { bgcolor: colors.soft, color: colors.ink },
|
||||||
|
'&.active': { bgcolor: colors.brandSoft, color: colors.brand, fontWeight: 600 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<item.icon sx={{ fontSize: 21 }} />
|
||||||
|
{item.label}
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{allowed('rounds.perform') && me?.organization && (
|
||||||
|
<>
|
||||||
|
<Divider sx={{ my: 1.5 }} />
|
||||||
|
<Box
|
||||||
|
component={NavLink}
|
||||||
|
to="/guard"
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.5,
|
||||||
|
px: 1.5,
|
||||||
|
py: 1.1,
|
||||||
|
borderRadius: '10px',
|
||||||
|
color: colors.ink,
|
||||||
|
textDecoration: 'none',
|
||||||
|
fontWeight: 600,
|
||||||
|
fontSize: 14.5,
|
||||||
|
border: `1px dashed ${colors.line}`,
|
||||||
|
'&:hover': { bgcolor: colors.soft },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DirectionsWalkIcon sx={{ fontSize: 21, color: colors.brand }} />
|
||||||
|
Режим обхода
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
<UserBox />
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserBox() {
|
||||||
|
const { me, logout } = useAuth();
|
||||||
|
const [anchor, setAnchor] = useState<HTMLElement | null>(null);
|
||||||
|
if (!me) return null;
|
||||||
|
return (
|
||||||
|
<Box sx={{ p: 1.5, borderTop: `1px solid ${colors.line}` }}>
|
||||||
|
<Box
|
||||||
|
component="button"
|
||||||
|
onClick={(e: React.MouseEvent<HTMLElement>) => setAnchor(e.currentTarget)}
|
||||||
|
sx={{
|
||||||
|
all: 'unset',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
width: '100%',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1.25,
|
||||||
|
p: 1,
|
||||||
|
borderRadius: '10px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
'&:hover': { bgcolor: colors.soft },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PersonAvatar name={me.fullName} id={me.id} size={34} />
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 600, fontSize: 14 }} noWrap>
|
||||||
|
{me.fullName}
|
||||||
|
</Typography>
|
||||||
|
<Typography sx={{ color: colors.muted, fontSize: 12 }} noWrap>
|
||||||
|
{me.isPlatformAdmin ? 'Владелец платформы' : me.role?.name || me.login}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Menu anchorEl={anchor} open={!!anchor} onClose={() => setAnchor(null)} anchorOrigin={{ vertical: 'top', horizontal: 'left' }} transformOrigin={{ vertical: 'bottom', horizontal: 'left' }}>
|
||||||
|
<MenuItem onClick={() => logout()}>
|
||||||
|
<ListItemIcon>
|
||||||
|
<LogoutIcon fontSize="small" />
|
||||||
|
</ListItemIcon>
|
||||||
|
Выйти
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Shell() {
|
||||||
|
const { me, can: allowed } = useAuth();
|
||||||
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const desktop = useMediaQuery('(min-width:900px)');
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
// Раздел, на который нет прав, — уводим на первый доступный.
|
||||||
|
const item = NAV.find((n) => location.pathname === n.to || location.pathname.startsWith(`${n.to}/`));
|
||||||
|
if (me && item && !item.allowed(me, allowed)) return <Navigate to={landingPath(me)} replace />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{ display: 'flex', minHeight: '100vh' }}>
|
||||||
|
{desktop ? (
|
||||||
|
<Box sx={{ position: 'sticky', top: 0, height: '100vh', flexShrink: 0 }}>
|
||||||
|
<Sidebar />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Drawer open={open} onClose={() => setOpen(false)}>
|
||||||
|
<Sidebar onNavigate={() => setOpen(false)} />
|
||||||
|
</Drawer>
|
||||||
|
)}
|
||||||
|
<Box component="main" sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
{!desktop && (
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 1, bgcolor: '#fff', borderBottom: `1px solid ${colors.line}`, position: 'sticky', top: 0, zIndex: 1100 }}>
|
||||||
|
<IconButton onClick={() => setOpen(true)} aria-label="Меню">
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Box onClick={() => navigate('/')} sx={{ cursor: 'pointer' }}>
|
||||||
|
<BrandLogo size={28} />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
<Box sx={{ px: { xs: 2, md: 4 }, py: { xs: 2.5, md: 4 }, maxWidth: 1440, mx: 'auto' }}>
|
||||||
|
<Outlet />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/web/src/ui/Brand.tsx
Normal file
41
apps/web/src/ui/Brand.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { Box } from '@mui/material';
|
||||||
|
|
||||||
|
export function BrandMark({ size = 34 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
component="span"
|
||||||
|
sx={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
borderRadius: '10px',
|
||||||
|
display: 'grid',
|
||||||
|
placeItems: 'center',
|
||||||
|
color: '#fff',
|
||||||
|
background: 'linear-gradient(135deg, #2563eb 0%, #4f46e5 100%)',
|
||||||
|
boxShadow: '0 6px 16px rgba(37, 99, 235, 0.28)',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 64 64" width={size * 0.62} height={size * 0.62} aria-hidden="true">
|
||||||
|
<path d="M32 10l17 6v13c0 11-7.5 19-17 23-9.5-4-17-12-17-23V16z" fill="none" stroke="currentColor" strokeWidth="5" strokeLinejoin="round" />
|
||||||
|
<path d="M24 32l6 6 11-12" fill="none" stroke="currentColor" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrandLogo({ size = 34, light = false, subtitle = 'Обходы' }: { size?: number; light?: boolean; subtitle?: string }) {
|
||||||
|
return (
|
||||||
|
<Box component="span" sx={{ display: 'inline-flex', alignItems: 'center', gap: 1.25, color: light ? '#fff' : 'inherit' }}>
|
||||||
|
<BrandMark size={size} />
|
||||||
|
<Box component="span" sx={{ display: 'flex', flexDirection: 'column', lineHeight: 1.1 }}>
|
||||||
|
<Box component="span" sx={{ fontWeight: 700, fontSize: 16, letterSpacing: '-0.01em' }}>
|
||||||
|
Aspans
|
||||||
|
</Box>
|
||||||
|
<Box component="span" sx={{ fontSize: 12, fontWeight: 500, color: light ? 'rgba(255,255,255,0.72)' : '#64748b' }}>
|
||||||
|
{subtitle}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user