675 lines
26 KiB
Vue
675 lines
26 KiB
Vue
<template>
|
|
<div class="admin-routes" :class="{ 'with-map': selectedRoute }">
|
|
<div class="header">
|
|
<button class="back-link" @click="handleBack">← Volver</button>
|
|
<h1>Gestión de Rutas</h1>
|
|
<button v-if="!selectedRoute" class="add-button" @click="createRoute">
|
|
<span class="material-icons">add</span> Nueva Ruta
|
|
</button>
|
|
</div>
|
|
|
|
<div class="main-layout">
|
|
<!-- MAP SIDE (Visible when editing) -->
|
|
<div v-if="selectedRoute" class="map-container">
|
|
<div id="route-map" class="route-map"></div>
|
|
<div class="map-hint">
|
|
<span class="material-icons">info</span>
|
|
Haz clic en el mapa para crear una nueva parada o en una existente para añadirla a la ruta.
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CONTENT SIDE -->
|
|
<div class="content-side">
|
|
<!-- Route List & Create Form -->
|
|
<div v-if="!selectedRoute" class="route-list">
|
|
<!-- Create Form (Simplified) -->
|
|
<div v-if="isCreating" class="create-route-form nexus-glass">
|
|
<div class="form-header">
|
|
<span class="material-icons">route</span>
|
|
<h3>Nueva Ruta</h3>
|
|
</div>
|
|
|
|
<div class="form-body">
|
|
<div class="form-group">
|
|
<label>Punto de Salida</label>
|
|
<input v-model="newRouteForm.origin" type="text" placeholder="Ej: David">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Punto de Llegada</label>
|
|
<input v-model="newRouteForm.destination" type="text" placeholder="Ej: Boquete">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Nombre de Ruta (Auto)</label>
|
|
<input :value="computedRouteName" disabled type="text" placeholder="Salida - Llegada">
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-footer">
|
|
<button class="cancel-btn" @click="isCreating = false">Cancelar</button>
|
|
<button class="save-btn" :disabled="!isFormValid" @click="confirmCreateRoute">
|
|
<span class="material-icons">save</span> Guardar Ruta
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Existing List -->
|
|
<div v-for="route in routes" :key="route.id" class="route-card" @click="selectRoute(route)">
|
|
<div class="route-info">
|
|
<h3>{{ route.name }}</h3>
|
|
<p>{{ route.origin_city }} → {{ route.destination_city }}</p>
|
|
<div class="status" :class="route.status">{{ translateStatus(route.status) }}</div>
|
|
</div>
|
|
<span class="material-icons">chevron_right</span>
|
|
</div>
|
|
<div v-if="routes.length === 0 && !isCreating" class="empty-state">No hay rutas registradas.</div>
|
|
</div>
|
|
|
|
<!-- Single Route Editor -->
|
|
<div v-else class="route-editor">
|
|
<div class="editor-header">
|
|
<h2>Editar: {{ selectedRoute.name }}</h2>
|
|
<div class="editor-actions">
|
|
<button class="delete-route-btn" @click="deleteRoute">Eliminar Ruta</button>
|
|
<button class="close-btn" @click="selectedRoute = null">Cerrar</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="editor-scroll">
|
|
<section class="form-section">
|
|
<h3>Información Básica</h3>
|
|
<div class="route-details-form">
|
|
<div class="form-group">
|
|
<label>Nombre de la Ruta</label>
|
|
<input v-model="selectedRoute.name" @change="updateRouteDetails" type="text" placeholder="Ej: Boquete - David">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Origen</label>
|
|
<input v-model="selectedRoute.origin_city" @change="updateRouteDetails" type="text" placeholder="David">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Destino</label>
|
|
<input v-model="selectedRoute.destination_city" @change="updateRouteDetails" type="text" placeholder="Boquete">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Velocidad Promedio (km/h)</label>
|
|
<input v-model.number="selectedRoute.average_speed_kmh" @change="updateRouteDetails" type="number" placeholder="30">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Estado</label>
|
|
<select v-model="selectedRoute.status" @change="updateRouteDetails">
|
|
<option value="active">Activa</option>
|
|
<option value="inactive">Inactiva</option>
|
|
<option value="maintenance">Mantenimiento</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Color de Línea</label>
|
|
<input v-model="selectedRoute.color" @change="updateRouteDetails" type="color">
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="stops-section">
|
|
<div class="section-header">
|
|
<h3>Paradas en la Ruta ({{ routeStops.length }})</h3>
|
|
<button class="save-changes-btn" @click="selectedRoute = null">Guardar Todo</button>
|
|
</div>
|
|
|
|
<div class="add-stop-inline">
|
|
<select v-model="newStopId">
|
|
<option value="">Selecciona una parada para añadir...</option>
|
|
<option v-for="stop in availableStops" :key="stop.id" :value="stop.id">
|
|
{{ stop.name }}
|
|
</option>
|
|
</select>
|
|
<button @click="addStop" :disabled="!newStopId" class="add-btn">Añadir</button>
|
|
</div>
|
|
|
|
<div class="stops-list-editor">
|
|
<div v-for="(stop, index) in routeStops" :key="stop.id" class="stop-item">
|
|
<div class="stop-main">
|
|
<div class="stop-rank">{{ index + 1 }}</div>
|
|
<div class="stop-details">
|
|
<div class="name">{{ stop.name }}</div>
|
|
<div class="stats">+{{ Math.round(arrivalTimes[index] || 0) }} min</div>
|
|
</div>
|
|
</div>
|
|
<div class="stop-edit-fields">
|
|
<div class="input-with-label">
|
|
<label>Espera (min)</label>
|
|
<input
|
|
v-model.number="stop.stop_delay_minutes"
|
|
type="number"
|
|
min="0"
|
|
@change="updateStop(stop)"
|
|
>
|
|
</div>
|
|
</div>
|
|
<div class="stop-controls">
|
|
<button class="move-btn" @click="moveStop(stop, index, -1)" :disabled="index === 0">
|
|
<span class="material-icons">expand_less</span>
|
|
</button>
|
|
<button class="move-btn" @click="moveStop(stop, index, 1)" :disabled="index === routeStops.length - 1">
|
|
<span class="material-icons">expand_more</span>
|
|
</button>
|
|
<button class="remove-btn" @click="removeStop(stop)">
|
|
<span class="material-icons">close</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div v-if="routeStops.length === 0" class="no-stops">
|
|
No hay paradas en esta ruta. Usa el mapa o el buscador superior.
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, computed, watch, nextTick } from 'vue'
|
|
import { useRouter } from 'vue-router'
|
|
import { routesService } from '@/services/routesService'
|
|
import { busStopsService } from '@/services/busStopsService'
|
|
import { useGoogleMaps } from '@/composables/useGoogleMaps'
|
|
import type { Route, BusStop } from '@/types'
|
|
|
|
const router = useRouter()
|
|
const routes = ref<Route[]>([])
|
|
const allStops = ref<BusStop[]>([])
|
|
const selectedRoute = ref<Route | null>(null)
|
|
const routeStops = ref<BusStop[]>([])
|
|
const newStopId = ref('')
|
|
const isCreating = ref(false)
|
|
const newRouteForm = ref({
|
|
origin: '',
|
|
destination: ''
|
|
})
|
|
|
|
const computedRouteName = computed(() => {
|
|
if (!newRouteForm.value.origin || !newRouteForm.value.destination) return ''
|
|
return `${newRouteForm.value.origin} - ${newRouteForm.value.destination}`
|
|
})
|
|
|
|
const isFormValid = computed(() => {
|
|
return newRouteForm.value.origin.trim() !== '' && newRouteForm.value.destination.trim() !== ''
|
|
})
|
|
|
|
// Map integration
|
|
const { initMap, addNumberedMarker, addMarker, addPolyline, clearAllOverlays, isLoaded: mapsLoaded, map: gmap } = useGoogleMaps()
|
|
|
|
onMounted(async () => {
|
|
await loadInitialData()
|
|
})
|
|
|
|
async function loadInitialData() {
|
|
routes.value = await routesService.getAllRoutes()
|
|
allStops.value = await busStopsService.getAllBusStops()
|
|
}
|
|
|
|
const availableStops = computed(() => {
|
|
const currentIds = new Set(routeStops.value.map((s: BusStop) => s.id))
|
|
return allStops.value.filter((s: BusStop) => !currentIds.has(s.id))
|
|
})
|
|
|
|
const arrivalTimes = computed(() => {
|
|
if (!selectedRoute.value || !routeStops.value.length) return []
|
|
const speed = selectedRoute.value.average_speed_kmh || 30
|
|
const speedKmPerMin = speed / 60
|
|
const times: number[] = []
|
|
let currentTime = 0
|
|
times.push(0)
|
|
for (let i = 1; i < routeStops.value.length; i++) {
|
|
const prev = routeStops.value[i-1]
|
|
const curr = routeStops.value[i]
|
|
if (!prev || !curr) continue;
|
|
currentTime += (prev.stop_delay_minutes || 0)
|
|
const dist = calculateDistance(prev.latitude, prev.longitude, curr.latitude, curr.longitude)
|
|
currentTime += (dist / speedKmPerMin)
|
|
times.push(currentTime)
|
|
}
|
|
return times
|
|
})
|
|
|
|
// Watchers for Map Rendering
|
|
watch([selectedRoute, mapsLoaded], ([route, loaded]) => {
|
|
if (route && loaded) {
|
|
nextTick(() => {
|
|
initRouteMap()
|
|
})
|
|
}
|
|
})
|
|
|
|
// Update map when stops change
|
|
watch(routeStops, () => {
|
|
if (selectedRoute.value) updateMapOverlays()
|
|
}, { deep: true })
|
|
|
|
async function initRouteMap() {
|
|
initMap('route-map', { lat: 8.4284, lng: -82.4309 }, 13)
|
|
|
|
// Add Click listener to Create New Stops
|
|
if (gmap.value) {
|
|
gmap.value.addListener('click', async (e: any) => {
|
|
const lat = e.latLng.lat()
|
|
const lng = e.latLng.lng()
|
|
|
|
const name = prompt('Crear nueva parada en este punto? Introduce el nombre:')
|
|
if (name) {
|
|
try {
|
|
const newStop = await busStopsService.createBusStop({
|
|
name,
|
|
latitude: lat,
|
|
longitude: lng,
|
|
stop_type: 'regular',
|
|
has_shelter: false,
|
|
has_seating: false,
|
|
is_accessible: true,
|
|
city: selectedRoute.value?.origin_city || 'David'
|
|
})
|
|
// Refetch all stops
|
|
allStops.value = await busStopsService.getAllBusStops()
|
|
// Add to current route
|
|
await addExistingStop(newStop.id)
|
|
} catch (err) {
|
|
alert('Error creando parada')
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
updateMapOverlays()
|
|
}
|
|
|
|
function updateMapOverlays() {
|
|
clearAllOverlays()
|
|
|
|
// 1. Draw Polyline
|
|
if (routeStops.value.length > 1) {
|
|
const path = routeStops.value.map(s => ({ lat: s.latitude, lng: s.longitude }))
|
|
addPolyline(path)
|
|
}
|
|
|
|
// 2. Add Route Markers (Yellow Numbered)
|
|
routeStops.value.forEach((stop, index) => {
|
|
addNumberedMarker(
|
|
{ lat: stop.latitude, lng: stop.longitude },
|
|
index + 1,
|
|
stop.name
|
|
)
|
|
})
|
|
|
|
// 3. Add Available Markers (Small Gray Dots)
|
|
availableStops.value.forEach(stop => {
|
|
const marker = addMarker(
|
|
{ lat: stop.latitude, lng: stop.longitude },
|
|
{
|
|
title: stop.name,
|
|
icon: {
|
|
path: google.maps.SymbolPath.CIRCLE,
|
|
scale: 6,
|
|
fillColor: '#666',
|
|
fillOpacity: 0.8,
|
|
strokeWeight: 1,
|
|
strokeColor: '#fff'
|
|
}
|
|
}
|
|
)
|
|
if (marker) {
|
|
marker.addListener('click', () => {
|
|
addExistingStop(stop.id)
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
// Actions
|
|
function handleBack() {
|
|
if (selectedRoute.value) {
|
|
selectedRoute.value = null
|
|
} else {
|
|
router.push('/admin')
|
|
}
|
|
}
|
|
|
|
async function createRoute() {
|
|
isCreating.value = true
|
|
newRouteForm.value = { origin: '', destination: '' }
|
|
}
|
|
|
|
async function confirmCreateRoute() {
|
|
try {
|
|
await routesService.createRoute({
|
|
name: computedRouteName.value,
|
|
origin_city: newRouteForm.value.origin,
|
|
destination_city: newRouteForm.value.destination,
|
|
status: 'ACTIVE',
|
|
color: '#FEE715',
|
|
direction: 'outbound'
|
|
})
|
|
routes.value = await routesService.getAllRoutes()
|
|
isCreating.value = false
|
|
} catch (err: any) {
|
|
console.error('Error creating route:', err)
|
|
|
|
if (err.response?.status === 401) {
|
|
// El interceptor ya redirige al login, pero mostramos aviso
|
|
alert('Tu sesión ha expirado. Serás redirigido al inicio de sesión.')
|
|
return
|
|
} else if (err.response?.status === 403) {
|
|
alert('No tienes permisos de administrador para crear rutas.')
|
|
return
|
|
} else if (!err.response && err.request) {
|
|
// Network Error - servidor no respondió
|
|
alert('No se pudo conectar al servidor. Si es la primera solicitud del día, el servidor puede tardar ~30 segundos en iniciar. Por favor, intenta de nuevo en un momento.')
|
|
return
|
|
}
|
|
|
|
const errorMsg = err.response?.data?.detail
|
|
|| err.response?.data?.message
|
|
|| err.message
|
|
|| 'Error desconocido'
|
|
alert(`No se pudo crear la ruta: ${errorMsg}`)
|
|
}
|
|
}
|
|
|
|
async function selectRoute(route: Route) {
|
|
selectedRoute.value = route
|
|
routeStops.value = await routesService.getRouteStops(route.id)
|
|
}
|
|
|
|
async function updateRouteDetails() {
|
|
if (!selectedRoute.value) return
|
|
try {
|
|
await routesService.updateRoute(selectedRoute.value.id, {
|
|
name: selectedRoute.value.name,
|
|
origin_city: selectedRoute.value.origin_city,
|
|
destination_city: selectedRoute.value.destination_city,
|
|
average_speed_kmh: selectedRoute.value.average_speed_kmh,
|
|
status: selectedRoute.value.status,
|
|
color: selectedRoute.value.color
|
|
})
|
|
} catch (err: any) {
|
|
console.error('Error updating route:', err)
|
|
// Opcional: mostrar notificación sutil en lugar de alert recurrente
|
|
}
|
|
}
|
|
|
|
async function addStop() {
|
|
if (newStopId.value) {
|
|
await addExistingStop(newStopId.value)
|
|
newStopId.value = ''
|
|
}
|
|
}
|
|
|
|
async function addExistingStop(stopId: string) {
|
|
if (!selectedRoute.value) return
|
|
try {
|
|
await routesService.addStopToRoute(selectedRoute.value.id, {
|
|
stop_id: stopId,
|
|
stop_order: routeStops.value.length + 1
|
|
})
|
|
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
|
|
} catch (err: any) {
|
|
console.error('Error adding stop:', err)
|
|
alert('Error al añadir parada: ' + (err.response?.data?.detail || err.message))
|
|
}
|
|
}
|
|
|
|
async function updateStop(stop: BusStop) {
|
|
if (!selectedRoute.value) return
|
|
await routesService.updateRouteStop(selectedRoute.value.id, stop.id, {
|
|
stop_delay_minutes: stop.stop_delay_minutes || 0
|
|
})
|
|
}
|
|
|
|
async function moveStop(stop: BusStop, index: number, direction: number) {
|
|
if (!selectedRoute.value) return
|
|
await routesService.updateRouteStop(selectedRoute.value.id, stop.id, {
|
|
stop_order: index + 1 + direction
|
|
})
|
|
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
|
|
}
|
|
|
|
async function removeStop(stop: BusStop) {
|
|
if (!selectedRoute.value) return
|
|
if (confirm(`Borrar ${stop.name} de la ruta?`)) {
|
|
await routesService.removeStopFromRoute(selectedRoute.value.id, stop.id)
|
|
routeStops.value = await routesService.getRouteStops(selectedRoute.value.id)
|
|
}
|
|
}
|
|
|
|
async function deleteRoute() {
|
|
if (!selectedRoute.value) return
|
|
if (confirm(`Estás SEGURO de que quieres eliminar la ruta ${selectedRoute.value.name}? Esta acción es permanente.`)) {
|
|
try {
|
|
await routesService.deleteRoute(selectedRoute.value.id)
|
|
selectedRoute.value = null
|
|
routes.value = await routesService.getAllRoutes()
|
|
} catch (err: any) {
|
|
console.error('Error deleting route:', err)
|
|
alert('No se pudo eliminar la ruta: ' + (err.response?.data?.detail || err.message))
|
|
}
|
|
}
|
|
}
|
|
|
|
function calculateDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
|
|
const R = 6371
|
|
const dLat = (lat2 - lat1) * Math.PI / 180
|
|
const dLon = (lon2 - lon1) * Math.PI / 180
|
|
const a = Math.sin(dLat/2) * Math.sin(dLat/2) + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLon/2) * Math.sin(dLon/2)
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a))
|
|
}
|
|
|
|
function translateStatus(s: string) {
|
|
return { active: 'Activa', inactive: 'Inactiva', maintenance: 'Mantenimiento' }[s] || s
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.admin-routes {
|
|
padding: 24px;
|
|
background: #0f172a;
|
|
min-height: 100vh;
|
|
color: white;
|
|
}
|
|
|
|
.header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
h1 { font-size: 1.5rem; font-weight: 800; color: #FEE715; margin: 0; }
|
|
|
|
.back-link {
|
|
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1);
|
|
color: white; padding: 8px 16px; border-radius: 8px; cursor: pointer;
|
|
}
|
|
|
|
.add-button {
|
|
background: #FEE715; color: #101820; border: none; padding: 10px 20px;
|
|
border-radius: 10px; font-weight: 800; cursor: pointer; display: flex; align-items: center; gap: 8px;
|
|
}
|
|
|
|
.main-layout {
|
|
display: flex;
|
|
gap: 24px;
|
|
height: calc(100vh - 100px);
|
|
}
|
|
|
|
.map-container {
|
|
flex: 1;
|
|
position: relative;
|
|
border-radius: 20px;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.route-map { width: 100%; height: 100%; }
|
|
|
|
.map-hint {
|
|
position: absolute; bottom: 16px; left: 16px; right: 16px;
|
|
background: rgba(0,0,0,0.8); backdrop-filter: blur(8px);
|
|
padding: 12px; border-radius: 12px; color: #94a3b8; font-size: 0.8rem;
|
|
display: flex; align-items: center; gap: 8px; border: 1px solid rgba(255,255,255,0.1);
|
|
}
|
|
|
|
.content-side {
|
|
width: 400px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.with-map .content-side { width: 45%; }
|
|
@media (max-width: 1000px) {
|
|
.main-layout { flex-direction: column; height: auto; }
|
|
.content-side { width: 100% !important; }
|
|
.map-container { height: 400px; order: -1; }
|
|
}
|
|
|
|
/* Route List */
|
|
.route-list { display: flex; flex-direction: column; gap: 12px; height: 100%; }
|
|
.route-card {
|
|
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.1);
|
|
padding: 16px; border-radius: 16px; cursor: pointer; display: flex;
|
|
justify-content: space-between; align-items: center; transition: all 0.2s;
|
|
}
|
|
.route-card:hover { border-color: #FEE715; background: rgba(254,231,21,0.05); }
|
|
.route-info h3 { margin: 0; font-size: 1.1rem; }
|
|
.route-info p { margin: 4px 0 10px; color: #94a3b8; font-size: 0.85rem; }
|
|
.status { font-size: 0.7rem; font-weight: 800; padding: 4px 8px; border-radius: 6px; width: fit-content; text-transform: uppercase; }
|
|
.status.active { background: rgba(34, 197, 94, 0.1); color: #4ade80; }
|
|
.status.inactive { background: rgba(239, 68, 68, 0.1); color: #f87171; }
|
|
|
|
/* Nexus Create Form */
|
|
.create-route-form {
|
|
margin-bottom: 24px;
|
|
padding: 24px;
|
|
border-radius: 20px;
|
|
background: rgba(30, 41, 59, 0.5);
|
|
}
|
|
|
|
.nexus-glass {
|
|
backdrop-filter: blur(20px);
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
}
|
|
|
|
.form-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
margin-bottom: 20px;
|
|
color: #FEE715;
|
|
}
|
|
|
|
.form-header h3 {
|
|
margin: 0;
|
|
font-size: 1.1rem;
|
|
font-weight: 800;
|
|
}
|
|
|
|
.form-body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.form-footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 12px;
|
|
}
|
|
|
|
.cancel-btn {
|
|
background: transparent;
|
|
border: 1px solid rgba(255,255,255,0.2);
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border-radius: 10px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.save-btn {
|
|
background: #FEE715;
|
|
color: #101820;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: 10px;
|
|
font-weight: 800;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.save-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
/* Editor View */
|
|
.route-editor {
|
|
background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.1);
|
|
border-radius: 20px; height: 100%; display: flex; flex-direction: column; overflow: hidden;
|
|
}
|
|
|
|
.editor-header { padding: 20px; border-bottom: 1px solid rgba(255,255,255,0.1); }
|
|
.editor-header h2 { margin: 0 0 12px; font-size: 1.25rem; font-weight: 800; }
|
|
.editor-actions { display: flex; gap: 10px; }
|
|
.close-btn { background: #334155; color: white; border: none; padding: 6px 16px; border-radius: 8px; cursor: pointer; }
|
|
.delete-route-btn { background: rgba(239, 68, 68, 0.1); color: #f87171; border: 1px solid rgba(239, 68, 68, 0.2); padding: 6px 16px; border-radius: 8px; cursor: pointer; }
|
|
|
|
.editor-scroll { flex: 1; overflow-y: auto; padding: 20px; }
|
|
|
|
.form-section { margin-bottom: 32px; }
|
|
.form-section h3 { font-size: 0.9rem; text-transform: uppercase; color: #FEE715; margin-bottom: 16px; letter-spacing: 0.05em; }
|
|
|
|
.route-details-form { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
|
.form-group { display: flex; flex-direction: column; gap: 6px; }
|
|
.form-group label { font-size: 0.75rem; color: #94a3b8; font-weight: 700; }
|
|
.form-group input, .form-group select {
|
|
background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 10px; color: white; font-size: 0.9rem;
|
|
}
|
|
|
|
.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
|
.save-changes-btn { background: #FEE715; color: #101820; border: none; padding: 8px 16px; border-radius: 8px; font-weight: 800; cursor: pointer; }
|
|
|
|
.add-stop-inline { display: flex; gap: 8px; margin-bottom: 20px; }
|
|
.add-stop-inline select { flex: 1; background: #1e293b; border: 1px solid #334155; border-radius: 8px; padding: 10px; color: white; }
|
|
.add-btn { background: #334155; color: white; border: none; padding: 0 16px; border-radius: 8px; cursor: pointer; }
|
|
|
|
.stops-list-editor { display: flex; flex-direction: column; gap: 8px; }
|
|
.stop-item {
|
|
background: #1e293b; border: 1px solid #334155; border-radius: 12px; padding: 12px;
|
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
|
}
|
|
.stop-main { display: flex; align-items: center; gap: 12px; flex: 1; }
|
|
.stop-rank { width: 28px; height: 28px; background: #FEE715; color: #101820; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 900; font-size: 0.8rem; }
|
|
.stop-details .name { font-weight: 700; font-size: 0.9rem; }
|
|
.stop-details .stats { font-size: 0.75rem; color: #94a3b8; margin-top: 2px; }
|
|
|
|
.stop-edit-fields { display: flex; gap: 12px; }
|
|
.input-with-label { display: flex; flex-direction: column; gap: 2px; }
|
|
.input-with-label label { font-size: 0.6rem; color: #64748b; text-transform: uppercase; font-weight: 800; }
|
|
.input-with-label input { width: 60px; background: #0f172a; border: 1px solid #334155; padding: 4px 6px; border-radius: 4px; color: white; text-align: center; }
|
|
|
|
.stop-controls { display: flex; gap: 4px; }
|
|
.move-btn, .remove-btn {
|
|
width: 32px; height: 32px; display: flex; align-items: center; justify-content: center;
|
|
border-radius: 6px; cursor: pointer; border: none; color: #94a3b8; background: transparent; transition: all 0.2s;
|
|
}
|
|
.move-btn:hover:not(:disabled) { background: rgba(255,255,255,0.05); color: #FEE715; }
|
|
.remove-btn:hover { background: rgba(239, 68, 68, 0.1); color: #f87171; }
|
|
.move-btn:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
|
|
.no-stops { text-align: center; padding: 40px; color: #64748b; font-style: italic; border: 1px dashed #334155; border-radius: 12px; }
|
|
.empty-state { text-align: center; padding: 40px; color: #64748b; }
|
|
</style>
|