Fix route stop draft editing

This commit is contained in:
2026-05-22 01:18:06 +03:00
parent e57fea321f
commit e61d1ede6f

View File

@@ -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() {
</div>
</div>
<button
onClick={() => setShowAddModal(true)}
className="btn-primary hidden sm:flex items-center justify-center w-full sm:w-auto"
disabled={availableStops.length === 0}
>
<Plus className="h-5 w-5 mr-2" />
Добавить остановку
</button>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
<button
onClick={() => setShowAddModal(true)}
className="btn-secondary hidden sm:flex items-center justify-center w-full sm:w-auto"
disabled={availableStops.length === 0 || savingStops}
>
<Plus className="h-5 w-5 mr-2" />
Добавить остановку
</button>
<button
onClick={handleResetStopDraft}
className="btn-secondary inline-flex items-center justify-center w-full sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!hasUnsavedChanges || savingStops}
>
<RotateCcw className="h-4 w-4 mr-2" />
Отменить
</button>
<button
onClick={handleSaveStops}
className="btn-primary inline-flex items-center justify-center w-full sm:w-auto disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!hasUnsavedChanges || savingStops}
>
<Save className="h-4 w-4 mr-2" />
{savingStops ? 'Сохранение...' : 'Сохранить'}
</button>
</div>
</div>
{hasUnsavedChanges && (
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
Есть несохраненные изменения в остановках маршрута. Они попадут в БД только после нажатия «Сохранить».
</div>
)}
{/* Mobile FAB for adding stop */}
<button
onClick={() => setShowAddModal(true)}
className="fab sm:hidden"
disabled={availableStops.length === 0}
disabled={availableStops.length === 0 || savingStops}
title="Добавить остановку"
>
<Plus className="h-6 w-6" />
@@ -461,7 +599,7 @@ export default function RouteEditorPage() {
{/* Route stops markers */}
{routeStops.map((stop, index) => (
<Marker
key={stop.route_stop_id}
key={getRouteStopKey(stop)}
position={[stop.latitude, stop.longitude]}
icon={createNumberedIcon(index + 1, route.color)}
>
@@ -469,10 +607,10 @@ export default function RouteEditorPage() {
<div className="p-1">
<h3 className="font-semibold text-sm">{stop.name}</h3>
<p className="text-xs text-gray-600">
Порядок: {stop.sequence}
Порядок: {index + 1}
</p>
<p className="text-xs text-gray-600">
Время: {stop.time_offset_minutes} мин.
Время: {normalizeTimeOffset(stop.time_offset_minutes)} мин.
</p>
</div>
</Popup>
@@ -495,7 +633,8 @@ export default function RouteEditorPage() {
setSelectedStopForAdd(stop);
setShowAddModal(true);
}}
className="mt-2 text-xs bg-primary-600 text-white px-2 py-1.5 rounded hover:bg-primary-700"
disabled={savingStops}
className="mt-2 text-xs bg-primary-600 text-white px-2 py-1.5 rounded hover:bg-primary-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Добавить
</button>
@@ -552,42 +691,61 @@ export default function RouteEditorPage() {
<div className="space-y-2 overflow-y-auto flex-1 smooth-scroll">
{routeStops.map((stop, index) => (
<div
key={stop.route_stop_id}
className="flex items-center p-2 sm:p-3 bg-gray-50 rounded-lg border border-gray-200 list-item-animated list-item-touch transition-all duration-200 hover:shadow-md hover:border-gray-300"
key={getRouteStopKey(stop)}
className="flex items-center gap-2 p-2 sm:p-3 bg-gray-50 rounded-lg border border-gray-200 list-item-animated list-item-touch transition-all duration-200 hover:shadow-md hover:border-gray-300"
style={{ animationDelay: `${index * 30}ms` }}
>
{/* Sequence number */}
<div
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-white font-bold text-xs sm:text-sm mr-2 sm:mr-3 flex-shrink-0"
className="w-7 h-7 sm:w-8 sm:h-8 rounded-full flex items-center justify-center text-white font-bold text-xs sm:text-sm flex-shrink-0"
style={{ backgroundColor: route.color }}
>
{index + 1}
</div>
{/* Stop info */}
<div className="flex-grow min-w-0 mr-2">
<div className="flex-grow min-w-0">
<h3 className="font-medium text-gray-900 truncate text-sm">{stop.name}</h3>
</div>
{/* Time offset */}
<div className="flex items-center flex-shrink-0">
<Clock className="h-3 w-3 sm:h-4 sm:w-4 text-gray-400 mr-1" />
<div className="flex items-center gap-1 flex-shrink-0">
<Clock className="h-3 w-3 sm:h-4 sm:w-4 text-gray-400" />
<button
type="button"
onClick={() => handleAdjustTimeOffset(getStopId(stop), -1)}
disabled={savingStops || normalizeTimeOffset(stop.time_offset_minutes) === 0}
className="h-6 w-6 rounded border border-gray-300 bg-white text-gray-600 inline-flex items-center justify-center hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
title="Уменьшить интервал"
>
<Minus className="h-3 w-3" />
</button>
<input
type="number"
min="0"
value={stop.time_offset_minutes}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => handleAdjustTimeOffset(getStopId(stop), 1)}
disabled={savingStops}
className="h-6 w-6 rounded border border-gray-300 bg-white text-gray-600 inline-flex items-center justify-center hover:bg-gray-100 disabled:opacity-30 disabled:cursor-not-allowed"
title="Увеличить интервал"
>
<Plus className="h-3 w-3" />
</button>
<span className="text-xs text-gray-500 ml-1 hidden sm:inline">мин</span>
</div>
{/* Move buttons */}
<div className="flex flex-col mx-1 sm:mx-2">
<div className="flex flex-col flex-shrink-0">
<button
onClick={() => handleMoveStop(index, 'up')}
disabled={index === 0}
className="text-gray-400 hover:text-gray-600 disabled:opacity-30 p-0.5"
disabled={savingStops || index === 0}
className="text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed p-0.5"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
@@ -595,8 +753,8 @@ export default function RouteEditorPage() {
</button>
<button
onClick={() => handleMoveStop(index, 'down')}
disabled={index === routeStops.length - 1}
className="text-gray-400 hover:text-gray-600 disabled:opacity-30 p-0.5"
disabled={savingStops || index === routeStops.length - 1}
className="text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed p-0.5"
>
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
@@ -606,8 +764,9 @@ export default function RouteEditorPage() {
{/* Delete button */}
<button
onClick={() => handleRemoveStop(stop.route_stop_id)}
className="text-red-500 hover:text-red-700 p-1 flex-shrink-0"
onClick={() => handleRemoveStop(stop)}
disabled={savingStops}
className="text-red-500 hover:text-red-700 disabled:opacity-30 disabled:cursor-not-allowed p-1 flex-shrink-0"
>
<Trash2 className="h-4 w-4 sm:h-5 sm:w-5" />
</button>
@@ -624,7 +783,7 @@ export default function RouteEditorPage() {
<span>
Время: <strong>
{routeStops.length > 0
? routeStops[routeStops.length - 1].time_offset_minutes
? normalizeTimeOffset(routeStops[routeStops.length - 1].time_offset_minutes)
: 0} мин
</strong>
</span>
@@ -648,8 +807,9 @@ export default function RouteEditorPage() {
<select
value={selectedStopForAdd?.id || ''}
onChange={(e) => {
const stop = availableStops.find(s => s.id === parseInt(e.target.value));
setSelectedStopForAdd(stop);
const stopId = Number.parseInt(e.target.value, 10);
const stop = availableStops.find(s => s.id === stopId);
setSelectedStopForAdd(stop || null);
}}
className="input"
>
@@ -668,7 +828,7 @@ export default function RouteEditorPage() {
type="number"
min="0"
value={timeOffsetInput}
onChange={(e) => setTimeOffsetInput(parseInt(e.target.value) || 0)}
onChange={(e) => setTimeOffsetInput(normalizeTimeOffsetInput(e.target.value))}
className="input"
placeholder="0"
/>
@@ -691,7 +851,7 @@ export default function RouteEditorPage() {
</button>
<button
onClick={handleAddStop}
disabled={!selectedStopForAdd}
disabled={!selectedStopForAdd || savingStops}
className="btn-primary w-full sm:w-auto disabled:opacity-50"
>
Добавить