feat/add-checklist-entities
This commit is contained in:
parent
d574936986
commit
86cf53502b
@ -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 {}
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
66
apps/api/src/database/migrations/1761200000000-Checklists.ts
Normal file
66
apps/api/src/database/migrations/1761200000000-Checklists.ts
Normal file
@ -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<void> {
|
||||
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<void> {
|
||||
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`);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
50
apps/api/src/modules/checklists/checklists.controller.ts
Normal file
50
apps/api/src/modules/checklists/checklists.controller.ts
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
213
apps/api/src/modules/checklists/checklists.service.ts
Normal file
213
apps/api/src/modules/checklists/checklists.service.ts
Normal file
@ -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<ChecklistRow[]> {
|
||||
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<string[]> {
|
||||
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<string[]> {
|
||||
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<Checklist> = { 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 };
|
||||
}
|
||||
}
|
||||
@ -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) {
|
||||
|
||||
@ -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<string>();
|
||||
for (const a of (raw as unknown[] | undefined) ?? []) {
|
||||
if (!a || typeof a !== 'object') continue;
|
||||
const { checklistId, itemId, checked: isChecked } = a as Record<string, unknown>;
|
||||
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),
|
||||
|
||||
@ -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<Checkpoint, 'routeId' | 'archivedAt' | 'id'> & { id?: string };
|
||||
// checklistIds не пришли (старый клиент) — чеклисты точки не трогаем.
|
||||
type CheckpointInput = Omit<Checkpoint, 'routeId' | 'archivedAt' | 'id'> & { id?: string; checklistIds?: string[] };
|
||||
|
||||
export function snapshotOf(route: Route, checkpoints: Checkpoint[]): RouteSnapshot {
|
||||
type Queryable = { query: (sql: string, params?: unknown[]) => Promise<any> };
|
||||
|
||||
/** Чеклисты точек с пунктами по порядку — для снимка маршрута и формы маршрута. Архивные не берём. */
|
||||
export async function checklistsOf(db: Queryable, checkpointIds: string[]): Promise<Map<string, SnapshotChecklist[]>> {
|
||||
const out = new Map<string, SnapshotChecklist[]>();
|
||||
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<RouteSnapshot> {
|
||||
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<string>();
|
||||
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<string, SnapshotChecklist[]>): 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() });
|
||||
|
||||
@ -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() {
|
||||
<Route path="/sites/new" element={<SiteEditPage />} />
|
||||
<Route path="/sites/:id" element={<SiteEditPage />} />
|
||||
<Route path="/schedule" element={<SchedulePage />} />
|
||||
<Route path="/checklists" element={<ChecklistsPage />} />
|
||||
<Route path="/checklists/:checklistId" element={<ChecklistsPage />} />
|
||||
<Route path="/routes" element={<RoutesPage />} />
|
||||
<Route path="/routes/:routeId" element={<RoutesPage />} />
|
||||
<Route path="/reports" element={<ReportsPage />} />
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -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() {
|
||||
<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>}
|
||||
{itemsOf(next).length > 0 && <Typography sx={{ fontSize: 12.5, color: colors.muted, mt: 0.5 }}>Чеклист: {itemsOf(next).length} п.</Typography>}
|
||||
{next.mediaRequired !== 'none' && <Typography sx={{ fontSize: 12.5, color: colors.muted, mt: 0.5 }}>Нужно: {MEDIA_REQUIREMENT_LABELS[next.mediaRequired].toLowerCase()}</Typography>}
|
||||
</Box>
|
||||
) : (
|
||||
@ -211,11 +216,25 @@ function MarkDialog({ round, cps, done, initial, fix, onClose }: {
|
||||
const [files, setFiles] = useState<Picked[]>([]);
|
||||
const [note, setNote] = useState('');
|
||||
const [issue, setIssue] = useState(initial.issue);
|
||||
const [checks, setChecks] = useState<Set<string>>(new Set());
|
||||
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 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 }: {
|
||||
</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)} />}
|
||||
{issue && <Chip label="Без точки" color={checkpointId === null ? 'error' : 'default'} onClick={() => pickCheckpoint(null)} />}
|
||||
{cps.map((c) => (
|
||||
<Chip
|
||||
key={c.id}
|
||||
label={`${c.position}. ${c.name}`}
|
||||
onClick={() => 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) ? <FlagIcon /> : undefined}
|
||||
@ -328,6 +348,37 @@ function MarkDialog({ round, cps, done, initial, fix, onClose }: {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ px: 2, py: 2, display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
{items.length > 0 && cp && (
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
Чеклист
|
||||
<Typography component="span" sx={{ color: checkedCount === items.length ? colors.success : colors.muted, fontSize: 13, ml: 1 }}>
|
||||
отмечено {checkedCount} из {items.length}
|
||||
</Typography>
|
||||
</Typography>
|
||||
{(cp.checklists ?? []).map(
|
||||
(l) =>
|
||||
l.items.length > 0 && (
|
||||
<Box key={l.id} sx={{ mb: 1 }}>
|
||||
{(cp.checklists ?? []).length > 1 && <Typography sx={{ fontSize: 13, color: colors.muted, mt: 1 }}>{l.name}</Typography>}
|
||||
{l.items.map((it) => {
|
||||
const key = `${l.id}:${it.id}`;
|
||||
return (
|
||||
<Box
|
||||
key={key}
|
||||
onClick={() => toggle(key)}
|
||||
sx={{ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.25, cursor: 'pointer', borderBottom: `1px solid ${colors.line}`, userSelect: 'none' }}
|
||||
>
|
||||
<Checkbox checked={checks.has(key)} sx={{ p: 1.25 }} tabIndex={-1} />
|
||||
<Typography sx={{ fontSize: 15.5 }}>{it.text}</Typography>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
),
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 600, mb: 1 }}>
|
||||
Фото и видео
|
||||
@ -372,6 +423,11 @@ function MarkDialog({ round, cps, done, initial, fix, onClose }: {
|
||||
<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>}
|
||||
{marked && mediaOk && items.length > 0 && checkedCount < items.length && (
|
||||
<Typography sx={{ color: colors.warning, fontSize: 13, mb: 1 }}>
|
||||
Отмечено {checkedCount} из {items.length} пунктов чеклиста — в отчёте будет пометка.
|
||||
</Typography>
|
||||
)}
|
||||
<Button fullWidth size="large" variant="contained" color={issue ? 'error' : 'primary'} disabled={!canSave} onClick={save} sx={{ py: 1.4, borderRadius: '14px' }}>
|
||||
{busy ? 'Сохраняем…' : issue ? 'Отправить' : 'Отметить точку'}
|
||||
</Button>
|
||||
|
||||
@ -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: [],
|
||||
|
||||
@ -14,6 +14,8 @@ export type VisitBody = {
|
||||
note: string | null;
|
||||
isIssue: boolean;
|
||||
mediaIds: string[];
|
||||
// Галочки по чеклистам точки. В операциях, записанных до появления чеклистов, поля нет.
|
||||
checklist?: { checklistId: string; itemId: string; checked: boolean }[];
|
||||
};
|
||||
|
||||
export type Op =
|
||||
|
||||
@ -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<Site[]>('/sites') });
|
||||
|
||||
@ -23,3 +23,6 @@ export const useEmployees = (siteId?: string, enabled = true) =>
|
||||
queryFn: () => api<Employee[]>(`/users${siteId ? `?siteId=${siteId}` : ''}`),
|
||||
enabled,
|
||||
});
|
||||
|
||||
export const useChecklists = (enabled = true) =>
|
||||
useQuery({ queryKey: ['checklists'], queryFn: () => api<Checklist[]>('/checklists'), enabled });
|
||||
|
||||
386
apps/web/src/pages/ChecklistsPage.tsx
Normal file
386
apps/web/src/pages/ChecklistsPage.tsx
Normal file
@ -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 (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Чеклисты"
|
||||
subtitle="Списки пунктов, которые охранник отмечает на контрольных точках"
|
||||
actions={
|
||||
can('routes.manage') && (
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/checklists/new')}>
|
||||
Новый чеклист
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{list.isLoading ? (
|
||||
<LoadingState />
|
||||
) : list.isError ? (
|
||||
<ErrorState error={list.error} onRetry={() => list.refetch()} />
|
||||
) : (
|
||||
<Grid container spacing={3}>
|
||||
<Grid size={{ xs: 12, md: 4 }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{list.data?.length === 0 && checklistId !== 'new' && (
|
||||
<Typography sx={{ color: colors.muted, px: 1 }}>Чеклистов пока нет.</Typography>
|
||||
)}
|
||||
{list.data?.map((c) => (
|
||||
<Box
|
||||
key={c.id}
|
||||
onClick={() => 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',
|
||||
}}
|
||||
>
|
||||
<Typography sx={{ fontWeight: 600 }}>{c.name}</Typography>
|
||||
<Typography sx={{ color: colors.muted, fontSize: 13 }}>
|
||||
Пунктов: {c.items.length} · {c.checkpointsCount ? `на точках: ${c.checkpointsCount}` : 'не прикреплён к точкам'}
|
||||
</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 8 }}>
|
||||
{checklistId ? (
|
||||
<ChecklistForm key={checklistId} checklistId={checklistId} existing={list.data?.find((c) => c.id === checklistId)} />
|
||||
) : (
|
||||
<Card>
|
||||
<EmptyState icon={ChecklistIcon} title="Выберите чеклист">
|
||||
{can('routes.manage') ? 'или создайте новый. Чеклисты прикрепляются к контрольным точкам на странице «Маршруты».' : null}
|
||||
</EmptyState>
|
||||
</Card>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<Form>(() => (existing ? toForm(existing) : { name: '', description: '', items: [] }));
|
||||
const [renaming, setRenaming] = useState<ItemForm | null>(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<Checklist>('/checklists', { body }) : api<Checklist>(`/checklists/${checklistId}`, { method: 'PUT', body });
|
||||
},
|
||||
onSuccess: (saved) => {
|
||||
// Сразу кладём в список, чтобы после перехода на /checklists/:id форма не мигнула «не найден».
|
||||
qc.setQueryData<Checklist[]>(['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 <Card><EmptyState title="Чеклист не найден" /></Card>;
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Card title={isNew ? 'Новый чеклист' : 'Чеклист'}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField label="Название" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required disabled={!editable} placeholder="Например: Периметр ночью" />
|
||||
<TextField label="Описание" multiline minRows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} disabled={!editable} />
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Card title="Пункты" subtitle={form.items.length ? `${form.items.length} шт. Охранник ставит галочку напротив каждого` : 'Пока пусто'} flush>
|
||||
<Box sx={{ px: 2, pb: 2, pt: 1.5, display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{form.items.map((it, i) => (
|
||||
<Box key={it.key} sx={{ display: 'flex', alignItems: 'center', gap: 1, p: 1.25, pl: 1.75, border: `1px solid ${colors.line}`, borderRadius: '12px', bgcolor: '#fff' }}>
|
||||
<Typography sx={{ color: colors.muted, fontSize: 13, minWidth: 18 }}>{i + 1}.</Typography>
|
||||
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography sx={{ fontSize: 15 }}>{it.text}</Typography>
|
||||
{it.id && it.usedIn > 1 && (
|
||||
<Typography sx={{ color: colors.muted, fontSize: 12.5 }}>Общий пункт: есть ещё в {it.usedIn - 1} чеклист{it.usedIn - 1 === 1 ? 'е' : 'ах'}</Typography>
|
||||
)}
|
||||
{!it.id && <Typography sx={{ color: colors.muted, fontSize: 12.5 }}>Новый пункт — появится после сохранения</Typography>}
|
||||
</Box>
|
||||
{editable && (
|
||||
<Box sx={{ display: 'flex', flexShrink: 0 }}>
|
||||
{it.id && (
|
||||
<Tooltip title="Переименовать пункт">
|
||||
<IconButton size="small" onClick={() => setRenaming(it)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="Выше">
|
||||
<span>
|
||||
<IconButton size="small" onClick={() => move(i, -1)} disabled={i === 0}>
|
||||
<ArrowUpwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Ниже">
|
||||
<span>
|
||||
<IconButton size="small" onClick={() => move(i, 1)} disabled={i === form.items.length - 1}>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Tooltip title="Убрать из чеклиста">
|
||||
<IconButton size="small" onClick={() => setForm((f) => ({ ...f, items: f.items.filter((x) => x.key !== it.key) }))}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
{editable && <ItemPicker onPick={add} exclude={form.items.map((i) => i.id).filter((x): x is string => !!x)} />}
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{!isNew && existing && (
|
||||
<Card title="Где используется">
|
||||
{existing.routes.length ? (
|
||||
<Box component="ul" sx={{ m: 0, pl: 2.5 }}>
|
||||
{existing.routes.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Typography sx={{ fontSize: 14 }}>
|
||||
{r.siteName} · маршрут «{r.name}»
|
||||
</Typography>
|
||||
</li>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography sx={{ color: colors.muted, fontSize: 14 }}>Не прикреплён ни к одной точке. Прикрепить можно на странице «Маршруты», у контрольной точки.</Typography>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{renaming && (
|
||||
<RenameDialog
|
||||
item={renaming}
|
||||
onClose={() => 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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Поле добавления пункта: подсказки из справочника компании, Enter — добавить свой текст.
|
||||
function ItemPicker({ onPick, exclude }: { onPick: (v: ChecklistItemRow | string) => void; exclude: string[] }) {
|
||||
const [input, setInput] = useState('');
|
||||
const [options, setOptions] = useState<ChecklistItemRow[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
const t = window.setTimeout(() => {
|
||||
api<ChecklistItemRow[]>(`/checklist-items?q=${encodeURIComponent(input.trim())}`, { signal: ctrl.signal })
|
||||
.then(setOptions)
|
||||
.catch(() => undefined);
|
||||
}, 250);
|
||||
return () => {
|
||||
ctrl.abort();
|
||||
window.clearTimeout(t);
|
||||
};
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<Autocomplete<ChecklistItemRow, false, false, true>
|
||||
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) => (
|
||||
<li {...props} key={o.id}>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 14 }}>{o.text}</Typography>
|
||||
{o.usedIn > 0 && <Typography sx={{ fontSize: 12, color: colors.muted }}>в чеклистах: {o.usedIn}</Typography>}
|
||||
</Box>
|
||||
</li>
|
||||
)}
|
||||
renderInput={(params) => (
|
||||
<TextField {...params} placeholder="Добавить пункт: начните вводить и выберите из списка или нажмите Enter" />
|
||||
)}
|
||||
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 (
|
||||
<Dialog open onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Переименовать пункт</DialogTitle>
|
||||
<DialogContent>
|
||||
{item.usedIn > 1 && (
|
||||
<Alert severity="info" sx={{ mb: 2, borderRadius: '10px' }}>
|
||||
Пункт общий: новое название будет во всех чеклистах, где он есть ({item.usedIn}). В прошлых отчётах останется старое.
|
||||
</Alert>
|
||||
)}
|
||||
<TextField autoFocus fullWidth value={text} onChange={(e) => setText(e.target.value)} sx={{ mt: item.usedIn > 1 ? 0 : 1 }} />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Отмена</Button>
|
||||
<Button variant="contained" onClick={save} disabled={!text.trim() || text.trim() === item.text || busy}>
|
||||
Сохранить
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@ -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() {
|
||||
</Box>
|
||||
</Box>
|
||||
{v.note && <Typography sx={{ mt: 1, fontSize: 14 }}>{v.note}</Typography>}
|
||||
{v.checklist && v.checklist.length > 0 && (
|
||||
<Box sx={{ mt: 1.25 }}>
|
||||
<Typography sx={{ fontSize: 13, color: colors.muted, mb: 0.5 }}>
|
||||
Чеклист: отмечено {v.checklist.filter((a) => a.checked).length} из {v.checklist.length}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: { xs: '1fr', md: '1fr 1fr' }, columnGap: 2, rowGap: 0.25 }}>
|
||||
{v.checklist.map((a) => (
|
||||
<Box key={`${a.checklistId}:${a.itemId}`} sx={{ display: 'flex', alignItems: 'center', gap: 0.75 }}>
|
||||
{a.checked ? (
|
||||
<CheckBoxIcon sx={{ fontSize: 18, color: colors.success }} />
|
||||
) : (
|
||||
<CheckBoxBlankIcon sx={{ fontSize: 18, color: colors.danger }} />
|
||||
)}
|
||||
<Typography sx={{ fontSize: 14, color: a.checked ? 'inherit' : colors.danger }}>{a.text}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{v.media.length > 0 && (
|
||||
<Box sx={{ mt: 1.5 }}>
|
||||
<MediaGallery media={v.media} />
|
||||
|
||||
@ -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<Route>(`/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<Form>(emptyForm);
|
||||
const [loadedKey, setLoadedKey] = useState(isNew ? 'new' : '');
|
||||
const [mode, setMode] = useState<EditorMode>('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<Route>('/routes', { body }) : api<Route>(`/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
|
||||
<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="Название точки" />
|
||||
{selected !== i && c.checklistIds.length > 0 && (
|
||||
<Chip size="small" variant="outlined" label={`чеклистов: ${c.checklistIds.length}`} sx={{ flexShrink: 0 }} />
|
||||
)}
|
||||
{editable && (
|
||||
<Box sx={{ display: 'flex', flexShrink: 0 }}>
|
||||
<Tooltip title="Выше">
|
||||
@ -427,6 +437,39 @@ function RouteForm({ routeId, siteId, perimeter }: { routeId: string; siteId: st
|
||||
placeholder="Например: дверь склада закрыта, пломба цела"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<Autocomplete
|
||||
multiple
|
||||
size="small"
|
||||
disabled={!editable}
|
||||
options={(checklists.data ?? []).map((x) => 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 (
|
||||
<li {...props} key={id}>
|
||||
<Box>
|
||||
<Typography sx={{ fontSize: 14 }}>{cl?.name}</Typography>
|
||||
<Typography sx={{ fontSize: 12, color: colors.muted }}>пунктов: {cl?.items.length ?? 0}</Typography>
|
||||
</Box>
|
||||
</li>
|
||||
);
|
||||
}}
|
||||
noOptionsText="Чеклистов нет — создайте их на странице «Чеклисты»"
|
||||
renderInput={(params) => <TextField {...params} label="Чеклисты на точке" placeholder={c.checklistIds.length ? '' : 'Выберите чеклисты'} />}
|
||||
/>
|
||||
{c.checklistIds.length > 0 && (
|
||||
<Box sx={{ mt: 1, pl: 0.5 }}>
|
||||
{[...new Map(c.checklistIds.flatMap((id) => checklistById.get(id)?.items ?? []).map((it) => [it.id, it])).values()].map((it) => (
|
||||
<Typography key={it.id} sx={{ fontSize: 13, color: colors.muted }}>
|
||||
☐ {it.text}
|
||||
</Typography>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@ -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') },
|
||||
{
|
||||
|
||||
@ -82,7 +82,7 @@ export const MEDIA_REQUIREMENT_LABELS: Record<MediaRequirement, string> = {
|
||||
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<VisitFlag, string> = {
|
||||
@ -92,6 +92,7 @@ export const VISIT_FLAG_LABELS: Record<VisitFlag, string> = {
|
||||
late: 'Позже окна обхода',
|
||||
out_of_order: 'Не по порядку',
|
||||
clock_skew: 'Время телефона расходится с сервером',
|
||||
checklist_incomplete: 'Чеклист заполнен не полностью',
|
||||
};
|
||||
|
||||
export const WEBHOOK_EVENTS = ['round.completed', 'round.partial', 'round.missed', 'visit.issue'] as const;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user