734 lines
24 KiB
HTML
734 lines
24 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Bulk ATS Scoring — Talent Pool</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: light;
|
|
--page: #f6f7f6;
|
|
--surface: #ffffff;
|
|
--ink: #0b0b0b;
|
|
--ink-secondary: #52514e;
|
|
--ink-muted: #898781;
|
|
--hairline: #e7e7e3;
|
|
--baseline: #c3c2b7;
|
|
--border: rgba(11, 11, 11, 0.08);
|
|
--shadow: 0 1px 2px rgba(11, 11, 11, 0.05);
|
|
--brand: #0e5c47; /* button / focus chrome, not a data color */
|
|
--brand-ink: #ffffff;
|
|
--chip-bg: #f1f1ee;
|
|
/* status palette — data colors for the score ring and failure badges */
|
|
--good: #0ca30c;
|
|
--warn: #fab219;
|
|
--crit: #d03b3b;
|
|
}
|
|
@media (prefers-color-scheme: dark) {
|
|
:root {
|
|
color-scheme: dark;
|
|
--page: #0d0d0d;
|
|
--surface: #1a1a19;
|
|
--ink: #ffffff;
|
|
--ink-secondary: #c3c2b7;
|
|
--ink-muted: #898781;
|
|
--hairline: #2c2c2a;
|
|
--baseline: #383835;
|
|
--border: rgba(255, 255, 255, 0.10);
|
|
--shadow: none;
|
|
--brand: #17755c;
|
|
--chip-bg: #262624;
|
|
}
|
|
}
|
|
|
|
* { box-sizing: border-box; }
|
|
body {
|
|
margin: 0;
|
|
background: var(--page);
|
|
color: var(--ink);
|
|
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
}
|
|
.wrap { max-width: 1180px; margin: 0 auto; padding: 36px 24px 72px; }
|
|
|
|
.page-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
|
.page-head h1 { margin: 0; font-size: 30px; font-weight: 650; letter-spacing: -0.01em; }
|
|
.page-head .sub { margin: 6px 0 0; color: var(--ink-secondary); }
|
|
|
|
.card {
|
|
background: var(--surface);
|
|
border: 1px solid var(--border);
|
|
border-radius: 12px;
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
form.card { padding: 22px; margin-top: 22px; }
|
|
label { display: block; font-weight: 600; margin-bottom: 6px; }
|
|
textarea {
|
|
width: 100%;
|
|
min-height: 130px;
|
|
resize: vertical;
|
|
padding: 10px 12px;
|
|
border: 1px solid var(--baseline);
|
|
border-radius: 9px;
|
|
background: var(--surface);
|
|
color: var(--ink);
|
|
font: inherit;
|
|
}
|
|
textarea:focus, input:focus, select:focus { outline: 2px solid var(--brand); outline-offset: 1px; }
|
|
|
|
.drop {
|
|
margin-top: 16px;
|
|
border: 2px dashed var(--baseline);
|
|
border-radius: 10px;
|
|
padding: 22px;
|
|
text-align: center;
|
|
cursor: pointer;
|
|
color: var(--ink-secondary);
|
|
}
|
|
.drop.dragover { border-color: var(--brand); color: var(--ink); }
|
|
.drop p { margin: 0; }
|
|
.drop .hint { margin-top: 4px; font-size: 13px; color: var(--ink-muted); }
|
|
|
|
ul.files { list-style: none; margin: 12px 0 0; padding: 0; }
|
|
ul.files li {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 7px 4px;
|
|
border-bottom: 1px solid var(--hairline);
|
|
font-size: 14px;
|
|
}
|
|
ul.files li:last-child { border-bottom: none; }
|
|
ul.files .fname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
ul.files .fsize { color: var(--ink-muted); font-variant-numeric: tabular-nums; }
|
|
ul.files button {
|
|
border: none; background: none;
|
|
color: var(--ink-muted);
|
|
font-size: 15px; cursor: pointer;
|
|
padding: 2px 6px; border-radius: 6px;
|
|
}
|
|
ul.files button:hover { color: var(--crit); background: var(--hairline); }
|
|
|
|
.msg { margin: 10px 0 0; font-size: 13px; color: var(--crit); }
|
|
|
|
.actions { margin-top: 16px; display: flex; align-items: center; gap: 14px; }
|
|
button.primary {
|
|
display: inline-flex; align-items: center; gap: 8px;
|
|
background: var(--brand);
|
|
color: var(--brand-ink);
|
|
border: none; border-radius: 9px;
|
|
padding: 11px 22px;
|
|
font: 600 15px/1 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
cursor: pointer;
|
|
}
|
|
button.primary:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
.progress-note { color: var(--ink-secondary); font-size: 14px; }
|
|
.spinner {
|
|
width: 15px; height: 15px;
|
|
border: 2px solid var(--hairline);
|
|
border-top-color: var(--brand);
|
|
border-radius: 50%;
|
|
display: inline-block; vertical-align: -3px;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
|
|
.error-box { border-color: var(--crit); padding: 18px 22px; margin-top: 20px; }
|
|
.error-box .code { font-weight: 650; color: var(--crit); }
|
|
.error-box p { margin: 6px 0 0; color: var(--ink-secondary); }
|
|
|
|
#results { margin-top: 34px; }
|
|
.results-head h2 { margin: 0; font-size: 22px; font-weight: 650; }
|
|
.results-head .sub { margin: 4px 0 0; color: var(--ink-secondary); font-size: 14px; }
|
|
|
|
.toolbar { display: flex; gap: 12px; padding: 14px 16px; margin-top: 16px; flex-wrap: wrap; }
|
|
.search {
|
|
flex: 1 1 260px;
|
|
display: flex; align-items: center; gap: 8px;
|
|
border: 1px solid var(--baseline);
|
|
border-radius: 9px;
|
|
padding: 8px 12px;
|
|
}
|
|
.search svg { flex: none; color: var(--ink-muted); }
|
|
.search input {
|
|
border: none; outline: none; background: none;
|
|
color: var(--ink); font: inherit; width: 100%;
|
|
}
|
|
.toolbar select {
|
|
border: 1px solid var(--baseline);
|
|
border-radius: 9px;
|
|
background: var(--surface);
|
|
color: var(--ink);
|
|
font: inherit;
|
|
padding: 8px 12px;
|
|
}
|
|
|
|
.grid {
|
|
margin-top: 18px;
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
|
gap: 16px;
|
|
}
|
|
|
|
.cand { padding: 18px 18px 14px; display: flex; flex-direction: column; gap: 12px; }
|
|
.cand-head { display: flex; align-items: flex-start; gap: 12px; }
|
|
.avatar {
|
|
flex: none;
|
|
width: 42px; height: 42px;
|
|
border-radius: 50%;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
color: #fff; font-weight: 650; font-size: 15px;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.who { flex: 1; min-width: 0; }
|
|
.who .name { display: block; font-weight: 650; overflow-wrap: anywhere; }
|
|
.who .title { display: block; color: var(--ink-secondary); font-size: 13.5px; }
|
|
|
|
.ring { flex: none; width: 44px; height: 44px; }
|
|
.ring circle { fill: none; stroke-width: 3.6; }
|
|
.ring .track { stroke: color-mix(in srgb, var(--ring-color) 18%, var(--surface)); }
|
|
.ring .fill { stroke: var(--ring-color); stroke-linecap: round; }
|
|
.ring text {
|
|
fill: var(--ink);
|
|
font: 650 12.5px system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
}
|
|
.band-good { --ring-color: var(--good); }
|
|
.band-warn { --ring-color: var(--warn); }
|
|
.band-crit { --ring-color: var(--crit); }
|
|
|
|
.chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
|
.chip {
|
|
font-size: 12.5px;
|
|
padding: 2px 10px;
|
|
border-radius: 999px;
|
|
background: var(--chip-bg);
|
|
color: var(--ink-secondary);
|
|
white-space: nowrap;
|
|
max-width: 100%;
|
|
overflow: hidden; text-overflow: ellipsis;
|
|
}
|
|
.chip.missing { background: none; border: 1px dashed var(--baseline); color: var(--ink-muted); }
|
|
.chip.more { background: none; color: var(--ink-muted); }
|
|
|
|
.critique {
|
|
margin: 0;
|
|
color: var(--ink-secondary);
|
|
font-size: 13.5px;
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.cand-foot {
|
|
margin-top: auto;
|
|
display: flex; align-items: center; gap: 10px;
|
|
border-top: 1px solid var(--hairline);
|
|
padding-top: 12px;
|
|
font-size: 13.5px;
|
|
color: var(--ink-secondary);
|
|
}
|
|
.cand-foot .yrs { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
|
.cand-foot .yrs svg { color: var(--ink-muted); }
|
|
.cand-foot .company {
|
|
flex: 1; text-align: center;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.tag {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
background: var(--chip-bg);
|
|
border-radius: 999px;
|
|
padding: 3px 11px;
|
|
font-size: 12.5px;
|
|
color: var(--ink-secondary);
|
|
max-width: 45%;
|
|
}
|
|
.tag .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--ink-muted); flex: none; }
|
|
.tag span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
|
|
.file-actions { display: inline-flex; gap: 2px; margin-left: auto; }
|
|
.icon-btn {
|
|
border: none; background: none;
|
|
padding: 4px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
color: var(--ink-muted);
|
|
display: inline-flex; align-items: center;
|
|
}
|
|
.icon-btn:hover { color: var(--brand); background: var(--chip-bg); }
|
|
|
|
.cand.failed .avatar { background: var(--ink-muted); }
|
|
.cand.failed .fail-tag {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
color: var(--crit);
|
|
font-weight: 650; font-size: 12.5px;
|
|
white-space: nowrap;
|
|
}
|
|
.cand.failed .why { color: var(--ink-secondary); font-size: 13.5px; margin: 0; }
|
|
|
|
.empty { color: var(--ink-muted); padding: 26px 0; text-align: center; grid-column: 1 / -1; }
|
|
.req-id { margin: 18px 0 0; font-size: 12.5px; color: var(--ink-muted); }
|
|
.req-id code { font-family: ui-monospace, Consolas, monospace; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="wrap">
|
|
<div class="page-head">
|
|
<div>
|
|
<h1>Talent Pool</h1>
|
|
<p class="sub">Bulk ATS Scoring — upload resume PDFs, score them against one job description, browse the ranked pool.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form id="form" class="card">
|
|
<label for="jd">Job description</label>
|
|
<textarea id="jd" placeholder="Paste the full job description here…"></textarea>
|
|
|
|
<div id="drop" class="drop" role="button" tabindex="0" aria-label="Add resume PDFs">
|
|
<p><strong>Drop resume PDFs here</strong> or click to browse</p>
|
|
<p class="hint">.pdf only · max 10 MB per file · up to 50 files</p>
|
|
<input type="file" id="picker" accept=".pdf,application/pdf" multiple hidden>
|
|
</div>
|
|
<ul id="file-list" class="files"></ul>
|
|
<p id="form-msg" class="msg" hidden></p>
|
|
|
|
<div class="actions">
|
|
<button id="submit" class="primary" type="submit" disabled>
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 2 11 13"/><path d="m22 2-7 20-4-9-9-4Z"/></svg>
|
|
Score resumes
|
|
</button>
|
|
<span id="progress" class="progress-note" hidden>
|
|
<span class="spinner" aria-hidden="true"></span>
|
|
<span id="progress-text"></span>
|
|
</span>
|
|
</div>
|
|
</form>
|
|
|
|
<section id="error" class="card error-box" hidden>
|
|
<span class="code" id="error-code"></span>
|
|
<p id="error-message"></p>
|
|
<p class="req-id" id="error-req"></p>
|
|
</section>
|
|
|
|
<section id="results" hidden>
|
|
<div class="results-head">
|
|
<h2>Candidates</h2>
|
|
<p class="sub" id="counts"></p>
|
|
</div>
|
|
|
|
<div class="card toolbar">
|
|
<div class="search">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
|
|
<input id="search" type="search" placeholder="Search by name, skill, company…" aria-label="Search candidates">
|
|
</div>
|
|
<select id="status-filter" aria-label="Filter by status">
|
|
<option value="all">All results</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="failed">Failed</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="grid" id="grid"></div>
|
|
<p class="req-id" id="result-req"></p>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
"use strict";
|
|
|
|
const MAX_FILES = 50;
|
|
const MAX_BYTES = 10 * 1024 * 1024;
|
|
const AVATAR_COLORS = ["#0f766e", "#4338ca", "#6d28d9", "#334155", "#166534", "#9f1239"];
|
|
const BRIEFCASE =
|
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
|
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
'<rect x="2" y="7" width="20" height="14" rx="2"/>' +
|
|
'<path d="M16 7V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v2"/></svg>';
|
|
|
|
const jd = document.getElementById("jd");
|
|
const drop = document.getElementById("drop");
|
|
const picker = document.getElementById("picker");
|
|
const fileList = document.getElementById("file-list");
|
|
const formMsg = document.getElementById("form-msg");
|
|
const submitBtn = document.getElementById("submit");
|
|
const progress = document.getElementById("progress");
|
|
const progressText = document.getElementById("progress-text");
|
|
const searchBox = document.getElementById("search");
|
|
const statusFilter = document.getElementById("status-filter");
|
|
|
|
let files = [];
|
|
let timer = null;
|
|
let lastResults = [];
|
|
let submittedFiles = new Map();
|
|
const urlCache = new Map();
|
|
|
|
const EYE_ICON =
|
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
|
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
'<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/>' +
|
|
'<circle cx="12" cy="12" r="3"/></svg>';
|
|
const DOWNLOAD_ICON =
|
|
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" ' +
|
|
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
'<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/></svg>';
|
|
|
|
function resetFileUrls() {
|
|
for (const url of urlCache.values()) URL.revokeObjectURL(url);
|
|
urlCache.clear();
|
|
}
|
|
|
|
function fileUrl(name) {
|
|
if (!urlCache.has(name)) {
|
|
const file = submittedFiles.get(name);
|
|
if (!file) return null;
|
|
urlCache.set(name, URL.createObjectURL(file));
|
|
}
|
|
return urlCache.get(name);
|
|
}
|
|
|
|
function fileActions(name) {
|
|
const wrap = el("span", "file-actions");
|
|
if (!submittedFiles.has(name)) return wrap;
|
|
|
|
const view = el("button", "icon-btn");
|
|
view.type = "button";
|
|
view.title = "View " + name;
|
|
view.setAttribute("aria-label", "View " + name);
|
|
view.innerHTML = EYE_ICON;
|
|
view.addEventListener("click", () => {
|
|
const url = fileUrl(name);
|
|
if (url) window.open(url, "_blank", "noopener");
|
|
});
|
|
|
|
const download = el("button", "icon-btn");
|
|
download.type = "button";
|
|
download.title = "Download " + name;
|
|
download.setAttribute("aria-label", "Download " + name);
|
|
download.innerHTML = DOWNLOAD_ICON;
|
|
download.addEventListener("click", () => {
|
|
const url = fileUrl(name);
|
|
if (!url) return;
|
|
const anchor = document.createElement("a");
|
|
anchor.href = url;
|
|
anchor.download = name;
|
|
document.body.append(anchor);
|
|
anchor.click();
|
|
anchor.remove();
|
|
});
|
|
|
|
wrap.append(view, download);
|
|
return wrap;
|
|
}
|
|
|
|
function el(tag, className, text) {
|
|
const node = document.createElement(tag);
|
|
if (className) node.className = className;
|
|
if (text !== undefined) node.textContent = text;
|
|
return node;
|
|
}
|
|
|
|
function fmtSize(bytes) {
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(0) + " KB";
|
|
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
|
|
}
|
|
|
|
function setMsg(text) {
|
|
formMsg.hidden = !text;
|
|
formMsg.textContent = text || "";
|
|
}
|
|
|
|
function refresh() {
|
|
fileList.replaceChildren();
|
|
files.forEach((file, index) => {
|
|
const li = el("li");
|
|
li.append(el("span", "fname", file.name), el("span", "fsize", fmtSize(file.size)));
|
|
const remove = el("button", "", "✕");
|
|
remove.type = "button";
|
|
remove.setAttribute("aria-label", "Remove " + file.name);
|
|
remove.addEventListener("click", () => {
|
|
files.splice(index, 1);
|
|
refresh();
|
|
});
|
|
li.append(remove);
|
|
fileList.append(li);
|
|
});
|
|
submitBtn.disabled = files.length === 0 || jd.value.trim() === "";
|
|
}
|
|
|
|
function addFiles(incoming) {
|
|
const skipped = [];
|
|
for (const file of incoming) {
|
|
if (!file.name.toLowerCase().endsWith(".pdf")) {
|
|
skipped.push(file.name + " (not a .pdf)");
|
|
} else if (file.size > MAX_BYTES) {
|
|
skipped.push(file.name + " (over 10 MB)");
|
|
} else if (files.some((f) => f.name === file.name && f.size === file.size)) {
|
|
skipped.push(file.name + " (already added)");
|
|
} else if (files.length >= MAX_FILES) {
|
|
skipped.push(file.name + " (file limit reached)");
|
|
} else {
|
|
files.push(file);
|
|
}
|
|
}
|
|
setMsg(skipped.length ? "Skipped: " + skipped.join(", ") : "");
|
|
refresh();
|
|
}
|
|
|
|
drop.addEventListener("click", () => picker.click());
|
|
drop.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
picker.click();
|
|
}
|
|
});
|
|
picker.addEventListener("change", () => {
|
|
addFiles(picker.files);
|
|
picker.value = "";
|
|
});
|
|
for (const name of ["dragenter", "dragover"]) {
|
|
drop.addEventListener(name, (event) => {
|
|
event.preventDefault();
|
|
drop.classList.add("dragover");
|
|
});
|
|
}
|
|
for (const name of ["dragleave", "drop"]) {
|
|
drop.addEventListener(name, (event) => {
|
|
event.preventDefault();
|
|
drop.classList.remove("dragover");
|
|
});
|
|
}
|
|
drop.addEventListener("drop", (event) => addFiles(event.dataTransfer.files));
|
|
jd.addEventListener("input", refresh);
|
|
|
|
function showProgress(count) {
|
|
const started = Date.now();
|
|
progress.hidden = false;
|
|
submitBtn.disabled = true;
|
|
const note = "Scoring " + count + " resume" + (count === 1 ? "" : "s") +
|
|
"… the first result primes the prompt cache, then the rest fan out. ";
|
|
progressText.textContent = note;
|
|
timer = setInterval(() => {
|
|
const seconds = Math.round((Date.now() - started) / 1000);
|
|
progressText.textContent = note + seconds + "s elapsed";
|
|
}, 1000);
|
|
}
|
|
|
|
function hideProgress() {
|
|
clearInterval(timer);
|
|
progress.hidden = true;
|
|
refresh();
|
|
}
|
|
|
|
function showError(code, message, requestId) {
|
|
document.getElementById("error-code").textContent = code;
|
|
document.getElementById("error-message").textContent = message;
|
|
document.getElementById("error-req").textContent = requestId ? "request_id " + requestId : "";
|
|
document.getElementById("error").hidden = false;
|
|
}
|
|
|
|
/* ---- card rendering ------------------------------------------------------ */
|
|
|
|
function initials(name) {
|
|
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
if (!words.length) return "?";
|
|
const first = words[0][0] || "?";
|
|
const second = words.length > 1 ? words[words.length - 1][0] : (words[0][1] || "");
|
|
return (first + second).toUpperCase();
|
|
}
|
|
|
|
function avatarColor(key) {
|
|
let hash = 0;
|
|
for (const ch of key) hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
|
|
return AVATAR_COLORS[hash % AVATAR_COLORS.length];
|
|
}
|
|
|
|
function displayName(item) {
|
|
return item.candidate_name || item.filename.replace(/\.pdf$/i, "");
|
|
}
|
|
|
|
function band(score) {
|
|
if (score >= 85) return "band-good";
|
|
if (score >= 70) return "band-warn";
|
|
return "band-crit";
|
|
}
|
|
|
|
function scoreRing(score) {
|
|
const holder = el("span", "ring-holder");
|
|
const value = Math.max(0, Math.min(100, Number(score) || 0));
|
|
holder.innerHTML =
|
|
'<svg class="ring ' + band(value) + '" viewBox="0 0 44 44" role="img" aria-label="Match score ' +
|
|
value + ' of 100">' +
|
|
'<circle class="track" cx="22" cy="22" r="18" pathLength="100"/>' +
|
|
'<circle class="fill" cx="22" cy="22" r="18" pathLength="100" stroke-dasharray="' +
|
|
value + ' 100" transform="rotate(-90 22 22)"/>' +
|
|
'<text x="22" y="23" text-anchor="middle" dominant-baseline="central">' + value + "</text>" +
|
|
"</svg>";
|
|
return holder;
|
|
}
|
|
|
|
function chipRow(matched, missing) {
|
|
const wrap = el("div", "chips");
|
|
const shownMatched = matched.slice(0, 5);
|
|
const shownMissing = missing.slice(0, 3);
|
|
for (const word of shownMatched) {
|
|
const chip = el("span", "chip", word);
|
|
chip.title = "Matched: " + word;
|
|
wrap.append(chip);
|
|
}
|
|
for (const word of shownMissing) {
|
|
const chip = el("span", "chip missing", "✕ " + word);
|
|
chip.title = "Missing: " + word;
|
|
wrap.append(chip);
|
|
}
|
|
const hidden = (matched.length - shownMatched.length) + (missing.length - shownMissing.length);
|
|
if (hidden > 0) {
|
|
const more = el("span", "chip more", "+" + hidden + " more");
|
|
more.title = matched.slice(5).concat(missing.slice(3).map((w) => "missing: " + w)).join(", ");
|
|
wrap.append(more);
|
|
}
|
|
return wrap;
|
|
}
|
|
|
|
function footRow(item) {
|
|
const foot = el("div", "cand-foot");
|
|
|
|
const yrs = el("span", "yrs");
|
|
const icon = el("span");
|
|
icon.innerHTML = BRIEFCASE;
|
|
yrs.append(icon,
|
|
document.createTextNode(item.years_experience == null ? "n/a" : item.years_experience + " yrs"));
|
|
foot.append(yrs);
|
|
|
|
foot.append(el("span", "company", item.current_company || "—"));
|
|
|
|
const tag = el("span", "tag");
|
|
tag.append(el("span", "dot"), el("span", "", item.filename));
|
|
tag.title = item.filename;
|
|
foot.append(tag, fileActions(item.filename));
|
|
return foot;
|
|
}
|
|
|
|
function completedCard(item) {
|
|
const card = el("article", "card cand");
|
|
const head = el("div", "cand-head");
|
|
|
|
const name = displayName(item);
|
|
const avatar = el("span", "avatar", initials(name));
|
|
avatar.style.background = avatarColor(name);
|
|
|
|
const who = el("div", "who");
|
|
who.append(el("span", "name", name), el("span", "title", item.job_title || "—"));
|
|
|
|
head.append(avatar, who, scoreRing(item.match_score));
|
|
card.append(head, chipRow(item.matched_keywords, item.missing_keywords));
|
|
|
|
const critique = el("p", "critique", item.summary_critique);
|
|
critique.title = item.summary_critique;
|
|
card.append(critique, footRow(item));
|
|
return card;
|
|
}
|
|
|
|
function failedCard(item) {
|
|
const card = el("article", "card cand failed");
|
|
const head = el("div", "cand-head");
|
|
|
|
const avatar = el("span", "avatar", "!");
|
|
const who = el("div", "who");
|
|
who.append(el("span", "name", item.filename), el("span", "title", "Could not be scored"));
|
|
head.append(avatar, who);
|
|
|
|
const why = el("p", "why", item.error_message);
|
|
const foot = el("div", "cand-foot");
|
|
foot.append(el("span", "fail-tag", "✕ " + item.error_code), fileActions(item.filename));
|
|
card.append(head, why, foot);
|
|
return card;
|
|
}
|
|
|
|
function matchesQuery(item, query) {
|
|
if (!query) return true;
|
|
const haystack = [
|
|
item.filename,
|
|
item.candidate_name || "",
|
|
item.job_title || "",
|
|
item.current_company || "",
|
|
(item.matched_keywords || []).join(" "),
|
|
(item.missing_keywords || []).join(" "),
|
|
].join(" ").toLowerCase();
|
|
return query.split(/\s+/).every((term) => haystack.includes(term));
|
|
}
|
|
|
|
function renderCards() {
|
|
const grid = document.getElementById("grid");
|
|
grid.replaceChildren();
|
|
|
|
const query = searchBox.value.trim().toLowerCase();
|
|
const status = statusFilter.value;
|
|
const visible = lastResults.filter(
|
|
(item) => (status === "all" || item.status === status) && matchesQuery(item, query),
|
|
);
|
|
|
|
for (const item of visible) {
|
|
grid.append(item.status === "completed" ? completedCard(item) : failedCard(item));
|
|
}
|
|
if (!visible.length) {
|
|
grid.append(el("p", "empty", "No candidates match the current filters."));
|
|
}
|
|
}
|
|
|
|
function renderResults(body) {
|
|
lastResults = body.results;
|
|
searchBox.value = "";
|
|
statusFilter.value = "all";
|
|
|
|
document.getElementById("counts").textContent =
|
|
body.total + " candidate" + (body.total === 1 ? "" : "s") + " · " +
|
|
body.succeeded + " scored · " + body.failed + " failed";
|
|
document.getElementById("result-req").textContent = "request_id " + body.request_id;
|
|
|
|
renderCards();
|
|
document.getElementById("results").hidden = false;
|
|
}
|
|
|
|
searchBox.addEventListener("input", renderCards);
|
|
statusFilter.addEventListener("change", renderCards);
|
|
|
|
document.getElementById("form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
document.getElementById("error").hidden = true;
|
|
setMsg("");
|
|
|
|
const data = new FormData();
|
|
data.append("job_description", jd.value.trim());
|
|
for (const file of files) data.append("resumes", file, file.name);
|
|
|
|
// Snapshot the submitted files so result cards can offer view/download even if the
|
|
// picker list is edited afterwards. Object URLs from the previous batch are revoked.
|
|
submittedFiles = new Map(files.map((f) => [f.name, f]));
|
|
resetFileUrls();
|
|
|
|
showProgress(files.length);
|
|
try {
|
|
const response = await fetch("/api/v1/score", { method: "POST", body: data });
|
|
let body = null;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
/* non-JSON body falls through to the generic error below */
|
|
}
|
|
if (!response.ok) {
|
|
showError(
|
|
(body && body.error_code) || "HTTP_" + response.status,
|
|
(body && body.error_message) || "The server returned an unexpected response.",
|
|
body && body.request_id,
|
|
);
|
|
return;
|
|
}
|
|
renderResults(body);
|
|
} catch {
|
|
showError("NETWORK_ERROR", "Could not reach the API. Is the server running?", null);
|
|
} finally {
|
|
hideProgress();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|