feat: batch polish — edit/delete positions, ZIP export, exports page, candidate batch links
This commit is contained in:
Binary file not shown.
214
main.py
214
main.py
@@ -205,12 +205,23 @@ async def get_candidate(candidate_id: str):
|
|||||||
)
|
)
|
||||||
skills_with_years.append(s_dict)
|
skills_with_years.append(s_dict)
|
||||||
|
|
||||||
|
batches = db.query("""
|
||||||
|
SELECT bi.id AS item_id, bi.batch_id, bi.position_title, bi.match_score,
|
||||||
|
bi.status AS item_status, bi.generated_cv_path, bi.updated_at AS item_updated_at,
|
||||||
|
b.name AS batch_name, b.status AS batch_status
|
||||||
|
FROM batch_items bi
|
||||||
|
JOIN cv_batches b ON b.id = bi.batch_id
|
||||||
|
WHERE bi.candidate_id = %s
|
||||||
|
ORDER BY bi.updated_at DESC
|
||||||
|
""", (candidate_id,), fetch='all')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"candidate": dict(candidate),
|
"candidate": dict(candidate),
|
||||||
"skills": skills_with_years,
|
"skills": skills_with_years,
|
||||||
"experience": [dict(e) for e in experience],
|
"experience": [dict(e) for e in experience],
|
||||||
"education": [dict(e) for e in education],
|
"education": [dict(e) for e in education],
|
||||||
"certifications": [dict(c) for c in certs]
|
"certifications": [dict(c) for c in certs],
|
||||||
|
"batches": [dict(b) for b in batches],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -531,27 +542,69 @@ async def generate_cvs_for_requirement(req_id: str, request: Request):
|
|||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
@app.get("/api/generated-cvs")
|
@app.get("/api/generated-cvs")
|
||||||
async def list_generated_cvs(req_id: str = None, candidate_id: str = None):
|
async def list_generated_cvs(req_id: str = None, candidate_id: str = None, batch_id: str = None):
|
||||||
where_parts = []
|
"""List batch-generated CV exports (plus any legacy generated_cvs rows)."""
|
||||||
|
where_parts = ["bi.generated_cv_path IS NOT NULL", "bi.generated_cv_path <> ''"]
|
||||||
params = []
|
params = []
|
||||||
if req_id:
|
|
||||||
where_parts.append("requirement_request_id = %s")
|
|
||||||
params.append(req_id)
|
|
||||||
if candidate_id:
|
if candidate_id:
|
||||||
where_parts.append("candidate_id = %s")
|
where_parts.append("bi.candidate_id = %s")
|
||||||
params.append(candidate_id)
|
params.append(candidate_id)
|
||||||
|
if batch_id:
|
||||||
|
where_parts.append("bi.batch_id = %s")
|
||||||
|
params.append(batch_id)
|
||||||
|
|
||||||
where_clause = "WHERE " + " AND ".join(where_parts) if where_parts else ""
|
where_clause = "WHERE " + " AND ".join(where_parts)
|
||||||
|
batch_rows = db.query(f"""
|
||||||
rows = db.query(f"""
|
SELECT bi.id,
|
||||||
SELECT gc.*, c.first_name, c.last_name
|
bi.candidate_id,
|
||||||
FROM generated_cvs gc
|
bi.batch_id,
|
||||||
LEFT JOIN candidates c ON gc.candidate_id = c.id
|
bi.position_title,
|
||||||
|
bi.match_score,
|
||||||
|
bi.match_reasoning,
|
||||||
|
bi.status AS item_status,
|
||||||
|
bi.generated_cv_path,
|
||||||
|
bi.created_at,
|
||||||
|
bi.updated_at,
|
||||||
|
c.first_name,
|
||||||
|
c.last_name,
|
||||||
|
b.name AS batch_name,
|
||||||
|
b.status AS batch_status,
|
||||||
|
b.template_id,
|
||||||
|
'batch' AS source
|
||||||
|
FROM batch_items bi
|
||||||
|
JOIN candidates c ON c.id = bi.candidate_id
|
||||||
|
JOIN cv_batches b ON b.id = bi.batch_id
|
||||||
{where_clause}
|
{where_clause}
|
||||||
ORDER BY gc.created_at DESC
|
ORDER BY bi.updated_at DESC
|
||||||
""", params if params else None, fetch='all')
|
""", params if params else None, fetch='all')
|
||||||
|
|
||||||
return {"generated_cvs": [dict(r) for r in rows]}
|
# Keep legacy requirements-generated rows visible until fully retired.
|
||||||
|
legacy_where = []
|
||||||
|
legacy_params = []
|
||||||
|
if req_id:
|
||||||
|
legacy_where.append("gc.requirement_request_id = %s")
|
||||||
|
legacy_params.append(req_id)
|
||||||
|
if candidate_id:
|
||||||
|
legacy_where.append("gc.candidate_id = %s")
|
||||||
|
legacy_params.append(candidate_id)
|
||||||
|
legacy_clause = ("WHERE " + " AND ".join(legacy_where)) if legacy_where else ""
|
||||||
|
legacy_rows = db.query(f"""
|
||||||
|
SELECT gc.id, gc.candidate_id, gc.requirement_request_id AS batch_id,
|
||||||
|
gc.position_title, gc.match_score, gc.match_reasoning, gc.status AS item_status,
|
||||||
|
NULL AS generated_cv_path, gc.created_at, gc.updated_at,
|
||||||
|
c.first_name, c.last_name,
|
||||||
|
rr.title AS batch_name, rr.status AS batch_status,
|
||||||
|
gc.template_id, 'legacy' AS source
|
||||||
|
FROM generated_cvs gc
|
||||||
|
LEFT JOIN candidates c ON gc.candidate_id = c.id
|
||||||
|
LEFT JOIN requirement_requests rr ON rr.id = gc.requirement_request_id
|
||||||
|
{legacy_clause}
|
||||||
|
ORDER BY gc.created_at DESC
|
||||||
|
""", legacy_params if legacy_params else None, fetch='all')
|
||||||
|
|
||||||
|
combined = [dict(r) for r in (batch_rows or [])] + [dict(r) for r in (legacy_rows or [])]
|
||||||
|
combined.sort(key=lambda r: str(r.get("updated_at") or r.get("created_at") or ""), reverse=True)
|
||||||
|
return {"generated_cvs": combined}
|
||||||
|
|
||||||
@app.get("/api/generated-cvs/{gen_id}")
|
@app.get("/api/generated-cvs/{gen_id}")
|
||||||
async def get_generated_cv(gen_id: str):
|
async def get_generated_cv(gen_id: str):
|
||||||
@@ -833,8 +886,27 @@ async def render_generated_cv_pdf(gen_id: str):
|
|||||||
|
|
||||||
@app.get("/api/batches")
|
@app.get("/api/batches")
|
||||||
async def list_batches():
|
async def list_batches():
|
||||||
"""List all CV batches."""
|
"""List all CV batches with item/export summary chips."""
|
||||||
rows = db.query("SELECT * FROM cv_batches ORDER BY created_at DESC")
|
rows = db.query("""
|
||||||
|
SELECT b.*,
|
||||||
|
COALESCE(stats.total_items, 0) AS total_items,
|
||||||
|
COALESCE(stats.approved_items, 0) AS approved_items,
|
||||||
|
COALESCE(stats.proposed_items, 0) AS proposed_items,
|
||||||
|
COALESCE(stats.removed_items, 0) AS removed_items,
|
||||||
|
COALESCE(stats.generated_items, 0) AS generated_items
|
||||||
|
FROM cv_batches b
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT batch_id,
|
||||||
|
COUNT(*) AS total_items,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'approved') AS approved_items,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'proposed') AS proposed_items,
|
||||||
|
COUNT(*) FILTER (WHERE status = 'removed') AS removed_items,
|
||||||
|
COUNT(*) FILTER (WHERE generated_cv_path IS NOT NULL AND generated_cv_path <> '') AS generated_items
|
||||||
|
FROM batch_items
|
||||||
|
GROUP BY batch_id
|
||||||
|
) stats ON stats.batch_id = b.id
|
||||||
|
ORDER BY b.created_at DESC
|
||||||
|
""")
|
||||||
return {"batches": [dict(r) for r in rows]}
|
return {"batches": [dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
@@ -1080,12 +1152,30 @@ async def update_batch_positions(batch_id: str, request: Request):
|
|||||||
"""Update the positions array on a batch (add/edit/remove positions)."""
|
"""Update the positions array on a batch (add/edit/remove positions)."""
|
||||||
body = await request.json()
|
body = await request.json()
|
||||||
positions = body.get("positions", [])
|
positions = body.get("positions", [])
|
||||||
|
title_renames = body.get("title_renames") or {} # {old_title: new_title}
|
||||||
|
|
||||||
|
batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one')
|
||||||
|
if not batch:
|
||||||
|
raise HTTPException(404, "Batch not found")
|
||||||
|
|
||||||
row = db.execute(
|
row = db.execute(
|
||||||
"UPDATE cv_batches SET positions = %s, updated_at = NOW() WHERE id = %s RETURNING *",
|
"UPDATE cv_batches SET positions = %s, updated_at = NOW() WHERE id = %s RETURNING *",
|
||||||
(Json(positions), batch_id)
|
(Json(positions), batch_id)
|
||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(404, "Batch not found")
|
raise HTTPException(404, "Batch not found")
|
||||||
|
|
||||||
|
# When a position title is renamed, remap linked batch items.
|
||||||
|
if isinstance(title_renames, dict):
|
||||||
|
for old_title, new_title in title_renames.items():
|
||||||
|
if not old_title or not new_title or old_title == new_title:
|
||||||
|
continue
|
||||||
|
db.execute(
|
||||||
|
"""UPDATE batch_items SET position_title = %s, updated_at = NOW()
|
||||||
|
WHERE batch_id = %s AND position_title = %s""",
|
||||||
|
(new_title, batch_id, old_title)
|
||||||
|
)
|
||||||
|
|
||||||
return dict(row)
|
return dict(row)
|
||||||
|
|
||||||
|
|
||||||
@@ -1167,6 +1257,96 @@ async def generate_batch_cvs(batch_id: str):
|
|||||||
return {"success": True, "generated": len([r for r in results if r["status"] == "ok"]), "results": results}
|
return {"success": True, "generated": len([r for r in results if r["status"] == "ok"]), "results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/batches/{batch_id}/download-zip")
|
||||||
|
async def download_batch_zip(batch_id: str):
|
||||||
|
"""Zip all generated PDFs for a batch into one download."""
|
||||||
|
import io
|
||||||
|
import zipfile
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
batch = db.query("SELECT * FROM cv_batches WHERE id = %s", (batch_id,), fetch='one')
|
||||||
|
if not batch:
|
||||||
|
raise HTTPException(404, "Batch not found")
|
||||||
|
|
||||||
|
items = db.query("""
|
||||||
|
SELECT bi.generated_cv_path, bi.position_title, c.first_name, c.last_name
|
||||||
|
FROM batch_items bi
|
||||||
|
JOIN candidates c ON c.id = bi.candidate_id
|
||||||
|
WHERE bi.batch_id = %s
|
||||||
|
AND bi.generated_cv_path IS NOT NULL
|
||||||
|
AND bi.generated_cv_path <> ''
|
||||||
|
ORDER BY bi.position_title, c.last_name, c.first_name
|
||||||
|
""", (batch_id,))
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
raise HTTPException(400, "No generated CVs available to zip")
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
added = 0
|
||||||
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for item in items:
|
||||||
|
rel = unquote(str(item["generated_cv_path"] or "")).lstrip("/")
|
||||||
|
# Paths are stored as /static/generated/<file>
|
||||||
|
if rel.startswith("static/"):
|
||||||
|
file_path = static_dir / rel[len("static/"):]
|
||||||
|
else:
|
||||||
|
file_path = Path(rel)
|
||||||
|
if not file_path.exists():
|
||||||
|
# Also try direct under static/generated
|
||||||
|
name_only = Path(rel).name
|
||||||
|
file_path = static_dir / "generated" / name_only
|
||||||
|
if not file_path.exists():
|
||||||
|
continue
|
||||||
|
safe_person = f"{item.get('first_name') or ''}_{item.get('last_name') or ''}".strip("_").replace(" ", "_") or "candidate"
|
||||||
|
safe_pos = (item.get("position_title") or "role").replace(" ", "_")
|
||||||
|
arcname = f"{safe_pos}/{safe_person}_{file_path.name}"
|
||||||
|
zf.write(str(file_path), arcname=arcname)
|
||||||
|
added += 1
|
||||||
|
|
||||||
|
if added == 0:
|
||||||
|
raise HTTPException(404, "Generated CV files not found on disk")
|
||||||
|
|
||||||
|
buf.seek(0)
|
||||||
|
safe_batch = str(batch.get("name") or "batch").replace(" ", "_")
|
||||||
|
return Response(
|
||||||
|
content=buf.getvalue(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{safe_batch}_CVs.zip"'}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/batches/{batch_id}/items/{item_id}/generated")
|
||||||
|
async def clear_batch_item_generated(batch_id: str, item_id: str):
|
||||||
|
"""Clear a generated CV path (optional file cleanup for batch exports)."""
|
||||||
|
item = db.query(
|
||||||
|
"SELECT * FROM batch_items WHERE id = %s AND batch_id = %s",
|
||||||
|
(item_id, batch_id),
|
||||||
|
fetch='one'
|
||||||
|
)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "Batch item not found")
|
||||||
|
|
||||||
|
path = item.get("generated_cv_path")
|
||||||
|
if path:
|
||||||
|
rel = str(path).lstrip("/")
|
||||||
|
if rel.startswith("static/"):
|
||||||
|
file_path = static_dir / rel[len("static/"):]
|
||||||
|
else:
|
||||||
|
file_path = static_dir / "generated" / Path(rel).name
|
||||||
|
try:
|
||||||
|
if file_path.exists() and file_path.is_file():
|
||||||
|
file_path.unlink()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
row = db.execute(
|
||||||
|
"""UPDATE batch_items SET generated_cv_path = NULL, updated_at = NOW()
|
||||||
|
WHERE id = %s AND batch_id = %s RETURNING *""",
|
||||||
|
(item_id, batch_id)
|
||||||
|
)
|
||||||
|
return dict(row) if row else {"success": True}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# SERVE FRONTEND
|
# SERVE FRONTEND
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -166,6 +166,22 @@ async function viewCandidate(id) {
|
|||||||
</div>
|
</div>
|
||||||
`).join('') || '<p class="text-muted">No certifications extracted</p>'}
|
`).join('') || '<p class="text-muted">No certifications extracted</p>'}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3 style="margin-bottom:12px">Batches (${(data.batches || []).length})</h3>
|
||||||
|
${(data.batches || []).length ? (data.batches || []).map(b => `
|
||||||
|
<div class="skill-row" style="margin-bottom:8px">
|
||||||
|
<div>
|
||||||
|
<strong>${escapeHtml(b.batch_name || 'Batch')}</strong>
|
||||||
|
<div class="text-muted text-sm">${escapeHtml(b.position_title || 'Unassigned')} · ${escapeHtml(b.item_status || '')} · Score ${b.match_score || 0}%</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<span class="badge badge-${b.batch_status === 'exported' ? 'blue' : b.batch_status === 'active' ? 'green' : 'orange'}">${escapeHtml(b.batch_status || 'draft')}</span>
|
||||||
|
${b.generated_cv_path ? `<a class="btn btn-sm btn-outline" href="${b.generated_cv_path}" target="_blank">PDF</a>` : ''}
|
||||||
|
<button class="btn btn-sm" onclick="closeModal(); document.querySelector('[data-page=\\'batches\\']')?.click(); setTimeout(() => openBatch('${b.batch_id}'), 100);">Open</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('') : '<p class="text-muted">Not linked to any batches yet.</p>'}
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h3 style="margin-bottom:12px">Raw CV Text</h3>
|
<h3 style="margin-bottom:12px">Raw CV Text</h3>
|
||||||
<div style="max-height:400px;overflow-y:auto;background:var(--bg-input);padding:12px;border-radius:6px;white-space:pre-wrap;font-size:13px">${c.raw_cv_text || 'No raw text available'}</div>
|
<div style="max-height:400px;overflow-y:auto;background:var(--bg-input);padding:12px;border-radius:6px;white-space:pre-wrap;font-size:13px">${c.raw_cv_text || 'No raw text available'}</div>
|
||||||
@@ -354,26 +370,52 @@ async function deleteTemplate(id) {
|
|||||||
async function loadGeneratedCVs() {
|
async function loadGeneratedCVs() {
|
||||||
const data = await api('/api/generated-cvs');
|
const data = await api('/api/generated-cvs');
|
||||||
const list = document.getElementById('generated-list');
|
const list = document.getElementById('generated-list');
|
||||||
if (!data.generated_cvs.length) {
|
const rows = data.generated_cvs || [];
|
||||||
list.innerHTML = '<p class="text-muted">No generated CVs yet. Match and generate from a requirement request.</p>';
|
if (!rows.length) {
|
||||||
|
list.innerHTML = '<p class="text-muted">No generated CVs yet. Approve candidates in a Batch and click Generate CVs.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = data.generated_cvs.map(g => `
|
list.innerHTML = rows.map(g => {
|
||||||
|
const source = g.source || 'legacy';
|
||||||
|
const status = g.item_status || g.status || 'draft';
|
||||||
|
const openBtn = g.generated_cv_path
|
||||||
|
? `<a class="btn btn-sm btn-outline" href="${g.generated_cv_path}" target="_blank">Open PDF</a>`
|
||||||
|
: `<button class="btn btn-sm btn-outline" onclick="viewGeneratedCV('${g.id}')">View</button>`;
|
||||||
|
const zipBtn = (source === 'batch' && g.batch_id)
|
||||||
|
? `<a class="btn btn-sm btn-outline" href="/api/batches/${g.batch_id}/download-zip" target="_blank">Batch ZIP</a>`
|
||||||
|
: '';
|
||||||
|
const delBtn = source === 'batch'
|
||||||
|
? `<button class="btn btn-sm btn-danger" onclick="deleteBatchGenerated('${g.batch_id}', '${g.id}')">Remove</button>`
|
||||||
|
: `<button class="btn btn-sm btn-danger" onclick="deleteGeneratedCV('${g.id}')">Delete</button>`;
|
||||||
|
|
||||||
|
return `
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex-between">
|
<div class="flex-between">
|
||||||
<div>
|
<div>
|
||||||
<strong>${g.first_name || ''} ${g.last_name || ''}</strong>
|
<strong>${escapeHtml(g.first_name || '')} ${escapeHtml(g.last_name || '')}</strong>
|
||||||
<span class="text-muted"> · ${g.position_title || 'N/A'}</span>
|
<span class="text-muted"> · ${escapeHtml(g.position_title || 'N/A')}</span>
|
||||||
<div class="text-sm text-muted mt-8">Generated: ${formatDate(g.generation_date)} · Score: ${g.match_score || 0}%</div>
|
<div class="text-sm text-muted mt-8">
|
||||||
|
Batch: ${escapeHtml(g.batch_name || '—')} · Score: ${g.match_score || 0}% ·
|
||||||
|
${g.updated_at ? ('Updated: ' + formatDate(g.updated_at)) : (g.created_at ? ('Created: ' + formatDate(g.created_at)) : '')}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-8">
|
<div class="flex gap-8">
|
||||||
<span class="badge badge-${g.status === 'approved' ? 'green' : g.status === 'draft' ? 'orange' : g.status === 'reviewed' ? 'blue' : 'red'}">${g.status}</span>
|
<span class="badge badge-${status === 'approved' ? 'green' : status === 'draft' || status === 'proposed' ? 'orange' : status === 'exported' || status === 'reviewed' ? 'blue' : 'red'}">${escapeHtml(status)}</span>
|
||||||
<button class="btn btn-sm btn-outline" onclick="viewGeneratedCV('${g.id}')">View</button>
|
<span class="badge badge-blue">${source === 'batch' ? 'Batch export' : 'Legacy'}</span>
|
||||||
<button class="btn btn-sm btn-danger" onclick="deleteGeneratedCV('${g.id}')">Delete</button>
|
${openBtn}
|
||||||
|
${zipBtn}
|
||||||
|
${delBtn}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBatchGenerated(batchId, itemId) {
|
||||||
|
if (!confirm('Remove this generated CV from the exports list?')) return;
|
||||||
|
await api('/api/batches/' + batchId + '/items/' + itemId + '/generated', 'DELETE');
|
||||||
|
toast('Generated CV removed');
|
||||||
|
loadGeneratedCVs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function viewGeneratedCV(id) {
|
async function viewGeneratedCV(id) {
|
||||||
|
|||||||
@@ -32,19 +32,31 @@ async function loadBatches() {
|
|||||||
list.innerHTML = '<p class="text-muted">No batches yet. Create one manually or upload a requirements document.</p>';
|
list.innerHTML = '<p class="text-muted">No batches yet. Create one manually or upload a requirements document.</p>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
list.innerHTML = data.batches.map(b => `
|
list.innerHTML = data.batches.map(b => {
|
||||||
|
const statusClass = b.status === 'active' ? 'green' : b.status === 'exported' ? 'blue' : 'orange';
|
||||||
|
const positionsCount = (b.positions && b.positions.length) ? b.positions.length : 0;
|
||||||
|
const approved = b.approved_items || 0;
|
||||||
|
const proposed = b.proposed_items || 0;
|
||||||
|
const generated = b.generated_items || 0;
|
||||||
|
return `
|
||||||
<div class="card mb-16" style="cursor:pointer" onclick="openBatch('${b.id}')">
|
<div class="card mb-16" style="cursor:pointer" onclick="openBatch('${b.id}')">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span class="card-title">${b.name}</span>
|
<span class="card-title">${escapeHtml(b.name)}</span>
|
||||||
<span class="badge badge-${b.status === 'active' ? 'green' : b.status === 'exported' ? 'blue' : 'orange'}">${b.status}</span>
|
<span class="badge badge-${statusClass}">${escapeHtml(b.status || 'draft')}</span>
|
||||||
</div>
|
</div>
|
||||||
${b.description ? '<p class="text-sm text-muted" style="margin-top:8px">' + escapeHtml(b.description) + '</p>' : ''}
|
${b.description ? '<p class="text-sm text-muted" style="margin-top:8px">' + escapeHtml(b.description) + '</p>' : ''}
|
||||||
<div class="text-muted text-sm" style="margin-top:8px">
|
<div class="text-muted text-sm" style="margin-top:8px">
|
||||||
Created: ${new Date(b.created_at).toLocaleDateString()}
|
Created: ${new Date(b.created_at).toLocaleDateString()}
|
||||||
${b.positions && b.positions.length ? ' · Positions: ' + b.positions.length : ''}
|
· Positions: ${positionsCount}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-8" style="margin-top:10px;flex-wrap:wrap">
|
||||||
|
<span class="badge badge-orange">Proposed ${proposed}</span>
|
||||||
|
<span class="badge badge-green">Approved ${approved}</span>
|
||||||
|
<span class="badge badge-blue">Generated ${generated}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
list.innerHTML = '<p class="text-muted">Error: ' + e.message + '</p>';
|
list.innerHTML = '<p class="text-muted">Error: ' + e.message + '</p>';
|
||||||
}
|
}
|
||||||
@@ -191,15 +203,23 @@ function renderBatchDetail(data) {
|
|||||||
let positionsHtml = '';
|
let positionsHtml = '';
|
||||||
if (positions.length) {
|
if (positions.length) {
|
||||||
positionsHtml = '<div class="card mb-16"><div class="card-header"><span class="card-title">Positions</span></div>';
|
positionsHtml = '<div class="card mb-16"><div class="card-header"><span class="card-title">Positions</span></div>';
|
||||||
positions.forEach(p => {
|
positions.forEach((p, idx) => {
|
||||||
const posItems = byPosition[p.job_title] || [];
|
const posItems = byPosition[p.job_title] || [];
|
||||||
positionsHtml += `
|
positionsHtml += `
|
||||||
<div style="margin-bottom:20px">
|
<div style="margin-bottom:20px;border-top:1px solid var(--border);padding-top:12px">
|
||||||
<h4 style="color:var(--accent)">${escapeHtml(p.job_title || '')} ${p.num_positions > 1 ? '×' + p.num_positions : ''}</h4>
|
<div class="flex-between" style="gap:8px;align-items:flex-start">
|
||||||
${p.description ? '<p class="text-sm text-muted">' + escapeHtml(p.description) + '</p>' : ''}
|
<div>
|
||||||
${p.required_skills && p.required_skills.length ? '<div class="text-sm">Required: ' + p.required_skills.map(s => '<span class="badge badge-blue">' + escapeHtml(s) + '</span>').join(' ') + '</div>' : ''}
|
<h4 style="color:var(--accent);margin:0">${escapeHtml(p.job_title || '')} ${p.num_positions > 1 ? '×' + p.num_positions : ''}</h4>
|
||||||
|
${p.description ? '<p class="text-sm text-muted" style="margin-top:6px">' + escapeHtml(p.description) + '</p>' : ''}
|
||||||
|
${p.required_skills && p.required_skills.length ? '<div class="text-sm" style="margin-top:6px">Required: ' + p.required_skills.map(s => '<span class="badge badge-blue">' + escapeHtml(s) + '</span>').join(' ') + '</div>' : ''}
|
||||||
${p.required_years ? '<div class="text-sm text-muted">Min years: ' + p.required_years + '</div>' : ''}
|
${p.required_years ? '<div class="text-sm text-muted">Min years: ' + p.required_years + '</div>' : ''}
|
||||||
${p.required_certs && p.required_certs.length ? '<div class="text-sm">Certs: ' + p.required_certs.map(c => '<span class="badge badge-orange">' + escapeHtml(c) + '</span>').join(' ') + '</div>' : ''}
|
${p.required_certs && p.required_certs.length ? '<div class="text-sm">Certs: ' + p.required_certs.map(c => '<span class="badge badge-orange">' + escapeHtml(c) + '</span>').join(' ') + '</div>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<button class="btn btn-sm btn-outline" onclick="editPosition(${idx})">Edit</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deletePosition(${idx})">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
${posItems.length ? `
|
${posItems.length ? `
|
||||||
<div style="margin-top:12px">
|
<div style="margin-top:12px">
|
||||||
${posItems.map(item => `
|
${posItems.map(item => `
|
||||||
@@ -212,17 +232,19 @@ function renderBatchDetail(data) {
|
|||||||
${item.generated_cv_path ? '<a href="' + item.generated_cv_path + '" target="_blank" class="btn btn-sm btn-outline" style="margin-top:4px">Download CV</a>' : ''}
|
${item.generated_cv_path ? '<a href="' + item.generated_cv_path + '" target="_blank" class="btn btn-sm btn-outline" style="margin-top:4px">Download CV</a>' : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-8">
|
<div class="flex gap-8">
|
||||||
${item.status !== 'approved' ? '<button class="btn btn-sm btn-green" onclick="approveItem(\'' + item.id + '\')">Approve</button>' : ''}
|
${item.status !== 'approved' ? `<button class="btn btn-sm btn-green" onclick="approveItem('${item.id}')">Approve</button>` : ''}
|
||||||
${item.status !== 'removed' ? '<button class="btn btn-sm btn-danger" onclick="removeItem(\'' + item.id + '\')">Remove</button>' : ''}
|
${item.status !== 'removed' ? `<button class="btn btn-sm btn-danger" onclick="removeItem('${item.id}')">Remove</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
</div>
|
</div>
|
||||||
` : '<p class="text-muted text-sm">No candidates matched yet. Click "Analyze & Match" to find candidates.</p>'}
|
` : '<p class="text-muted text-sm" style="margin-top:8px">No candidates matched yet. Click "Analyze & Match" to find candidates.</p>'}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
positionsHtml += '</div>';
|
positionsHtml += '</div>';
|
||||||
|
} else {
|
||||||
|
positionsHtml = '<div class="card mb-16"><p class="text-muted">No positions yet. Add one below, or upload a requirements document when creating a batch.</p></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unassigned items
|
// Unassigned items
|
||||||
@@ -255,6 +277,7 @@ function renderBatchDetail(data) {
|
|||||||
<div class="flex gap-8">
|
<div class="flex gap-8">
|
||||||
<button class="btn" onclick="analyzeBatch()">Analyze & Match</button>
|
<button class="btn" onclick="analyzeBatch()">Analyze & Match</button>
|
||||||
<button class="btn btn-green" onclick="generateBatchCVs()">Generate CVs</button>
|
<button class="btn btn-green" onclick="generateBatchCVs()">Generate CVs</button>
|
||||||
|
<button class="btn btn-outline" onclick="downloadBatchZip()">Download ZIP</button>
|
||||||
<button class="btn btn-outline" onclick="openBatchChat()">Discuss</button>
|
<button class="btn btn-outline" onclick="openBatchChat()">Discuss</button>
|
||||||
<button class="btn btn-danger" onclick="deleteBatch()">Delete</button>
|
<button class="btn btn-danger" onclick="deleteBatch()">Delete</button>
|
||||||
<button class="btn btn-outline" onclick="closeBatchDetail()">Back</button>
|
<button class="btn btn-outline" onclick="closeBatchDetail()">Back</button>
|
||||||
@@ -346,7 +369,7 @@ async function addPosition() {
|
|||||||
const certs = document.getElementById('new-pos-certs').value.split(',').map(s => s.trim()).filter(s => s);
|
const certs = document.getElementById('new-pos-certs').value.split(',').map(s => s.trim()).filter(s => s);
|
||||||
const desc = document.getElementById('new-pos-desc').value.trim();
|
const desc = document.getElementById('new-pos-desc').value.trim();
|
||||||
|
|
||||||
const positions = currentBatch.batch.positions || [];
|
const positions = [...(currentBatch.batch.positions || [])];
|
||||||
positions.push({
|
positions.push({
|
||||||
job_title: title,
|
job_title: title,
|
||||||
num_positions: qty,
|
num_positions: qty,
|
||||||
@@ -371,6 +394,111 @@ async function addPosition() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function editPosition(idx) {
|
||||||
|
const positions = currentBatch?.batch?.positions || [];
|
||||||
|
const p = positions[idx];
|
||||||
|
if (!p) return;
|
||||||
|
|
||||||
|
const body = `
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Job Title</label>
|
||||||
|
<input type="text" id="edit-pos-title" value="${escapeHtml(p.job_title || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-8">
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>Quantity</label>
|
||||||
|
<input type="number" id="edit-pos-qty" value="${p.num_positions || 1}" min="1" style="width:80px">
|
||||||
|
</div>
|
||||||
|
<div class="form-group" style="flex:1">
|
||||||
|
<label>Min Years</label>
|
||||||
|
<input type="number" id="edit-pos-years" value="${p.required_years || ''}" style="width:80px">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Required Skills (comma-separated)</label>
|
||||||
|
<input type="text" id="edit-pos-skills" value="${escapeHtml((p.required_skills || []).join(', '))}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Required Certs (comma-separated)</label>
|
||||||
|
<input type="text" id="edit-pos-certs" value="${escapeHtml((p.required_certs || []).join(', '))}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Description</label>
|
||||||
|
<textarea id="edit-pos-desc">${escapeHtml(p.description || '')}</textarea>
|
||||||
|
</div>
|
||||||
|
<button class="btn" onclick="saveEditedPosition(${idx})">Save Position</button>
|
||||||
|
`;
|
||||||
|
showModal(body, 'Edit Position');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEditedPosition(idx) {
|
||||||
|
const title = document.getElementById('edit-pos-title').value.trim();
|
||||||
|
if (!title) { toast('Job title required', 'error'); return; }
|
||||||
|
|
||||||
|
const positions = [...(currentBatch.batch.positions || [])];
|
||||||
|
if (!positions[idx]) return;
|
||||||
|
|
||||||
|
const oldTitle = positions[idx].job_title;
|
||||||
|
positions[idx] = {
|
||||||
|
...positions[idx],
|
||||||
|
job_title: title,
|
||||||
|
num_positions: parseInt(document.getElementById('edit-pos-qty').value) || 1,
|
||||||
|
required_years: document.getElementById('edit-pos-years').value ? parseInt(document.getElementById('edit-pos-years').value) : null,
|
||||||
|
required_skills: document.getElementById('edit-pos-skills').value.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
required_certs: document.getElementById('edit-pos-certs').value.split(',').map(s => s.trim()).filter(Boolean),
|
||||||
|
description: document.getElementById('edit-pos-desc').value.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
const payload = { positions };
|
||||||
|
if (oldTitle && oldTitle !== title) {
|
||||||
|
payload.title_renames = { [oldTitle]: title };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/batches/' + currentBatchId + '/positions', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
closeModal();
|
||||||
|
toast('Position updated');
|
||||||
|
openBatch(currentBatchId);
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePosition(idx) {
|
||||||
|
const positions = [...(currentBatch?.batch?.positions || [])];
|
||||||
|
const p = positions[idx];
|
||||||
|
if (!p) return;
|
||||||
|
if (!confirm('Delete position "' + (p.job_title || '') + '"? Candidate matches for this title will remain listed under Unassigned until rematched.')) return;
|
||||||
|
|
||||||
|
positions.splice(idx, 1);
|
||||||
|
try {
|
||||||
|
await fetch('/api/batches/' + currentBatchId + '/positions', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ positions })
|
||||||
|
});
|
||||||
|
toast('Position deleted');
|
||||||
|
openBatch(currentBatchId);
|
||||||
|
} catch (e) {
|
||||||
|
toast('Error: ' + e.message, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadBatchZip() {
|
||||||
|
if (!currentBatchId) return;
|
||||||
|
const generatedCount = (currentBatch.items || []).filter(i => i.generated_cv_path).length;
|
||||||
|
if (!generatedCount) {
|
||||||
|
toast('No generated CVs to download yet', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('Preparing ZIP...');
|
||||||
|
window.open('/api/batches/' + currentBatchId + '/download-zip', '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// GENERATE CVs
|
// GENERATE CVs
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -201,8 +201,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js?v=19"></script>
|
<script src="/static/app.js?v=20"></script>
|
||||||
<script src="/static/carbone.js?v=16"></script>
|
<script src="/static/carbone.js?v=16"></script>
|
||||||
<script src="/static/batches.js?v=3"></script>
|
<script src="/static/batches.js?v=4"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user