428 lines
17 KiB
JavaScript
428 lines
17 KiB
JavaScript
// 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;
|
||
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 = '<div class="loading">Loading batch...</div>';
|
||
|
||
await loadBatchTemplates();
|
||
|
||
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>';
|
||
}
|
||
|
||
// Build template selector options
|
||
const currentTemplateId = batch.template_id || '';
|
||
const templateOptions = batchTemplates.map(t =>
|
||
'<option value="' + t.id + '"' + (t.id === currentTemplateId ? ' selected' : '') + '>' + escapeHtml(t.name) + '</option>'
|
||
).join('');
|
||
|
||
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>
|
||
<div class="card mb-16">
|
||
<div class="form-group" style="margin:0">
|
||
<label>Template for CV Generation</label>
|
||
<div class="flex gap-8">
|
||
<select id="batch-template-select" style="flex:1" onchange="saveBatchTemplate()">
|
||
<option value="">— Select a template —</option>
|
||
${templateOptions}
|
||
</select>
|
||
${batchTemplates.length === 0 ? '<span class="text-muted text-sm">No templates uploaded. Go to Templates page to add one.</span>' : ''}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
${positionsHtml}
|
||
`;
|
||
}
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
}); |