init-commit

This commit is contained in:
Artem Gorlenko 2026-10-22 10:53:02 +05:00
commit 78007b65e9
120 changed files with 22285 additions and 0 deletions

59
.env.example Normal file
View 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
View 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
View File

@ -0,0 +1,8 @@
node_modules/
dist/
.env
*.log
tiles/*.mbtiles
tiles/planetiler/
.DS_Store
*.tsbuildinfo

81
README.md Normal file
View 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
View 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"
}
}

View 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 {}

View 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;
}
}

View 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
View 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;
}

View 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`);
}

View 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[];
}

View 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
View 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',
};

View 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);

View 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}`);
}
}
}

View 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 ''`);
}
}

View 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,
];

View 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
View 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();

View 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 } };
}
}

View 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],
};
}
}

View 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 ?? '');
}
}

View 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);
}
}

View 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);
}
}

View 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) };
}
}

View 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,
};
}
}

View 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];
}

View 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;
}
}
}

View 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 });
}
}

View 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);
}
}

View 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) };
}
}

View 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);
}
}

View 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);
}
}

View 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],
);
}
}

View 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);
}
}

View 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);
}
}

View 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) ?? [] } : {}),
};
});
}
}

View 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);
}
}

View 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 };
}
}

View 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);
}
}

View 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 };
}
}

View 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);
}
}

View 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 };
}
}

View 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() });
}
}

View 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;
}

View 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',
});
}

View 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 };
}

View 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],
);
},
};

View 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));
}
}

View 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 {}

View 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;
}
}

View 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);
}
}

View 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 };
}
}

View 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);
}
}

View 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
View 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 };

View File

@ -0,0 +1,4 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "**/*.spec.ts"]
}

18
apps/api/tsconfig.json Normal file
View 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
View 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
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

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
View 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>
);
}

View 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
View 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 } };

View 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)!;

View 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 */
}
}

View 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>
);
}

View 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
View 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
View 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
View 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);
};
}

View File

@ -0,0 +1,4 @@
import { useSyncExternalStore } from 'react';
import { getState, subscribe } from './queue';
export const useQueue = () => useSyncExternalStore(subscribe, getState);

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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 }} />;
}

View 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'];

View 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
View 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>,
);

View 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;
}

View 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;
}

View 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
View 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='&copy; <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' };

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
)}
</>
);
}

View 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>
);
}

View 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
View 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