diff --git a/frontend/src/pages/RouteEditorPage.jsx b/frontend/src/pages/RouteEditorPage.jsx index 06bcc58..55c5fb2 100644 --- a/frontend/src/pages/RouteEditorPage.jsx +++ b/frontend/src/pages/RouteEditorPage.jsx @@ -1,7 +1,7 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; import { MapContainer, TileLayer, Marker, Popup, Polyline } from 'react-leaflet'; -import { ArrowLeft, Plus, Trash2, Clock, MapPin, Bus, Radio, Send } from 'lucide-react'; +import { ArrowLeft, Plus, Trash2, Clock, MapPin, Bus, Radio, Send, Save, RotateCcw, Minus } from 'lucide-react'; import { routesApi, routeStopsApi, realtimeApi, telemetryApi } from '../services/api'; import { RouteRealtimeClient } from '../services/realtime'; import { PageLoader } from '../components/AnimatedComponents'; @@ -131,16 +131,81 @@ const formatVehicleAge = (secondsSinceUpdate) => { const POLOTSK_CENTER = [55.4869, 28.7856]; +const cloneStops = (stops) => stops.map((stop) => ({ ...stop })); + +const getStopId = (stop) => stop.stop_id ?? stop.id; + +const getRouteStopKey = (stop) => stop.route_stop_id || `draft-${getStopId(stop)}`; + +const normalizeTimeOffset = (value) => { + if (value === '') { + return 0; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : 0; +}; + +const normalizeTimeOffsetInput = (value) => { + if (value === '') { + return ''; + } + + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : ''; +}; + +const withSequences = (stops) => + stops.map((stop, index) => ({ + ...stop, + sequence: index + 1 + })); + +const sortAvailableStops = (stops) => + [...stops].sort((left, right) => left.name.localeCompare(right.name, 'ru')); + +const toAvailableStop = (stop) => ({ + id: getStopId(stop), + name: stop.name, + address: stop.address, + latitude: stop.latitude, + longitude: stop.longitude +}); + +const toRouteStopDraft = (stop, sequence, timeOffsetMinutes) => ({ + route_stop_id: `draft-${stop.id}`, + sequence, + time_offset_minutes: normalizeTimeOffset(timeOffsetMinutes), + stop_id: stop.id, + name: stop.name, + address: stop.address, + latitude: stop.latitude, + longitude: stop.longitude +}); + +const buildStopsPayload = (stops) => + stops.map((stop, index) => ({ + stopId: getStopId(stop), + sequence: index + 1, + timeOffsetMinutes: normalizeTimeOffset(stop.time_offset_minutes) + })); + +const getStopsSignature = (stops) => + JSON.stringify(buildStopsPayload(stops)); + export default function RouteEditorPage() { const { id } = useParams(); const [route, setRoute] = useState(null); const [routeStops, setRouteStops] = useState([]); + const [savedRouteStops, setSavedRouteStops] = useState([]); const [availableStops, setAvailableStops] = useState([]); + const [savedAvailableStops, setSavedAvailableStops] = useState([]); const [liveVehicles, setLiveVehicles] = useState([]); const [realtimeStatus, setRealtimeStatus] = useState({ state: 'idle' }); const [realtimeError, setRealtimeError] = useState(null); const [sendingDemo, setSendingDemo] = useState(false); + const [savingStops, setSavingStops] = useState(false); const [demoStep, setDemoStep] = useState(0); const [loading, setLoading] = useState(true); const [showAddModal, setShowAddModal] = useState(false); @@ -163,8 +228,10 @@ export default function RouteEditorPage() { ]); setRoute(routeRes.data); - setRouteStops(stopsRes.data); - setAvailableStops(availableRes.data); + setRouteStops(cloneStops(stopsRes.data)); + setSavedRouteStops(cloneStops(stopsRes.data)); + setAvailableStops(cloneStops(availableRes.data)); + setSavedAvailableStops(cloneStops(availableRes.data)); setLiveVehicles(liveVehiclesRes.data || []); } catch (error) { console.error('Error loading route data:', error); @@ -210,71 +277,118 @@ export default function RouteEditorPage() { }; }, [id]); - const handleAddStop = async () => { + const hasUnsavedChanges = useMemo( + () => getStopsSignature(routeStops) !== getStopsSignature(savedRouteStops), + [routeStops, savedRouteStops] + ); + + const handleAddStop = () => { if (!selectedStopForAdd) return; - try { - await routeStopsApi.addStop(id, { - stopId: selectedStopForAdd.id, - timeOffsetMinutes: timeOffsetInput - }); - - setShowAddModal(false); - setSelectedStopForAdd(null); - setTimeOffsetInput(0); - await loadData(); - } catch (error) { - console.error('Error adding stop:', error); - alert('Ошибка добавления остановки'); - } + setRouteStops((currentStops) => + withSequences([ + ...currentStops, + toRouteStopDraft(selectedStopForAdd, currentStops.length + 1, timeOffsetInput) + ]) + ); + setAvailableStops((currentStops) => + currentStops.filter((stop) => stop.id !== selectedStopForAdd.id) + ); + setShowAddModal(false); + setSelectedStopForAdd(null); + setTimeOffsetInput(0); }; - const handleRemoveStop = async (routeStopId) => { + const handleRemoveStop = (stopToRemove) => { if (!confirm('Удалить остановку из маршрута?')) return; - try { - await routeStopsApi.removeStop(id, routeStopId); - await loadData(); - } catch (error) { - console.error('Error removing stop:', error); - alert('Ошибка удаления остановки'); - } + const stopId = getStopId(stopToRemove); + + setRouteStops((currentStops) => + withSequences(currentStops.filter((stop) => getStopId(stop) !== stopId)) + ); + setAvailableStops((currentStops) => { + if (currentStops.some((stop) => stop.id === stopId)) { + return currentStops; + } + + return sortAvailableStops([...currentStops, toAvailableStop(stopToRemove)]); + }); }; - const handleUpdateTimeOffset = async (routeStopId, newOffset) => { - try { - await routeStopsApi.updateStop(id, routeStopId, { - timeOffsetMinutes: parseInt(newOffset) - }); - await loadData(); - } catch (error) { - console.error('Error updating time offset:', error); - alert('Ошибка обновления времени'); - } + const handleUpdateTimeOffset = (stopId, newOffset) => { + const nextOffset = normalizeTimeOffsetInput(newOffset); + + setRouteStops((currentStops) => + currentStops.map((stop) => + getStopId(stop) === stopId + ? { ...stop, time_offset_minutes: nextOffset } + : stop + ) + ); }; - const handleMoveStop = async (index, direction) => { - const newStops = [...routeStops]; - const targetIndex = direction === 'up' ? index - 1 : index + 1; - - if (targetIndex < 0 || targetIndex >= newStops.length) return; + const handleAdjustTimeOffset = (stopId, delta) => { + setRouteStops((currentStops) => + currentStops.map((stop) => + getStopId(stop) === stopId + ? { + ...stop, + time_offset_minutes: Math.max(0, normalizeTimeOffset(stop.time_offset_minutes) + delta) + } + : stop + ) + ); + }; - // Swap stops - [newStops[index], newStops[targetIndex]] = [newStops[targetIndex], newStops[index]]; - - // Update sequences - const reorderedStops = newStops.map((stop, idx) => ({ - stopId: stop.stop_id, - sequence: idx + 1, - timeOffsetMinutes: stop.time_offset_minutes - })); + const handleMoveStop = (index, direction) => { + setRouteStops((currentStops) => { + const newStops = [...currentStops]; + const targetIndex = direction === 'up' ? index - 1 : index + 1; + + if (targetIndex < 0 || targetIndex >= newStops.length) { + return currentStops; + } + + [newStops[index], newStops[targetIndex]] = [newStops[targetIndex], newStops[index]]; + return withSequences(newStops); + }); + }; + + const handleResetStopDraft = () => { + if (!hasUnsavedChanges || !confirm('Отменить несохраненные изменения?')) return; + + setRouteStops(cloneStops(savedRouteStops)); + setAvailableStops(cloneStops(savedAvailableStops)); + setShowAddModal(false); + setSelectedStopForAdd(null); + setTimeOffsetInput(0); + }; + + const handleSaveStops = async () => { + if (!hasUnsavedChanges || savingStops) return; try { - await routeStopsApi.reorderStops(id, reorderedStops); + setSavingStops(true); + + const stopsPayload = buildStopsPayload(routeStops); + + if (stopsPayload.length > 0) { + await routeStopsApi.reorderStops(id, stopsPayload); + } else { + await Promise.all( + savedRouteStops + .filter((stop) => stop.route_stop_id) + .map((stop) => routeStopsApi.removeStop(id, stop.route_stop_id)) + ); + } + await loadData(); } catch (error) { - console.error('Error reordering stops:', error); - alert('Ошибка изменения порядка'); + console.error('Error saving route stops:', error); + alert('Ошибка сохранения остановок'); + } finally { + setSavingStops(false); } }; @@ -352,21 +466,45 @@ export default function RouteEditorPage() { - +
+ + + +
+ {hasUnsavedChanges && ( +
+ Есть несохраненные изменения в остановках маршрута. Они попадут в БД только после нажатия «Сохранить». +
+ )} + {/* Mobile FAB for adding stop */} @@ -552,42 +691,61 @@ export default function RouteEditorPage() {
{routeStops.map((stop, index) => (
{/* Sequence number */}
{index + 1}
{/* Stop info */} -
+

{stop.name}

{/* Time offset */} -
- +
+ + handleUpdateTimeOffset(stop.route_stop_id, e.target.value)} - className="w-12 sm:w-14 text-center text-xs sm:text-sm border border-gray-300 rounded px-1 py-1" + onChange={(e) => handleUpdateTimeOffset(getStopId(stop), e.target.value)} + disabled={savingStops} + className="w-10 sm:w-14 text-center text-xs sm:text-sm border border-gray-300 rounded px-1 py-1" /> + мин
{/* Move buttons */} -
+