feat: batch polish — edit/delete positions, ZIP export, exports page, candidate batch links
This commit is contained in:
@@ -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>';
|
||||
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-header">
|
||||
<span class="card-title">${b.name}</span>
|
||||
<span class="badge badge-${b.status === 'active' ? 'green' : b.status === 'exported' ? 'blue' : 'orange'}">${b.status}</span>
|
||||
<span class="card-title">${escapeHtml(b.name)}</span>
|
||||
<span class="badge badge-${statusClass}">${escapeHtml(b.status || 'draft')}</span>
|
||||
</div>
|
||||
${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">
|
||||
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>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
list.innerHTML = '<p class="text-muted">Error: ' + e.message + '</p>';
|
||||
}
|
||||
@@ -187,43 +199,53 @@ function renderBatchDetail(data) {
|
||||
});
|
||||
|
||||
// Build positions HTML
|
||||
const positions = batch.positions || [];
|
||||
let positionsHtml = '';
|
||||
if (positions.length) {
|
||||
positionsHtml = '<div class="card mb-16"><div class="card-header"><span class="card-title">Positions</span></div>';
|
||||
positions.forEach(p => {
|
||||
const posItems = byPosition[p.job_title] || [];
|
||||
positionsHtml += `
|
||||
<div style="margin-bottom:20px">
|
||||
<h4 style="color:var(--accent)">${escapeHtml(p.job_title || '')} ${p.num_positions > 1 ? '×' + p.num_positions : ''}</h4>
|
||||
${p.description ? '<p class="text-sm text-muted">' + escapeHtml(p.description) + '</p>' : ''}
|
||||
${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>' : ''}
|
||||
${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>' : ''}
|
||||
${posItems.length ? `
|
||||
<div style="margin-top:12px">
|
||||
${posItems.map(item => `
|
||||
<div class="skill-row" style="margin-bottom:8px">
|
||||
<div>
|
||||
<strong>${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')}</strong>
|
||||
<span class="badge badge-${item.match_score >= 70 ? 'green' : item.match_score >= 50 ? 'orange' : 'red'}">${item.match_score}%</span>
|
||||
<span class="badge badge-${item.status === 'approved' ? 'green' : item.status === 'removed' ? 'red' : 'orange'}">${item.status}</span>
|
||||
${item.match_reasoning ? '<div class="text-muted text-sm" style="margin-top:4px">' + escapeHtml(item.match_reasoning) + '</div>' : ''}
|
||||
${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 class="flex gap-8">
|
||||
${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>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
const positions = batch.positions || [];
|
||||
let positionsHtml = '';
|
||||
if (positions.length) {
|
||||
positionsHtml = '<div class="card mb-16"><div class="card-header"><span class="card-title">Positions</span></div>';
|
||||
positions.forEach((p, idx) => {
|
||||
const posItems = byPosition[p.job_title] || [];
|
||||
positionsHtml += `
|
||||
<div style="margin-bottom:20px;border-top:1px solid var(--border);padding-top:12px">
|
||||
<div class="flex-between" style="gap:8px;align-items:flex-start">
|
||||
<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_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>
|
||||
` : '<p class="text-muted text-sm">No candidates matched yet. Click "Analyze & Match" to find candidates.</p>'}
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
positionsHtml += '</div>';
|
||||
}
|
||||
${posItems.length ? `
|
||||
<div style="margin-top:12px">
|
||||
${posItems.map(item => `
|
||||
<div class="skill-row" style="margin-bottom:8px">
|
||||
<div>
|
||||
<strong>${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')}</strong>
|
||||
<span class="badge badge-${item.match_score >= 70 ? 'green' : item.match_score >= 50 ? 'orange' : 'red'}">${item.match_score}%</span>
|
||||
<span class="badge badge-${item.status === 'approved' ? 'green' : item.status === 'removed' ? 'red' : 'orange'}">${item.status}</span>
|
||||
${item.match_reasoning ? '<div class="text-muted text-sm" style="margin-top:4px">' + escapeHtml(item.match_reasoning) + '</div>' : ''}
|
||||
${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 class="flex gap-8">
|
||||
${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>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : '<p class="text-muted text-sm" style="margin-top:8px">No candidates matched yet. Click "Analyze & Match" to find candidates.</p>'}
|
||||
</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
|
||||
const unassigned = byPosition['Unassigned'] || [];
|
||||
@@ -255,6 +277,7 @@ function renderBatchDetail(data) {
|
||||
<div class="flex gap-8">
|
||||
<button class="btn" onclick="analyzeBatch()">Analyze & Match</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-danger" onclick="deleteBatch()">Delete</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 desc = document.getElementById('new-pos-desc').value.trim();
|
||||
|
||||
const positions = currentBatch.batch.positions || [];
|
||||
const positions = [...(currentBatch.batch.positions || [])];
|
||||
positions.push({
|
||||
job_title: title,
|
||||
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
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user