Fix route stop draft editing
This commit is contained in:
@@ -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"
|
||||
>
|
||||
Добавить
|
||||
|
||||
Reference in New Issue
Block a user