469 lines
24 KiB
TypeScript
469 lines
24 KiB
TypeScript
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>
|
||
);
|
||
}
|
||
|