523 lines
22 KiB
JavaScript
523 lines
22 KiB
JavaScript
// CV Application - Frontend JavaScript
|
|
const API = '';
|
|
let currentChatId = null;
|
|
|
|
// ============================================================
|
|
// NAVIGATION
|
|
// ============================================================
|
|
document.querySelectorAll('.nav-link').forEach(link => {
|
|
link.addEventListener('click', e => {
|
|
e.preventDefault();
|
|
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
|
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
|
|
link.classList.add('active');
|
|
const page = link.dataset.page;
|
|
document.getElementById('page-' + page).classList.add('active');
|
|
// Load data for page
|
|
if (page === 'dashboard') loadDashboard();
|
|
if (page === 'candidates') loadCandidates();
|
|
if (page === 'templates') loadCarboneTemplates();
|
|
if (page === 'generated') loadGeneratedCVs();
|
|
if (page === 'batches') loadBatches();
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// API helpers
|
|
// ============================================================
|
|
async function api(path, method = 'GET', body = null) {
|
|
const opts = { method, headers: {} };
|
|
if (body) {
|
|
opts.headers['Content-Type'] = 'application/json';
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
const resp = await fetch(API + path, opts);
|
|
if (!resp.ok) {
|
|
const err = await resp.text();
|
|
throw new Error(err);
|
|
}
|
|
return resp.json();
|
|
}
|
|
|
|
function toast(msg, type = 'success') {
|
|
const el = document.createElement('div');
|
|
el.className = 'toast ' + type;
|
|
el.textContent = msg;
|
|
document.body.appendChild(el);
|
|
setTimeout(() => el.remove(), 4000);
|
|
}
|
|
|
|
// ============================================================
|
|
// DASHBOARD
|
|
// ============================================================
|
|
async function loadDashboard() {
|
|
const stats = await api('/api/stats');
|
|
const grid = document.getElementById('stats-grid');
|
|
grid.innerHTML = `
|
|
<div class="stat-card"><div class="stat-value">${stats.total_candidates}</div><div class="stat-label">Total Candidates</div></div>
|
|
<div class="stat-card"><div class="stat-value">${stats.parsed_candidates}</div><div class="stat-label">Parsed CVs</div></div>
|
|
<div class="stat-card"><div class="stat-value">${stats.unique_skills}</div><div class="stat-label">Unique Skills</div></div>
|
|
<div class="stat-card"><div class="stat-value">${stats.total_templates}</div><div class="stat-label">Templates</div></div>
|
|
<div class="stat-card"><div class="stat-value">${stats.total_requirements}</div><div class="stat-label">Requirements</div></div>
|
|
<div class="stat-card"><div class="stat-value">${stats.total_generated_cvs}</div><div class="stat-label">Generated CVs</div></div>
|
|
`;
|
|
// Recent candidates
|
|
const data = await api('/api/candidates?limit=5');
|
|
document.getElementById('recent-candidates').innerHTML = data.candidates.map(c => `
|
|
<div class="skill-row">
|
|
<span><strong>${c.first_name || ''} ${c.last_name || ''}</strong> ${c.email ? '· ' + c.email : ''}</span>
|
|
<span class="badge badge-${c.parse_status === 'parsed' ? 'green' : c.parse_status === 'error' ? 'red' : 'orange'}">${c.parse_status}</span>
|
|
</div>
|
|
`).join('') || '<p class="text-muted">No candidates yet</p>';
|
|
}
|
|
|
|
// ============================================================
|
|
// CANDIDATES
|
|
// ============================================================
|
|
async function loadCandidates(search = '') {
|
|
const data = await api('/api/candidates' + (search ? '?search=' + encodeURIComponent(search) : ''));
|
|
const list = document.getElementById('candidates-list');
|
|
if (!data.candidates.length) {
|
|
list.innerHTML = '<p class="text-muted">No candidates found. Upload some CVs to get started.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = data.candidates.map(c => `
|
|
<div class="card" style="cursor:pointer" onclick="viewCandidate('${c.id}')">
|
|
<div class="flex-between">
|
|
<div>
|
|
<strong>${c.first_name || ''} ${c.last_name || ''}</strong>
|
|
${c.email ? '· ' + c.email : ''}
|
|
<div class="text-muted text-sm mt-8">${(c.preview || '').substring(0, 100)}...</div>
|
|
</div>
|
|
<div class="flex gap-8">
|
|
<span class="badge badge-${c.parse_status === 'parsed' ? 'green' : c.parse_status === 'error' ? 'red' : 'orange'}">${c.parse_status}</span>
|
|
<button class="btn btn-sm btn-danger" onclick="event.stopPropagation();deleteCandidate('${c.id}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function searchCandidates() {
|
|
const q = document.getElementById('search-input').value;
|
|
loadCandidates(q);
|
|
}
|
|
|
|
document.getElementById('search-input')?.addEventListener('keypress', e => {
|
|
if (e.key === 'Enter') searchCandidates();
|
|
});
|
|
|
|
async function viewCandidate(id) {
|
|
const data = await api('/api/candidates/' + id);
|
|
const c = data.candidate;
|
|
const body = `
|
|
<div style="margin-bottom:20px">
|
|
<h2>${c.first_name || ''} ${c.last_name || ''}</h2>
|
|
${c.email ? '<div class="text-muted">' + c.email + '</div>' : ''}
|
|
${c.phone ? '<div class="text-muted">' + c.phone + '</div>' : ''}
|
|
${c.linkedin ? '<div class="text-muted">LinkedIn: ' + c.linkedin + '</div>' : ''}
|
|
</div>
|
|
${c.summary ? '<div class="card"><h3>Summary</h3><p>' + c.summary + '</p></div>' : ''}
|
|
<div class="card">
|
|
<h3 style="margin-bottom:12px">Skills (${data.skills.length})</h3>
|
|
${data.skills.map(s => `
|
|
<div class="skill-row">
|
|
<div>
|
|
<span class="skill-name">${s.skill_name}</span>
|
|
<span class="skill-meta"> · ${s.skill_category || 'General'} · ${s.proficiency || 'N/A'}</span>
|
|
</div>
|
|
<span class="badge badge-blue">${s.years_experience || 0} yrs</span>
|
|
</div>
|
|
`).join('') || '<p class="text-muted">No skills extracted</p>'}
|
|
</div>
|
|
<div class="card">
|
|
<h3 style="margin-bottom:12px">Experience (${data.experience.length})</h3>
|
|
${data.experience.map(e => `
|
|
<div style="margin-bottom:16px">
|
|
<strong>${e.position || ''}</strong> at ${e.company || ''}
|
|
<div class="text-muted text-sm">${formatDate(e.start_date)} — ${e.end_date ? formatDate(e.end_date) : 'Present'}</div>
|
|
${e.description ? '<p class="text-sm mt-8">' + e.description + '</p>' : ''}
|
|
</div>
|
|
`).join('') || '<p class="text-muted">No experience extracted</p>'}
|
|
</div>
|
|
<div class="card">
|
|
<h3 style="margin-bottom:12px">Education (${data.education.length})</h3>
|
|
${data.education.map(e => `
|
|
<div style="margin-bottom:12px">
|
|
<strong>${e.degree || ''}</strong> ${e.field_of_study ? 'in ' + e.field_of_study : ''}
|
|
<div class="text-muted text-sm">${e.institution || ''}</div>
|
|
</div>
|
|
`).join('') || '<p class="text-muted">No education extracted</p>'}
|
|
</div>
|
|
<div class="card">
|
|
<h3 style="margin-bottom:12px">Certifications & Training (${data.certifications ? data.certifications.length : 0})</h3>
|
|
${(data.certifications || []).map(c => `
|
|
<div style="margin-bottom:12px">
|
|
<strong>${c.name || ''}</strong>
|
|
${c.issuer ? '<div class="text-muted text-sm">' + c.issuer + '</div>' : ''}
|
|
${c.issue_date ? '<div class="text-muted text-sm">Issued: ' + formatDate(c.issue_date) + '</div>' : ''}
|
|
${c.credential_id ? '<div class="text-muted text-sm">ID: ' + c.credential_id + '</div>' : ''}
|
|
</div>
|
|
`).join('') || '<p class="text-muted">No certifications extracted</p>'}
|
|
</div>
|
|
<div class="card">
|
|
<h3 style="margin-bottom:12px">Raw CV Text</h3>
|
|
<div style="max-height:400px;overflow-y:auto;background:var(--bg-input);padding:12px;border-radius:6px;white-space:pre-wrap;font-size:13px">${c.raw_cv_text || 'No raw text available'}</div>
|
|
</div>
|
|
`;
|
|
showModal(body, 'Candidate Details');
|
|
}
|
|
|
|
async function deleteCandidate(id) {
|
|
if (!confirm('Delete this candidate and all related data?')) return;
|
|
await api('/api/candidates/' + id, 'DELETE');
|
|
toast('Candidate deleted');
|
|
loadCandidates();
|
|
}
|
|
|
|
// ============================================================
|
|
// UPLOAD (inline on Candidates page)
|
|
// ============================================================
|
|
function showUploadZone() {
|
|
const area = document.getElementById('candidate-upload-area');
|
|
area.style.display = area.style.display === 'none' ? 'block' : 'none';
|
|
}
|
|
|
|
const uploadZone = document.getElementById('upload-zone');
|
|
uploadZone?.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); });
|
|
uploadZone?.addEventListener('dragleave', () => uploadZone.classList.remove('dragover'));
|
|
uploadZone?.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
uploadZone.classList.remove('dragover');
|
|
if (e.dataTransfer.files.length) uploadFile(e.dataTransfer.files[0]);
|
|
});
|
|
|
|
async function uploadFile(file) {
|
|
if (!file) return;
|
|
const status = document.getElementById('upload-status');
|
|
status.innerHTML = '<div class="loading"><span class="spinner"></span> Uploading and parsing with AI... this may take 30-60 seconds</div>';
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const resp = await fetch(API + '/api/candidates/upload', { method: 'POST', body: formData });
|
|
const data = await resp.json();
|
|
if (data.status === 'parsed') {
|
|
status.innerHTML = `<div class="toast success" style="position:relative;bottom:0;right:0">
|
|
<strong>Parsed successfully!</strong><br>
|
|
Name: ${data.parsed_data.first_name || ''} ${data.parsed_data.last_name || ''}<br>
|
|
Skills: ${data.parsed_data.skills?.length || 0} | Experience: ${data.parsed_data.experience?.length || 0} | Education: ${data.parsed_data.education?.length || 0} | Certs: ${data.parsed_data.certifications?.length || 0}
|
|
</div>`;
|
|
toast('CV uploaded and parsed successfully');
|
|
} else {
|
|
status.innerHTML = `<div class="toast error" style="position:relative;bottom:0;right:0">Parse error: ${data.error}</div>`;
|
|
}
|
|
} catch (e) {
|
|
status.innerHTML = `<div class="toast error" style="position:relative;bottom:0;right:0">Upload failed: ${e.message}</div>`;
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// TEMPLATES
|
|
// ============================================================
|
|
async function loadTemplates() {
|
|
const data = await api('/api/templates');
|
|
const list = document.getElementById('templates-list');
|
|
if (!data.templates.length) {
|
|
list.innerHTML = '<p class="text-muted">No templates yet. Create one manually or generate with AI.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = data.templates.map(t => `
|
|
<div class="card">
|
|
<div class="flex-between">
|
|
<div>
|
|
<strong>${t.name}</strong>
|
|
<span class="badge badge-${t.created_by === 'ai' ? 'purple' : 'blue'}">${t.created_by}</span>
|
|
<div class="text-muted text-sm mt-8">${t.description || ''}</div>
|
|
</div>
|
|
<div class="flex gap-8">
|
|
<button class="btn btn-sm btn-outline" onclick="showDesigner('${t.id}')">A4 Designer</button>
|
|
<button class="btn btn-sm btn-outline" onclick="showBuilder('${t.id}')">Visual Builder</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteTemplate('${t.id}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function showTemplateModal() {
|
|
const body = `
|
|
<div class="form-group">
|
|
<label>Template Name</label>
|
|
<input type="text" id="tmpl-name" placeholder="e.g. Standard Tech CV">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Description</label>
|
|
<textarea id="tmpl-desc" placeholder="Brief description of this template"></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Template Structure (JSON)</label>
|
|
<textarea id="tmpl-structure" style="min-height:300px;font-family:monospace" placeholder='{"sections":[{"name":"Header","type":"header","order":1,"fields":["name","email","phone"]},{"name":"Summary","type":"summary","order":2},{"name":"Experience","type":"experience","order":3,"styling":{"show_years":true}},{"name":"Skills","type":"skills","order":4,"styling":{"group_by_category":true}},{"name":"Education","type":"education","order":5}],"styling":"font-family: Arial;"}'></textarea>
|
|
</div>
|
|
<div class="form-group">
|
|
<label>CSS Styling (optional)</label>
|
|
<textarea id="tmpl-styling" placeholder="Additional CSS for the template"></textarea>
|
|
</div>
|
|
<button class="btn" onclick="createTemplate()">Create Template</button>
|
|
`;
|
|
showModal(body, 'Create Template');
|
|
}
|
|
|
|
function showTemplateGenModal() {
|
|
const body = `
|
|
<div class="form-group">
|
|
<label>Template Name</label>
|
|
<input type="text" id="tmpl-gen-name" placeholder="e.g. Senior Developer CV">
|
|
</div>
|
|
<div class="form-group">
|
|
<label>Describe the template you want</label>
|
|
<textarea id="tmpl-gen-desc" style="min-height:120px" placeholder="e.g. A modern CV template for software engineers with emphasis on technical skills, project highlights, and cloud certifications. Dark theme. Group skills by category. Show years of experience for each skill."></textarea>
|
|
</div>
|
|
<button class="btn" onclick="generateTemplate()">Generate with AI</button>
|
|
`;
|
|
showModal(body, 'Generate Template with AI');
|
|
}
|
|
|
|
async function createTemplate() {
|
|
const structure = JSON.parse(document.getElementById('tmpl-structure').value || '{}');
|
|
await api('/api/templates', 'POST', {
|
|
name: document.getElementById('tmpl-name').value,
|
|
description: document.getElementById('tmpl-desc').value,
|
|
template_structure: structure,
|
|
styling: document.getElementById('tmpl-styling').value,
|
|
created_by: 'manual'
|
|
});
|
|
closeModal();
|
|
toast('Template created');
|
|
loadTemplates();
|
|
}
|
|
|
|
async function generateTemplate() {
|
|
const desc = document.getElementById('tmpl-gen-desc').value;
|
|
if (!desc) { toast('Description required', 'error'); return; }
|
|
const btn = document.querySelector('.modal button');
|
|
btn.textContent = 'Generating...';
|
|
btn.disabled = true;
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 120000); // 2 min timeout
|
|
const resp = await fetch(API + '/api/templates/generate', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name: document.getElementById('tmpl-gen-name').value || 'AI Generated Template',
|
|
description: desc
|
|
}),
|
|
signal: controller.signal
|
|
});
|
|
clearTimeout(timeout);
|
|
if (!resp.ok) {
|
|
const err = await resp.text();
|
|
throw new Error(err);
|
|
}
|
|
closeModal();
|
|
toast('Template generated with AI');
|
|
loadTemplates();
|
|
} catch(e) {
|
|
btn.textContent = 'Generate with AI';
|
|
btn.disabled = false;
|
|
if (e.name === 'AbortError') {
|
|
toast('Generation timed out after 2 minutes. Try a shorter description.', 'error');
|
|
} else {
|
|
toast('Generation failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
}
|
|
|
|
async function deleteTemplate(id) {
|
|
if (!confirm('Delete this template?')) return;
|
|
await api('/api/templates/' + id, 'DELETE');
|
|
toast('Template deleted');
|
|
loadTemplates();
|
|
}
|
|
|
|
// ============================================================
|
|
// GENERATED CVs
|
|
// ============================================================
|
|
async function loadGeneratedCVs() {
|
|
const data = await api('/api/generated-cvs');
|
|
const list = document.getElementById('generated-list');
|
|
if (!data.generated_cvs.length) {
|
|
list.innerHTML = '<p class="text-muted">No generated CVs yet. Match and generate from a requirement request.</p>';
|
|
return;
|
|
}
|
|
list.innerHTML = data.generated_cvs.map(g => `
|
|
<div class="card">
|
|
<div class="flex-between">
|
|
<div>
|
|
<strong>${g.first_name || ''} ${g.last_name || ''}</strong>
|
|
<span class="text-muted"> · ${g.position_title || 'N/A'}</span>
|
|
<div class="text-sm text-muted mt-8">Generated: ${formatDate(g.generation_date)} · Score: ${g.match_score || 0}%</div>
|
|
</div>
|
|
<div class="flex gap-8">
|
|
<span class="badge badge-${g.status === 'approved' ? 'green' : g.status === 'draft' ? 'orange' : g.status === 'reviewed' ? 'blue' : 'red'}">${g.status}</span>
|
|
<button class="btn btn-sm btn-outline" onclick="viewGeneratedCV('${g.id}')">View</button>
|
|
<button class="btn btn-sm btn-danger" onclick="deleteGeneratedCV('${g.id}')">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function viewGeneratedCV(id) {
|
|
const data = await api('/api/generated-cvs/' + id);
|
|
const content = data.edited_content || data.generated_content || '';
|
|
const body = `
|
|
<h2>${data.first_name || ''} ${data.last_name || ''} — ${data.position_title || ''}</h2>
|
|
<p class="text-muted">Generated: ${formatDate(data.generation_date)} · Score: ${data.match_score || 0}% · Status: ${data.status}</p>
|
|
${data.match_reasoning ? '<p class="text-sm mt-8"><strong>Changes made:</strong> ' + data.match_reasoning + '</p>' : ''}
|
|
<div class="tabs mt-16">
|
|
<div class="tab active" onclick="switchTab(this, 'cv-preview-tab')">Preview</div>
|
|
<div class="tab" onclick="switchTab(this, 'cv-edit-tab')">Edit</div>
|
|
</div>
|
|
<div id="cv-preview-tab">
|
|
<div class="cv-preview mt-16">${content}</div>
|
|
</div>
|
|
<div id="cv-edit-tab" style="display:none">
|
|
<textarea id="cv-edit-content" style="min-height:500px;font-family:monospace;font-size:13px">${content}</textarea>
|
|
<div class="flex gap-8 mt-16">
|
|
<button class="btn btn-green" onclick="saveGeneratedCV('${id}')">Save</button>
|
|
<button class="btn btn-outline" onclick="downloadGenPDF('${id}')">Download PDF</button>
|
|
<select id="cv-status-select" class="btn btn-outline">
|
|
<option value="draft" ${data.status === 'draft' ? 'selected' : ''}>Draft</option>
|
|
<option value="reviewed" ${data.status === 'reviewed' ? 'selected' : ''}>Reviewed</option>
|
|
<option value="approved" ${data.status === 'approved' ? 'selected' : ''}>Approved</option>
|
|
<option value="rejected" ${data.status === 'rejected' ? 'selected' : ''}>Rejected</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
`;
|
|
showModal(body, 'Generated CV', 'large');
|
|
}
|
|
|
|
function switchTab(el, tabId) {
|
|
el.parentElement.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
el.classList.add('active');
|
|
document.getElementById('cv-preview-tab').style.display = tabId === 'cv-preview-tab' ? 'block' : 'none';
|
|
document.getElementById('cv-edit-tab').style.display = tabId === 'cv-edit-tab' ? 'block' : 'none';
|
|
}
|
|
|
|
async function saveGeneratedCV(id) {
|
|
const content = document.getElementById('cv-edit-content').value;
|
|
const status = document.getElementById('cv-status-select').value;
|
|
await api('/api/generated-cvs/' + id, 'PUT', { edited_content: content, status });
|
|
toast('CV saved');
|
|
closeModal();
|
|
loadGeneratedCVs();
|
|
}
|
|
|
|
async function deleteGeneratedCV(id) {
|
|
if (!confirm('Delete this generated CV?')) return;
|
|
await api('/api/generated-cvs/' + id, 'DELETE');
|
|
toast('Generated CV deleted');
|
|
loadGeneratedCVs();
|
|
}
|
|
|
|
async function downloadGenPDF(genId) {
|
|
toast('Rendering PDF...');
|
|
try {
|
|
const result = await api('/api/generated-cvs/' + genId + '/render-pdf', 'POST');
|
|
if (result.pdf_url) {
|
|
window.open(result.pdf_url, '_blank');
|
|
toast('PDF generated');
|
|
}
|
|
} catch(e) {
|
|
toast('PDF render failed: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// ============================================================
|
|
// CHAT
|
|
// ============================================================
|
|
async function loadChat() {
|
|
// Just prepare the chat interface
|
|
}
|
|
|
|
async function sendChat() {
|
|
const input = document.getElementById('chat-input');
|
|
const msg = input.value.trim();
|
|
if (!msg) return;
|
|
input.value = '';
|
|
|
|
const messagesEl = document.getElementById('chat-messages');
|
|
// Clear placeholder
|
|
if (messagesEl.querySelector('.text-muted')) messagesEl.innerHTML = '';
|
|
|
|
// Add user message
|
|
messagesEl.innerHTML += `<div class="chat-msg user"><div class="bubble">${escapeHtml(msg)}</div></div>`;
|
|
messagesEl.innerHTML += `<div class="chat-msg assistant" id="chat-loading"><div class="bubble loading"><span class="spinner"></span> Thinking...</div></div>`;
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
|
|
try {
|
|
const data = await api('/api/chat', 'POST', { message: msg, conversation_id: currentChatId });
|
|
currentChatId = data.conversation_id;
|
|
document.getElementById('chat-loading').remove();
|
|
messagesEl.innerHTML += `<div class="chat-msg assistant"><div class="bubble">${escapeHtml(data.response)}</div></div>`;
|
|
messagesEl.scrollTop = messagesEl.scrollHeight;
|
|
} catch(e) {
|
|
document.getElementById('chat-loading').remove();
|
|
messagesEl.innerHTML += `<div class="chat-msg assistant"><div class="bubble" style="color:var(--red)">Error: ${e.message}</div></div>`;
|
|
}
|
|
}
|
|
|
|
document.getElementById('chat-input')?.addEventListener('keypress', e => {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
sendChat();
|
|
}
|
|
});
|
|
|
|
// ============================================================
|
|
// MODAL + UTILITIES
|
|
// ============================================================
|
|
function showModal(body, title, size = '') {
|
|
const overlay = document.getElementById('modal-overlay');
|
|
const headerHTML = title ? `
|
|
<div class="modal-header">
|
|
<h2>${title}</h2>
|
|
<button class="modal-close" onclick="closeModal()">×</button>
|
|
</div>` : '';
|
|
const sizeStyle = size === 'xlarge' ? 'max-width:1400px;width:95vw' : size === 'large' ? 'max-width:1000px' : '';
|
|
overlay.innerHTML = `
|
|
<div class="modal" style="${sizeStyle}">
|
|
${headerHTML}
|
|
<div>${body}</div>
|
|
</div>
|
|
`;
|
|
overlay.classList.add('active');
|
|
}
|
|
|
|
function closeModal() {
|
|
document.getElementById('modal-overlay').classList.remove('active');
|
|
}
|
|
|
|
document.getElementById('modal-overlay')?.addEventListener('click', e => {
|
|
if (e.target === document.getElementById('modal-overlay')) closeModal();
|
|
});
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(d) {
|
|
if (!d) return '';
|
|
try {
|
|
return new Date(d).toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
|
|
} catch { return d; }
|
|
}
|
|
|
|
// Initial load
|
|
loadDashboard(); |