441 lines
18 KiB
TypeScript
441 lines
18 KiB
TypeScript
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>
|
||
);
|
||
}
|