// 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 = '
Loading...
'; try { const resp = await fetch('/api/batches'); const data = await resp.json(); if (!data.batches || !data.batches.length) { list.innerHTML = '

No batches yet. Create one manually or upload a requirements document.

'; return; } 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 `
${escapeHtml(b.name)} ${escapeHtml(b.status || 'draft')}
${b.description ? '

' + escapeHtml(b.description) + '

' : ''}
Created: ${new Date(b.created_at).toLocaleDateString()} · Positions: ${positionsCount}
Proposed ${proposed} Approved ${approved} Generated ${generated}
`; }).join(''); } catch (e) { list.innerHTML = '

Error: ' + e.message + '

'; } } // ============================================================ // CREATE BATCH (manual) // ============================================================ function showCreateBatchModal() { const body = `
`; 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 = `

Drop requirements document here or click to browse

PDF, DOCX, or TXT — AI will extract job positions automatically

`; 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 = '
Extracting requirements with AI... this may take 30-60 seconds
'; 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 = '
Batch created! Extracted ' + (data.positions?.length || 0) + ' positions.
'; setTimeout(() => { closeModal(); openBatch(data.id); }, 1500); toast('Batch created from document'); } else { status.innerHTML = '
Upload failed: ' + (data.detail || 'Unknown error') + '
'; } } catch (e) { status.innerHTML = '
Upload failed: ' + e.message + '
'; } } // ============================================================ // BATCH DETAIL VIEW // ============================================================ let currentBatch = null; let batchTemplates = []; async function loadBatchTemplates() { try { const resp = await fetch('http://' + location.hostname + ':8771/api/carbone/templates'); const data = await resp.json(); batchTemplates = data.templates || []; } catch (e) { batchTemplates = []; } } 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 = '
Loading batch...
'; await loadBatchTemplates(); try { const resp = await fetch('/api/batches/' + batchId); const data = await resp.json(); currentBatch = data; renderBatchDetail(data); } catch (e) { detail.innerHTML = '

Error: ' + e.message + '

'; } } 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 = '
Positions
'; positions.forEach((p, idx) => { const posItems = byPosition[p.job_title] || []; positionsHtml += `

${escapeHtml(p.job_title || '')} ${p.num_positions > 1 ? '×' + p.num_positions : ''}

${p.description ? '

' + escapeHtml(p.description) + '

' : ''} ${p.required_skills && p.required_skills.length ? '
Required: ' + p.required_skills.map(s => '' + escapeHtml(s) + '').join(' ') + '
' : ''} ${p.required_years ? '
Min years: ' + p.required_years + '
' : ''} ${p.required_certs && p.required_certs.length ? '
Certs: ' + p.required_certs.map(c => '' + escapeHtml(c) + '').join(' ') + '
' : ''}
${posItems.length ? `
${posItems.map(item => `
${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')} ${item.match_score}% ${item.status} ${item.match_reasoning ? '
' + escapeHtml(item.match_reasoning) + '
' : ''} ${item.generated_cv_path ? 'Download CV' : ''}
${item.status !== 'approved' ? `` : ''} ${item.status !== 'removed' ? `` : ''}
`).join('')}
` : '

No candidates matched yet. Click "Analyze & Match" to find candidates.

'}
`; }); positionsHtml += '
'; } else { positionsHtml = '

No positions yet. Add one below, or upload a requirements document when creating a batch.

'; } // Unassigned items const unassigned = byPosition['Unassigned'] || []; if (unassigned.length) { positionsHtml += '
Unassigned
'; positionsHtml += unassigned.map(item => `
${escapeHtml(item.first_name || '')} ${escapeHtml(item.last_name || '')} ${item.match_score}%
`).join(''); positionsHtml += '
'; } // Build template selector options const currentTemplateId = batch.template_id || ''; const templateOptions = batchTemplates.map(t => '' ).join(''); document.getElementById('batch-detail-view').innerHTML = `

${escapeHtml(batch.name)}

${escapeHtml(batch.description || '')}

${batchTemplates.length === 0 ? 'No templates uploaded. Go to Templates page to add one.' : ''}
${positionsHtml}
Add Position
`; } async function saveBatchTemplate() { const templateId = document.getElementById('batch-template-select').value; try { await fetch('/api/batches/' + currentBatchId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: currentBatch.batch.name, description: currentBatch.batch.description, template_id: templateId }) }); toast('Template saved for this batch'); } catch (e) { toast('Error saving template: ' + e.message, 'error'); } } // ============================================================ // ADD POSITION // ============================================================ function showAddPositionForm() { const form = document.getElementById('add-position-form'); form.style.display = form.style.display === 'none' ? 'block' : 'none'; } async function addPosition() { const title = document.getElementById('new-pos-title').value.trim(); if (!title) { toast('Job title required', 'error'); return; } const qty = parseInt(document.getElementById('new-pos-qty').value) || 1; const years = document.getElementById('new-pos-years').value ? parseInt(document.getElementById('new-pos-years').value) : null; const skills = document.getElementById('new-pos-skills').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 positions = [...(currentBatch.batch.positions || [])]; positions.push({ job_title: title, num_positions: qty, required_years: years, required_skills: skills, required_certs: certs, nice_to_have: [], disqualifiers: [], description: desc }); try { await fetch('/api/batches/' + currentBatchId + '/positions', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ positions }) }); toast('Position added'); openBatch(currentBatchId); } catch (e) { toast('Error: ' + e.message, 'error'); } } function editPosition(idx) { const positions = currentBatch?.batch?.positions || []; const p = positions[idx]; if (!p) return; const body = `
`; 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 // ============================================================ async function generateBatchCVs() { if (!currentBatchId) return; const templateId = currentBatch.batch.template_id; if (!templateId) { toast('Select a template first', 'error'); return; } const approvedCount = (currentBatch.items || []).filter(i => i.status === 'approved').length; if (approvedCount === 0) { toast('No approved candidates to generate CVs for', 'error'); return; } if (!confirm('Generate ' + approvedCount + ' CV(s) using the selected template?')) return; toast('Generating CVs... this may take a minute'); try { const resp = await fetch('/api/batches/' + currentBatchId + '/generate', { method: 'POST' }); const data = await resp.json(); if (resp.ok) { const ok = data.generated || 0; const failed = (data.results || []).filter(r => r.status === 'error').length; if (failed > 0) { toast('Generated ' + ok + ' CVs, ' + failed + ' failed', 'error'); } else { toast('Generated ' + ok + ' CVs successfully'); } openBatch(currentBatchId); } else { toast('Error: ' + (data.detail || 'Generation failed'), 'error'); } } catch (e) { toast('Error: ' + e.message, 'error'); } } 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 = '
Start discussing this batch with the AI...
'; return; } container.innerHTML = messages.map(m => `
${escapeHtml(m.content)}
`).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 += `
${escapeHtml(message)}
`; container.scrollTop = container.scrollHeight; // Show loading container.innerHTML += '
Thinking...
'; 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 += `
${escapeHtml(data.response)}
`; 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(); } }); } });