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()}} ) : ( Все точки отмечены — можно завершать. )} 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 ( {issue ? 'Сообщить о проблеме' : 'Отметка на точке'} {issue && pickCheckpoint(null)} />} {cps.map((c) => ( pickCheckpoint(c.id)} color={checkpointId === c.id ? 'primary' : 'default'} variant={done.has(c.id) && checkpointId !== c.id ? 'outlined' : 'filled'} icon={done.has(c.id) ? : undefined} sx={{ flexShrink: 0 }} /> ))} {cp?.instruction && ( Проверить: {cp.instruction} )} {round.perimeter && } {cp && } {cp && } {marked && } {marked ? 'Метка «я тут» поставлена. Нажмите на карту, чтобы поправить.' : 'Нажмите на карту там, где вы находитесь.'} {items.length > 0 && cp && ( Чеклист отмечено {checkedCount} из {items.length} {(cp.checklists ?? []).map( (l) => l.items.length > 0 && ( {(cp.checklists ?? []).length > 1 && {l.name}} {l.items.map((it) => { const key = `${l.id}:${it.id}`; return ( toggle(key)} sx={{ display: 'flex', alignItems: 'center', gap: 0.5, py: 0.25, cursor: 'pointer', borderBottom: `1px solid ${colors.line}`, userSelect: 'none' }} > {it.text} ); })} ), )} )} Фото и видео {need !== 'none' && ({MEDIA_REQUIREMENT_LABELS[need].toLowerCase()})} (add(e.target.files), (e.target.value = ''))} /> (add(e.target.files), (e.target.value = ''))} /> (add(e.target.files), (e.target.value = ''))} /> {files.length > 0 && ( {files.map((f) => ( {f.kind === 'photo' ? ( ) : ( ))} )} setNote(e.target.value)} required={issue && !files.length} /> setIssue(e.target.checked)} color="error" />} label="Это проблема — сообщить руководителю" /> {!marked && Поставьте метку на карте.} {marked && !mediaOk && Для этой точки нужно: {MEDIA_REQUIREMENT_LABELS[need].toLowerCase()}.} {marked && mediaOk && items.length > 0 && checkedCount < items.length && ( Отмечено {checkedCount} из {items.length} пунктов чеклиста — в отчёте будет пометка. )} ); } 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 ( Завершить обход? {missing > 0 ? ( Не отмечено точек: {missing}. Обход будет закрыт как «частично». ) : ( Все точки отмечены. )} setComment(e.target.value)} placeholder={missing ? 'Почему не все точки' : 'Необязательно'} /> ); }