333 lines
20 KiB
JavaScript
333 lines
20 KiB
JavaScript
/* ============================================================
|
||
inbox.js — Central Recruitment Inbox + Outlook Email tab
|
||
============================================================ */
|
||
window.Views = window.Views || {};
|
||
window.Inbox = {};
|
||
|
||
Views.inbox = function () {
|
||
const state = { tab: 'All Applications', selected: null, emailSelected: null, q: '' };
|
||
const tabs = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email'];
|
||
|
||
function filtered() {
|
||
let list = DB.inbox;
|
||
if (state.tab === 'Unread') list = list.filter(i => i.processing === 'Unread');
|
||
else if (state.tab === 'Imported') list = list.filter(i => i.processing === 'Imported');
|
||
else if (state.tab === 'Processed') list = list.filter(i => i.processing === 'Processed');
|
||
else if (state.tab === 'Rejected') list = list.filter(i => i.processing === 'Rejected');
|
||
else if (state.tab === 'Duplicates') list = list.filter(i => i.duplicate);
|
||
if (state.q) list = list.filter(i => (i.name + i.position + i.source).toLowerCase().includes(state.q.toLowerCase()));
|
||
return list;
|
||
}
|
||
|
||
function counts() {
|
||
return {
|
||
'All Applications': DB.inbox.length,
|
||
'Unread': DB.inbox.filter(i => i.processing === 'Unread').length,
|
||
'Imported': DB.inbox.filter(i => i.processing === 'Imported').length,
|
||
'Processed': DB.inbox.filter(i => i.processing === 'Processed').length,
|
||
'Rejected': DB.inbox.filter(i => i.processing === 'Rejected').length,
|
||
'Duplicates': DB.inbox.filter(i => i.duplicate).length,
|
||
'Email': DB.emails.filter(e => e.unread).length
|
||
};
|
||
}
|
||
|
||
function sourceChip(item) {
|
||
const m = item.sourceMeta;
|
||
// The dot carries the partner's brand colour; the label uses theme text.
|
||
// Rendering 11px labels in the partner colour failed AA in both themes.
|
||
// `--chip` carries the source colour; CSS mixes the tint. String-concat
|
||
// alpha ("#0a66c214") breaks for the tokenised sources (var(--c1)14).
|
||
return `<span class="source-chip" style="--chip:${m.color}"><span class="source-dot"></span>${item.source}</span>`;
|
||
}
|
||
|
||
function renderList() {
|
||
const el = document.getElementById('inboxList');
|
||
if (!el) return;
|
||
const list = filtered();
|
||
if (!list.length) { el.innerHTML = `<div class="empty-state">${UI.icon('inbox')}<h3>Nothing here</h3><p>No applications in this view.</p></div>`; return; }
|
||
el.innerHTML = list.map(i => `
|
||
<div class="inbox-item ${i.unread ? 'unread' : ''} ${state.selected === i.id ? 'active' : ''}" data-id="${i.id}">
|
||
${UI.avatar(i.name, i.initials, i.color)}
|
||
<div class="ii-main">
|
||
<div class="ii-name">${i.name} ${i.duplicate ? '<span class="badge b-red badge-plain" style="padding:1px 6px;font-size:10px">DUP</span>' : ''}</div>
|
||
<div class="ii-pos">${i.position}</div>
|
||
<div class="ii-meta">${sourceChip(i)} ${UI.badge(i.processing)}</div>
|
||
</div>
|
||
<div style="text-align:right;flex-shrink:0">
|
||
<div class="ii-time">${DB.relTime(Math.round((new Date('2026-07-09T20:00') - i.received) / 60000))}</div>
|
||
<div style="margin-top:6px">${UI.scoreChip(i.atsScore)}</div>
|
||
</div>
|
||
</div>`).join('');
|
||
el.querySelectorAll('.inbox-item').forEach(row => row.onclick = () => {
|
||
state.selected = row.dataset.id;
|
||
const it = DB.inbox.find(x => x.id === state.selected); if (it) it.unread = false;
|
||
renderList(); renderDetail(); App.updateBadges();
|
||
});
|
||
}
|
||
|
||
function renderDetail() {
|
||
const el = document.getElementById('inboxDetail');
|
||
if (!el) return;
|
||
const i = DB.inbox.find(x => x.id === state.selected);
|
||
if (!i) { el.innerHTML = `<div class="empty-state" style="padding:100px 20px">${UI.icon('inbox')}<h3>Select an application</h3><p>Choose an item from the list to view details and take action.</p></div>`; return; }
|
||
const rec = DB.atsRecommendationClass;
|
||
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match';
|
||
el.innerHTML = `
|
||
<div style="padding:24px">
|
||
<div class="flex items-center gap-16" style="margin-bottom:20px">
|
||
${UI.avatar(i.name, i.initials, i.color, 'avatar-lg')}
|
||
<div style="flex:1"><div class="ph-name" style="font-size:19px">${i.name}</div>
|
||
<div class="ph-role">${i.position}</div>
|
||
<div class="ph-tags" style="margin-top:8px">${sourceChip(i)} ${UI.badge(i.processing)} ${UI.badge(i.resumeStatus, i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber')}</div>
|
||
</div>
|
||
<div style="text-align:center">
|
||
<div class="ats-ring" style="width:84px;height:84px;--pct:${i.atsScore};--c:${i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'}">
|
||
<div class="ats-val"><div class="ats-num" style="font-size:22px">${i.atsScore}</div></div></div>
|
||
<div class="cell-sub" style="margin-top:4px">ATS Score</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="info-grid" style="margin-bottom:20px">
|
||
<div class="info-item"><div class="il">Email</div><div class="iv">${i.email}</div></div>
|
||
<div class="info-item"><div class="il">Phone</div><div class="iv">${i.phone}</div></div>
|
||
<div class="info-item"><div class="il">Experience</div><div class="iv">${i.experience} years</div></div>
|
||
<div class="info-item"><div class="il">Assigned Recruiter</div><div class="iv">${i.recruiter}</div></div>
|
||
<div class="info-item"><div class="il">Received</div><div class="iv">${DB.fmtDate(i.received)}</div></div>
|
||
<div class="info-item"><div class="il">Match</div><div class="iv">${UI.badge(recLabel, rec(recLabel))}</div></div>
|
||
</div>
|
||
|
||
<div class="card" style="box-shadow:none;background:var(--bg-sunken);margin-bottom:20px"><div class="card-body">
|
||
<div class="flex items-center gap-12" style="justify-content:space-between;margin-bottom:12px">
|
||
<div class="fw-600">${UI.icon('paperclip')} ${i.attachment}</div>
|
||
<button class="btn btn-secondary btn-sm" onclick="Inbox.viewResume('${i.id}')">${UI.icon('eye')} Preview</button>
|
||
</div>
|
||
<div class="resume-thumb">${Inbox._resumeText(i)}</div>
|
||
</div></div>
|
||
|
||
<div class="flex gap-8" style="flex-wrap:wrap">
|
||
<button class="btn btn-primary" onclick="Inbox.import('${i.id}')">${UI.icon('user-plus')} Import Candidate</button>
|
||
<button class="btn btn-secondary" onclick="Inbox.parse('${i.id}')">${UI.icon('sparkles')} Parse Resume</button>
|
||
<button class="btn btn-secondary" onclick="Inbox.assign('${i.id}')">${UI.icon('users')} Assign Recruiter</button>
|
||
<button class="btn btn-secondary" onclick="Inbox.moveToPipeline('${i.id}')">${UI.icon('layers')} Move to Pipeline</button>
|
||
<button class="btn btn-secondary" onclick="Inbox.note('${i.id}')">${UI.icon('edit')} Add Note</button>
|
||
<button class="btn btn-ghost" style="color:var(--danger)" onclick="Inbox.reject('${i.id}')">${UI.icon('x')} Reject</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderBody() {
|
||
const body = document.getElementById('inboxBody');
|
||
if (state.tab === 'Email') { body.innerHTML = Inbox._emailView(); Inbox._bindEmail(state); return; }
|
||
body.innerHTML = `
|
||
<div class="split">
|
||
<div class="split-list">
|
||
<div style="padding:12px 16px;border-bottom:1px solid var(--border)">
|
||
<div class="toolbar-search" style="max-width:none"><svg viewBox="0 0 24 24"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><input id="inboxSearch" placeholder="Search applications…" value="${state.q}"/></div>
|
||
</div>
|
||
<div id="inboxList"></div>
|
||
</div>
|
||
<div class="split-detail" id="inboxDetail"></div>
|
||
</div>`;
|
||
renderList(); renderDetail();
|
||
const s = document.getElementById('inboxSearch');
|
||
s.oninput = () => { state.q = s.value; renderList(); };
|
||
}
|
||
|
||
Inbox._render = { list: renderList, detail: renderDetail, body: renderBody };
|
||
Inbox._state = state;
|
||
|
||
const c = counts();
|
||
const tabHtml = tabs.map(t => `<div class="tab ${t === state.tab ? 'active' : ''}" data-tab="${t}">${t} <span class="k-count" style="margin-left:4px">${c[t]}</span></div>`).join('');
|
||
|
||
const html = `
|
||
<div class="page">
|
||
<div class="page-head">
|
||
<div><h1 class="page-title">Recruitment Inbox</h1><p class="page-sub">Every candidate, every source — one unified queue</p></div>
|
||
<div class="page-head-actions">
|
||
<span class="integration-status"><span class="pulse"></span>Microsoft Graph API · Connected</span>
|
||
<button class="btn btn-secondary" onclick="UI.toast('Syncing all sources…','info');setTimeout(()=>UI.toast('Inbox synced','success'),900)">${UI.icon('refresh')} Sync</button>
|
||
<button class="btn btn-primary" onclick="Router.go('import')">${UI.icon('upload')} Upload CVs</button>
|
||
</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="tabs" id="inboxTabs" style="margin:0 16px;padding-top:8px">${tabHtml}</div>
|
||
<div id="inboxBody"></div>
|
||
</div>
|
||
</div>`;
|
||
|
||
return {
|
||
html,
|
||
onMount() {
|
||
renderBody();
|
||
document.querySelectorAll('#inboxTabs .tab').forEach(tab => tab.onclick = () => {
|
||
state.tab = tab.dataset.tab; state.selected = null;
|
||
document.querySelectorAll('#inboxTabs .tab').forEach(t => t.classList.remove('active'));
|
||
tab.classList.add('active');
|
||
renderBody();
|
||
});
|
||
}
|
||
};
|
||
};
|
||
|
||
Inbox._resumeText = function (i) {
|
||
return `${i.name.toUpperCase()}\n${i.email} · ${i.phone}\n${'—'.repeat(30)}\nPROFESSIONAL SUMMARY\n${i.experience} years of experience. Applied for ${i.position} via ${i.source}.\n\nEXPERIENCE\n• ${DB.pick(DB.companies)} — Senior role (2021–Present)\n• ${DB.pick(DB.companies)} — Associate (2018–2021)\n\nEDUCATION\n• Bachelor's Degree, Computer Science\n\nSKILLS\n• ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}, ${DB.pick(DB.skillsPool)}`;
|
||
};
|
||
|
||
Inbox.viewResume = function (id) {
|
||
const i = DB.inbox.find(x => x.id === id);
|
||
UI.modal({
|
||
title: i.attachment, subtitle: 'Resume preview · ' + i.name,
|
||
body: `<div class="resume-thumb" style="max-height:none;font-size:12px">${Inbox._resumeText(i)}</div>`,
|
||
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Close</button><button class="btn btn-primary" onclick="UI.closeModal();Inbox.import('${id}')">${UI.icon('user-plus')} Import Candidate</button>`,
|
||
size: 'modal-lg'
|
||
});
|
||
};
|
||
|
||
Inbox.parse = function (id) {
|
||
const i = DB.inbox.find(x => x.id === id);
|
||
i.resumeStatus = 'Parsing';
|
||
Inbox._render.detail();
|
||
UI.toast('Parsing resume with AI…', 'info');
|
||
setTimeout(() => { i.resumeStatus = 'Parsed'; i.atsScore = DB.int(60, 96); Inbox._render.list(); Inbox._render.detail(); UI.toast('Resume parsed — profile fields extracted', 'success'); }, 1100);
|
||
};
|
||
|
||
Inbox.import = function (id) {
|
||
const i = DB.inbox.find(x => x.id === id);
|
||
const job = DB.getJob(i.jobId) || DB.jobs[0];
|
||
// create candidate
|
||
const newC = {
|
||
id: 'CAN-' + (5001 + DB.candidates.length), name: i.name, initials: i.initials, color: i.color,
|
||
email: i.email, phone: i.phone, jobId: job.id, jobTitle: job.title, department: job.department,
|
||
experience: i.experience, currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations),
|
||
stage: 'Applied', status: 'Applied', aiScore: i.atsScore, source: i.source, recruiter: i.recruiter, recruiterId: '',
|
||
applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: DB.int(90, 180) * 1000,
|
||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: i.atsScore >= 82 ? 'Strong Match' : 'Potential Match',
|
||
subScores: { skills: i.atsScore, experience: i.atsScore, education: 80, keywords: i.atsScore, location: 100, salary: 90 },
|
||
noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled'
|
||
};
|
||
DB.candidates.unshift(newC);
|
||
i.processing = 'Imported'; i.unread = false;
|
||
Inbox._render.list(); Inbox._render.detail(); App.updateBadges();
|
||
UI.toast(`${i.name} imported → Applied stage of ${job.title}`, 'success');
|
||
};
|
||
|
||
Inbox.moveToPipeline = function (id) {
|
||
const i = DB.inbox.find(x => x.id === id);
|
||
if (i.processing !== 'Imported' && i.processing !== 'Processed') Inbox.import(id);
|
||
i.processing = 'Processed';
|
||
Inbox._render.list(); Inbox._render.detail();
|
||
UI.toast(`${i.name} moved to pipeline`, 'success');
|
||
setTimeout(() => Router.go('pipeline'), 700);
|
||
};
|
||
|
||
Inbox.assign = function (id) {
|
||
const i = DB.inbox.find(x => x.id === id);
|
||
const opts = DB.recruiters.map(r => `<option ${r.name === i.recruiter ? 'selected' : ''}>${r.name}</option>`).join('');
|
||
UI.modal({
|
||
title: 'Assign Recruiter', subtitle: i.name,
|
||
body: `<div class="form-field"><label>Recruiter</label><select id="assignRec">${opts}</select></div>
|
||
<p class="text-muted text-sm" style="margin-top:10px">Current workload is factored automatically. This recruiter has ${DB.getRecruiterByName(i.recruiter) ? DB.getRecruiterByName(i.recruiter).openReqs : 5} open reqs.</p>`,
|
||
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="Inbox._doAssign('${id}')">Assign</button>`
|
||
});
|
||
};
|
||
Inbox._doAssign = function (id) {
|
||
const i = DB.inbox.find(x => x.id === id);
|
||
i.recruiter = document.getElementById('assignRec').value;
|
||
UI.closeModal(); Inbox._render.detail();
|
||
UI.toast('Recruiter assigned to ' + i.name, 'success');
|
||
};
|
||
|
||
Inbox.note = function (id) {
|
||
const i = DB.inbox.find(x => x.id === id);
|
||
UI.modal({
|
||
title: 'Add Note', subtitle: i.name,
|
||
body: `<div class="form-field"><label>Note</label><textarea id="inboxNote" placeholder="Add a note about this application…"></textarea></div>`,
|
||
footer: `<button class="btn btn-secondary" onclick="UI.closeModal()">Cancel</button><button class="btn btn-primary" onclick="UI.closeModal();UI.toast('Note added','success')">${UI.icon('check')} Save Note</button>`
|
||
});
|
||
};
|
||
|
||
Inbox.reject = function (id) {
|
||
const i = DB.inbox.find(x => x.id === id);
|
||
i.processing = 'Rejected'; i.unread = false;
|
||
Inbox._render.list(); Inbox._render.detail(); App.updateBadges();
|
||
UI.toast(`${i.name} rejected`, 'warning');
|
||
};
|
||
|
||
// ---------------- Email (Outlook) tab ----------------
|
||
Inbox._emailView = function () {
|
||
const st = Inbox._state;
|
||
const listHtml = DB.emails.map(e => `
|
||
<div class="inbox-item ${e.unread ? 'unread' : ''} ${st.emailSelected === e.id ? 'active' : ''}" data-email="${e.id}">
|
||
${UI.avatar(e.from, e.initials, e.color)}
|
||
<div class="ii-main">
|
||
<div class="ii-name">${e.from}</div>
|
||
<div class="ii-pos">${e.subject}</div>
|
||
<div class="ii-meta"><span class="source-chip" style="--chip:#0078d4"><svg viewBox="0 0 24 24"><path d="M4 4h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>Outlook</span>${e.imported ? UI.badge('Imported', 'b-green') : ''}</div>
|
||
</div>
|
||
<div class="ii-time">${DB.fmtShort(e.when)}</div>
|
||
</div>`).join('');
|
||
return `
|
||
<div style="padding:12px 18px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px">
|
||
<span class="integration-status"><span class="pulse"></span>Outlook · Microsoft Graph API</span>
|
||
<span class="text-muted text-sm">Last sync: 2 min ago · ${DB.emails.filter(e => e.unread).length} unread</span>
|
||
<button class="btn btn-secondary btn-sm" style="margin-left:auto" onclick="UI.toast('Fetching from Outlook…','info');setTimeout(()=>UI.toast('Mailbox synced','success'),800)">${UI.icon('refresh')} Sync Mailbox</button>
|
||
</div>
|
||
<div class="split">
|
||
<div class="split-list" id="emailList">${listHtml}</div>
|
||
<div class="split-detail" id="emailDetail"></div>
|
||
</div>`;
|
||
};
|
||
Inbox._bindEmail = function (state) {
|
||
const detail = document.getElementById('emailDetail');
|
||
function renderDetail() {
|
||
const e = DB.emails.find(x => x.id === state.emailSelected);
|
||
if (!e) { detail.innerHTML = `<div class="empty-state" style="padding:100px 20px">${UI.icon('mail')}<h3>Select an email</h3><p>Preview email body and resume attachments here.</p></div>`; return; }
|
||
detail.innerHTML = `<div style="padding:24px">
|
||
<div class="flex items-center gap-12" style="margin-bottom:6px">
|
||
<h2 style="font-size:18px;flex:1">${e.subject}</h2>${e.imported ? UI.badge('Imported', 'b-green') : UI.badge('New', 'b-blue')}</div>
|
||
<div class="flex items-center gap-12" style="margin-bottom:20px">
|
||
${UI.avatar(e.from, e.initials, e.color)}
|
||
<div><div class="fw-600">${e.from}</div><div class="cell-sub">${e.fromEmail} · ${DB.fmtDate(e.when)}</div></div>
|
||
</div>
|
||
<div class="email-preview" style="margin-bottom:18px">${e.body}</div>
|
||
<div class="attach-card" style="margin-bottom:18px">
|
||
<span class="attach-icn">${UI.icon('file')}</span>
|
||
<div style="flex:1"><div class="fw-600">${e.attachment}</div><div class="cell-sub">${e.attachmentSize} · PDF</div></div>
|
||
<div class="flex items-center gap-8">${UI.scoreChip(e.atsScore)}
|
||
<button class="btn btn-secondary btn-sm" onclick="UI.toast('Opening attachment preview','info')">${UI.icon('eye')} Preview</button></div>
|
||
</div>
|
||
<div class="flex gap-8">
|
||
${e.imported ? `<button class="btn btn-secondary" disabled>${UI.icon('check')} Already Imported</button>` :
|
||
`<button class="btn btn-primary" onclick="Inbox._importEmail('${e.id}')">${UI.icon('user-plus')} Import Candidate</button>`}
|
||
<button class="btn btn-secondary" onclick="UI.toast('Reply drafted','info')">${UI.icon('mail')} Reply</button>
|
||
<button class="btn btn-ghost" style="color:var(--danger)" onclick="UI.toast('Email archived','info')">${UI.icon('trash')} Archive</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
document.querySelectorAll('#emailList .inbox-item').forEach(row => row.onclick = () => {
|
||
state.emailSelected = row.dataset.email;
|
||
const e = DB.emails.find(x => x.id === state.emailSelected); if (e) e.unread = false;
|
||
document.querySelectorAll('#emailList .inbox-item').forEach(r => r.classList.remove('active', 'unread'));
|
||
row.classList.add('active');
|
||
renderDetail(); App.updateBadges();
|
||
});
|
||
renderDetail();
|
||
};
|
||
Inbox._importEmail = function (id) {
|
||
const e = DB.emails.find(x => x.id === id);
|
||
const job = DB.getJob(e.jobId) || DB.jobs[0];
|
||
DB.candidates.unshift({
|
||
id: 'CAN-' + (5001 + DB.candidates.length), name: e.from, initials: e.initials, color: e.color,
|
||
email: e.fromEmail, phone: '+1 (555) 000-0000', jobId: job.id, jobTitle: job.title, department: job.department,
|
||
experience: DB.int(2, 10), currentCompany: DB.pick(DB.companies), currentTitle: job.title, location: DB.pick(DB.locations),
|
||
stage: 'Applied', status: 'Applied', aiScore: e.atsScore, source: 'Microsoft Outlook', recruiter: DB.pick(DB.recruiters).name, recruiterId: '',
|
||
applied: new Date('2026-07-09'), education: "Bachelor's Degree", skills: job.skills.slice(0, 4), rating: '4.0', salary: 130000,
|
||
matchedSkills: job.skills.slice(0, 3), missingSkills: job.skills.slice(3), recommendation: 'Potential Match',
|
||
subScores: { skills: e.atsScore, experience: 80, education: 80, keywords: e.atsScore, location: 100, salary: 90 },
|
||
noticePeriod: '1 month', availability: '2 weeks', certifications: [], favorite: false, interviewStatus: 'Not Scheduled'
|
||
});
|
||
e.imported = true; e.unread = false;
|
||
Inbox._bindEmail(Inbox._state); App.updateBadges();
|
||
UI.toast(`${e.from} imported from Outlook → ${job.title}`, 'success');
|
||
};
|