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])
|
||||
async def get_scan_routes(
|
||||
scan_id: int,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 ────────────────────────────────────────────── */}
|
||||
|
||||
Reference in New Issue
Block a user