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:
2026-02-27 16:33:45 +01:00
parent f9411edd3c
commit 4926e89e46
3 changed files with 125 additions and 0 deletions

View File

@@ -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])
async def get_scan_routes(
scan_id: int,

View File

@@ -123,6 +123,8 @@ export const scanApi = {
if (destination) params.destination = destination;
return api.get<PaginatedResponse<Flight>>(`/scans/${id}/flights`, { params });
},
delete: (id: number) => api.delete(`/scans/${id}`),
};
export const airportApi = {

View File

@@ -13,6 +13,8 @@ import {
MapPin,
AlertCircle,
Loader2,
RotateCcw,
Trash2,
} from 'lucide-react';
import { scanApi } 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 [flightsByDest, setFlightsByDest] = useState<Record<string, Flight[]>>({});
const [loadingFlights, setLoadingFlights] = useState<string | null>(null);
const [rerunning, setRerunning] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
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 }) => {
if (sortField !== field) return <ChevronUp size={14} className="opacity-30" />;
return sortDirection === 'asc'
@@ -203,6 +247,49 @@ export default function ScanDetails() {
Created {formatDate(scan.created_at)}
</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>
{/* ── Stat cards ────────────────────────────────────────────── */}