feat: re-run and delete scan from detail page
Backend:
- DELETE /api/v1/scans/{id} — 204 on success, 404 if missing,
409 if pending/running; CASCADE removes routes and flights
Frontend (api.ts):
- scanApi.delete(id)
Frontend (ScanDetails.tsx):
- Re-run button: derives window_months from stored dates, detects
country vs airports mode via comma in scan.country, creates new
scan and navigates to it; disabled while scan is active
- Delete button: inline two-step confirm (no modal), navigates to
dashboard on success; disabled while scan is active
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1337,6 +1337,42 @@ async def get_scan_status(scan_id: int):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router_v1.delete("/scans/{scan_id}", status_code=204)
|
||||||
|
async def delete_scan(scan_id: int):
|
||||||
|
"""
|
||||||
|
Delete a scan and all its associated routes and flights (CASCADE).
|
||||||
|
Returns 409 if the scan is currently running or pending.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute("SELECT status FROM scans WHERE id = ?", (scan_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(status_code=404, detail=f"Scan not found: {scan_id}")
|
||||||
|
|
||||||
|
if row[0] in ('pending', 'running'):
|
||||||
|
conn.close()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Cannot delete a scan that is currently pending or running."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor.execute("DELETE FROM scans WHERE id = ?", (scan_id,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logging.info(f"Scan {scan_id} deleted")
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to delete scan: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
@router_v1.get("/scans/{scan_id}/routes", response_model=PaginatedResponse[Route])
|
@router_v1.get("/scans/{scan_id}/routes", response_model=PaginatedResponse[Route])
|
||||||
async def get_scan_routes(
|
async def get_scan_routes(
|
||||||
scan_id: int,
|
scan_id: int,
|
||||||
|
|||||||
@@ -123,6 +123,8 @@ export const scanApi = {
|
|||||||
if (destination) params.destination = destination;
|
if (destination) params.destination = destination;
|
||||||
return api.get<PaginatedResponse<Flight>>(`/scans/${id}/flights`, { params });
|
return api.get<PaginatedResponse<Flight>>(`/scans/${id}/flights`, { params });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
delete: (id: number) => api.delete(`/scans/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const airportApi = {
|
export const airportApi = {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
RotateCcw,
|
||||||
|
Trash2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { scanApi } from '../api';
|
import { scanApi } from '../api';
|
||||||
import type { Scan, Route, Flight } from '../api';
|
import type { Scan, Route, Flight } from '../api';
|
||||||
@@ -45,6 +47,9 @@ export default function ScanDetails() {
|
|||||||
const [expandedRoute, setExpandedRoute] = useState<string | null>(null);
|
const [expandedRoute, setExpandedRoute] = useState<string | null>(null);
|
||||||
const [flightsByDest, setFlightsByDest] = useState<Record<string, Flight[]>>({});
|
const [flightsByDest, setFlightsByDest] = useState<Record<string, Flight[]>>({});
|
||||||
const [loadingFlights, setLoadingFlights] = useState<string | null>(null);
|
const [loadingFlights, setLoadingFlights] = useState<string | null>(null);
|
||||||
|
const [rerunning, setRerunning] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) loadScanDetails();
|
if (id) loadScanDetails();
|
||||||
@@ -110,6 +115,45 @@ export default function ScanDetails() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRerun = async () => {
|
||||||
|
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({
|
||||||
|
origin: scan.origin,
|
||||||
|
window_months,
|
||||||
|
seat_class: scan.seat_class as 'economy' | 'premium' | 'business' | 'first',
|
||||||
|
adults: scan.adults,
|
||||||
|
...(isAirports
|
||||||
|
? { destinations: scan.country.split(',') }
|
||||||
|
: { country: scan.country }),
|
||||||
|
});
|
||||||
|
navigate(`/scans/${resp.data.id}`);
|
||||||
|
} catch {
|
||||||
|
// silently fall through — the navigate won't happen
|
||||||
|
} finally {
|
||||||
|
setRerunning(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!scan) return;
|
||||||
|
setDeleting(true);
|
||||||
|
try {
|
||||||
|
await scanApi.delete(scan.id);
|
||||||
|
navigate('/');
|
||||||
|
} catch {
|
||||||
|
setDeleting(false);
|
||||||
|
setConfirmDelete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const SortIcon = ({ field }: { field: typeof sortField }) => {
|
const SortIcon = ({ field }: { field: typeof sortField }) => {
|
||||||
if (sortField !== field) return <ChevronUp size={14} className="opacity-30" />;
|
if (sortField !== field) return <ChevronUp size={14} className="opacity-30" />;
|
||||||
return sortDirection === 'asc'
|
return sortDirection === 'asc'
|
||||||
@@ -203,6 +247,49 @@ export default function ScanDetails() {
|
|||||||
Created {formatDate(scan.created_at)}
|
Created {formatDate(scan.created_at)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Row 4: actions */}
|
||||||
|
<div className="mt-4 pt-4 border-t border-outline flex items-center justify-end gap-2">
|
||||||
|
{/* Re-run */}
|
||||||
|
<button
|
||||||
|
onClick={handleRerun}
|
||||||
|
disabled={rerunning || isActive}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} className={rerunning ? 'animate-spin' : ''} />
|
||||||
|
{rerunning ? 'Starting…' : 'Re-run'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Delete — inline confirm */}
|
||||||
|
{confirmDelete ? (
|
||||||
|
<div className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="text-sm text-on-surface-variant">Delete this scan?</span>
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
disabled={deleting}
|
||||||
|
className="inline-flex items-center gap-1 px-3 py-1.5 text-sm font-medium rounded-xs bg-error text-white hover:bg-error/90 disabled:opacity-60 transition-colors"
|
||||||
|
>
|
||||||
|
{deleting ? 'Deleting…' : 'Yes, delete'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(false)}
|
||||||
|
disabled={deleting}
|
||||||
|
className="px-3 py-1.5 text-sm font-medium rounded-xs border border-outline text-on-surface hover:bg-surface-2 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
disabled={isActive}
|
||||||
|
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-xs border border-error/40 text-error hover:bg-error/5 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Stat cards ────────────────────────────────────────────── */}
|
{/* ── Stat cards ────────────────────────────────────────────── */}
|
||||||
|
|||||||
Reference in New Issue
Block a user