aspans_patrol/apps/web/src/pages/RoutesPage.tsx
2026-10-22 10:53:02 +05:00

441 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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