From 86cf53502b8e6e28e53d14eff54080b0fb276f67 Mon Sep 17 00:00:00 2001 From: Artem Gorlenko Date: Thu, 22 Oct 2026 16:01:00 +0500 Subject: [PATCH] feat/add-checklist-entities --- apps/api/src/app.module.ts | 4 + apps/api/src/database/data-source.ts | 3 +- .../migrations/1761200000000-Checklists.ts | 66 +++ apps/api/src/entities/index.ts | 46 +++ .../checklists/checklists.controller.ts | 50 +++ .../modules/checklists/checklists.service.ts | 213 ++++++++++ .../src/modules/rounds/generator.service.ts | 4 +- apps/api/src/modules/rounds/rounds.service.ts | 28 +- apps/api/src/modules/routes/routes.service.ts | 103 ++++- apps/web/src/App.tsx | 3 + apps/web/src/api/types.ts | 21 + apps/web/src/guard/RoundScreen.tsx | 60 ++- apps/web/src/guard/data.ts | 4 + apps/web/src/guard/queue.ts | 2 + apps/web/src/lib/queries.ts | 5 +- apps/web/src/pages/ChecklistsPage.tsx | 386 ++++++++++++++++++ apps/web/src/pages/RoundDetailPage.tsx | 21 + apps/web/src/pages/RoutesPage.tsx | 47 ++- apps/web/src/shell/Shell.tsx | 2 + packages/shared/src/index.ts | 3 +- 20 files changed, 1046 insertions(+), 25 deletions(-) create mode 100644 apps/api/src/database/migrations/1761200000000-Checklists.ts create mode 100644 apps/api/src/modules/checklists/checklists.controller.ts create mode 100644 apps/api/src/modules/checklists/checklists.service.ts create mode 100644 apps/web/src/pages/ChecklistsPage.tsx diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 18f5684..51c3d71 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -38,6 +38,8 @@ 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'; +import { ChecklistsController } from './modules/checklists/checklists.controller'; +import { ChecklistsService } from './modules/checklists/checklists.service'; // Один модуль на всё: сервис небольшой, а так проще следить за зависимостями. @Module({ @@ -60,6 +62,7 @@ import { GeocodeService } from './modules/geocode/geocode.service'; ReportsController, WebhooksController, GeocodeController, + ChecklistsController, ], providers: [ { provide: APP_GUARD, useClass: AuthGuard }, @@ -82,6 +85,7 @@ import { GeocodeService } from './modules/geocode/geocode.service'; ReportsService, WebhooksService, GeocodeService, + ChecklistsService, ], }) export class AppModule {} diff --git a/apps/api/src/database/data-source.ts b/apps/api/src/database/data-source.ts index ce02540..51b349b 100644 --- a/apps/api/src/database/data-source.ts +++ b/apps/api/src/database/data-source.ts @@ -4,6 +4,7 @@ import { config } from '../config'; import { ENTITIES } from '../entities'; import { Init1761000000000 } from './migrations/1761000000000-Init'; import { Passport1761100000000 } from './migrations/1761100000000-Passport'; +import { Checklists1761200000000 } from './migrations/1761200000000-Checklists'; export const dataSourceOptions: DataSourceOptions = { type: 'postgres', @@ -13,7 +14,7 @@ export const dataSourceOptions: DataSourceOptions = { username: config.db.user, password: config.db.password, entities: ENTITIES, - migrations: [Init1761000000000, Passport1761100000000], + migrations: [Init1761000000000, Passport1761100000000, Checklists1761200000000], synchronize: false, migrationsRun: true, }; diff --git a/apps/api/src/database/migrations/1761200000000-Checklists.ts b/apps/api/src/database/migrations/1761200000000-Checklists.ts new file mode 100644 index 0000000..3894459 --- /dev/null +++ b/apps/api/src/database/migrations/1761200000000-Checklists.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Чеклисты. + * + * checklist_items — справочник пунктов компании («Стёкла не выбиты»). Один пункт — одна запись, + * сколько бы чеклистов его ни включали. У другой компании такой же текст — своя запись. + * checklists + checklist_entries — именованные списки пунктов с порядком. + * checkpoint_checklists — какие чеклисты висят на контрольной точке маршрута. + * checkpoint_visits.checklist — ответы охранника: [{ checklistId, itemId, text, checked }]. + */ +export class Checklists1761200000000 implements MigrationInterface { + name = 'Checklists1761200000000'; + + async up(q: QueryRunner): Promise { + await q.query(` + CREATE TABLE checklist_items ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() + )`); + // Один и тот же текст в компании — один пункт: без учёта регистра, пробелов по краям и «ё»/«е». + await q.query(`CREATE UNIQUE INDEX checklist_items_org_text_key ON checklist_items (organization_id, replace(lower(btrim(text)), 'ё', 'е'))`); + + await q.query(` + CREATE TABLE checklists ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organization_id uuid NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + name text NOT NULL, + description text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + archived_at timestamptz + )`); + await q.query(`CREATE INDEX checklists_org_idx ON checklists (organization_id) WHERE archived_at IS NULL`); + + await q.query(` + CREATE TABLE checklist_entries ( + checklist_id uuid NOT NULL REFERENCES checklists(id) ON DELETE CASCADE, + item_id uuid NOT NULL REFERENCES checklist_items(id) ON DELETE RESTRICT, + position int NOT NULL, + PRIMARY KEY (checklist_id, item_id) + )`); + await q.query(`CREATE INDEX checklist_entries_item_idx ON checklist_entries (item_id)`); + + await q.query(` + CREATE TABLE checkpoint_checklists ( + checkpoint_id uuid NOT NULL REFERENCES checkpoints(id) ON DELETE CASCADE, + checklist_id uuid NOT NULL REFERENCES checklists(id) ON DELETE CASCADE, + position int NOT NULL, + PRIMARY KEY (checkpoint_id, checklist_id) + )`); + await q.query(`CREATE INDEX checkpoint_checklists_checklist_idx ON checkpoint_checklists (checklist_id)`); + + await q.query(`ALTER TABLE checkpoint_visits ADD COLUMN checklist jsonb NOT NULL DEFAULT '[]'`); + } + + async down(q: QueryRunner): Promise { + await q.query(`ALTER TABLE checkpoint_visits DROP COLUMN checklist`); + await q.query(`DROP TABLE checkpoint_checklists`); + await q.query(`DROP TABLE checklist_entries`); + await q.query(`DROP TABLE checklists`); + await q.query(`DROP TABLE checklist_items`); + } +} diff --git a/apps/api/src/entities/index.ts b/apps/api/src/entities/index.ts index f552f49..0a49c14 100644 --- a/apps/api/src/entities/index.ts +++ b/apps/api/src/entities/index.ts @@ -155,6 +155,40 @@ export class Checkpoint { @Column({ type: 'timestamptz', name: 'archived_at', nullable: true }) archivedAt: Date | null; } +// Пункт чеклиста — общий для всех чеклистов компании. +@Entity('checklist_items') +export class ChecklistItem { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; + @Column('text') text: string; + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; +} + +@Entity('checklists') +export class Checklist { + @PrimaryGeneratedColumn('uuid') id: string; + @Column({ type: 'uuid', name: 'organization_id' }) organizationId: string; + @Column('text') name: string; + @Column({ type: 'text', nullable: true }) description: 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('checklist_entries') +export class ChecklistEntry { + @PrimaryColumn({ type: 'uuid', name: 'checklist_id' }) checklistId: string; + @PrimaryColumn({ type: 'uuid', name: 'item_id' }) itemId: string; + @Column('int') position: number; +} + +@Entity('checkpoint_checklists') +export class CheckpointChecklist { + @PrimaryColumn({ type: 'uuid', name: 'checkpoint_id' }) checkpointId: string; + @PrimaryColumn({ type: 'uuid', name: 'checklist_id' }) checklistId: string; + @Column('int') position: number; +} + @Entity('patrol_schedules') export class PatrolSchedule { @PrimaryGeneratedColumn('uuid') id: string; @@ -195,9 +229,16 @@ export type RouteSnapshot = { radiusM: number; mediaRequired: MediaRequirement; instruction: string | null; + // В снимках до появления чеклистов поля нет — читать как []. + checklists?: SnapshotChecklist[]; }[]; }; +export type SnapshotChecklist = { id: string; name: string; items: { id: string; text: string }[] }; + +// Ответ охранника по пункту. Текст копируем: отчёт читается и после правки пункта. +export type ChecklistAnswer = { checklistId: string; itemId: string; text: string; checked: boolean }; + @Entity('rounds') @Index(['siteId', 'plannedStart']) export class Round { @@ -240,6 +281,7 @@ export class CheckpointVisit { @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: 'jsonb', default: () => "'[]'" }) checklist: ChecklistAnswer[]; @Column({ type: 'timestamptz', name: 'client_created_at' }) clientCreatedAt: Date; @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) createdAt: Date; } @@ -284,6 +326,10 @@ export const ENTITIES = [ Site, Route, Checkpoint, + ChecklistItem, + Checklist, + ChecklistEntry, + CheckpointChecklist, PatrolSchedule, ScheduleAssignment, Round, diff --git a/apps/api/src/modules/checklists/checklists.controller.ts b/apps/api/src/modules/checklists/checklists.controller.ts new file mode 100644 index 0000000..0d88b53 --- /dev/null +++ b/apps/api/src/modules/checklists/checklists.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Put, Query } from '@nestjs/common'; +import { AuthUser, CurrentUser, RequirePermissions } from '../../common/auth'; +import { ChecklistsService } from './checklists.service'; + +@Controller() +export class ChecklistsController { + constructor(private readonly checklists: ChecklistsService) {} + + @RequirePermissions('sites.view') + @Get('checklists') + list(@CurrentUser() user: AuthUser) { + return this.checklists.list(user); + } + + @RequirePermissions('sites.view') + @Get('checklists/:id') + get(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return this.checklists.get(user, id); + } + + @RequirePermissions('routes.manage') + @Post('checklists') + create(@CurrentUser() user: AuthUser, @Body() body: unknown) { + return this.checklists.create(user, body); + } + + @RequirePermissions('routes.manage') + @Put('checklists/:id') + update(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) { + return this.checklists.update(user, id, body); + } + + @RequirePermissions('routes.manage') + @Post('checklists/:id/archive') + archive(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string) { + return this.checklists.archive(user, id); + } + + @RequirePermissions('routes.manage') + @Get('checklist-items') + items(@CurrentUser() user: AuthUser, @Query('q') q?: string) { + return this.checklists.items(user, q); + } + + @RequirePermissions('routes.manage') + @Patch('checklist-items/:id') + renameItem(@CurrentUser() user: AuthUser, @Param('id', ParseUUIDPipe) id: string, @Body() body: unknown) { + return this.checklists.renameItem(user, id, body); + } +} diff --git a/apps/api/src/modules/checklists/checklists.service.ts b/apps/api/src/modules/checklists/checklists.service.ts new file mode 100644 index 0000000..f5b06e6 --- /dev/null +++ b/apps/api/src/modules/checklists/checklists.service.ts @@ -0,0 +1,213 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { DataSource, EntityManager } from 'typeorm'; +import { Checklist } from '../../entities'; +import { AuthUser, requireOrg } from '../../common/auth'; +import { isUuid, obj, reqStr, str } from '../../common/input'; +import { RoutesService } from '../routes/routes.service'; + +const MAX_ITEMS = 100; + +// Ключ сравнения текстов пунктов — тот же, что в уникальном индексе checklist_items_org_text_key. +const KEY = (col: string) => `replace(lower(btrim(${col})), 'ё', 'е')`; +const MAX_TEXT = 500; + +// Пункт в теле запроса: уже существующий ({ id }) или новый текст ({ text } или просто строка). +type ItemInput = { id: string } | { text: string }; + +type ChecklistRow = { + id: string; + name: string; + description: string | null; + updatedAt: string; + items: { id: string; text: string; usedIn: number }[]; + checkpointsCount: number; + routes: { id: string; name: string; siteName: string }[]; +}; + +/** + * Чеклисты компании. Пункт — общая запись справочника checklist_items: один и тот же текст + * в компании — один пункт, сколько бы чеклистов его ни включали. + */ +@Injectable() +export class ChecklistsService { + constructor( + @InjectDataSource() private readonly db: DataSource, + private readonly routes: RoutesService, + ) {} + + async list(user: AuthUser, id?: string): Promise { + const orgId = requireOrg(user); + return this.db.query( + `SELECT c.id, c.name, c.description, c.updated_at AS "updatedAt", + COALESCE((SELECT json_agg(json_build_object( + 'id', i.id, 'text', i.text, + 'usedIn', (SELECT count(*)::int FROM checklist_entries e2 JOIN checklists c2 ON c2.id = e2.checklist_id + WHERE e2.item_id = i.id AND c2.archived_at IS NULL)) + ORDER BY e.position) + FROM checklist_entries e JOIN checklist_items i ON i.id = e.item_id + WHERE e.checklist_id = c.id), '[]') AS items, + (SELECT count(*)::int FROM checkpoint_checklists cc + JOIN checkpoints cp ON cp.id = cc.checkpoint_id AND cp.archived_at IS NULL + JOIN routes r ON r.id = cp.route_id AND r.archived_at IS NULL + WHERE cc.checklist_id = c.id) AS "checkpointsCount", + COALESCE((SELECT json_agg(DISTINCT jsonb_build_object('id', r.id, 'name', r.name, 'siteName', s.name)) + FROM checkpoint_checklists cc + JOIN checkpoints cp ON cp.id = cc.checkpoint_id AND cp.archived_at IS NULL + JOIN routes r ON r.id = cp.route_id AND r.archived_at IS NULL + JOIN sites s ON s.id = r.site_id AND s.archived_at IS NULL + WHERE cc.checklist_id = c.id), '[]') AS routes + FROM checklists c + WHERE c.organization_id = $1 AND c.archived_at IS NULL AND ($2::uuid IS NULL OR c.id = $2) + ORDER BY lower(c.name)`, + [orgId, id ?? null], + ); + } + + async get(user: AuthUser, id: string) { + const [row] = await this.list(user, id); + if (!row) throw new NotFoundException('Чеклист не найден'); + return row; + } + + /** Справочник пунктов для подсказок при наборе. */ + async items(user: AuthUser, q?: string) { + const orgId = requireOrg(user); + const query = (q ?? '').trim(); + return this.db.query( + `SELECT i.id, i.text, + (SELECT count(*)::int FROM checklist_entries e JOIN checklists c ON c.id = e.checklist_id + WHERE e.item_id = i.id AND c.archived_at IS NULL) AS "usedIn" + FROM checklist_items i + WHERE i.organization_id = $1 AND ($2 = '' OR ${KEY('i.text')} LIKE '%' || ${KEY('$2')} || '%') + ORDER BY "usedIn" DESC, lower(i.text) + LIMIT 30`, + [orgId, query.replace(/[%_\\]/g, (ch) => `\\${ch}`)], + ); + } + + private parse(body: unknown) { + const o = obj(body); + const raw = o.items; + if (raw !== undefined && !Array.isArray(raw)) throw new BadRequestException('items: нужен массив'); + const items: ItemInput[] | undefined = (raw as unknown[] | undefined)?.map((it, i) => { + if (typeof it === 'string') return { text: it }; + const x = obj(it, `items[${i}]`); + if (isUuid(x.id)) return { id: x.id }; + return { text: reqStr(x, 'text', { max: MAX_TEXT }) }; + }); + if (items && items.length > MAX_ITEMS) throw new BadRequestException(`В чеклисте не больше ${MAX_ITEMS} пунктов`); + return { + name: o.name !== undefined ? reqStr(o, 'name', { max: 300 }) : undefined, + description: 'description' in o ? str(o, 'description', { max: 2000 }) ?? null : undefined, + items, + }; + } + + /** Тексты → пункты справочника (существующие по тексту или новые), id → проверка, что пункт этой компании. */ + private async resolveItems(m: EntityManager, orgId: string, items: ItemInput[]): Promise { + const ids: string[] = []; + for (const it of items) { + if ('id' in it) { + const [row] = await m.query(`SELECT id FROM checklist_items WHERE id = $1 AND organization_id = $2`, [it.id, orgId]); + if (!row) throw new BadRequestException('Пункт чеклиста не найден'); + ids.push(row.id); + continue; + } + const text = it.text.trim().replace(/\s+/g, ' '); + if (!text) continue; + if (text.length > MAX_TEXT) throw new BadRequestException('Пункт чеклиста слишком длинный'); + const [row] = await m.query( + `INSERT INTO checklist_items (organization_id, text) VALUES ($1, $2) + ON CONFLICT (organization_id, ${KEY('text')}) DO UPDATE SET text = checklist_items.text + RETURNING id`, + [orgId, text], + ); + ids.push(row.id); + } + return [...new Set(ids)]; + } + + private async setEntries(m: EntityManager, checklistId: string, itemIds: string[]) { + await m.query(`DELETE FROM checklist_entries WHERE checklist_id = $1`, [checklistId]); + for (const [i, itemId] of itemIds.entries()) { + await m.query(`INSERT INTO checklist_entries (checklist_id, item_id, position) VALUES ($1, $2, $3)`, [checklistId, itemId, i + 1]); + } + } + + /** Маршруты, на точках которых висят эти чеклисты, — им нужно обновить запланированные обходы. */ + private async routesUsing(m: EntityManager, checklistIds: string[]): Promise { + if (!checklistIds.length) return []; + const rows: { route_id: string }[] = await m.query( + `SELECT DISTINCT cp.route_id FROM checkpoint_checklists cc JOIN checkpoints cp ON cp.id = cc.checkpoint_id + WHERE cc.checklist_id = ANY($1) AND cp.archived_at IS NULL`, + [checklistIds], + ); + return rows.map((r) => r.route_id); + } + + private async own(m: EntityManager | DataSource, orgId: string, id: string) { + const c = await m.getRepository(Checklist).findOneBy({ id, organizationId: orgId }); + if (!c || c.archivedAt) throw new NotFoundException('Чеклист не найден'); + return c; + } + + async create(user: AuthUser, body: unknown) { + const orgId = requireOrg(user); + const data = this.parse(body); + if (!data.name) throw new BadRequestException('Поле «name» обязательно'); + const id = await this.db.transaction(async (m) => { + const c = await m.getRepository(Checklist).save({ organizationId: orgId, name: data.name!, description: data.description ?? null }); + await this.setEntries(m, c.id, await this.resolveItems(m, orgId, data.items ?? [])); + return c.id; + }); + return this.get(user, id); + } + + async update(user: AuthUser, id: string, body: unknown) { + const orgId = requireOrg(user); + const data = this.parse(body); + await this.db.transaction(async (m) => { + await this.own(m, orgId, id); + const patch: Partial = { updatedAt: new Date() }; + if (data.name !== undefined) patch.name = data.name; + if (data.description !== undefined) patch.description = data.description; + await m.getRepository(Checklist).update({ id }, patch); + if (data.items) await this.setEntries(m, id, await this.resolveItems(m, orgId, data.items)); + if (data.items || data.name !== undefined) await this.routes.refreshRoutes(m, await this.routesUsing(m, [id])); + }); + return this.get(user, id); + } + + /** Переименовать пункт: меняется во всех чеклистах, где он есть. Прошлые отчёты хранят старый текст. */ + async renameItem(user: AuthUser, itemId: string, body: unknown) { + const orgId = requireOrg(user); + const text = reqStr(obj(body), 'text', { max: MAX_TEXT }).replace(/\s+/g, ' '); + return this.db.transaction(async (m) => { + const [item] = await m.query(`SELECT id FROM checklist_items WHERE id = $1 AND organization_id = $2`, [itemId, orgId]); + if (!item) throw new NotFoundException('Пункт чеклиста не найден'); + const [dup] = await m.query( + `SELECT id FROM checklist_items WHERE organization_id = $1 AND ${KEY('text')} = ${KEY('$2')} AND id <> $3`, + [orgId, text, itemId], + ); + if (dup) throw new ConflictException('Такой пункт уже есть — выберите его из списка'); + await m.query(`UPDATE checklist_items SET text = $2 WHERE id = $1`, [itemId, text]); + const lists: { checklist_id: string }[] = await m.query(`SELECT checklist_id FROM checklist_entries WHERE item_id = $1`, [itemId]); + await this.routes.refreshRoutes(m, await this.routesUsing(m, lists.map((l) => l.checklist_id))); + return { id: itemId, text }; + }); + } + + /** В архив: снимается со всех точек. Прошлые обходы и отчёты не меняются — у них свой снимок. */ + async archive(user: AuthUser, id: string) { + const orgId = requireOrg(user); + await this.db.transaction(async (m) => { + await this.own(m, orgId, id); + const routeIds = await this.routesUsing(m, [id]); + await m.getRepository(Checklist).update({ id }, { archivedAt: new Date() }); + await m.query(`DELETE FROM checkpoint_checklists WHERE checklist_id = $1`, [id]); + await this.routes.refreshRoutes(m, routeIds); + }); + return { id }; + } +} diff --git a/apps/api/src/modules/rounds/generator.service.ts b/apps/api/src/modules/rounds/generator.service.ts index 25e5510..b9c7dc1 100644 --- a/apps/api/src/modules/rounds/generator.service.ts +++ b/apps/api/src/modules/rounds/generator.service.ts @@ -5,7 +5,7 @@ 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'; +import { buildSnapshot } from '../routes/routes.service'; // Из графика (правило + назначения по дням) делает конкретные обходы на неделю вперёд. @Injectable() @@ -70,7 +70,7 @@ export class GeneratorService { 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); + const snapshot = await buildSnapshot(this.db, route, checkpoints); let created = 0; for (const { date } of days) { diff --git a/apps/api/src/modules/rounds/rounds.service.ts b/apps/api/src/modules/rounds/rounds.service.ts index de7d608..b7ebc20 100644 --- a/apps/api/src/modules/rounds/rounds.service.ts +++ b/apps/api/src/modules/rounds/rounds.service.ts @@ -4,17 +4,34 @@ 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 { ChecklistAnswer, Checkpoint, CheckpointVisit, Media, Route, Round, RouteSnapshot, 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 { buildSnapshot } from '../routes/routes.service'; import { WebhooksService } from '../webhooks/webhooks.service'; const CLOSED: RoundStatus[] = ['completed', 'partial', 'missed', 'cancelled']; +/** + * Ответы по чеклистам точки. Берём пункты из снимка обхода (а не из тела запроса): в отчёт попадают + * все пункты, неотмеченные — с checked: false. Галочки на пункты не из этой точки игнорируем. + */ +function checklistAnswers(cp: RouteSnapshot['checkpoints'][number] | undefined, raw: unknown): ChecklistAnswer[] { + const lists = cp?.checklists ?? []; + if (!lists.length) return []; + if (raw !== undefined && raw !== null && !Array.isArray(raw)) throw new BadRequestException('checklist: нужен массив'); + const checked = new Set(); + for (const a of (raw as unknown[] | undefined) ?? []) { + if (!a || typeof a !== 'object') continue; + const { checklistId, itemId, checked: isChecked } = a as Record; + if (isChecked === true && typeof checklistId === 'string' && typeof itemId === 'string') checked.add(`${checklistId}:${itemId}`); + } + return lists.flatMap((l) => l.items.map((i) => ({ checklistId: l.id, itemId: i.id, text: i.text, checked: checked.has(`${l.id}:${i.id}`) }))); +} + @Injectable() export class RoundsService { private readonly logger = new Logger(RoundsService.name); @@ -105,7 +122,7 @@ export class RoundsService { 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 snapshot = await buildSnapshot(this.db, route, checkpoints); const start = timestamp(o, 'clientStartedAt') ?? new Date(); const round = await this.db.getRepository(Round).save({ ...(id ? { id } : {}), @@ -163,6 +180,9 @@ export class RoundsService { if (skipped) flags.push('out_of_order'); } + const checklist = checklistAnswers(cp, o.checklist); + if (checklist.some((a) => !a.checked)) flags.push('checklist_incomplete'); + const mediaIds = uuidArray(o, 'mediaIds') ?? []; const isIssue = bool(o, 'isIssue') ?? false; @@ -180,6 +200,7 @@ export class RoundsService { flags, note: str(o, 'note', { max: 5000 }) ?? null, isIssue, + checklist, clientCreatedAt, }); if (mediaIds.length) { @@ -336,6 +357,7 @@ export class RoundsService { flags: v.flags, note: v.note, isIssue: v.isIssue, + checklist: v.checklist ?? [], clientCreatedAt: v.clientCreatedAt, createdAt: v.createdAt, media: mediaViews.filter((m) => m.visitId === v.id), diff --git a/apps/api/src/modules/routes/routes.service.ts b/apps/api/src/modules/routes/routes.service.ts index 1b3205b..8391669 100644 --- a/apps/api/src/modules/routes/routes.service.ts +++ b/apps/api/src/modules/routes/routes.service.ts @@ -2,15 +2,54 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm 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 { Checkpoint, Route, RouteSnapshot, SnapshotChecklist } from '../../entities'; import { AccessService } from '../../common/access.service'; -import { AuthUser } from '../../common/auth'; +import { AuthUser, requireOrg } from '../../common/auth'; import { assertLineString, assertPoint } from '../../common/geo'; -import { bool, int, isUuid, obj, oneOf, reqStr, str, uuid } from '../../common/input'; +import { bool, int, isUuid, obj, oneOf, reqStr, str, uuid, uuidArray } from '../../common/input'; -type CheckpointInput = Omit & { id?: string }; +// checklistIds не пришли (старый клиент) — чеклисты точки не трогаем. +type CheckpointInput = Omit & { id?: string; checklistIds?: string[] }; -export function snapshotOf(route: Route, checkpoints: Checkpoint[]): RouteSnapshot { +type Queryable = { query: (sql: string, params?: unknown[]) => Promise }; + +/** Чеклисты точек с пунктами по порядку — для снимка маршрута и формы маршрута. Архивные не берём. */ +export async function checklistsOf(db: Queryable, checkpointIds: string[]): Promise> { + const out = new Map(); + if (!checkpointIds.length) return out; + const rows: { checkpoint_id: string; id: string; name: string; items: { id: string; text: string }[] }[] = await db.query( + `SELECT cc.checkpoint_id, c.id, c.name, + COALESCE(json_agg(json_build_object('id', i.id, 'text', i.text) ORDER BY e.position) FILTER (WHERE i.id IS NOT NULL), '[]') AS items + FROM checkpoint_checklists cc + JOIN checklists c ON c.id = cc.checklist_id AND c.archived_at IS NULL + LEFT JOIN checklist_entries e ON e.checklist_id = c.id + LEFT JOIN checklist_items i ON i.id = e.item_id + WHERE cc.checkpoint_id = ANY($1) + GROUP BY cc.checkpoint_id, c.id, c.name, cc.position + ORDER BY cc.checkpoint_id, cc.position`, + [checkpointIds], + ); + for (const r of rows) { + const list = out.get(r.checkpoint_id) ?? []; + list.push({ id: r.id, name: r.name, items: r.items }); + out.set(r.checkpoint_id, list); + } + return out; +} + +/** Снимок маршрута для обхода: точки и их чеклисты на момент создания обхода. */ +export async function buildSnapshot(db: Queryable, route: Route, checkpoints: Checkpoint[]): Promise { + const active = checkpoints.filter((c) => !c.archivedAt); + return snapshotOf(route, active, await checklistsOf(db, active.map((c) => c.id))); +} + +// Пункт, общий для двух чеклистов одной точки, охранник отмечает один раз — в первом из них. +function withoutRepeats(lists: SnapshotChecklist[]): SnapshotChecklist[] { + const seen = new Set(); + return lists.map((l) => ({ ...l, items: l.items.filter((i) => !seen.has(i.id) && (seen.add(i.id), true)) })); +} + +function snapshotOf(route: Route, checkpoints: Checkpoint[], checklists: Map): RouteSnapshot { return { version: route.version, name: route.name, @@ -27,6 +66,7 @@ export function snapshotOf(route: Route, checkpoints: Checkpoint[]): RouteSnapsh radiusM: c.radiusM, mediaRequired: c.mediaRequired, instruction: c.instruction, + checklists: withoutRepeats(checklists.get(c.id) ?? []), })), }; } @@ -44,7 +84,14 @@ export class RoutesService { 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) })); + const lists = await checklistsOf(this.db, cps.map((c) => c.id)); + return routes.map((r) => ({ + ...r, + // checklists — для экрана охранника: внеплановый обход офлайн собирается из этого списка. + checkpoints: cps + .filter((c) => c.routeId === r.id) + .map((c) => ({ ...c, checklistIds: (lists.get(c.id) ?? []).map((l) => l.id), checklists: withoutRepeats(lists.get(c.id) ?? []) })), + })); } async list(user: AuthUser, siteId?: string) { @@ -82,6 +129,7 @@ export class RoutesService { radiusM: int(c, 'radiusM', { min: 1, max: 5000 }) ?? 30, mediaRequired: oneOf(c, 'mediaRequired', MEDIA_REQUIREMENTS) ?? 'photo', instruction: str(c, 'instruction', { max: 2000 }) ?? null, + checklistIds: uuidArray(c, 'checklistIds'), }; }); return { @@ -111,8 +159,16 @@ export class RoutesService { return out; } - private async saveCheckpoints(m: EntityManager, routeId: string, input: CheckpointInput[]) { + private async saveCheckpoints(m: EntityManager, orgId: string, routeId: string, input: CheckpointInput[]) { const repo = m.getRepository(Checkpoint); + const wanted = [...new Set(input.flatMap((c) => c.checklistIds ?? []))]; + if (wanted.length) { + const found: { id: string }[] = await m.query( + `SELECT id FROM checklists WHERE id = ANY($1) AND organization_id = $2 AND archived_at IS NULL`, + [wanted, orgId], + ); + if (found.length !== wanted.length) throw new BadRequestException('Чеклист не найден или в архиве'); + } 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)); @@ -130,16 +186,27 @@ export class RoutesService { } 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 }); + const { id, checklistIds, ...data } = c; + let cpId: string; + if (id && known.has(id)) { + await repo.update({ id }, data); + cpId = id; + } else { + cpId = (await repo.insert({ ...data, routeId })).identifiers[0].id as string; + } + if (checklistIds !== undefined) { + await m.query(`DELETE FROM checkpoint_checklists WHERE checkpoint_id = $1`, [cpId]); + for (const [i, checklistId] of checklistIds.entries()) { + await m.query(`INSERT INTO checkpoint_checklists (checkpoint_id, checklist_id, position) VALUES ($1, $2, $3)`, [cpId, checklistId, i + 1]); + } + } } return repo.find({ where: { routeId, archivedAt: IsNull() }, order: { position: 'ASC' } }); } // Ещё не начатые обходы получают новую версию маршрута, начатые и прошлые остаются как были. private async refreshPlannedRounds(m: EntityManager, route: Route, checkpoints: Checkpoint[]) { - const snapshot = snapshotOf(route, checkpoints); + const snapshot = await buildSnapshot(m, 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()`, @@ -162,7 +229,7 @@ export class RoutesService { expectedMinutes: data.expectedMinutes ?? null, strictOrder: data.strictOrder ?? false, }); - await this.saveCheckpoints(m, route.id, data.checkpoints ?? []); + await this.saveCheckpoints(m, requireOrg(user), route.id, data.checkpoints ?? []); return route.id; }); return this.get(user, id); @@ -182,7 +249,7 @@ export class RoutesService { 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 this.saveCheckpoints(m, requireOrg(user), 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 }); @@ -192,6 +259,16 @@ export class RoutesService { return this.get(user, id); } + /** Чеклист поменяли: ещё не начатые обходы этих маршрутов получают новый снимок. Версия маршрута не меняется. */ + async refreshRoutes(m: EntityManager, routeIds: string[]) { + for (const routeId of routeIds) { + const route = await m.getRepository(Route).findOneBy({ id: routeId }); + if (!route || route.archivedAt) continue; + const checkpoints = await m.getRepository(Checkpoint).find({ where: { routeId, archivedAt: IsNull() } }); + await this.refreshPlannedRounds(m, route, checkpoints); + } + } + async archive(user: AuthUser, id: string) { await this.route(user, id); await this.db.getRepository(Route).update({ id }, { archivedAt: new Date() }); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f970858..db2ac87 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -4,6 +4,7 @@ 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 { ChecklistsPage } from './pages/ChecklistsPage'; import { EmployeesPage } from './pages/EmployeesPage'; import { IntegrationsPage } from './pages/IntegrationsPage'; import { LoginPage } from './pages/LoginPage'; @@ -52,6 +53,8 @@ function Gate() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/apps/web/src/api/types.ts b/apps/web/src/api/types.ts index 2c38734..1b821d8 100644 --- a/apps/web/src/api/types.ts +++ b/apps/web/src/api/types.ts @@ -34,6 +34,9 @@ export type Site = { roundsToday?: number; }; +// Чеклист в снимке маршрута / у точки: пункты по порядку. +export type PointChecklist = { id: string; name: string; items: { id: string; text: string }[] }; + export type Checkpoint = { id?: string; position: number; @@ -42,8 +45,25 @@ export type Checkpoint = { radiusM: number; mediaRequired: MediaRequirement; instruction: string | null; + checklistIds?: string[]; + // В снимках обходов до появления чеклистов поля нет. + checklists?: PointChecklist[]; }; +export type Checklist = { + id: string; + name: string; + description: string | null; + updatedAt: string; + items: { id: string; text: string; usedIn: number }[]; + checkpointsCount: number; + routes: { id: string; name: string; siteName: string }[]; +}; + +export type ChecklistItemRow = { id: string; text: string; usedIn: number }; + +export type ChecklistAnswer = { checklistId: string; itemId: string; text: string; checked: boolean }; + export type Route = { id: string; siteId: string; @@ -116,6 +136,7 @@ export type Visit = { flags: string[]; note: string | null; isIssue: boolean; + checklist?: ChecklistAnswer[]; clientCreatedAt: string; createdAt: string; media: MediaView[]; diff --git a/apps/web/src/guard/RoundScreen.tsx b/apps/web/src/guard/RoundScreen.tsx index 63f7128..d6736c7 100644 --- a/apps/web/src/guard/RoundScreen.tsx +++ b/apps/web/src/guard/RoundScreen.tsx @@ -4,6 +4,7 @@ import { Alert, Box, Button, + Checkbox, Chip, Dialog, DialogActions, @@ -41,6 +42,9 @@ import { Op, enqueue } from './queue'; const ll = (c: [number, number]) => [c[1], c[0]] as [number, number]; type Cp = LocalRound['route']['checkpoints'][number]; +// Все пункты чеклистов точки (в снимке общий пункт уже встречается один раз). +const itemsOf = (cp?: Cp) => (cp?.checklists ?? []).flatMap((l) => l.items.map((i) => ({ checklistId: l.id, itemId: i.id, key: `${l.id}:${i.id}` }))); + function MeLayer({ fix }: { fix: Fix | null }) { if (!fix) return null; return ( @@ -162,6 +166,7 @@ export function RoundScreen() { Следующая точка · {next.position} {next.name} {next.instruction && {next.instruction}} + {itemsOf(next).length > 0 && Чеклист: {itemsOf(next).length} п.} {next.mediaRequired !== 'none' && Нужно: {MEDIA_REQUIREMENT_LABELS[next.mediaRequired].toLowerCase()}} ) : ( @@ -211,11 +216,25 @@ function MarkDialog({ round, cps, done, initial, fix, onClose }: { const [files, setFiles] = useState([]); const [note, setNote] = useState(''); const [issue, setIssue] = useState(initial.issue); + const [checks, setChecks] = useState>(new Set()); const [busy, setBusy] = useState(false); const photoRef = useRef(null); const videoRef = useRef(null); const galleryRef = useRef(null); const cp = cps.find((c) => c.id === checkpointId); + const items = itemsOf(cp); + const checkedCount = items.filter((i) => checks.has(i.key)).length; + const pickCheckpoint = (id: string | null) => { + setCheckpointId(id); + setChecks(new Set()); + }; + const toggle = (key: string) => + setChecks((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); const add = (list: FileList | null) => { if (!list) return; @@ -272,6 +291,7 @@ function MarkDialog({ round, cps, done, initial, fix, onClose }: { note: note.trim() || null, isIssue: issue, mediaIds: files.map((f) => f.id), + checklist: items.map((i) => ({ checklistId: i.checklistId, itemId: i.itemId, checked: checks.has(i.key) })), }, }); await enqueue(ops, blobs); @@ -294,12 +314,12 @@ function MarkDialog({ round, cps, done, initial, fix, onClose }: { - {issue && setCheckpointId(null)} />} + {issue && pickCheckpoint(null)} />} {cps.map((c) => ( setCheckpointId(c.id)} + onClick={() => pickCheckpoint(c.id)} color={checkpointId === c.id ? 'primary' : 'default'} variant={done.has(c.id) && checkpointId !== c.id ? 'outlined' : 'filled'} icon={done.has(c.id) ? : undefined} @@ -328,6 +348,37 @@ function MarkDialog({ round, cps, done, initial, fix, onClose }: { + {items.length > 0 && cp && ( + + + Чеклист + + отмечено {checkedCount} из {items.length} + + + {(cp.checklists ?? []).map( + (l) => + l.items.length > 0 && ( + + {(cp.checklists ?? []).length > 1 && {l.name}} + {l.items.map((it) => { + const key = `${l.id}:${it.id}`; + return ( + toggle(key)} + sx={{ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.25, cursor: 'pointer', borderBottom: `1px solid ${colors.line}`, userSelect: 'none' }} + > + + {it.text} + + ); + })} + + ), + )} + + )} Фото и видео @@ -372,6 +423,11 @@ function MarkDialog({ round, cps, done, initial, fix, onClose }: { {!marked && Поставьте метку на карте.} {marked && !mediaOk && Для этой точки нужно: {MEDIA_REQUIREMENT_LABELS[need].toLowerCase()}.} + {marked && mediaOk && items.length > 0 && checkedCount < items.length && ( + + Отмечено {checkedCount} из {items.length} пунктов чеклиста — в отчёте будет пометка. + + )} diff --git a/apps/web/src/guard/data.ts b/apps/web/src/guard/data.ts index f221b12..4a05713 100644 --- a/apps/web/src/guard/data.ts +++ b/apps/web/src/guard/data.ts @@ -88,6 +88,10 @@ export function overlay(rounds: RoundDetail[], ops: Op[], userId: string, extra: flags: [], note: op.body.note, isIssue: op.body.isIssue, + checklist: (op.body.checklist ?? []).map((a) => ({ + ...a, + text: r.route.checkpoints.find((c) => c.id === op.body.checkpointId)?.checklists?.flatMap((l) => l.items).find((i) => i.id === a.itemId)?.text ?? '', + })), clientCreatedAt: op.body.clientCreatedAt, createdAt: op.body.clientCreatedAt, media: [], diff --git a/apps/web/src/guard/queue.ts b/apps/web/src/guard/queue.ts index 2dfb137..0573063 100644 --- a/apps/web/src/guard/queue.ts +++ b/apps/web/src/guard/queue.ts @@ -14,6 +14,8 @@ export type VisitBody = { note: string | null; isIssue: boolean; mediaIds: string[]; + // Галочки по чеклистам точки. В операциях, записанных до появления чеклистов, поля нет. + checklist?: { checklistId: string; itemId: string; checked: boolean }[]; }; export type Op = diff --git a/apps/web/src/lib/queries.ts b/apps/web/src/lib/queries.ts index f045279..ef9bb0e 100644 --- a/apps/web/src/lib/queries.ts +++ b/apps/web/src/lib/queries.ts @@ -1,6 +1,6 @@ import { useQuery } from '@tanstack/react-query'; import { api } from '../api/client'; -import type { Employee, Route, Schedule, Site } from '../api/types'; +import type { Checklist, Employee, Route, Schedule, Site } from '../api/types'; export const useSites = () => useQuery({ queryKey: ['sites'], queryFn: () => api('/sites') }); @@ -23,3 +23,6 @@ export const useEmployees = (siteId?: string, enabled = true) => queryFn: () => api(`/users${siteId ? `?siteId=${siteId}` : ''}`), enabled, }); + +export const useChecklists = (enabled = true) => + useQuery({ queryKey: ['checklists'], queryFn: () => api('/checklists'), enabled }); diff --git a/apps/web/src/pages/ChecklistsPage.tsx b/apps/web/src/pages/ChecklistsPage.tsx new file mode 100644 index 0000000..1c1d3a0 --- /dev/null +++ b/apps/web/src/pages/ChecklistsPage.tsx @@ -0,0 +1,386 @@ +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { + Alert, + Autocomplete, + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Grid2 as Grid, + IconButton, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import ChecklistIcon from '@mui/icons-material/Checklist'; +import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; +import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import DeleteIcon from '@mui/icons-material/DeleteOutline'; +import EditIcon from '@mui/icons-material/EditOutlined'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { api } from '../api/client'; +import type { Checklist, ChecklistItemRow } from '../api/types'; +import { useAuth } from '../auth/AuthContext'; +import { useChecklists } from '../lib/queries'; +import { useConfirm, useNotify } from '../ui/feedback'; +import { Card, EmptyState, ErrorState, LoadingState, PageHeader } from '../ui/layout'; +import { colors } from '../ui/theme'; + +// Пункт в форме: сохранённый (id) или новый текст. usedIn — во скольких чеклистах компании он есть. +type ItemForm = { key: string; id?: string; text: string; usedIn: number }; +type Form = { name: string; description: string; items: ItemForm[] }; + +const toForm = (c: Checklist): Form => ({ + name: c.name, + description: c.description || '', + items: c.items.map((i) => ({ key: i.id, id: i.id, text: i.text, usedIn: i.usedIn })), +}); + +// Сравнение текстов — как на сервере: без регистра, пробелов по краям и «ё»/«е». +const norm = (s: string) => s.trim().replace(/\s+/g, ' ').toLowerCase().replace(/ё/g, 'е'); + +export function ChecklistsPage() { + const { checklistId } = useParams(); + const navigate = useNavigate(); + const { can } = useAuth(); + const list = useChecklists(); + + return ( + <> + } onClick={() => navigate('/checklists/new')}> + Новый чеклист + + ) + } + /> + {list.isLoading ? ( + + ) : list.isError ? ( + list.refetch()} /> + ) : ( + + + + {list.data?.length === 0 && checklistId !== 'new' && ( + Чеклистов пока нет. + )} + {list.data?.map((c) => ( + navigate(`/checklists/${c.id}`)} + sx={{ + p: 1.75, + borderRadius: '12px', + cursor: 'pointer', + bgcolor: '#fff', + border: `1px solid ${checklistId === c.id ? colors.brand : colors.line}`, + boxShadow: checklistId === c.id ? `0 0 0 3px ${colors.brandSoft}` : 'none', + }} + > + {c.name} + + Пунктов: {c.items.length} · {c.checkpointsCount ? `на точках: ${c.checkpointsCount}` : 'не прикреплён к точкам'} + + + ))} + + + + {checklistId ? ( + c.id === checklistId)} /> + ) : ( + + + {can('routes.manage') ? 'или создайте новый. Чеклисты прикрепляются к контрольным точкам на странице «Маршруты».' : null} + + + )} + + + )} + + ); +} + +function ChecklistForm({ checklistId, existing }: { checklistId: string; existing?: Checklist }) { + const isNew = checklistId === 'new'; + const navigate = useNavigate(); + const qc = useQueryClient(); + const notify = useNotify(); + const confirm = useConfirm(); + const { can } = useAuth(); + const editable = can('routes.manage'); + const [form, setForm] = useState
(() => (existing ? toForm(existing) : { name: '', description: '', items: [] })); + const [renaming, setRenaming] = useState(null); + + // Список загрузился уже после открытия формы — подставляем. + useEffect(() => { + if (existing && !form.name && !form.items.length) setForm(toForm(existing)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [existing]); + + const save = useMutation({ + mutationFn: () => { + const body = { + name: form.name.trim(), + description: form.description.trim() || null, + items: form.items.map((i) => (i.id ? { id: i.id } : { text: i.text.trim() })), + }; + return isNew ? api('/checklists', { body }) : api(`/checklists/${checklistId}`, { method: 'PUT', body }); + }, + onSuccess: (saved) => { + // Сразу кладём в список, чтобы после перехода на /checklists/:id форма не мигнула «не найден». + qc.setQueryData(['checklists'], (old) => (old ? [...old.filter((c) => c.id !== saved.id), saved] : [saved])); + qc.invalidateQueries({ queryKey: ['checklists'] }); + qc.invalidateQueries({ queryKey: ['routes'] }); + setForm(toForm(saved)); + notify.success('Чеклист сохранён'); + if (isNew) navigate(`/checklists/${saved.id}`, { replace: true }); + }, + onError: notify.error, + }); + + const archive = async () => { + const ok = await confirm({ + title: 'Убрать чеклист в архив?', + message: existing?.checkpointsCount + ? `Он снимется с точек (${existing.checkpointsCount}). Прошлые отчёты сохранятся.` + : 'Прошлые отчёты сохранятся.', + confirmText: 'В архив', + danger: true, + }); + if (!ok) return; + try { + await api(`/checklists/${checklistId}/archive`, { method: 'POST' }); + qc.invalidateQueries({ queryKey: ['checklists'] }); + qc.invalidateQueries({ queryKey: ['routes'] }); + notify.success('Чеклист в архиве'); + navigate('/checklists'); + } catch (e) { + notify.error(e); + } + }; + + const add = (picked: ChecklistItemRow | string) => { + const text = typeof picked === 'string' ? picked.trim().replace(/\s+/g, ' ') : picked.text; + if (!text) return; + if (form.items.some((i) => norm(i.text) === norm(text))) { + notify.info('Такой пункт уже есть в этом чеклисте'); + return; + } + const item: ItemForm = + typeof picked === 'string' + ? { key: crypto.randomUUID(), text, usedIn: 0 } + : { key: picked.id, id: picked.id, text: picked.text, usedIn: picked.usedIn }; + setForm((f) => ({ ...f, items: [...f.items, item] })); + }; + + const move = (i: number, dir: -1 | 1) => + setForm((f) => { + const items = [...f.items]; + const j = i + dir; + if (j < 0 || j >= items.length) return f; + [items[i], items[j]] = [items[j], items[i]]; + return { ...f, items }; + }); + + if (!isNew && !existing) return ; + + return ( + + + + setForm({ ...form, name: e.target.value })} required disabled={!editable} placeholder="Например: Периметр ночью" /> + setForm({ ...form, description: e.target.value })} disabled={!editable} /> + + + + + + {form.items.map((it, i) => ( + + {i + 1}. + + {it.text} + {it.id && it.usedIn > 1 && ( + Общий пункт: есть ещё в {it.usedIn - 1} чеклист{it.usedIn - 1 === 1 ? 'е' : 'ах'} + )} + {!it.id && Новый пункт — появится после сохранения} + + {editable && ( + + {it.id && ( + + setRenaming(it)}> + + + + )} + + + move(i, -1)} disabled={i === 0}> + + + + + + + move(i, 1)} disabled={i === form.items.length - 1}> + + + + + + setForm((f) => ({ ...f, items: f.items.filter((x) => x.key !== it.key) }))}> + + + + + )} + + ))} + {editable && i.id).filter((x): x is string => !!x)} />} + + + + {!isNew && existing && ( + + {existing.routes.length ? ( + + {existing.routes.map((r) => ( +
  • + + {r.siteName} · маршрут «{r.name}» + +
  • + ))} +
    + ) : ( + Не прикреплён ни к одной точке. Прикрепить можно на странице «Маршруты», у контрольной точки. + )} +
    + )} + + {editable && ( + + {!isNew && ( + + )} + + + )} + + {renaming && ( + setRenaming(null)} + onDone={(text) => { + setForm((f) => ({ ...f, items: f.items.map((x) => (x.id === renaming.id ? { ...x, text } : x)) })); + qc.invalidateQueries({ queryKey: ['checklists'] }); + qc.invalidateQueries({ queryKey: ['routes'] }); + setRenaming(null); + }} + /> + )} +
    + ); +} + +// Поле добавления пункта: подсказки из справочника компании, Enter — добавить свой текст. +function ItemPicker({ onPick, exclude }: { onPick: (v: ChecklistItemRow | string) => void; exclude: string[] }) { + const [input, setInput] = useState(''); + const [options, setOptions] = useState([]); + + useEffect(() => { + const ctrl = new AbortController(); + const t = window.setTimeout(() => { + api(`/checklist-items?q=${encodeURIComponent(input.trim())}`, { signal: ctrl.signal }) + .then(setOptions) + .catch(() => undefined); + }, 250); + return () => { + ctrl.abort(); + window.clearTimeout(t); + }; + }, [input]); + + return ( + + freeSolo + size="small" + options={options.filter((o) => !exclude.includes(o.id))} + filterOptions={(x) => x} + getOptionLabel={(o) => (typeof o === 'string' ? o : o.text)} + inputValue={input} + value={null} + onInputChange={(_, v, reason) => reason !== 'reset' && setInput(v)} + onChange={(_, v) => { + if (!v) return; + onPick(v); + setInput(''); + }} + renderOption={(props, o) => ( +
  • + + {o.text} + {o.usedIn > 0 && в чеклистах: {o.usedIn}} + +
  • + )} + renderInput={(params) => ( + + )} + sx={{ mt: 0.5 }} + /> + ); +} + +function RenameDialog({ item, onClose, onDone }: { item: ItemForm; onClose: () => void; onDone: (text: string) => void }) { + const notify = useNotify(); + const [text, setText] = useState(item.text); + const [busy, setBusy] = useState(false); + const save = async () => { + setBusy(true); + try { + const r = await api<{ id: string; text: string }>(`/checklist-items/${item.id}`, { method: 'PATCH', body: { text: text.trim() } }); + notify.success('Пункт переименован'); + onDone(r.text); + } catch (e) { + notify.error(e); + } finally { + setBusy(false); + } + }; + return ( + + Переименовать пункт + + {item.usedIn > 1 && ( + + Пункт общий: новое название будет во всех чеклистах, где он есть ({item.usedIn}). В прошлых отчётах останется старое. + + )} + setText(e.target.value)} sx={{ mt: item.usedIn > 1 ? 0 : 1 }} /> + + + + + + + ); +} diff --git a/apps/web/src/pages/RoundDetailPage.tsx b/apps/web/src/pages/RoundDetailPage.tsx index 9de7c0c..189d0ed 100644 --- a/apps/web/src/pages/RoundDetailPage.tsx +++ b/apps/web/src/pages/RoundDetailPage.tsx @@ -2,6 +2,8 @@ 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 CheckBoxIcon from '@mui/icons-material/CheckBox'; +import CheckBoxBlankIcon from '@mui/icons-material/CheckBoxOutlineBlank'; import { GeoJSON, Marker, Polyline, Tooltip } from 'react-leaflet'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { VISIT_FLAG_LABELS, type VisitFlag } from '@patrol/shared'; @@ -175,6 +177,25 @@ export function RoundDetailPage() { {v.note && {v.note}} + {v.checklist && v.checklist.length > 0 && ( + + + Чеклист: отмечено {v.checklist.filter((a) => a.checked).length} из {v.checklist.length} + + + {v.checklist.map((a) => ( + + {a.checked ? ( + + ) : ( + + )} + {a.text} + + ))} + + + )} {v.media.length > 0 && ( diff --git a/apps/web/src/pages/RoutesPage.tsx b/apps/web/src/pages/RoutesPage.tsx index e7d24c6..6154323 100644 --- a/apps/web/src/pages/RoutesPage.tsx +++ b/apps/web/src/pages/RoutesPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Alert, + Autocomplete, Box, Button, Chip, @@ -29,7 +30,7 @@ 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 { useChecklists, useRoutes, useSites } from '../lib/queries'; import { BaseMap, FitTo } from '../map/base'; import { EditorMode, RouteEditor } from '../map/RouteEditor'; import { useConfirm, useNotify } from '../ui/feedback'; @@ -44,6 +45,7 @@ type CpForm = { radiusM: number; mediaRequired: MediaRequirement; instruction: string; + checklistIds: string[]; }; type Form = { name: string; description: string; expectedMinutes: string; strictOrder: boolean; path: GeoJsonLineString | null; checkpoints: CpForm[] }; @@ -67,6 +69,7 @@ function toForm(r: Route): Form { radiusM: c.radiusM, mediaRequired: c.mediaRequired, instruction: c.instruction || '', + checklistIds: c.checklistIds ?? [], })), }; } @@ -171,6 +174,8 @@ function RouteForm({ routeId, siteId, perimeter }: { routeId: string; siteId: st const editable = can('routes.manage'); const route = useQuery({ queryKey: ['route', routeId], queryFn: () => api(`/routes/${routeId}`), enabled: !isNew }); + const checklists = useChecklists(); + const checklistById = useMemo(() => new Map((checklists.data ?? []).map((c) => [c.id, c])), [checklists.data]); const [form, setForm] = useState(emptyForm); const [loadedKey, setLoadedKey] = useState(isNew ? 'new' : ''); const [mode, setMode] = useState('points'); @@ -201,12 +206,14 @@ function RouteForm({ routeId, siteId, perimeter }: { routeId: string; siteId: st radiusM: c.radiusM, mediaRequired: c.mediaRequired, instruction: c.instruction.trim() || null, + checklistIds: c.checklistIds, })), }; return isNew ? api('/routes', { body }) : api(`/routes/${routeId}`, { method: 'PUT', body }); }, onSuccess: (saved) => { qc.invalidateQueries({ queryKey: ['routes'] }); + qc.invalidateQueries({ queryKey: ['checklists'] }); qc.setQueryData(['route', saved.id], saved); setWarnings(saved.warnings || []); notify.success(saved.warnings?.length ? 'Маршрут сохранён, есть предупреждения' : 'Маршрут сохранён'); @@ -303,7 +310,7 @@ function RouteForm({ routeId, siteId, perimeter }: { routeId: string; siteId: st ...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; + return prev ? { ...prev, location: c.location } : ({ ...c, mediaRequired: 'photo', instruction: '', checklistIds: [] } as CpForm); }), })) } @@ -370,6 +377,9 @@ function RouteForm({ routeId, siteId, perimeter }: { routeId: string; siteId: st setCp(i, { name: e.target.value })} disabled={!editable} placeholder="Название точки" /> + {selected !== i && c.checklistIds.length > 0 && ( + + )} {editable && ( @@ -427,6 +437,39 @@ function RouteForm({ routeId, siteId, perimeter }: { routeId: string; siteId: st placeholder="Например: дверь склада закрыта, пломба цела" /> + + x.id)} + value={c.checklistIds} + onChange={(_, v) => setCp(i, { checklistIds: v })} + getOptionLabel={(id) => checklistById.get(id)?.name ?? 'Чеклист в архиве'} + renderOption={(props, id) => { + const cl = checklistById.get(id); + return ( +
  • + + {cl?.name} + пунктов: {cl?.items.length ?? 0} + +
  • + ); + }} + noOptionsText="Чеклистов нет — создайте их на странице «Чеклисты»" + renderInput={(params) => } + /> + {c.checklistIds.length > 0 && ( + + {[...new Map(c.checklistIds.flatMap((id) => checklistById.get(id)?.items ?? []).map((it) => [it.id, it])).values()].map((it) => ( + + ☐ {it.text} + + ))} + + )} +
    )}
    diff --git a/apps/web/src/shell/Shell.tsx b/apps/web/src/shell/Shell.tsx index 0ed8e91..5563280 100644 --- a/apps/web/src/shell/Shell.tsx +++ b/apps/web/src/shell/Shell.tsx @@ -5,6 +5,7 @@ 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 ChecklistIcon from '@mui/icons-material/Checklist'; import AssessmentIcon from '@mui/icons-material/AssessmentOutlined'; import GroupIcon from '@mui/icons-material/GroupOutlined'; import WebhookIcon from '@mui/icons-material/WebhookOutlined'; @@ -23,6 +24,7 @@ type NavItem = { to: string; label: string; icon: SvgIconComponent; allowed: (me const NAV: NavItem[] = [ { to: '/sites', label: 'Мои объекты', icon: ApartmentIcon, allowed: (me, can) => !!me.organization && can('sites.view') }, + { to: '/checklists', label: 'Чеклисты', icon: ChecklistIcon, allowed: (me, can) => !!me.organization && can('sites.view') }, { to: '/routes', label: 'Маршруты', icon: RouteIcon, allowed: (me, can) => !!me.organization && can('sites.view') }, { to: '/schedule', label: 'График обходов', icon: CalendarMonthIcon, allowed: (me, can) => !!me.organization && can('sites.view') }, { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b6076e0..d0e563f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -82,7 +82,7 @@ export const MEDIA_REQUIREMENT_LABELS: Record = { photo_or_video: 'Фото или видео', }; -export const VISIT_FLAGS = ['outside_radius', 'gps_far_from_mark', 'no_gps', 'late', 'out_of_order', 'clock_skew'] as const; +export const VISIT_FLAGS = ['outside_radius', 'gps_far_from_mark', 'no_gps', 'late', 'out_of_order', 'clock_skew', 'checklist_incomplete'] as const; export type VisitFlag = (typeof VISIT_FLAGS)[number]; export const VISIT_FLAG_LABELS: Record = { @@ -92,6 +92,7 @@ export const VISIT_FLAG_LABELS: Record = { late: 'Позже окна обхода', out_of_order: 'Не по порядку', clock_skew: 'Время телефона расходится с сервером', + checklist_incomplete: 'Чеклист заполнен не полностью', }; export const WEBHOOK_EVENTS = ['round.completed', 'round.partial', 'round.missed', 'visit.issue'] as const;