HR-ATS-Portal/app/static/index.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>