import { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { Alert, Box, Button, Chip, FormControlLabel, Grid2 as Grid, IconButton, MenuItem, Switch, TextField, ToggleButton, ToggleButtonGroup, Tooltip, Typography, } from '@mui/material'; import AddIcon from '@mui/icons-material/Add'; import RouteIcon from '@mui/icons-material/RouteOutlined'; import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; import DeleteIcon from '@mui/icons-material/DeleteOutline'; import TimelineIcon from '@mui/icons-material/Timeline'; import PlaceIcon from '@mui/icons-material/PlaceOutlined'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { MEDIA_REQUIREMENTS, MEDIA_REQUIREMENT_LABELS, type GeoJsonLineString, type MediaRequirement } from '@patrol/shared'; import { api } from '../api/client'; import type { Route } from '../api/types'; import { useAuth } from '../auth/AuthContext'; import { SitePicker } from '../lib/SitePicker'; import { useRoutes, useSites } from '../lib/queries'; import { BaseMap, FitTo } from '../map/base'; import { EditorMode, RouteEditor } from '../map/RouteEditor'; import { useConfirm, useNotify } from '../ui/feedback'; import { Card, EmptyState, ErrorState, LoadingState, PageHeader } from '../ui/layout'; import { colors } from '../ui/theme'; type CpForm = { key: string; id?: string; name: string; location: { type: 'Point'; coordinates: [number, number] }; radiusM: number; mediaRequired: MediaRequirement; instruction: string; }; type Form = { name: string; description: string; expectedMinutes: string; strictOrder: boolean; path: GeoJsonLineString | null; checkpoints: CpForm[] }; const emptyForm = (): Form => ({ name: '', description: '', expectedMinutes: '', strictOrder: false, path: null, checkpoints: [] }); function toForm(r: Route): Form { return { name: r.name, description: r.description || '', expectedMinutes: r.expectedMinutes ? String(r.expectedMinutes) : '', strictOrder: r.strictOrder, path: r.path, checkpoints: [...r.checkpoints] .sort((a, b) => a.position - b.position) .map((c) => ({ key: c.id || crypto.randomUUID(), id: c.id, name: c.name, location: c.location, radiusM: c.radiusM, mediaRequired: c.mediaRequired, instruction: c.instruction || '', })), }; } export function RoutesPage() { const { routeId } = useParams(); const [params, setParams] = useSearchParams(); const navigate = useNavigate(); const sites = useSites(); const siteId = params.get('site') || sites.data?.[0]?.id || ''; const routes = useRoutes(siteId || undefined); const { can } = useAuth(); const pickSite = (id: string) => { setParams({ site: id }); if (routeId) navigate(`/routes?site=${id}`); }; if (sites.isLoading) return ; if (sites.isError) return sites.refetch()} />; if (!sites.data?.length) { return ( <> Маршрут рисуется внутри периметра объекта. ); } const site = sites.data.find((s) => s.id === siteId); return ( <> {can('routes.manage') && ( )} } /> {routes.isLoading && } {routes.data?.length === 0 && routeId !== 'new' && На объекте пока нет маршрутов.} {routes.data?.map((r) => ( navigate(`/routes/${r.id}?site=${siteId}`)} sx={{ p: 1.75, borderRadius: '12px', cursor: 'pointer', bgcolor: '#fff', border: `1px solid ${routeId === r.id ? colors.brand : colors.line}`, boxShadow: routeId === r.id ? `0 0 0 3px ${colors.brandSoft}` : 'none', }} > {r.name} Точек: {r.checkpoints.length} · версия {r.version} {r.strictOrder ? ' · строгий порядок' : ''} ))} {routeId ? ( ) : ( {can('routes.manage') ? 'или создайте новый для этого объекта' : null} )} ); } function RouteForm({ routeId, siteId, perimeter }: { routeId: string; siteId: string; perimeter: import('@patrol/shared').GeoJsonMultiPolygon | null }) { const isNew = routeId === 'new'; const navigate = useNavigate(); const qc = useQueryClient(); const notify = useNotify(); const confirm = useConfirm(); const { can } = useAuth(); const editable = can('routes.manage'); const route = useQuery({ queryKey: ['route', routeId], queryFn: () => api(`/routes/${routeId}`), enabled: !isNew }); const [form, setForm] = useState
(emptyForm); const [loadedKey, setLoadedKey] = useState(isNew ? 'new' : ''); const [mode, setMode] = useState('points'); const [selected, setSelected] = useState(null); const [warnings, setWarnings] = useState([]); useEffect(() => { if (route.data) { setForm(toForm(route.data)); setWarnings(route.data.warnings || []); setLoadedKey(`${route.data.id}-${route.data.version}`); } }, [route.data]); const save = useMutation({ mutationFn: () => { const body = { siteId, name: form.name.trim(), description: form.description.trim() || null, expectedMinutes: form.expectedMinutes ? Number(form.expectedMinutes) : null, strictOrder: form.strictOrder, path: form.path, checkpoints: form.checkpoints.map((c) => ({ ...(c.id ? { id: c.id } : {}), name: c.name.trim(), location: c.location, radiusM: c.radiusM, mediaRequired: c.mediaRequired, instruction: c.instruction.trim() || null, })), }; return isNew ? api('/routes', { body }) : api(`/routes/${routeId}`, { method: 'PUT', body }); }, onSuccess: (saved) => { qc.invalidateQueries({ queryKey: ['routes'] }); qc.setQueryData(['route', saved.id], saved); setWarnings(saved.warnings || []); notify.success(saved.warnings?.length ? 'Маршрут сохранён, есть предупреждения' : 'Маршрут сохранён'); if (isNew) navigate(`/routes/${saved.id}?site=${siteId}`, { replace: true }); }, onError: notify.error, }); const archive = async () => { const ok = await confirm({ title: 'Убрать маршрут в архив?', message: 'Графики по нему отключатся, запланированные обходы отменятся. Отчёты сохранятся.', confirmText: 'В архив', danger: true, }); if (!ok) return; try { await api(`/routes/${routeId}/archive`, { method: 'POST' }); qc.invalidateQueries({ queryKey: ['routes'] }); notify.success('Маршрут в архиве'); navigate(`/routes?site=${siteId}`); } catch (e) { notify.error(e); } }; const setCp = (i: number, patch: Partial) => setForm((f) => ({ ...f, checkpoints: f.checkpoints.map((c, j) => (j === i ? { ...c, ...patch } : c)) })); const moveCp = (i: number, dir: -1 | 1) => setForm((f) => { const list = [...f.checkpoints]; const j = i + dir; if (j < 0 || j >= list.length) return f; [list[i], list[j]] = [list[j], list[i]]; setSelected(j); return { ...f, checkpoints: list }; }); const removeCp = (i: number) => { setForm((f) => ({ ...f, checkpoints: f.checkpoints.filter((_, j) => j !== i) })); setSelected(null); }; const fitGeos = useMemo( () => (isNew ? [perimeter] : [perimeter, route.data?.path ?? null, ...(route.data?.checkpoints || []).map((c) => c.location)]), [isNew, perimeter, route.data], ); if (!isNew && route.isLoading) return ; if (!isNew && route.isError) return route.refetch()} />; if (!loadedKey) return ; return ( {warnings.length > 0 && ( {warnings.map((w) => (
{w}
))}
)} {editable ? ( v && setMode(v)}> Точки Линия маршрута ) : ( )} {!editable ? 'Только просмотр' : mode === 'points' ? 'Кликните по карте, чтобы добавить точку. Точку можно перетащить.' : 'Нарисуйте линию инструментом слева, двойной клик — закончить.'} setForm((f) => ({ ...f, path }))} checkpoints={form.checkpoints} onCheckpointsChange={(list) => setForm((f) => ({ ...f, checkpoints: list.map((c) => { const prev = f.checkpoints.find((p) => p.key === c.key); return prev ? { ...prev, location: c.location } : { ...c, mediaRequired: 'photo', instruction: '' } as CpForm; }), })) } mode={editable ? mode : 'view'} selected={selected} onSelect={setSelected} /> setForm({ ...form, name: e.target.value })} required disabled={!editable} /> setForm({ ...form, description: e.target.value })} disabled={!editable} /> setForm({ ...form, expectedMinutes: e.target.value })} disabled={!editable} slotProps={{ htmlInput: { min: 1, max: 1440 } }} /> setForm({ ...form, strictOrder: e.target.checked })} disabled={!editable} />} label="Строгий порядок точек" /> Отметка не по порядку не блокируется, а помечается в отчёте. {editable && ( {!isNew && ( )} )} {form.checkpoints.length === 0 && ( Точек нет. В режиме «Точки» кликните по карте. )} {form.checkpoints.map((c, i) => ( setSelected(i)} sx={{ p: 1.5, borderRadius: '12px', border: `1px solid ${selected === i ? colors.brand : colors.line}`, bgcolor: selected === i ? colors.brandSoft : '#fff', }} > setCp(i, { name: e.target.value })} disabled={!editable} placeholder="Название точки" /> {editable && ( moveCp(i, -1)} disabled={i === 0}> moveCp(i, 1)} disabled={i === form.checkpoints.length - 1}> removeCp(i)}> )} {selected === i && ( setCp(i, { radiusM: Math.max(1, Number(e.target.value) || 1) })} disabled={!editable} slotProps={{ htmlInput: { min: 1, max: 5000 } }} /> setCp(i, { mediaRequired: e.target.value as MediaRequirement })} disabled={!editable}> {MEDIA_REQUIREMENTS.map((m) => ( {MEDIA_REQUIREMENT_LABELS[m]} ))} setCp(i, { instruction: e.target.value })} disabled={!editable} placeholder="Например: дверь склада закрыта, пломба цела" /> )} ))}
); }