aspans_patrol/apps/web/src/guard/RoundScreen.tsx

469 lines
24 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 { useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import {
Alert,
Box,
Button,
Checkbox,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
Switch,
TextField,
Typography,
} from '@mui/material';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import MyLocationIcon from '@mui/icons-material/MyLocation';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import VideocamIcon from '@mui/icons-material/Videocam';
import CollectionsIcon from '@mui/icons-material/Collections';
import CloseIcon from '@mui/icons-material/Close';
import ReportIcon from '@mui/icons-material/ReportGmailerrorred';
import FlagIcon from '@mui/icons-material/Flag';
import ScheduleIcon from '@mui/icons-material/Schedule';
import { Circle, GeoJSON, Marker, Polyline, Tooltip, useMap, useMapEvents } from 'react-leaflet';
import exifr from 'exifr';
import { MEDIA_REQUIREMENT_LABELS } from '@patrol/shared';
import { useAuth } from '../auth/AuthContext';
import { fmtTime } from '../lib/format';
import { StatusChip } from '../lib/StatusChip';
import { BaseMap, FitTo, PERIMETER_STYLE, ROUTE_STYLE, dotIcon, numberIcon, pinIcon } from '../map/base';
import { EmptyState, LoadingState } from '../ui/layout';
import { colors } from '../ui/theme';
import type { LocalRound } from './data';
import { Fix, useGeolocation } from './geo';
import { useGuardRounds } from './GuardApp';
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 (
<>
{fix.accuracy && fix.accuracy < 500 && <Circle center={[fix.lat, fix.lng]} radius={fix.accuracy} pathOptions={{ color: '#0ea5e9', weight: 1, fillOpacity: 0.1 }} interactive={false} />}
<Marker position={[fix.lat, fix.lng]} icon={dotIcon('#0ea5e9', 16)} interactive={false} />
</>
);
}
function CenterOnMe({ fix }: { fix: Fix | null }) {
const map = useMap();
if (!fix) return null;
return (
<IconButton
onClick={() => map.setView([fix.lat, fix.lng], Math.max(map.getZoom(), 17))}
sx={{ position: 'absolute', right: 12, bottom: 32, zIndex: 1000, bgcolor: '#fff', boxShadow: '0 2px 8px rgba(15,23,42,.2)', '&:hover': { bgcolor: '#fff' } }}
aria-label="Где я"
>
<MyLocationIcon sx={{ color: colors.brand }} />
</IconButton>
);
}
export function RoundScreen() {
const { id } = useParams();
const navigate = useNavigate();
const { rounds, my } = useGuardRounds();
const { fix, error: geoError } = useGeolocation();
const [marking, setMarking] = useState<{ checkpointId: string | null; issue: boolean } | null>(null);
const [finishing, setFinishing] = useState(false);
const r = rounds?.find((x) => x.id === id);
const done = useMemo(() => new Set(r?.visits.map((v) => v.checkpointId).filter(Boolean)), [r]);
const cps = useMemo(() => [...(r?.route.checkpoints || [])].sort((a, b) => a.position - b.position), [r]);
const next = cps.find((c) => !done.has(c.id));
if (my.isLoading) return <LoadingState />;
if (!r) {
return (
<Box sx={{ p: 3 }}>
<EmptyState title="Обход не найден">
<Button onClick={() => navigate('/guard')}>К списку</Button>
</EmptyState>
</Box>
);
}
const tz = r.timezone;
const finished = !['planned', 'in_progress'].includes(r.status) || r.pendingFinish;
const start = () => enqueue([{ type: 'start', opId: crypto.randomUUID(), roundId: r.id, clientStartedAt: new Date().toISOString() }]);
return (
<Box sx={{ display: 'flex', flexDirection: 'column', height: 'calc(100dvh - 57px)' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 1, bgcolor: '#fff', borderBottom: `1px solid ${colors.line}` }}>
<IconButton onClick={() => navigate('/guard')} aria-label="Назад">
<ArrowBackIcon />
</IconButton>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography sx={{ fontWeight: 600 }} noWrap>
{r.route.name}
</Typography>
<Typography sx={{ color: colors.muted, fontSize: 13 }} noWrap>
{fmtTime(r.plannedStart, tz)}–{fmtTime(r.plannedEnd, tz)} · {r.siteName}
</Typography>
</Box>
<StatusChip status={r.status} />
</Box>
<Box sx={{ flex: 1, position: 'relative', minHeight: 200 }}>
<BaseMap height="100%" style={{ borderRadius: 0, position: 'absolute', inset: 0 }} zoom={16}>
<FitTo geos={[r.perimeter, r.route.path, ...cps.map((c) => c.location)]} padding={24} />
{r.perimeter && <GeoJSON data={r.perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} interactive={false} />}
{r.route.path && <Polyline positions={r.route.path.coordinates.map(ll)} pathOptions={ROUTE_STYLE} interactive={false} />}
{cps.map((c) => (
<Marker
key={c.id}
position={ll(c.location.coordinates)}
icon={numberIcon(c.position, { done: done.has(c.id), active: next?.id === c.id, size: 32 })}
eventHandlers={{ click: () => !finished && setMarking({ checkpointId: c.id, issue: false }) }}
>
<Tooltip direction="top" offset={[0, -16]}>
{c.name}
</Tooltip>
</Marker>
))}
{r.visits.map((v) => (
<Marker key={v.id} position={[v.marked.lat, v.marked.lng]} icon={pinIcon(v.isIssue ? colors.danger : colors.success)} interactive={false} />
))}
<MeLayer fix={fix} />
<CenterOnMe fix={fix} />
</BaseMap>
</Box>
<Box sx={{ bgcolor: '#fff', borderTop: `1px solid ${colors.line}`, px: 2, pt: 1.75, pb: 'max(16px, env(safe-area-inset-bottom))', boxShadow: '0 -8px 24px rgba(15,23,42,.06)' }}>
{geoError && !finished && (
<Typography sx={{ color: colors.warning, fontSize: 12.5, mb: 1 }}>{geoError}. Отметку ставьте нажатием на карту.</Typography>
)}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.25 }}>
<Typography sx={{ fontWeight: 600 }}>
Точек {r.checkpointsDone} из {r.checkpointsTotal}
</Typography>
{r.pendingVisitIds.size > 0 && <Chip size="small" icon={<ScheduleIcon />} label={`не отправлено: ${r.pendingVisitIds.size}`} />}
</Box>
{finished ? (
<Alert severity={r.status === 'completed' ? 'success' : 'info'} sx={{ borderRadius: '12px' }}>
{r.pendingFinish ? 'Обход завершён, отчёт отправится при появлении сети.' : 'Обход закрыт.'}
</Alert>
) : r.status === 'planned' ? (
<Button fullWidth size="large" variant="contained" onClick={start} sx={{ py: 1.5, borderRadius: '14px', fontSize: 17 }}>
Начать обход
</Button>
) : (
<>
{next ? (
<Box sx={{ p: 1.5, mb: 1.5, borderRadius: '12px', bgcolor: colors.brandSoft }}>
<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>
) : (
<Alert severity="success" sx={{ mb: 1.5, borderRadius: '12px' }}>
Все точки отмечены — можно завершать.
</Alert>
)}
<Box sx={{ display: 'flex', gap: 1 }}>
<Button fullWidth size="large" variant="contained" startIcon={<FlagIcon />} onClick={() => setMarking({ checkpointId: next?.id ?? null, issue: false })} sx={{ py: 1.4, borderRadius: '14px' }} disabled={!next}>
Я на точке
</Button>
<IconButton onClick={() => setMarking({ checkpointId: null, issue: true })} sx={{ border: `1px solid #fecaca`, borderRadius: '14px', color: colors.danger, px: 1.75 }} aria-label="Сообщить о проблеме">
<ReportIcon />
</IconButton>
</Box>
<Button fullWidth onClick={() => setFinishing(true)} sx={{ mt: 1 }}>
Завершить обход
</Button>
</>
)}
</Box>
{marking && <MarkDialog round={r} cps={cps} done={done} initial={marking} fix={fix} onClose={() => setMarking(null)} />}
{finishing && <FinishDialog round={r} onClose={() => setFinishing(false)} />}
</Box>
);
}
function TapToMark({ onMark }: { onMark: (p: { lat: number; lng: number }) => void }) {
useMapEvents({ click: (e) => onMark({ lat: e.latlng.lat, lng: e.latlng.lng }) });
return null;
}
type Picked = { id: string; file: File; kind: 'photo' | 'video'; url: string };
function MarkDialog({ round, cps, done, initial, fix, onClose }: {
round: LocalRound;
cps: Cp[];
done: Set<string | null>;
initial: { checkpointId: string | null; issue: boolean };
fix: Fix | null;
onClose: () => void;
}) {
const { me } = useAuth();
const [checkpointId, setCheckpointId] = useState<string | null>(initial.checkpointId);
const [marked, setMarked] = useState<{ lat: number; lng: number } | null>(fix ? { lat: fix.lat, lng: fix.lng } : null);
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;
const picked: Picked[] = [];
for (const file of Array.from(list)) {
const kind = file.type.startsWith('video/') ? 'video' : 'photo';
picked.push({ id: crypto.randomUUID(), file, kind, url: URL.createObjectURL(file) });
}
setFiles((f) => [...f, ...picked]);
};
const remove = (id: string) =>
setFiles((f) => {
const p = f.find((x) => x.id === id);
if (p) URL.revokeObjectURL(p.url);
return f.filter((x) => x.id !== id);
});
const hasPhoto = files.some((f) => f.kind === 'photo');
const hasVideo = files.some((f) => f.kind === 'video');
const need = cp && !issue ? cp.mediaRequired : 'none';
const mediaOk = need === 'none' || (need === 'photo' && hasPhoto) || (need === 'video' && hasVideo) || (need === 'photo_or_video' && files.length > 0);
const canSave = !!marked && mediaOk && (!issue || note.trim() || files.length > 0) && !busy;
const save = async () => {
if (!marked || !me) return;
setBusy(true);
try {
const ops: Op[] = [];
const blobs: { mediaId: string; blob: Blob }[] = [];
if (round.status === 'planned') ops.push({ type: 'start', opId: crypto.randomUUID(), roundId: round.id, clientStartedAt: new Date().toISOString() });
for (const f of files) {
let capturedAt: string | null = null;
if (f.kind === 'photo') {
const exif = await exifr.parse(f.file, ['DateTimeOriginal', 'CreateDate']).catch(() => null);
const d = exif?.DateTimeOriginal || exif?.CreateDate;
if (d instanceof Date && !Number.isNaN(d.getTime())) capturedAt = d.toISOString();
}
if (!capturedAt && f.file.lastModified) capturedAt = new Date(f.file.lastModified).toISOString();
const mime = f.file.type || (f.kind === 'photo' ? 'image/jpeg' : 'video/mp4');
ops.push({ type: 'media', opId: crypto.randomUUID(), id: f.id, kind: f.kind, mime, size: f.file.size, filename: f.file.name || `${f.kind}.${mime.split('/')[1]}`, capturedAt });
blobs.push({ mediaId: f.id, blob: f.file });
}
ops.push({
type: 'visit',
opId: crypto.randomUUID(),
roundId: round.id,
body: {
id: crypto.randomUUID(),
checkpointId: issue && !checkpointId ? null : checkpointId,
marked: { lat: Number(marked.lat.toFixed(7)), lng: Number(marked.lng.toFixed(7)) },
device: fix ? { lat: fix.lat, lng: fix.lng, accuracy: fix.accuracy } : null,
clientCreatedAt: new Date().toISOString(),
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);
files.forEach((f) => URL.revokeObjectURL(f.url));
onClose();
} finally {
setBusy(false);
}
};
const center: [number, number] = cp ? ll(cp.location.coordinates) : fix ? [fix.lat, fix.lng] : round.route.checkpoints[0] ? ll(round.route.checkpoints[0].location.coordinates) : [43.2389, 76.8897];
return (
<Dialog open onClose={onClose} fullScreen>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 1, borderBottom: `1px solid ${colors.line}`, pt: 'max(8px, env(safe-area-inset-top))' }}>
<IconButton onClick={onClose} aria-label="Закрыть">
<CloseIcon />
</IconButton>
<Typography sx={{ fontWeight: 600, flex: 1 }}>{issue ? 'Сообщить о проблеме' : 'Отметка на точке'}</Typography>
</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={() => pickCheckpoint(null)} />}
{cps.map((c) => (
<Chip
key={c.id}
label={`${c.position}. ${c.name}`}
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}
sx={{ flexShrink: 0 }}
/>
))}
</Box>
{cp?.instruction && (
<Typography sx={{ px: 2, pb: 1, fontSize: 14 }}>
<b>Проверить:</b> {cp.instruction}
</Typography>
)}
<Box sx={{ position: 'relative', height: 280 }}>
<BaseMap height={280} style={{ borderRadius: 0 }} center={center} zoom={18}>
{round.perimeter && <GeoJSON data={round.perimeter as GeoJSON.GeoJsonObject} style={PERIMETER_STYLE} interactive={false} />}
{cp && <Circle center={ll(cp.location.coordinates)} radius={cp.radiusM} pathOptions={{ color: colors.brand, weight: 1, fillOpacity: 0.1 }} interactive={false} />}
{cp && <Marker position={ll(cp.location.coordinates)} icon={numberIcon(cp.position)} interactive={false} />}
<MeLayer fix={fix} />
{marked && <Marker position={[marked.lat, marked.lng]} icon={pinIcon(issue ? colors.danger : colors.success)} interactive={false} />}
<TapToMark onMark={setMarked} />
</BaseMap>
<Box sx={{ position: 'absolute', left: 8, right: 8, bottom: 8, zIndex: 1000, bgcolor: 'rgba(255,255,255,.95)', borderRadius: '10px', px: 1.5, py: 1, fontSize: 13.5, boxShadow: '0 2px 8px rgba(15,23,42,.15)' }}>
{marked ? 'Метка «я тут» поставлена. Нажмите на карту, чтобы поправить.' : 'Нажмите на карту там, где вы находитесь.'}
</Box>
</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 }}>
Фото и видео
{need !== 'none' && <Typography component="span" sx={{ color: mediaOk ? colors.success : colors.warning, fontSize: 13, ml: 1 }}>({MEDIA_REQUIREMENT_LABELS[need].toLowerCase()})</Typography>}
</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" startIcon={<PhotoCameraIcon />} onClick={() => photoRef.current?.click()} sx={{ flex: 1 }}>
Фото
</Button>
<Button variant="outlined" startIcon={<VideocamIcon />} onClick={() => videoRef.current?.click()} sx={{ flex: 1 }}>
Видео
</Button>
<Button variant="outlined" startIcon={<CollectionsIcon />} onClick={() => galleryRef.current?.click()} sx={{ flex: 1 }}>
Галерея
</Button>
</Box>
<input ref={photoRef} hidden type="file" accept="image/*" capture="environment" onChange={(e) => (add(e.target.files), (e.target.value = ''))} />
<input ref={videoRef} hidden type="file" accept="video/*" capture="environment" onChange={(e) => (add(e.target.files), (e.target.value = ''))} />
<input ref={galleryRef} hidden type="file" accept="image/*,video/*" multiple onChange={(e) => (add(e.target.files), (e.target.value = ''))} />
{files.length > 0 && (
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mt: 1.5 }}>
{files.map((f) => (
<Box key={f.id} sx={{ position: 'relative', width: 84, height: 84, borderRadius: '10px', overflow: 'hidden', bgcolor: colors.soft }}>
{f.kind === 'photo' ? (
<img src={f.url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
) : (
<video src={f.url} muted style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
)}
<IconButton size="small" onClick={() => remove(f.id)} sx={{ position: 'absolute', top: 2, right: 2, bgcolor: 'rgba(0,0,0,.5)', color: '#fff', p: 0.25, '&:hover': { bgcolor: 'rgba(0,0,0,.7)' } }}>
<CloseIcon sx={{ fontSize: 16 }} />
</IconButton>
{f.kind === 'video' && <VideocamIcon sx={{ position: 'absolute', left: 4, bottom: 4, color: '#fff', fontSize: 18 }} />}
</Box>
))}
</Box>
)}
</Box>
<TextField label={issue ? 'Что случилось' : 'Комментарий'} multiline minRows={2} value={note} onChange={(e) => setNote(e.target.value)} required={issue && !files.length} />
<FormControlLabel control={<Switch checked={issue} onChange={(e) => setIssue(e.target.checked)} color="error" />} label="Это проблема — сообщить руководителю" />
</Box>
</Box>
<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>
</Box>
</Dialog>
);
}
function FinishDialog({ round, onClose }: { round: LocalRound; onClose: () => void }) {
const [comment, setComment] = useState('');
const missing = round.checkpointsTotal - round.checkpointsDone;
const finish = async () => {
await enqueue([{ type: 'finish', opId: crypto.randomUUID(), roundId: round.id, clientFinishedAt: new Date().toISOString(), comment: comment.trim() || null }]);
onClose();
};
return (
<Dialog open onClose={onClose} fullWidth maxWidth="xs">
<DialogTitle>Завершить обход?</DialogTitle>
<DialogContent>
{missing > 0 ? (
<Alert severity="warning" sx={{ mb: 2, borderRadius: '10px' }}>
Не отмечено точек: {missing}. Обход будет закрыт как «частично».
</Alert>
) : (
<Typography sx={{ mb: 2, color: colors.muted }}>Все точки отмечены.</Typography>
)}
<TextField label="Комментарий" multiline minRows={2} value={comment} onChange={(e) => setComment(e.target.value)} placeholder={missing ? 'Почему не все точки' : 'Необязательно'} />
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Назад</Button>
<Button variant="contained" onClick={finish}>
Завершить
</Button>
</DialogActions>
</Dialog>
);
}