Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos, inspiraciones, watchlist, tracking de precios y mapa interactivo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
205
app/components/tracking/CreateTrackingForm.vue
Normal file
205
app/components/tracking/CreateTrackingForm.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { create } = useTrackedSearches()
|
||||
|
||||
const name = ref('')
|
||||
const departures = ref('')
|
||||
const destination = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const stayMinDays = ref(2)
|
||||
const stayMaxDays = ref(7)
|
||||
const passengers = ref({ adult: 1, child: 0, infant: 0 })
|
||||
const intervalHours = ref(24)
|
||||
const expiresAt = ref('')
|
||||
const submitting = ref(false)
|
||||
const error = ref('')
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
watch(dateFrom, (from) => {
|
||||
if (from && dateTo.value && dateTo.value < from) {
|
||||
dateTo.value = from
|
||||
}
|
||||
})
|
||||
|
||||
const intervalOptions = [
|
||||
{ label: 'Cada 6 horas', value: 6 },
|
||||
{ label: 'Cada 12 horas', value: 12 },
|
||||
{ label: 'Diario (24h)', value: 24 },
|
||||
{ label: 'Cada 2 dias', value: 48 },
|
||||
{ label: 'Semanal', value: 168 }
|
||||
]
|
||||
|
||||
// Cargar preferencias de usuario
|
||||
const { profile } = useUserPreferences()
|
||||
watch(profile, (p) => {
|
||||
if (p?.home_airports?.length) {
|
||||
departures.value = p.home_airports.join(',')
|
||||
}
|
||||
if (p?.default_adults) passengers.value.adult = p.default_adults
|
||||
if (p?.default_children) passengers.value.child = p.default_children
|
||||
if (p?.default_infants) passengers.value.infant = p.default_infants
|
||||
}, { immediate: true })
|
||||
|
||||
function buildRouteSummary() {
|
||||
const dep = departures.value.split(',').filter(Boolean).join(',')
|
||||
const dest = destination.value || '?'
|
||||
return `${dep} > ${dest}`
|
||||
}
|
||||
|
||||
function buildSearchParams() {
|
||||
const depList = departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||
const dest = destination.value.trim().toUpperCase()
|
||||
|
||||
const now = new Date()
|
||||
const defaultFrom = now.toISOString().slice(0, 10)
|
||||
const defaultTo = new Date(now.getTime() + 30 * 86400000).toISOString().slice(0, 10)
|
||||
const from = dateFrom.value || defaultFrom
|
||||
const to = dateTo.value || defaultTo
|
||||
|
||||
const isOneWay = from === to
|
||||
|
||||
const stops = isOneWay
|
||||
? [{
|
||||
locations: [dest],
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}]
|
||||
: [
|
||||
{
|
||||
locations: [dest],
|
||||
stayRange: { min: stayMinDays.value * 24, max: stayMaxDays.value * 24 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: true
|
||||
},
|
||||
{
|
||||
locations: depList,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
departures: depList,
|
||||
local: 'en',
|
||||
departureDateInterval: {
|
||||
begin: `${from}T00:00:00+00:00`,
|
||||
end: `${to}T00:00:00+00:00`
|
||||
},
|
||||
stops,
|
||||
endInSameLocation: !isOneWay,
|
||||
maxStops: null,
|
||||
fixStopsOrder: false,
|
||||
stopLength: { min: 0, max: 0, isSet: false },
|
||||
maxResults: 45,
|
||||
passengersCount: passengers.value
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!departures.value || !destination.value || !name.value) {
|
||||
error.value = 'Rellena nombre, origen y destino'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await create({
|
||||
name: name.value,
|
||||
searchParams: buildSearchParams(),
|
||||
routeSummary: buildRouteSummary(),
|
||||
intervalHours: intervalHours.value,
|
||||
expiresAt: expiresAt.value || undefined
|
||||
})
|
||||
emit('created')
|
||||
} catch (e: unknown) {
|
||||
const err = e as { data?: { message?: string } }
|
||||
error.value = err?.data?.message || 'Error al crear seguimiento'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">Nuevo seguimiento de precios</h3>
|
||||
<UButton icon="i-lucide-x" color="neutral" variant="ghost" size="xs" @click="emit('cancel')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Nombre" required>
|
||||
<UInput v-model="name" placeholder="Ej: Madrid-Londres julio" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Origen" required>
|
||||
<SearchAirportInput v-model="departures" placeholder="Aeropuerto origen" icon="i-lucide-plane-takeoff" multiple />
|
||||
</UFormField>
|
||||
<UFormField label="Destino" required>
|
||||
<SearchAirportInput v-model="destination" placeholder="Aeropuerto destino" icon="i-lucide-plane-landing" multiple />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Fecha desde">
|
||||
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Fecha hasta">
|
||||
<UInput v-model="dateTo" type="date" :min="dateFrom || today" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Estancia minima (dias)">
|
||||
<UInput v-model.number="stayMinDays" type="number" :min="1" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Estancia maxima (dias)">
|
||||
<UInput v-model.number="stayMaxDays" type="number" :min="1" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Pasajeros">
|
||||
<PassengerPicker v-model="passengers" />
|
||||
</UFormField>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Frecuencia de busqueda">
|
||||
<select
|
||||
v-model.number="intervalHours"
|
||||
class="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm"
|
||||
>
|
||||
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</UFormField>
|
||||
<UFormField label="Expira el (opcional)">
|
||||
<UInput v-model="expiresAt" type="date" :min="today" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UAlert v-if="error" :title="error" color="error" icon="i-lucide-alert-circle" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton label="Cancelar" color="neutral" variant="outline" @click="emit('cancel')" />
|
||||
<UButton label="Crear seguimiento" icon="i-lucide-plus" :loading="submitting" @click="onSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
112
app/components/tracking/PriceChart.vue
Normal file
112
app/components/tracking/PriceChart.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { Line } from 'vue-chartjs'
|
||||
import type { ChartOptions } from 'chart.js'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
|
||||
|
||||
const props = defineProps<{
|
||||
snapshots: Array<{
|
||||
cheapest_price: number
|
||||
avg_price: number | null
|
||||
median_price: number | null
|
||||
total_results: number
|
||||
recorded_at: string
|
||||
}>
|
||||
}>()
|
||||
|
||||
const chartData = computed(() => {
|
||||
const labels = props.snapshots.map(s =>
|
||||
new Date(s.recorded_at).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||
)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Precio mas barato',
|
||||
data: props.snapshots.map(s => s.cheapest_price),
|
||||
borderColor: '#16a34a',
|
||||
backgroundColor: 'rgba(22, 163, 74, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
},
|
||||
{
|
||||
label: 'Precio medio',
|
||||
data: props.snapshots.map(s => s.avg_price),
|
||||
borderColor: '#9ca3af',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [],
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: 'Mediana',
|
||||
data: props.snapshots.map(s => s.median_price),
|
||||
borderColor: '#d1d5db',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 16
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(0)}\u20AC`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
ticks: {
|
||||
callback: (value) => `${value}\u20AC`
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="snapshots.length < 2" class="text-center py-8">
|
||||
<UIcon name="i-lucide-chart-line" class="text-3xl text-neutral-300 mb-2" />
|
||||
<p class="text-neutral-500 text-sm">Se necesitan al menos 2 puntos de datos para mostrar el grafico</p>
|
||||
</div>
|
||||
<div v-else class="h-72">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
96
app/components/tracking/RunHistory.vue
Normal file
96
app/components/tracking/RunHistory.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
runs: SearchRun[]
|
||||
}>()
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'completed': return { label: 'Completado', color: 'success' as const }
|
||||
case 'running': return { label: 'Ejecutando', color: 'info' as const }
|
||||
case 'failed': return { label: 'Error', color: 'error' as const }
|
||||
default: return { label: 'Pendiente', color: 'neutral' as const }
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatShortDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function duration(start: string | null, end: string | null) {
|
||||
if (!start || !end) return '-'
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-semibold text-sm mb-3">Historial de ejecuciones</h3>
|
||||
|
||||
<div v-if="runs.length === 0" class="text-center py-6">
|
||||
<p class="text-sm text-neutral-500">Aun no hay ejecuciones</p>
|
||||
</div>
|
||||
|
||||
<UCard v-for="run in runs" :key="run.id" class="!p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<UBadge :label="statusBadge(run.status).label" :color="statusBadge(run.status).color" size="xs" />
|
||||
<span class="text-xs text-muted">{{ formatDate(run.created_at) }}</span>
|
||||
<UBadge v-if="run.from_cache" label="Cache" color="neutral" variant="outline" size="xs" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-sm shrink-0">
|
||||
<span v-if="run.cheapest_price != null" class="font-medium">
|
||||
{{ run.cheapest_price.toFixed(0) }}€
|
||||
</span>
|
||||
<span v-if="run.total_trips_found > 0" class="text-xs text-muted">
|
||||
{{ run.total_trips_found }} vuelos
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{{ duration(run.started_at, run.completed_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<p v-if="run.error_message" class="text-xs text-red-500 mt-1">
|
||||
{{ run.error_message }}
|
||||
</p>
|
||||
|
||||
<!-- Top trips preview -->
|
||||
<div v-if="run.top_trips && run.top_trips.length > 0" class="mt-2 space-y-1.5">
|
||||
<NuxtLink
|
||||
v-for="(trip, i) in run.top_trips.slice(0, 3)"
|
||||
:key="i"
|
||||
:to="trip.bookingToken ? `/detail/${encodeURIComponent(trip.bookingToken)}?adults=1` : undefined"
|
||||
class="block text-xs text-muted rounded px-1.5 py-1 -mx-1.5 transition-colors"
|
||||
:class="trip.bookingToken ? 'hover:bg-neutral-100 dark:hover:bg-neutral-800 cursor-pointer' : ''"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground">{{ trip.price?.toFixed(0) }}€</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 flex-1">
|
||||
<span v-for="(leg, j) in trip.legs" :key="j" class="flex items-center gap-1">
|
||||
<UIcon :name="j === 0 ? 'i-lucide-plane-takeoff' : 'i-lucide-plane-landing'" class="text-[10px]" />
|
||||
{{ leg.from }} > {{ leg.to }}
|
||||
<span v-if="leg.departure" class="text-muted">{{ formatShortDate(leg.departure) }}</span>
|
||||
<span v-if="leg.airlines?.length" class="text-muted">({{ leg.airlines.join(', ') }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<UIcon v-if="trip.bookingToken" name="i-lucide-arrow-right" class="text-neutral-400 text-xs shrink-0" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
124
app/components/tracking/TrackedSearchCard.vue
Normal file
124
app/components/tracking/TrackedSearchCard.vue
Normal file
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
search: {
|
||||
id: string
|
||||
name: string
|
||||
route_summary: string
|
||||
interval_hours: number
|
||||
is_active: boolean
|
||||
next_run_at: string | null
|
||||
last_run_at: string | null
|
||||
run_count: number
|
||||
last_error: string | null
|
||||
expires_at: string | null
|
||||
latest_snapshot: {
|
||||
cheapest_price: number
|
||||
avg_price: number | null
|
||||
total_results: number
|
||||
recorded_at: string
|
||||
} | null
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggle: [id: string, active: boolean]
|
||||
remove: [id: string]
|
||||
}>()
|
||||
|
||||
function statusBadge() {
|
||||
if (!props.search.is_active) return { label: 'Pausada', color: 'neutral' as const }
|
||||
if (props.search.last_error) return { label: 'Error', color: 'error' as const }
|
||||
if (props.search.expires_at && new Date(props.search.expires_at) < new Date()) return { label: 'Expirada', color: 'warning' as const }
|
||||
return { label: 'Activa', color: 'success' as const }
|
||||
}
|
||||
|
||||
function formatInterval(hours: number) {
|
||||
if (hours < 24) return `Cada ${hours}h`
|
||||
if (hours === 24) return 'Diario'
|
||||
if (hours === 48) return 'Cada 2 dias'
|
||||
if (hours === 168) return 'Semanal'
|
||||
return `Cada ${Math.round(hours / 24)} dias`
|
||||
}
|
||||
|
||||
function timeAgo(date: string) {
|
||||
const diff = Date.now() - new Date(date).getTime()
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 60) return `hace ${mins}min`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `hace ${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `hace ${days}d`
|
||||
}
|
||||
|
||||
function timeUntil(date: string) {
|
||||
const diff = new Date(date).getTime() - Date.now()
|
||||
if (diff < 0) return 'pendiente'
|
||||
const mins = Math.floor(diff / 60000)
|
||||
if (mins < 60) return `en ${mins}min`
|
||||
const hours = Math.floor(mins / 60)
|
||||
if (hours < 24) return `en ${hours}h`
|
||||
const days = Math.floor(hours / 24)
|
||||
return `en ${days}d`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4 hover:ring-1 hover:ring-primary-500 transition-all">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<NuxtLink :to="`/tracking/${search.id}`" class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<p class="font-semibold truncate">{{ search.name }}</p>
|
||||
<UBadge :label="statusBadge().label" :color="statusBadge().color" size="xs" />
|
||||
</div>
|
||||
<p class="text-sm text-muted mb-1">{{ search.route_summary }}</p>
|
||||
<div class="flex items-center gap-3 text-xs text-muted flex-wrap">
|
||||
<span class="flex items-center gap-1">
|
||||
<UIcon name="i-lucide-timer" class="text-xs" />
|
||||
{{ formatInterval(search.interval_hours) }}
|
||||
</span>
|
||||
<span v-if="search.run_count > 0" class="flex items-center gap-1">
|
||||
<UIcon name="i-lucide-activity" class="text-xs" />
|
||||
{{ search.run_count }} ejecucion{{ search.run_count !== 1 ? 'es' : '' }}
|
||||
</span>
|
||||
<span v-if="search.last_run_at" class="flex items-center gap-1">
|
||||
<UIcon name="i-lucide-clock" class="text-xs" />
|
||||
{{ timeAgo(search.last_run_at) }}
|
||||
</span>
|
||||
<span v-if="search.is_active && search.next_run_at" class="flex items-center gap-1">
|
||||
<UIcon name="i-lucide-calendar-clock" class="text-xs" />
|
||||
Proxima: {{ timeUntil(search.next_run_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Precio actual -->
|
||||
<NuxtLink :to="`/tracking/${search.id}`" class="text-right shrink-0">
|
||||
<div v-if="search.latest_snapshot" class="mb-1">
|
||||
<p class="text-lg font-bold">{{ search.latest_snapshot.cheapest_price.toFixed(0) }}€</p>
|
||||
<p class="text-xs text-muted">{{ search.latest_snapshot.total_results }} resultados</p>
|
||||
</div>
|
||||
<p v-else class="text-sm text-muted">Sin datos</p>
|
||||
</NuxtLink>
|
||||
|
||||
<!-- Acciones -->
|
||||
<div class="flex flex-col gap-1 shrink-0">
|
||||
<UButton
|
||||
:icon="search.is_active ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:title="search.is_active ? 'Pausar' : 'Reanudar'"
|
||||
@click="emit('toggle', search.id, !search.is_active)"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
title="Eliminar"
|
||||
@click="emit('remove', search.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
191
app/components/tracking/TrackingConfig.vue
Normal file
191
app/components/tracking/TrackingConfig.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
search: {
|
||||
id: string
|
||||
name: string
|
||||
interval_hours: number
|
||||
is_active: boolean
|
||||
expires_at: string | null
|
||||
search_params: Record<string, unknown>
|
||||
route_summary: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const { update } = useTrackedSearches()
|
||||
|
||||
const name = ref(props.search.name)
|
||||
const intervalHours = ref(props.search.interval_hours)
|
||||
const isActive = ref(props.search.is_active)
|
||||
const expiresAt = ref(props.search.expires_at?.slice(0, 10) || '')
|
||||
const saving = ref(false)
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
// Parametros de busqueda editables
|
||||
const params = props.search.search_params
|
||||
const departures = ref((params.departures as string[])?.join(',') || '')
|
||||
const destination = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
|
||||
watch(dateFrom, (from) => {
|
||||
if (from && dateTo.value && dateTo.value < from) {
|
||||
dateTo.value = from
|
||||
}
|
||||
})
|
||||
const stayMinDays = ref(2)
|
||||
const stayMaxDays = ref(7)
|
||||
|
||||
// Extraer destino y fechas de los search_params guardados
|
||||
const interval = params.departureDateInterval as { begin?: string; end?: string } | undefined
|
||||
if (interval?.begin) dateFrom.value = interval.begin.slice(0, 10)
|
||||
if (interval?.end) dateTo.value = interval.end.slice(0, 10)
|
||||
|
||||
const stops = params.stops as Array<{ locations: string[]; stayRange?: { min: number; max: number } }> | undefined
|
||||
if (stops?.[0]?.locations?.length) destination.value = stops[0].locations.join(',')
|
||||
if (stops?.[0]?.stayRange) {
|
||||
stayMinDays.value = Math.round((stops[0].stayRange.min || 0) / 24) || 2
|
||||
stayMaxDays.value = Math.round((stops[0].stayRange.max || 0) / 24) || 7
|
||||
}
|
||||
|
||||
const intervalOptions = [
|
||||
{ label: 'Cada 6 horas', value: 6 },
|
||||
{ label: 'Cada 12 horas', value: 12 },
|
||||
{ label: 'Diario (24h)', value: 24 },
|
||||
{ label: 'Cada 2 dias', value: 48 },
|
||||
{ label: 'Semanal', value: 168 }
|
||||
]
|
||||
|
||||
function buildSearchParams() {
|
||||
const depList = departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||
const destList = destination.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||
|
||||
const from = dateFrom.value || new Date().toISOString().slice(0, 10)
|
||||
const to = dateTo.value || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10)
|
||||
const isOneWay = from === to
|
||||
|
||||
const newStops = isOneWay
|
||||
? [{
|
||||
locations: destList,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}]
|
||||
: [
|
||||
{
|
||||
locations: destList,
|
||||
stayRange: { min: stayMinDays.value * 24, max: stayMaxDays.value * 24 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: true
|
||||
},
|
||||
{
|
||||
locations: depList,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
...params,
|
||||
departures: depList,
|
||||
departureDateInterval: {
|
||||
begin: `${from}T00:00:00+00:00`,
|
||||
end: `${to}T00:00:00+00:00`
|
||||
},
|
||||
stops: newStops,
|
||||
endInSameLocation: !isOneWay
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const depList = departures.value.split(',').filter(Boolean)
|
||||
const destList = destination.value.split(',').filter(Boolean)
|
||||
const routeSummary = `${depList.join(',')} > ${destList.join(',')}`
|
||||
|
||||
await update(props.search.id, {
|
||||
name: name.value,
|
||||
interval_hours: intervalHours.value,
|
||||
is_active: isActive.value,
|
||||
expires_at: expiresAt.value || null,
|
||||
search_params: buildSearchParams(),
|
||||
route_summary: routeSummary
|
||||
})
|
||||
emit('updated')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="font-semibold text-sm">Configuracion</h3>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<UFormField label="Nombre">
|
||||
<UInput v-model="name" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Origen">
|
||||
<SearchAirportInput v-model="departures" placeholder="MAD" icon="i-lucide-plane-takeoff" multiple />
|
||||
</UFormField>
|
||||
<UFormField label="Destino">
|
||||
<SearchAirportInput v-model="destination" placeholder="BCN" icon="i-lucide-plane-landing" multiple />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Fecha desde">
|
||||
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Fecha hasta">
|
||||
<UInput v-model="dateTo" type="date" :min="dateFrom || today" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Estancia min (dias)">
|
||||
<UInput v-model.number="stayMinDays" type="number" :min="1" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Estancia max (dias)">
|
||||
<UInput v-model.number="stayMaxDays" type="number" :min="1" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<UFormField label="Frecuencia">
|
||||
<select
|
||||
v-model.number="intervalHours"
|
||||
class="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm"
|
||||
>
|
||||
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Expira el">
|
||||
<UInput v-model="expiresAt" type="date" :min="today" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Activa</span>
|
||||
<USwitch v-model="isActive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UButton label="Guardar cambios" size="sm" :loading="saving" @click="save" />
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
Reference in New Issue
Block a user