Files
cv-app/static/batches.js

548 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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>' : ''}
${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">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-green" onclick="generateBatchCVs()">Generate CVs</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}
<div class="card mb-16">
<div class="card-header">
<span class="card-title">Add Position</span>
<button class="btn btn-sm" onclick="showAddPositionForm()">+ Add</button>
</div>
<div id="add-position-form" style="display:none;margin-top:12px">
<div class="form-group">
<label>Job Title</label>
<input type="text" id="new-pos-title" placeholder="e.g. Senior Developer">
</div>
<div class="flex gap-8">
<div class="form-group" style="flex:1">
<label>Quantity</label>
<input type="number" id="new-pos-qty" value="1" min="1" style="width:80px">
</div>
<div class="form-group" style="flex:1">
<label>Min Years</label>
<input type="number" id="new-pos-years" placeholder="5" style="width:80px">
</div>
</div>
<div class="form-group">
<label>Required Skills (comma-separated)</label>
<input type="text" id="new-pos-skills" placeholder="Python, Docker, AWS">
</div>
<div class="form-group">
<label>Required Certs (comma-separated)</label>
<input type="text" id="new-pos-certs" placeholder="AWS, CKAD">
</div>
<div class="form-group">
<label>Description</label>
<textarea id="new-pos-desc" placeholder="Role description"></textarea>
</div>
<button class="btn" onclick="addPosition()">Add Position</button>
</div>
</div>
`;
}
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');
}
}
// ============================================================
// 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 = '<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();
}
});
}
});