feat: implement reverse scan (country → specific airports)
All checks were successful
Deploy / deploy (push) Successful in 30s
All checks were successful
Deploy / deploy (push) Successful in 30s
- DB schema: relaxed origin CHECK to >=2 chars, added scan_mode column to
scans and scheduled_scans, added origin_airport to routes and flights,
updated unique index to (scan_id, COALESCE(origin_airport,''), destination)
- Migrations: init_db.py recreates tables and adds columns via guarded ALTERs
- API: scan_mode field on ScanRequest/Scan; Route/Flight expose origin_airport;
GET /scans/{id}/flights accepts origin_airport filter; CreateScheduleRequest
and Schedule carry scan_mode; scheduler and run-now pass scan_mode through
- scan_processor: _write_route_incremental accepts origin_airport; process_scan
branches on scan_mode=reverse (country → airports × destinations × dates)
- Frontend: new CountrySelect component (populated from GET /api/v1/countries);
Scans page adds Direction toggle + CountrySelect for both modes; ScanDetails
shows Origin column for reverse scans and uses composite route keys; Re-run
preserves scan_mode
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ const api = axios.create({
|
||||
// Types
|
||||
export interface Scan {
|
||||
id: number;
|
||||
scan_mode: 'forward' | 'reverse';
|
||||
origin: string;
|
||||
country: string;
|
||||
start_date: string;
|
||||
@@ -30,6 +31,7 @@ export interface Scan {
|
||||
|
||||
export interface Schedule {
|
||||
id: number;
|
||||
scan_mode: 'forward' | 'reverse';
|
||||
origin: string;
|
||||
country: string;
|
||||
window_months: number;
|
||||
@@ -49,6 +51,7 @@ export interface Schedule {
|
||||
}
|
||||
|
||||
export interface CreateScheduleRequest {
|
||||
scan_mode?: 'forward' | 'reverse';
|
||||
origin: string;
|
||||
country: string;
|
||||
window_months?: number;
|
||||
@@ -65,6 +68,7 @@ export interface CreateScheduleRequest {
|
||||
export interface Route {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
origin_airport?: string;
|
||||
destination: string;
|
||||
destination_name: string;
|
||||
destination_city?: string;
|
||||
@@ -79,6 +83,7 @@ export interface Route {
|
||||
export interface Flight {
|
||||
id: number;
|
||||
scan_id: number;
|
||||
origin_airport?: string;
|
||||
destination: string;
|
||||
date: string;
|
||||
airline?: string;
|
||||
@@ -116,7 +121,14 @@ export interface PaginatedResponse<T> {
|
||||
};
|
||||
}
|
||||
|
||||
export interface Country {
|
||||
code: string;
|
||||
name: string;
|
||||
airport_count: number;
|
||||
}
|
||||
|
||||
export interface CreateScanRequest {
|
||||
scan_mode?: 'forward' | 'reverse';
|
||||
origin: string;
|
||||
country?: string; // Optional: provide either country or destinations
|
||||
destinations?: string[]; // Optional: provide either country or destinations
|
||||
@@ -155,9 +167,10 @@ export const scanApi = {
|
||||
});
|
||||
},
|
||||
|
||||
getFlights: (id: number, destination?: string, page = 1, limit = 50) => {
|
||||
getFlights: (id: number, destination?: string, originAirport?: string, page = 1, limit = 50) => {
|
||||
const params: Record<string, unknown> = { page, limit };
|
||||
if (destination) params.destination = destination;
|
||||
if (originAirport) params.origin_airport = originAirport;
|
||||
return api.get<PaginatedResponse<Flight>>(`/scans/${id}/flights`, { params });
|
||||
},
|
||||
|
||||
@@ -176,6 +189,10 @@ export const airportApi = {
|
||||
},
|
||||
};
|
||||
|
||||
export const countriesApi = {
|
||||
list: () => api.get<Country[]>('/countries'),
|
||||
};
|
||||
|
||||
export const scheduleApi = {
|
||||
list: (page = 1, limit = 20) =>
|
||||
api.get<PaginatedResponse<Schedule>>('/schedules', { params: { page, limit } }),
|
||||
|
||||
51
flight-comparator/frontend/src/components/CountrySelect.tsx
Normal file
51
flight-comparator/frontend/src/components/CountrySelect.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { countriesApi } from '../api';
|
||||
import type { Country } from '../api';
|
||||
|
||||
interface CountrySelectProps {
|
||||
value: string;
|
||||
onChange: (code: string) => void;
|
||||
placeholder?: string;
|
||||
hasError?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function CountrySelect({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select a country…',
|
||||
hasError = false,
|
||||
className,
|
||||
}: CountrySelectProps) {
|
||||
const [countries, setCountries] = useState<Country[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
countriesApi.list()
|
||||
.then(resp => setCountries(resp.data))
|
||||
.catch(() => setCountries([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const baseCls =
|
||||
'w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors ' +
|
||||
(hasError
|
||||
? 'border-error focus:border-error focus:ring-2 focus:ring-error/20'
|
||||
: 'border-outline focus:border-primary focus:ring-2 focus:ring-primary/20');
|
||||
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
disabled={loading}
|
||||
className={`${baseCls} ${className ?? ''}`}
|
||||
>
|
||||
<option value="">{loading ? 'Loading countries…' : placeholder}</option>
|
||||
{countries.map(c => (
|
||||
<option key={c.code} value={c.code}>
|
||||
{c.name} ({c.code}) — {c.airport_count} airport{c.airport_count !== 1 ? 's' : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
@@ -136,16 +136,21 @@ export default function ScanDetails() {
|
||||
return 0;
|
||||
});
|
||||
|
||||
const toggleFlights = async (destination: string) => {
|
||||
if (expandedRoute === destination) { setExpandedRoute(null); return; }
|
||||
setExpandedRoute(destination);
|
||||
if (flightsByDest[destination]) return;
|
||||
setLoadingFlights(destination);
|
||||
// For reverse scans, route key = "ORIG:DEST"; for forward scans = "DEST"
|
||||
const routeKey = (route: Route) =>
|
||||
route.origin_airport ? `${route.origin_airport}:${route.destination}` : route.destination;
|
||||
|
||||
const toggleFlights = async (route: Route) => {
|
||||
const key = routeKey(route);
|
||||
if (expandedRoute === key) { setExpandedRoute(null); return; }
|
||||
setExpandedRoute(key);
|
||||
if (flightsByDest[key]) return;
|
||||
setLoadingFlights(key);
|
||||
try {
|
||||
const resp = await scanApi.getFlights(Number(id), destination, 1, 200);
|
||||
setFlightsByDest(prev => ({ ...prev, [destination]: resp.data.data }));
|
||||
const resp = await scanApi.getFlights(Number(id), route.destination, route.origin_airport, 1, 200);
|
||||
setFlightsByDest(prev => ({ ...prev, [key]: resp.data.data }));
|
||||
} catch {
|
||||
setFlightsByDest(prev => ({ ...prev, [destination]: [] }));
|
||||
setFlightsByDest(prev => ({ ...prev, [key]: [] }));
|
||||
} finally {
|
||||
setLoadingFlights(null);
|
||||
}
|
||||
@@ -155,21 +160,30 @@ export default function ScanDetails() {
|
||||
if (!scan) return;
|
||||
setRerunning(true);
|
||||
try {
|
||||
// Compute window from stored dates so the new scan covers the same span
|
||||
const ms = new Date(scan.end_date).getTime() - new Date(scan.start_date).getTime();
|
||||
const window_months = Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24 * 30)));
|
||||
|
||||
// country column holds either "IT" or "BRI,BDS"
|
||||
const isAirports = scan.country.includes(',');
|
||||
const resp = await scanApi.create({
|
||||
const base = {
|
||||
scan_mode: (scan.scan_mode ?? 'forward') as 'forward' | 'reverse',
|
||||
origin: scan.origin,
|
||||
window_months,
|
||||
seat_class: scan.seat_class as 'economy' | 'premium' | 'business' | 'first',
|
||||
adults: scan.adults,
|
||||
...(isAirports
|
||||
};
|
||||
|
||||
let extra: Record<string, unknown>;
|
||||
if (scan.scan_mode === 'reverse') {
|
||||
// For reverse: country column holds comma-separated dest IATAs
|
||||
extra = { destinations: scan.country.split(',') };
|
||||
} else {
|
||||
// For forward: country column holds ISO code or comma-separated IATAs
|
||||
const isAirports = scan.country.includes(',');
|
||||
extra = isAirports
|
||||
? { destinations: scan.country.split(',') }
|
||||
: { country: scan.country }),
|
||||
});
|
||||
: { country: scan.country };
|
||||
}
|
||||
|
||||
const resp = await scanApi.create({ ...base, ...extra });
|
||||
navigate(`/scans/${resp.data.id}`);
|
||||
} catch {
|
||||
// silently fall through — the navigate won't happen
|
||||
@@ -302,7 +316,9 @@ export default function ScanDetails() {
|
||||
<div className="flex items-center gap-2 flex-wrap min-w-0">
|
||||
<PlaneTakeoff size={20} className="text-primary shrink-0" aria-hidden="true" />
|
||||
<h1 className="text-xl font-semibold text-on-surface">
|
||||
{scan.origin} → {scan.country}
|
||||
{scan.scan_mode === 'reverse'
|
||||
? `${scan.origin} → ${scan.country.split(',').join(', ')}`
|
||||
: `${scan.origin} → ${scan.country}`}
|
||||
</h1>
|
||||
{scan.scheduled_scan_id != null && (
|
||||
<Link
|
||||
@@ -586,6 +602,9 @@ export default function ScanDetails() {
|
||||
<table className="w-full">
|
||||
<thead className="bg-surface-2 border-b border-outline">
|
||||
<tr>
|
||||
{scan.scan_mode === 'reverse' && (
|
||||
<th className={thCls()}>Origin</th>
|
||||
)}
|
||||
<th
|
||||
className={thCls('destination')}
|
||||
onClick={() => handleSort('destination')}
|
||||
@@ -617,13 +636,23 @@ export default function ScanDetails() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-outline">
|
||||
{routes.map((route) => {
|
||||
const isExpanded = expandedRoute === route.destination;
|
||||
const key = routeKey(route);
|
||||
const isExpanded = expandedRoute === key;
|
||||
const colSpan = scan.scan_mode === 'reverse' ? 7 : 6;
|
||||
return (
|
||||
<Fragment key={route.id}>
|
||||
<tr
|
||||
className="hover:bg-surface-2 cursor-pointer transition-colors duration-150"
|
||||
onClick={() => toggleFlights(route.destination)}
|
||||
onClick={() => toggleFlights(route)}
|
||||
>
|
||||
{/* Origin (reverse scans only) */}
|
||||
{scan.scan_mode === 'reverse' && (
|
||||
<td className="px-4 py-4">
|
||||
<span className="font-mono text-secondary bg-surface-2 px-2 py-0.5 rounded-sm text-sm font-medium">
|
||||
{route.origin_airport ?? '—'}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
{/* Destination */}
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -680,13 +709,13 @@ export default function ScanDetails() {
|
||||
|
||||
{/* Expanded flights sub-row */}
|
||||
<tr key={`${route.id}-flights`}>
|
||||
<td colSpan={6} className="p-0">
|
||||
<td colSpan={colSpan} className="p-0">
|
||||
<div
|
||||
className="overflow-hidden transition-all duration-250 ease-in-out"
|
||||
style={{ maxHeight: isExpanded ? '600px' : '0' }}
|
||||
>
|
||||
<div className="bg-[#F8FDF9]">
|
||||
{loadingFlights === route.destination ? (
|
||||
{loadingFlights === key ? (
|
||||
<table className="w-full">
|
||||
<tbody>
|
||||
<SkeletonTableRow />
|
||||
@@ -720,7 +749,7 @@ export default function ScanDetails() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[#D4EDDA]">
|
||||
{sortedFlights(flightsByDest[route.destination] || []).map((f) => (
|
||||
{sortedFlights(flightsByDest[key] || []).map((f) => (
|
||||
<tr key={f.id} className="hover:bg-[#EEF7F0] transition-colors">
|
||||
<td className="pl-12 pr-4 py-2.5 text-sm text-on-surface">
|
||||
<span className="font-mono text-xs font-semibold text-on-surface-variant mr-2">{weekday(f.date)}</span>
|
||||
@@ -734,7 +763,7 @@ export default function ScanDetails() {
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(flightsByDest[route.destination] || []).length === 0 && (
|
||||
{(flightsByDest[key] || []).length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="pl-12 py-4 text-sm text-on-surface-variant">
|
||||
No flight details available
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Globe, PlaneTakeoff, Minus, Plus } from 'lucide-react';
|
||||
import { Globe, PlaneTakeoff, Minus, Plus, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
import { scanApi } from '../api';
|
||||
import type { CreateScanRequest } from '../api';
|
||||
import AirportSearch from '../components/AirportSearch';
|
||||
import SegmentedButton from '../components/SegmentedButton';
|
||||
import AirportChip from '../components/AirportChip';
|
||||
import CountrySelect from '../components/CountrySelect';
|
||||
import Button from '../components/Button';
|
||||
import Toast from '../components/Toast';
|
||||
|
||||
@@ -19,6 +20,11 @@ interface FormErrors {
|
||||
|
||||
export default function Scans() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Direction: forward (fixed origin → variable destinations) or reverse (variable origins → fixed destinations)
|
||||
const [scanMode, setScanMode] = useState<'forward' | 'reverse'>('forward');
|
||||
|
||||
// Forward mode state
|
||||
const [destinationMode, setDestinationMode] = useState<'country' | 'airports'>('country');
|
||||
const [formData, setFormData] = useState<CreateScanRequest>({
|
||||
origin: '',
|
||||
@@ -27,22 +33,37 @@ export default function Scans() {
|
||||
seat_class: 'economy',
|
||||
adults: 1,
|
||||
});
|
||||
|
||||
// Shared state
|
||||
const [selectedAirports, setSelectedAirports] = useState<string[]>([]);
|
||||
const [selectedOriginCountry, setSelectedOriginCountry] = useState('');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [toast, setToast] = useState<{ message: string; type: 'success' | 'error' } | null>(null);
|
||||
|
||||
const validate = (): boolean => {
|
||||
const next: FormErrors = {};
|
||||
if (!formData.origin || formData.origin.length !== 3) {
|
||||
next.origin = 'Enter a valid 3-letter IATA code';
|
||||
}
|
||||
if (destinationMode === 'country' && (!formData.country || formData.country.length !== 2)) {
|
||||
next.country = 'Enter a valid 2-letter country code';
|
||||
}
|
||||
if (destinationMode === 'airports' && selectedAirports.length === 0) {
|
||||
next.airports = 'Add at least one destination airport';
|
||||
|
||||
if (scanMode === 'reverse') {
|
||||
if (!selectedOriginCountry) {
|
||||
next.country = 'Select an origin country';
|
||||
}
|
||||
if (selectedAirports.length === 0) {
|
||||
next.airports = 'Add at least one destination airport';
|
||||
}
|
||||
} else {
|
||||
if (!formData.origin || formData.origin.length !== 3) {
|
||||
next.origin = 'Enter a valid 3-letter IATA code';
|
||||
}
|
||||
if (destinationMode === 'country' && !formData.country) {
|
||||
next.country = 'Select a destination country';
|
||||
}
|
||||
if (destinationMode === 'airports' && selectedAirports.length === 0) {
|
||||
next.airports = 'Add at least one destination airport';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(next);
|
||||
return Object.keys(next).length === 0;
|
||||
};
|
||||
@@ -53,17 +74,28 @@ export default function Scans() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const requestData: any = {
|
||||
origin: formData.origin,
|
||||
window_months: formData.window_months,
|
||||
seat_class: formData.seat_class,
|
||||
adults: formData.adults,
|
||||
};
|
||||
let requestData: CreateScanRequest;
|
||||
|
||||
if (destinationMode === 'country') {
|
||||
requestData.country = formData.country;
|
||||
if (scanMode === 'reverse') {
|
||||
requestData = {
|
||||
scan_mode: 'reverse',
|
||||
origin: selectedOriginCountry,
|
||||
destinations: selectedAirports,
|
||||
window_months: formData.window_months,
|
||||
seat_class: formData.seat_class,
|
||||
adults: formData.adults,
|
||||
};
|
||||
} else {
|
||||
requestData.destinations = selectedAirports;
|
||||
requestData = {
|
||||
scan_mode: 'forward',
|
||||
origin: formData.origin,
|
||||
window_months: formData.window_months,
|
||||
seat_class: formData.seat_class,
|
||||
adults: formData.adults,
|
||||
...(destinationMode === 'country'
|
||||
? { country: formData.country }
|
||||
: { destinations: selectedAirports }),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await scanApi.create(requestData);
|
||||
@@ -86,7 +118,6 @@ export default function Scans() {
|
||||
}));
|
||||
};
|
||||
|
||||
// Shared input class
|
||||
const inputCls = (hasError?: boolean) =>
|
||||
`w-full h-12 px-3 border rounded-xs bg-surface text-on-surface text-sm outline-none transition-colors ` +
|
||||
(hasError
|
||||
@@ -97,31 +128,77 @@ export default function Scans() {
|
||||
<>
|
||||
<form onSubmit={handleSubmit} className="space-y-4 max-w-2xl">
|
||||
|
||||
{/* ── Section: Direction ───────────────────────────────────── */}
|
||||
<div className="bg-surface rounded-lg shadow-level-1 p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">
|
||||
Direction
|
||||
</p>
|
||||
<SegmentedButton
|
||||
options={[
|
||||
{ value: 'forward', label: 'Forward', icon: ArrowRight },
|
||||
{ value: 'reverse', label: 'Reverse', icon: ArrowLeft },
|
||||
]}
|
||||
value={scanMode}
|
||||
onChange={(v) => {
|
||||
setScanMode(v as 'forward' | 'reverse');
|
||||
setErrors({});
|
||||
setSelectedAirports([]);
|
||||
}}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-on-surface-variant">
|
||||
{scanMode === 'forward'
|
||||
? 'Fixed origin airport → all airports in a country (or specific airports)'
|
||||
: 'All airports in a country → specific destination airport(s)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Section: Origin ─────────────────────────────────────── */}
|
||||
<div className="bg-surface rounded-lg shadow-level-1 p-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-on-surface-variant mb-4">
|
||||
Origin
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Origin Airport
|
||||
</label>
|
||||
<AirportSearch
|
||||
value={formData.origin}
|
||||
onChange={(value) => {
|
||||
setFormData(prev => ({ ...prev, origin: value }));
|
||||
if (errors.origin) setErrors(prev => ({ ...prev, origin: undefined }));
|
||||
}}
|
||||
placeholder="e.g. BDS, MUC, FRA"
|
||||
hasError={!!errors.origin}
|
||||
/>
|
||||
{errors.origin ? (
|
||||
<p className="mt-1 text-xs text-error">{errors.origin}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-on-surface-variant">3-letter IATA code (e.g. BDS for Brindisi)</p>
|
||||
)}
|
||||
</div>
|
||||
{scanMode === 'reverse' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Origin Country
|
||||
</label>
|
||||
<CountrySelect
|
||||
value={selectedOriginCountry}
|
||||
onChange={(code) => {
|
||||
setSelectedOriginCountry(code);
|
||||
if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
|
||||
}}
|
||||
placeholder="Select origin country…"
|
||||
hasError={!!errors.country}
|
||||
/>
|
||||
{errors.country ? (
|
||||
<p className="mt-1 text-xs text-error">{errors.country}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-on-surface-variant">All airports in this country will be checked</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Origin Airport
|
||||
</label>
|
||||
<AirportSearch
|
||||
value={formData.origin}
|
||||
onChange={(value) => {
|
||||
setFormData(prev => ({ ...prev, origin: value }));
|
||||
if (errors.origin) setErrors(prev => ({ ...prev, origin: undefined }));
|
||||
}}
|
||||
placeholder="e.g. BDS, MUC, FRA"
|
||||
hasError={!!errors.origin}
|
||||
/>
|
||||
{errors.origin ? (
|
||||
<p className="mt-1 text-xs text-error">{errors.origin}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-on-surface-variant">3-letter IATA code (e.g. BDS for Brindisi)</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section: Destination ────────────────────────────────── */}
|
||||
@@ -130,42 +207,8 @@ export default function Scans() {
|
||||
Destination
|
||||
</p>
|
||||
|
||||
<SegmentedButton
|
||||
options={[
|
||||
{ value: 'country', label: 'By Country', icon: Globe },
|
||||
{ value: 'airports', label: 'By Airports', icon: PlaneTakeoff },
|
||||
]}
|
||||
value={destinationMode}
|
||||
onChange={(v) => {
|
||||
setDestinationMode(v as 'country' | 'airports');
|
||||
setErrors(prev => ({ ...prev, country: undefined, airports: undefined }));
|
||||
}}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{destinationMode === 'country' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Destination Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.country}
|
||||
onChange={(e) => {
|
||||
setFormData(prev => ({ ...prev, country: e.target.value.toUpperCase() }));
|
||||
if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
|
||||
}}
|
||||
maxLength={2}
|
||||
placeholder="e.g. DE, IT, ES"
|
||||
className={inputCls(!!errors.country)}
|
||||
/>
|
||||
{errors.country ? (
|
||||
<p className="mt-1 text-xs text-error">{errors.country}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-on-surface-variant">ISO 2-letter country code (e.g. DE for Germany)</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
{scanMode === 'reverse' ? (
|
||||
/* Reverse: specific destination airports */
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Destination Airports
|
||||
@@ -203,6 +246,82 @@ export default function Scans() {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Forward: by country or by specific airports */
|
||||
<>
|
||||
<SegmentedButton
|
||||
options={[
|
||||
{ value: 'country', label: 'By Country', icon: Globe },
|
||||
{ value: 'airports', label: 'By Airports', icon: PlaneTakeoff },
|
||||
]}
|
||||
value={destinationMode}
|
||||
onChange={(v) => {
|
||||
setDestinationMode(v as 'country' | 'airports');
|
||||
setErrors(prev => ({ ...prev, country: undefined, airports: undefined }));
|
||||
}}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{destinationMode === 'country' ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Destination Country
|
||||
</label>
|
||||
<CountrySelect
|
||||
value={formData.country ?? ''}
|
||||
onChange={(code) => {
|
||||
setFormData(prev => ({ ...prev, country: code }));
|
||||
if (errors.country) setErrors(prev => ({ ...prev, country: undefined }));
|
||||
}}
|
||||
placeholder="Select destination country…"
|
||||
hasError={!!errors.country}
|
||||
/>
|
||||
{errors.country ? (
|
||||
<p className="mt-1 text-xs text-error">{errors.country}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-on-surface-variant">All airports in this country will be searched</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-on-surface-variant mb-1.5">
|
||||
Destination Airports
|
||||
</label>
|
||||
<AirportSearch
|
||||
value=""
|
||||
onChange={(code) => {
|
||||
if (code && code.length === 3 && !selectedAirports.includes(code)) {
|
||||
setSelectedAirports(prev => [...prev, code]);
|
||||
if (errors.airports) setErrors(prev => ({ ...prev, airports: undefined }));
|
||||
}
|
||||
}}
|
||||
clearAfterSelect
|
||||
placeholder="Search and add airports…"
|
||||
hasError={!!errors.airports}
|
||||
/>
|
||||
{selectedAirports.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{selectedAirports.map((code) => (
|
||||
<AirportChip
|
||||
key={code}
|
||||
code={code}
|
||||
onRemove={() => setSelectedAirports(prev => prev.filter(c => c !== code))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{errors.airports ? (
|
||||
<p className="mt-1 text-xs text-error">{errors.airports}</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-on-surface-variant">
|
||||
{selectedAirports.length === 0
|
||||
? 'Search and add destination airports (up to 50)'
|
||||
: `${selectedAirports.length} airport${selectedAirports.length !== 1 ? 's' : ''} selected`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user