feat: CV Batches system with AI extraction, matching, and slide-in chat

This commit is contained in:
root
2026-07-25 09:41:26 +00:00
parent 692b383aa5
commit ecb9ebb507
10 changed files with 910 additions and 18 deletions

379
static/batches.js Normal file
View File

@@ -0,0 +1,379 @@
// CV Batches - Frontend
let currentBatchId = null;
// ============================================================
// NAV — load batches when page is shown
// ============================================================
const batchNavObserver = new MutationObserver(() => {
if (document.getElementById('page-batches') && document.getElementById('page-batches').classList.contains('active')) {
loadBatches();
}
});
document.addEventListener('DOMContentLoaded', () => {
const bp = document.getElementById('page-batches');
if (bp) {
if (bp.classList.contains('active')) loadBatches();
batchNavObserver.observe(bp, { attributes: true, attributeFilter: ['class'] });
}
});
// ============================================================
// LIST BATCHES
// ============================================================
async function loadBatches() {
const list = document.getElementById('batches-list');
if (!list) return;
list.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await fetch('/api/batches');
const data = await resp.json();
if (!data.batches || !data.batches.length) {
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 => `
<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>
</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 : ''}
</div>
</div>
`).join('');
} catch (e) {
list.innerHTML = '<p class="text-muted">Error: ' + e.message + '</p>';
}
}
// ============================================================
// CREATE BATCH (manual)
// ============================================================
function showCreateBatchModal() {
const body = `
<div class="form-group">
<label>Batch Name</label>
<input type="text" id="new-batch-name" placeholder="e.g. Senior DevOps Team">
</div>
<div class="form-group">
<label>Description</label>
<textarea id="new-batch-desc" placeholder="What is this batch for?"></textarea>
</div>
<button class="btn" onclick="createBatch()">Create</button>
`;
showModal(body, 'New Batch');
}
async function createBatch() {
const name = document.getElementById('new-batch-name').value.trim();
if (!name) { toast('Name required', 'error'); return; }
const description = document.getElementById('new-batch-desc').value.trim();
try {
const resp = await fetch('/api/batches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description })
});
const data = await resp.json();
closeModal();
toast('Batch created');
openBatch(data.id);
} catch (e) {
toast('Error: ' + e.message, 'error');
}
}
// ============================================================
// UPLOAD DOCUMENT to create batch
// ============================================================
function showUploadBatchModal() {
const body = `
<div class="upload-zone" id="batch-upload-zone" onclick="document.getElementById('batch-file-input').click()">
<p style="font-size:16px; margin-bottom:8px">Drop requirements document here or click to browse</p>
<p class="text-muted text-sm">PDF, DOCX, or TXT — AI will extract job positions automatically</p>
</div>
<input type="file" id="batch-file-input" accept=".pdf,.docx,.doc,.txt,.rtf" style="display:none" onchange="uploadBatchDoc(this.files[0])">
<div id="batch-upload-status" style="margin-top:12px"></div>
`;
showModal(body, 'Upload Requirements Document');
// Setup drag-and-drop
const zone = document.getElementById('batch-upload-zone');
zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); });
zone.addEventListener('dragleave', () => zone.classList.remove('dragover'));
zone.addEventListener('drop', e => {
e.preventDefault();
zone.classList.remove('dragover');
if (e.dataTransfer.files.length) uploadBatchDoc(e.dataTransfer.files[0]);
});
}
async function uploadBatchDoc(file) {
if (!file) return;
const status = document.getElementById('batch-upload-status');
status.innerHTML = '<div class="loading"><span class="spinner"></span> Extracting requirements with AI... this may take 30-60 seconds</div>';
const formData = new FormData();
formData.append('file', file);
try {
const resp = await fetch('/api/batches/upload', { method: 'POST', body: formData });
const data = await resp.json();
if (resp.ok) {
status.innerHTML = '<div class="toast success" style="position:relative">Batch created! Extracted ' + (data.positions?.length || 0) + ' positions.</div>';
setTimeout(() => {
closeModal();
openBatch(data.id);
}, 1500);
toast('Batch created from document');
} else {
status.innerHTML = '<div class="toast error" style="position:relative">Upload failed: ' + (data.detail || 'Unknown error') + '</div>';
}
} catch (e) {
status.innerHTML = '<div class="toast error" style="position:relative">Upload failed: ' + e.message + '</div>';
}
}
// ============================================================
// BATCH DETAIL VIEW
// ============================================================
let currentBatch = null;
async function openBatch(batchId) {
currentBatchId = batchId;
document.getElementById('batches-list-view').style.display = 'none';
const detail = document.getElementById('batch-detail-view');
detail.style.display = 'block';
detail.innerHTML = '<div class="loading">Loading batch...</div>';
try {
const resp = await fetch('/api/batches/' + batchId);
const data = await resp.json();
currentBatch = data;
renderBatchDetail(data);
} catch (e) {
detail.innerHTML = '<p class="text-muted">Error: ' + e.message + '</p>';
}
}
function renderBatchDetail(data) {
const batch = data.batch;
const items = data.items || [];
// Group items by position
const byPosition = {};
items.forEach(item => {
const pos = item.position_title || 'Unassigned';
if (!byPosition[pos]) byPosition[pos] = [];
byPosition[pos].push(item);
});
// 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>' : ''}
</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">No candidates matched yet. Click "Analyze & Match" to find candidates.</p>'}
</div>
`;
});
positionsHtml += '</div>';
}
// Unassigned items
const unassigned = byPosition['Unassigned'] || [];
if (unassigned.length) {
positionsHtml += '<div class="card mb-16"><div class="card-header"><span class="card-title">Unassigned</span></div>';
positionsHtml += unassigned.map(item => `
<div class="skill-row">
<div><strong>${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')}</strong> <span class="badge badge-orange">${item.match_score}%</span></div>
<div class="flex gap-8">
<button class="btn btn-sm btn-danger" onclick="removeItem('${item.id}')">Remove</button>
</div>
</div>
`).join('');
positionsHtml += '</div>';
}
document.getElementById('batch-detail-view').innerHTML = `
<div class="flex-between mb-16">
<div>
<h2>${escapeHtml(batch.name)}</h2>
<p class="text-muted text-sm">${escapeHtml(batch.description || '')}</p>
</div>
<div class="flex gap-8">
<button class="btn" onclick="analyzeBatch()">Analyze & Match</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>
</div>
</div>
${positionsHtml}
`;
}
function closeBatchDetail() {
document.getElementById('batches-list-view').style.display = 'block';
document.getElementById('batch-detail-view').style.display = 'none';
currentBatchId = null;
closeBatchChat();
loadBatches();
}
// ============================================================
// ANALYZE & MATCH
// ============================================================
async function analyzeBatch() {
if (!currentBatchId) return;
toast('Analyzing candidates... this may take a minute');
try {
const resp = await fetch('/api/batches/' + currentBatchId + '/analyze', { method: 'POST' });
const data = await resp.json();
if (resp.ok) {
toast('Matched ' + data.matched + ' candidates');
openBatch(currentBatchId);
} else {
toast('Error: ' + (data.detail || 'Analysis failed'), 'error');
}
} catch (e) {
toast('Error: ' + e.message, 'error');
}
}
// ============================================================
// APPROVE / REMOVE ITEMS
// ============================================================
async function approveItem(itemId) {
await fetch('/api/batches/' + currentBatchId + '/items/' + itemId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'approved' })
});
toast('Candidate approved');
openBatch(currentBatchId);
}
async function removeItem(itemId) {
await fetch('/api/batches/' + currentBatchId + '/items/' + itemId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'removed' })
});
toast('Candidate removed');
openBatch(currentBatchId);
}
// ============================================================
// DELETE BATCH
// ============================================================
async function deleteBatch() {
if (!currentBatchId || !confirm('Delete this batch and all its items?')) return;
await fetch('/api/batches/' + currentBatchId, { method: 'DELETE' });
toast('Batch deleted');
closeBatchDetail();
}
// ============================================================
// BATCH CHAT (slide-in panel)
// ============================================================
function openBatchChat() {
const panel = document.getElementById('batch-chat-panel');
panel.style.display = 'flex';
renderBatchChatMessages(currentBatch?.chat || []);
}
function closeBatchChat() {
document.getElementById('batch-chat-panel').style.display = 'none';
}
function renderBatchChatMessages(messages) {
const container = document.getElementById('batch-chat-messages');
if (!messages.length) {
container.innerHTML = '<div class="text-muted">Start discussing this batch with the AI...</div>';
return;
}
container.innerHTML = messages.map(m => `
<div class="chat-msg ${m.role}">
<div class="bubble">${escapeHtml(m.content)}</div>
</div>
`).join('');
container.scrollTop = container.scrollHeight;
}
async function sendBatchChat() {
const input = document.getElementById('batch-chat-input');
const message = input.value.trim();
if (!message || !currentBatchId) return;
input.value = '';
// Show user message immediately
const container = document.getElementById('batch-chat-messages');
container.innerHTML += `<div class="chat-msg user"><div class="bubble">${escapeHtml(message)}</div></div>`;
container.scrollTop = container.scrollHeight;
// Show loading
container.innerHTML += '<div class="chat-msg assistant" id="chat-loading"><div class="bubble">Thinking...</div></div>';
container.scrollTop = container.scrollHeight;
try {
const resp = await fetch('/api/batches/' + currentBatchId + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const data = await resp.json();
// Remove loading and show response
document.getElementById('chat-loading')?.remove();
container.innerHTML += `<div class="chat-msg assistant"><div class="bubble">${escapeHtml(data.response)}</div></div>`;
container.scrollTop = container.scrollHeight;
} catch (e) {
document.getElementById('chat-loading')?.remove();
toast('Chat error: ' + e.message, 'error');
}
}
// Enter key to send in chat
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('batch-chat-input');
if (input) {
input.addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendBatchChat();
}
});
}
});