feat: CV Batches system with AI extraction, matching, and slide-in chat
This commit is contained in:
379
static/batches.js
Normal file
379
static/batches.js
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user