Skip to content

Commit f3930c9

Browse files
mcherifclaude
andcommitted
Add bulk-reject-stale UI for Review and Shortlisted queues
- POST /api/jobs/bulk-reject-stale endpoint rejects all jobs with the given status created more than N days ago (default 14) - Reject Stale button appears in the header strip only when viewing Review or Shortlisted tabs - Clicking it shows a confirm modal with the exact count of stale jobs before committing - On confirm, reloads the tab and refreshes stats Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d309bc7 commit f3930c9

2 files changed

Lines changed: 90 additions & 1 deletion

File tree

ui/app.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import subprocess
1111
import sys
1212
import threading
13-
from datetime import datetime
13+
from datetime import datetime, timedelta
1414
from pathlib import Path
1515
from typing import Any, Dict, List
1616

@@ -384,6 +384,36 @@ async def update_status(job_id: int, body: StatusUpdate):
384384
session.close()
385385

386386

387+
class BulkRejectStaleRequest(BaseModel):
388+
status: str
389+
older_than_days: int = 14
390+
391+
392+
@app.post("/api/jobs/bulk-reject-stale")
393+
async def bulk_reject_stale(body: BulkRejectStaleRequest):
394+
allowed = {"shortlisted", "review"}
395+
if body.status not in allowed:
396+
raise HTTPException(400, f"bulk-reject-stale only supports: {', '.join(sorted(allowed))}")
397+
cutoff = datetime.utcnow() - timedelta(days=body.older_than_days)
398+
session = _Session()
399+
try:
400+
stale = (
401+
session.query(Job)
402+
.filter(Job.status == body.status, Job.created_at < cutoff)
403+
.all()
404+
)
405+
count = len(stale)
406+
for job in stale:
407+
job.status = "rejected"
408+
session.commit()
409+
return {"ok": True, "rejected": count}
410+
except Exception as exc:
411+
session.rollback()
412+
raise HTTPException(500, str(exc))
413+
finally:
414+
session.close()
415+
416+
387417
@app.post("/api/jobs/{job_id}/cover-letter")
388418
async def generate_cover(job_id: int):
389419
from utils.cover_letter import generate_cover_letter

ui/index.html

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@
4545
.strip-spacer{flex:1;}
4646
.btn-fill-url{display:flex;align-items:center;gap:6px;padding:7px 13px;background:var(--bg-subtle);color:var(--text-secondary);border:1px solid var(--border);border-radius:var(--radius);font-family:'Inter',sans-serif;font-size:12.5px;font-weight:600;cursor:pointer;transition:all var(--transition);margin-right:6px;}
4747
.btn-fill-url:hover{background:var(--bg-card);color:var(--accent);border-color:var(--accent-mid);}
48+
.btn-reject-stale{display:none;align-items:center;gap:6px;padding:7px 13px;background:var(--red-light);color:var(--red);border:1px solid var(--red-mid);border-radius:var(--radius);font-family:'Inter',sans-serif;font-size:12.5px;font-weight:600;cursor:pointer;transition:all var(--transition);margin-right:6px;}
49+
.btn-reject-stale:hover{background:var(--red-mid);color:var(--red);border-color:var(--red);}
50+
.btn-reject-stale.visible{display:flex;}
4851
.btn-pipeline{display:flex;align-items:center;gap:8px;padding:8px 16px;background:var(--accent);color:#fff;border:none;border-radius:var(--radius);font-family:'Inter',sans-serif;font-size:13px;font-weight:600;cursor:pointer;transition:all var(--transition);box-shadow:var(--shadow-sm);}
4952
.btn-pipeline:hover{background:#2563eb;box-shadow:0 0 0 3px rgba(59,130,246,.25);}
5053
.btn-pipeline svg{width:14px;height:14px;}
@@ -286,6 +289,10 @@
286289
<span>Expired</span><span class="count"></span>
287290
</div>
288291
<div class="strip-spacer"></div>
292+
<button class="btn-reject-stale" id="rejectStaleBtn" onclick="openRejectStaleModal()" title="Reject all jobs in this tab older than 2 weeks">
293+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>
294+
Reject Stale
295+
</button>
289296
<button class="btn-fill-url" onclick="openFillUrlModal()" title="Fill a known application form URL directly">
290297
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" width="13" height="13"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
291298
Fill URL
@@ -546,6 +553,22 @@
546553
</div>
547554
</div>
548555

556+
<!-- Reject Stale modal -->
557+
<div class="modal-overlay" id="rejectStaleOverlay" onclick="closeRejectStaleModal()" style="display:none"></div>
558+
<div class="modal-box" id="rejectStaleModal" style="display:none">
559+
<div class="modal-header">
560+
<span class="modal-title">Reject Stale Jobs</span>
561+
<button class="btn-close" onclick="closeRejectStaleModal()">×</button>
562+
</div>
563+
<div class="modal-body">
564+
<p class="modal-hint" id="rejectStaleMsg"></p>
565+
</div>
566+
<div class="modal-footer">
567+
<button class="btn-modal-cancel" onclick="closeRejectStaleModal()">Cancel</button>
568+
<button class="btn-modal-fill" style="background:var(--red);border:none;" id="rejectStaleConfirmBtn" onclick="confirmRejectStale()">Reject All</button>
569+
</div>
570+
</div>
571+
549572
<script>
550573
// ── State ──────────────────────────────────────────────────────────────────
551574
let jobs = [];
@@ -625,6 +648,42 @@
625648
document.querySelectorAll('.status-pill').forEach(p => p.classList.remove('active'));
626649
if (el) el.classList.add('active');
627650
loadJobs(status);
651+
const staleBtn = document.getElementById('rejectStaleBtn');
652+
if (staleBtn) staleBtn.classList.toggle('visible', status === 'review' || status === 'shortlisted');
653+
}
654+
655+
function openRejectStaleModal() {
656+
const cutoff = Date.now() - 14 * 24 * 60 * 60 * 1000;
657+
const staleCount = jobs.filter(j => j.created_at && new Date(j.created_at).getTime() < cutoff).length;
658+
const label = currentStatus === 'shortlisted' ? 'Shortlisted' : 'Review';
659+
document.getElementById('rejectStaleMsg').textContent =
660+
staleCount > 0
661+
? `Permanently reject ${staleCount} ${label} job${staleCount === 1 ? '' : 's'} added more than 14 days ago?`
662+
: `No jobs older than 14 days found in the ${label} queue.`;
663+
document.getElementById('rejectStaleConfirmBtn').style.display = staleCount > 0 ? '' : 'none';
664+
document.getElementById('rejectStaleOverlay').style.display = '';
665+
document.getElementById('rejectStaleModal').style.display = '';
666+
}
667+
668+
function closeRejectStaleModal() {
669+
document.getElementById('rejectStaleOverlay').style.display = 'none';
670+
document.getElementById('rejectStaleModal').style.display = 'none';
671+
}
672+
673+
async function confirmRejectStale() {
674+
closeRejectStaleModal();
675+
try {
676+
const res = await api('/api/jobs/bulk-reject-stale', {
677+
method: 'POST',
678+
headers: {'Content-Type': 'application/json'},
679+
body: JSON.stringify({status: currentStatus, older_than_days: 14})
680+
});
681+
showToast(`Rejected ${res.rejected} stale job${res.rejected === 1 ? '' : 's'}`, '');
682+
loadStats();
683+
loadJobs(currentStatus);
684+
} catch(e) {
685+
showToast('Bulk reject failed', 'error');
686+
}
628687
}
629688

630689
// ── Card render ────────────────────────────────────────────────────────────

0 commit comments

Comments
 (0)