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 && }
>
);
}
function CenterOnMe({ fix }: { fix: Fix | null }) {
const map = useMap();
if (!fix) return null;
return (
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="Где я"
>
);
}
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 ;
if (!r) {
return (
);
}
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 (
navigate('/guard')} aria-label="Назад">
{r.route.name}
{fmtTime(r.plannedStart, tz)}–{fmtTime(r.plannedEnd, tz)} · {r.siteName}
c.location)]} padding={24} />
{r.perimeter && }
{r.route.path && }
{cps.map((c) => (
!finished && setMarking({ checkpointId: c.id, issue: false }) }}
>
{c.name}
))}
{r.visits.map((v) => (
))}
{geoError && !finished && (
{geoError}. Отметку ставьте нажатием на карту.
)}
Точек {r.checkpointsDone} из {r.checkpointsTotal}
{r.pendingVisitIds.size > 0 && } label={`не отправлено: ${r.pendingVisitIds.size}`} />}
{finished ? (
{r.pendingFinish ? 'Обход завершён, отчёт отправится при появлении сети.' : 'Обход закрыт.'}
) : r.status === 'planned' ? (
) : (
<>
{next ? (
Следующая точка · {next.position}
{next.name}
{next.instruction && {next.instruction}}
{itemsOf(next).length > 0 && Чеклист: {itemsOf(next).length} п.}
{next.mediaRequired !== 'none' && Нужно: {MEDIA_REQUIREMENT_LABELS[next.mediaRequired].toLowerCase()}}
) : (
Все точки отмечены — можно завершать.
)}
} onClick={() => setMarking({ checkpointId: next?.id ?? null, issue: false })} sx={{ py: 1.4, borderRadius: '14px' }} disabled={!next}>
Я на точке
setMarking({ checkpointId: null, issue: true })} sx={{ border: `1px solid #fecaca`, borderRadius: '14px', color: colors.danger, px: 1.75 }} aria-label="Сообщить о проблеме">
>
)}
{marking && setMarking(null)} />}
{finishing && setFinishing(false)} />}
);
}
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;
initial: { checkpointId: string | null; issue: boolean };
fix: Fix | null;
onClose: () => void;
}) {
const { me } = useAuth();
const [checkpointId, setCheckpointId] = useState(initial.checkpointId);
const [marked, setMarked] = useState<{ lat: number; lng: number } | null>(fix ? { lat: fix.lat, lng: fix.lng } : null);
const [files, setFiles] = useState([]);
const [note, setNote] = useState('');
const [issue, setIssue] = useState(initial.issue);
const [checks, setChecks] = useState>(new Set());
const [busy, setBusy] = useState(false);
const photoRef = useRef(null);
const videoRef = useRef(null);
const galleryRef = useRef(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 (
);
}
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 (
);
}