Files
verdicttank/ops-portal/index.html
T
root 6ac8a185ef Add demo cases (HarvestLink v1/v2), subscription technical contract, portal + ops-portal HTML
- demo-case-harvestlink.md + v2: fictional staged proposals for product-marketing video (no PII)
- CONTRACT.md: subscription model / contact form / receipt fixes technical contract (read-only analysis 2026-08-19)
- portal/index.html + ops-portal/: customer portal + ops portal single-file builds
- .gitignore: demo-creds*.txt excluded
2026-08-26 02:26:50 -04:00

1061 lines
38 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VerdictTank Staff Ops</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
:root{
--crimson: #dc2626;
--crimson-dark: #b91c1c;
--crimson-light: #fee2e2;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 24px;
--space-6: 32px;
--space-7: 48px;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 16px;
--font: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
html[data-theme="light"]{
--bg: #f5f6f8;
--bg-elevated: #ffffff;
--bg-subtle: #eef0f3;
--border: #e0e3e8;
--text: #14171f;
--text-muted: #5b6270;
--text-faint: #90959f;
--shadow: 0 1px 3px rgba(15,18,25,0.08), 0 1px 2px rgba(15,18,25,0.06);
--shadow-lg: 0 10px 30px rgba(15,18,25,0.12);
--success: #16a34a;
--success-bg: #dcfce7;
--warn: #d97706;
--warn-bg: #fef3c7;
--info: #2563eb;
--info-bg: #dbeafe;
--danger-bg: #fee2e2;
}
html[data-theme="dark"]{
--bg: #0d0f14;
--bg-elevated: #161923;
--bg-subtle: #1d212c;
--border: #2a2f3d;
--text: #eef0f4;
--text-muted: #9aa1b0;
--text-faint: #676e7d;
--shadow: 0 1px 3px rgba(0,0,0,0.4);
--shadow-lg: 0 10px 30px rgba(0,0,0,0.5);
--success: #22c55e;
--success-bg: #123321;
--warn: #f59e0b;
--warn-bg: #3a2a0d;
--info: #3b82f6;
--info-bg: #10203a;
--danger-bg: #3a1414;
}
*{ box-sizing: border-box; }
html, body{ margin: 0; padding: 0; }
body{
font-family: var(--font);
background: var(--bg);
color: var(--text);
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
button, input, select{ font-family: inherit; }
a{ color: var(--crimson); text-decoration: none; }
a:hover{ text-decoration: underline; }
.hidden{ display: none !important; }
/* ---------- Login ---------- */
#login-view{
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-5);
}
.login-card{
width: 100%;
max-width: 380px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: var(--space-7) var(--space-6);
box-shadow: var(--shadow-lg);
}
.login-brand{
display: flex;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-6);
justify-content: center;
}
.login-brand .mark{
width: 32px; height: 32px;
border-radius: 8px;
background: var(--crimson);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 800; font-size: 15px;
}
.login-brand .name{
font-weight: 700;
font-size: 18px;
letter-spacing: -0.01em;
}
.login-card h1{
font-size: 15px;
font-weight: 600;
text-align: center;
color: var(--text-muted);
margin: 0 0 var(--space-6);
}
.field{ margin-bottom: var(--space-4); }
.field label{
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: var(--space-2);
color: var(--text-muted);
}
.field input{
width: 100%;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text);
font-size: 14px;
transition: border-color 0.15s, box-shadow 0.15s;
}
.field input:focus{
outline: none;
border-color: var(--crimson);
box-shadow: 0 0 0 3px rgba(220,38,38,0.15);
}
.btn{
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
padding: 10px 16px;
border-radius: var(--radius-sm);
border: 1px solid transparent;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, opacity 0.15s;
}
.btn-primary{
background: var(--crimson);
color: #fff;
width: 100%;
}
.btn-primary:hover{ background: var(--crimson-dark); }
.btn-primary:disabled{ opacity: 0.6; cursor: not-allowed; }
.btn-ghost{
background: transparent;
border-color: var(--border);
color: var(--text);
}
.btn-ghost:hover{ background: var(--bg-subtle); }
.btn-sm{ padding: 6px 12px; font-size: 13px; }
.error-text{
color: var(--crimson);
font-size: 13px;
margin-top: var(--space-3);
min-height: 18px;
text-align: center;
}
/* ---------- App shell ---------- */
#app-view{ min-height: 100vh; display: flex; flex-direction: column; }
.topbar{
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--space-4) var(--space-6);
background: var(--bg-elevated);
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
z-index: 20;
}
.topbar-brand{ display: flex; align-items: center; gap: var(--space-3); }
.topbar-brand .mark{
width: 28px; height: 28px;
border-radius: 7px;
background: var(--crimson);
display: flex; align-items: center; justify-content: center;
color: #fff; font-weight: 800; font-size: 13px;
}
.topbar-brand .name{ font-weight: 700; font-size: 16px; letter-spacing: -0.01em; }
.topbar-brand .tag{
font-size: 11px;
font-weight: 600;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.05em;
border-left: 1px solid var(--border);
padding-left: var(--space-3);
margin-left: var(--space-1);
}
.topbar-right{ display: flex; align-items: center; gap: var(--space-3); }
.user-email{ font-size: 13px; color: var(--text-muted); }
.theme-toggle{
width: 36px; height: 36px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-subtle);
color: var(--text);
cursor: pointer;
display: flex; align-items: center; justify-content: center;
font-size: 15px;
}
.theme-toggle:hover{ background: var(--bg); }
main{ flex: 1; padding: var(--space-6); max-width: 1280px; width: 100%; margin: 0 auto; }
/* ---------- Stats ---------- */
.stats-row{
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: var(--space-4);
margin-bottom: var(--space-6);
}
.stat-card{
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
box-shadow: var(--shadow);
}
.stat-card .label{
font-size: 12px;
font-weight: 600;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: var(--space-2);
}
.stat-card .value{ font-size: 28px; font-weight: 800; letter-spacing: -0.02em; }
.stat-card.total .value{ color: var(--text); }
.stat-card.completed .value{ color: var(--success); }
.stat-card.failed .value{ color: var(--crimson); }
.stat-card.queued .value{ color: var(--text-muted); }
.stat-card.processing .value{ color: var(--info); }
/* ---------- Toolbar ---------- */
.toolbar{
display: flex;
align-items: center;
gap: var(--space-3);
margin-bottom: var(--space-4);
flex-wrap: wrap;
}
.search-input{
flex: 1;
min-width: 220px;
padding: 9px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
font-size: 14px;
}
.search-input:focus{ outline: none; border-color: var(--crimson); box-shadow: 0 0 0 3px rgba(220,38,38,0.15); }
.status-select{
padding: 9px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text);
font-size: 14px;
cursor: pointer;
}
.pagination{
display: flex;
align-items: center;
gap: var(--space-2);
margin-left: auto;
font-size: 13px;
color: var(--text-muted);
white-space: nowrap;
}
/* ---------- Table ---------- */
.table-wrap{
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
overflow: hidden;
box-shadow: var(--shadow);
overflow-x: auto;
}
table{ width: 100%; border-collapse: collapse; min-width: 900px; }
thead th{
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
background: var(--bg-subtle);
white-space: nowrap;
}
tbody td{
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border);
font-size: 13.5px;
white-space: nowrap;
}
tbody tr{ cursor: pointer; transition: background 0.1s; }
tbody tr:hover{ background: var(--bg-subtle); }
tbody tr:last-child td{ border-bottom: none; }
.mono{ font-family: 'SF Mono', Consolas, Monaco, monospace; font-size: 12.5px; color: var(--text-muted); }
.empty-state{
padding: var(--space-7);
text-align: center;
color: var(--text-faint);
font-size: 14px;
}
.pill{
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.pill-queued{ background: var(--bg-subtle); color: var(--text-muted); }
.pill-processing{ background: var(--info-bg); color: var(--info); }
.pill-complete{ background: var(--success-bg); color: var(--success); }
.pill-failed{ background: var(--danger-bg); color: var(--crimson); }
/* ---------- Detail view ---------- */
.detail-back{ margin-bottom: var(--space-4); }
.detail-header{
display: flex;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
gap: var(--space-3);
margin-bottom: var(--space-5);
}
.detail-header h2{ margin: 0 0 var(--space-1); font-size: 20px; letter-spacing: -0.01em; }
.detail-sub{ font-size: 13px; color: var(--text-muted); display: flex; gap: var(--space-3); align-items: center; flex-wrap: wrap; }
.detail-actions{ display: flex; gap: var(--space-2); }
.score-row{
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.score-card{
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-5);
box-shadow: var(--shadow);
text-align: center;
}
.score-card .label{ font-size: 13px; font-weight: 600; color: var(--text-muted); margin-bottom: var(--space-2); }
.score-card .score-value{ font-size: 44px; font-weight: 800; letter-spacing: -0.02em; line-height: 1; }
.score-card .score-max{ font-size: 15px; color: var(--text-faint); font-weight: 600; }
.meta-row{
display: flex;
gap: var(--space-4);
margin-bottom: var(--space-5);
flex-wrap: wrap;
}
.meta-card{
flex: 1;
min-width: 160px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-4);
box-shadow: var(--shadow);
}
.meta-card .label{ font-size: 12px; color: var(--text-faint); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: var(--space-1); }
.meta-card .value{ font-size: 20px; font-weight: 700; }
.verdict-banner{
border-radius: var(--radius-md);
padding: var(--space-4) var(--space-5);
margin-bottom: var(--space-5);
border: 1px solid var(--border);
}
.verdict-banner .verdict-label{
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em;
margin-bottom: var(--space-2); opacity: 0.85;
}
.verdict-banner .verdict-title{ font-size: 17px; font-weight: 700; margin-bottom: var(--space-2); }
.verdict-banner .verdict-rationale{ font-size: 14px; line-height: 1.55; }
.verdict-pass{ background: var(--success-bg); color: var(--success); border-color: transparent; }
.verdict-fail{ background: var(--danger-bg); color: var(--crimson); border-color: transparent; }
.verdict-neutral{ background: var(--bg-subtle); color: var(--text-muted); }
.section-card{
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-md);
padding: var(--space-5);
margin-bottom: var(--space-4);
box-shadow: var(--shadow);
}
.section-card h3{ margin: 0 0 var(--space-3); font-size: 14px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--text-muted); }
.section-card p{ margin: 0; font-size: 14px; line-height: 1.6; color: var(--text); }
.section-card ul{ margin: 0; padding-left: var(--space-5); font-size: 14px; line-height: 1.7; }
.section-card ul li{ margin-bottom: var(--space-2); }
.section-card ul li:last-child{ margin-bottom: 0; }
.dim-row{ display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-3); }
.dim-row:last-child{ margin-bottom: 0; }
.dim-label{ flex: 0 0 200px; font-size: 13px; color: var(--text-muted); }
.dim-bar-track{ flex: 1; height: 8px; border-radius: 999px; background: var(--bg-subtle); overflow: hidden; }
.dim-bar-fill{ height: 100%; background: var(--crimson); border-radius: 999px; transition: width 0.3s; }
.dim-score{ flex: 0 0 42px; text-align: right; font-size: 13px; font-weight: 700; }
.two-col{ display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
details.section-card summary{ cursor: pointer; font-size: 14px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.03em; list-style: none; }
details.section-card summary::-webkit-details-marker{ display: none; }
details.section-card summary::before{ content: '▸ '; font-size: 12px; }
details.section-card[open] summary::before{ content: '▾ '; }
details.section-card .audit-body{ margin-top: var(--space-3); }
.audit-table{ width: 100%; border-collapse: collapse; font-size: 13px; }
.audit-table th, .audit-table td{ text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
.audit-table th{ color: var(--text-faint); font-weight: 600; width: 30%; }
.empty-inline{ color: var(--text-faint); font-size: 13.5px; font-style: italic; }
footer{
text-align: center;
padding: var(--space-5);
font-size: 12.5px;
color: var(--text-faint);
border-top: 1px solid var(--border);
}
.loading-row{ text-align: center; padding: var(--space-6); color: var(--text-faint); }
@media (max-width: 900px){
.stats-row{ grid-template-columns: repeat(2, 1fr); }
.score-row{ grid-template-columns: 1fr; }
.two-col{ grid-template-columns: 1fr; }
main{ padding: var(--space-4); }
.topbar{ padding: var(--space-3) var(--space-4); }
.topbar-brand .tag{ display: none; }
.dim-label{ flex-basis: 130px; }
}
@media (max-width: 600px){
.stats-row{ grid-template-columns: repeat(2, 1fr); }
.toolbar{ flex-direction: column; align-items: stretch; }
.pagination{ margin-left: 0; justify-content: space-between; }
}
</style>
</head>
<body>
<!-- ========== LOGIN VIEW ========== -->
<div id="login-view">
<div class="login-card">
<div class="login-brand">
<div class="mark">VT</div>
<div class="name">VerdictTank</div>
</div>
<h1>Staff Ops Sign In</h1>
<form id="login-form" autocomplete="on">
<div class="field">
<label for="login-email">Email</label>
<input type="email" id="login-email" name="email" required autocomplete="username">
</div>
<div class="field">
<label for="login-password">Password</label>
<input type="password" id="login-password" name="password" required autocomplete="current-password">
</div>
<button type="submit" class="btn btn-primary" id="login-submit">Sign In</button>
<div class="error-text" id="login-error" role="alert"></div>
</form>
</div>
</div>
<!-- ========== APP VIEW ========== -->
<div id="app-view" class="hidden">
<div class="topbar">
<div class="topbar-brand">
<div class="mark">VT</div>
<div class="name">VerdictTank</div>
<div class="tag">Staff Ops</div>
</div>
<div class="topbar-right">
<span class="user-email" id="user-email-label"></span>
<button class="theme-toggle" id="theme-toggle" title="Toggle theme" type="button">&#9788;</button>
<button class="btn btn-ghost btn-sm" id="logout-btn" type="button">Log Out</button>
</div>
</div>
<main>
<!-- ---- List view ---- -->
<section id="list-view">
<div class="stats-row" id="stats-row">
<div class="stat-card total"><div class="label">Total</div><div class="value" id="stat-total">-</div></div>
<div class="stat-card completed"><div class="label">Completed</div><div class="value" id="stat-completed">-</div></div>
<div class="stat-card failed"><div class="label">Failed</div><div class="value" id="stat-failed">-</div></div>
<div class="stat-card queued"><div class="label">Queued</div><div class="value" id="stat-queued">-</div></div>
<div class="stat-card processing"><div class="label">Processing</div><div class="value" id="stat-processing">-</div></div>
</div>
<div class="toolbar">
<input type="search" class="search-input" id="search-input" placeholder="Search name, email, or proposal...">
<select class="status-select" id="status-select">
<option value="">All statuses</option>
<option value="queued">Queued</option>
<option value="processing">Processing</option>
<option value="complete">Complete</option>
<option value="failed">Failed</option>
</select>
<div class="pagination">
<button class="btn btn-ghost btn-sm" id="prev-page" type="button">&larr; Prev</button>
<span id="page-label">0 of 0</span>
<button class="btn btn-ghost btn-sm" id="next-page" type="button">Next &rarr;</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Review ID</th>
<th>Proposal</th>
<th>Name</th>
<th>Email</th>
<th>Tier</th>
<th>Status</th>
<th>Verdict</th>
<th>Strength</th>
<th>Readiness</th>
<th>Submitted</th>
</tr>
</thead>
<tbody id="submissions-tbody">
<tr><td colspan="10" class="loading-row">Loading...</td></tr>
</tbody>
</table>
</div>
</section>
<!-- ---- Detail view ---- -->
<section id="detail-view" class="hidden">
<button class="btn btn-ghost btn-sm detail-back" id="back-btn" type="button">&larr; Back to submissions</button>
<div id="detail-content">
<div class="loading-row">Loading...</div>
</div>
</section>
</main>
<footer>VerdictTank, a product of IT Pro Partner</footer>
</div>
<script>
(function(){
"use strict";
/* ---------------- State ---------------- */
var STORAGE_KEY = "vt_staff";
var PAGE_SIZE = 20;
var state = {
token: null,
user: null,
page: 0,
total: 0,
status: "",
q: "",
submissions: []
};
var searchDebounceTimer = null;
/* ---------------- Elements ---------------- */
var loginView = document.getElementById("login-view");
var appView = document.getElementById("app-view");
var listView = document.getElementById("list-view");
var detailView = document.getElementById("detail-view");
var loginForm = document.getElementById("login-form");
var loginError = document.getElementById("login-error");
var loginSubmit = document.getElementById("login-submit");
var userEmailLabel = document.getElementById("user-email-label");
var themeToggle = document.getElementById("theme-toggle");
var logoutBtn = document.getElementById("logout-btn");
var searchInput = document.getElementById("search-input");
var statusSelect = document.getElementById("status-select");
var prevPageBtn = document.getElementById("prev-page");
var nextPageBtn = document.getElementById("next-page");
var pageLabel = document.getElementById("page-label");
var tbody = document.getElementById("submissions-tbody");
var backBtn = document.getElementById("back-btn");
var detailContent = document.getElementById("detail-content");
/* ---------------- Theme ---------------- */
function applyTheme(theme){
document.documentElement.setAttribute("data-theme", theme);
themeToggle.innerHTML = theme === "dark" ? "&#9789;" : "&#9788;";
}
function initTheme(){
var saved = localStorage.getItem("vt_theme");
if(saved === "dark" || saved === "light"){
applyTheme(saved);
} else {
var prefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
applyTheme(prefersDark ? "dark" : "light");
}
}
themeToggle.addEventListener("click", function(){
var current = document.documentElement.getAttribute("data-theme");
var next = current === "dark" ? "light" : "dark";
applyTheme(next);
localStorage.setItem("vt_theme", next);
});
initTheme();
/* ---------------- Utility ---------------- */
function esc(v){
if(v === null || v === undefined) return "";
return String(v).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
function emptyOr(v, statusHint){
if(v === null || v === undefined || v === ""){
if(statusHint === "queued" || statusHint === "processing") return "Pending";
return "-";
}
return v;
}
function shortId(id){
if(!id) return "-";
return id.length > 10 ? id.slice(0,10) + "..." : id;
}
function fmtDate(iso){
if(!iso) return "-";
try {
var d = new Date(iso);
if(isNaN(d.getTime())) return "-";
return d.toLocaleString(undefined, { year:"numeric", month:"short", day:"numeric", hour:"2-digit", minute:"2-digit" });
} catch(e){ return "-"; }
}
function statusPillClass(status){
if(status === "queued") return "pill-queued";
if(status === "processing") return "pill-processing";
if(status === "complete") return "pill-complete";
if(status === "failed") return "pill-failed";
return "pill-queued";
}
function statusPillHtml(status){
var s = status || "unknown";
return '<span class="pill ' + statusPillClass(s) + '">' + esc(s) + '</span>';
}
function scoreOrDash(v, status){
if(v === null || v === undefined) return emptyOr(null, status);
return v;
}
/* ---------------- Storage / session ---------------- */
function loadSession(){
try {
var raw = localStorage.getItem(STORAGE_KEY);
if(!raw) return null;
var parsed = JSON.parse(raw);
if(parsed && parsed.token && parsed.user) return parsed;
return null;
} catch(e){ return null; }
}
function saveSession(token, user){
localStorage.setItem(STORAGE_KEY, JSON.stringify({ token: token, user: user }));
}
function clearSession(){
localStorage.removeItem(STORAGE_KEY);
}
/* ---------------- API ---------------- */
function apiFetch(path, options){
options = options || {};
var headers = options.headers || {};
if(state.token) headers["Authorization"] = "Bearer " + state.token;
options.headers = headers;
return fetch(path, options).then(function(resp){
if(resp.status === 401 || resp.status === 403){
handleAuthFailure();
return resp.json().catch(function(){ return {}; }).then(function(body){
var err = new Error((body && body.detail) || "Session expired");
err.status = resp.status;
throw err;
});
}
return resp.json().catch(function(){ return {}; }).then(function(body){
if(!resp.ok){
var err = new Error((body && body.detail) || ("Request failed (" + resp.status + ")"));
err.status = resp.status;
throw err;
}
return body;
});
});
}
function handleAuthFailure(){
clearSession();
state.token = null;
state.user = null;
showLogin();
}
/* ---------------- View switching ---------------- */
function showLogin(){
loginView.classList.remove("hidden");
appView.classList.add("hidden");
}
function showApp(){
loginView.classList.add("hidden");
appView.classList.remove("hidden");
userEmailLabel.textContent = (state.user && state.user.email) || "";
showListView();
refreshStats();
refreshSubmissions();
}
function showListView(){
listView.classList.remove("hidden");
detailView.classList.add("hidden");
}
function showDetailView(){
listView.classList.add("hidden");
detailView.classList.remove("hidden");
}
/* ---------------- Login ---------------- */
loginForm.addEventListener("submit", function(e){
e.preventDefault();
loginError.textContent = "";
var email = document.getElementById("login-email").value.trim();
var password = document.getElementById("login-password").value;
if(!email || !password){
loginError.textContent = "Email and password are required.";
return;
}
loginSubmit.disabled = true;
loginSubmit.textContent = "Signing in...";
fetch("/api/verdicttank/admin/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email, password: password })
}).then(function(resp){
return resp.json().catch(function(){ return {}; }).then(function(body){
if(!resp.ok){
var msg = (body && body.detail) || "Sign in failed.";
throw new Error(msg);
}
return body;
});
}).then(function(data){
state.token = data.token;
state.user = data.user;
saveSession(data.token, data.user);
loginForm.reset();
showApp();
}).catch(function(err){
loginError.textContent = err.message || "Sign in failed.";
}).finally(function(){
loginSubmit.disabled = false;
loginSubmit.textContent = "Sign In";
});
});
logoutBtn.addEventListener("click", function(){
clearSession();
state.token = null;
state.user = null;
showLogin();
});
/* ---------------- Stats ---------------- */
function refreshStats(){
apiFetch("/api/verdicttank/admin/stats").then(function(data){
document.getElementById("stat-total").textContent = emptyOr(data.total);
document.getElementById("stat-completed").textContent = emptyOr(data.completed);
document.getElementById("stat-failed").textContent = emptyOr(data.failed);
document.getElementById("stat-queued").textContent = emptyOr(data.queued);
document.getElementById("stat-processing").textContent = emptyOr(data.processing);
}).catch(function(err){
if(err.status === 401 || err.status === 403) return;
document.getElementById("stat-total").textContent = "-";
document.getElementById("stat-completed").textContent = "-";
document.getElementById("stat-failed").textContent = "-";
document.getElementById("stat-queued").textContent = "-";
document.getElementById("stat-processing").textContent = "-";
});
}
/* ---------------- Submissions list ---------------- */
function refreshSubmissions(){
tbody.innerHTML = '<tr><td colspan="10" class="loading-row">Loading...</td></tr>';
var params = new URLSearchParams();
if(state.status) params.set("status", state.status);
if(state.q) params.set("q", state.q);
params.set("limit", PAGE_SIZE);
params.set("offset", state.page * PAGE_SIZE);
apiFetch("/api/verdicttank/admin/submissions?" + params.toString()).then(function(data){
state.submissions = data.submissions || [];
state.total = data.total || 0;
renderSubmissions();
renderPagination();
}).catch(function(err){
if(err.status === 401 || err.status === 403) return;
tbody.innerHTML = '<tr><td colspan="10" class="empty-state">Failed to load submissions: ' + esc(err.message) + '</td></tr>';
});
}
function renderSubmissions(){
if(!state.submissions.length){
tbody.innerHTML = '<tr><td colspan="10" class="empty-state">No submissions found.</td></tr>';
return;
}
var rows = state.submissions.map(function(s){
return '<tr data-review-id="' + esc(s.review_id) + '">' +
'<td class="mono">' + esc(shortId(s.review_id)) + '</td>' +
'<td>' + esc(emptyOr(s.proposal_name)) + '</td>' +
'<td>' + esc(emptyOr(s.name)) + '</td>' +
'<td>' + esc(emptyOr(s.email)) + '</td>' +
'<td>' + esc(emptyOr(s.tier_label || s.tier)) + '</td>' +
'<td>' + statusPillHtml(s.status) + '</td>' +
'<td>' + esc(emptyOr(s.verdict, s.status)) + '</td>' +
'<td>' + esc(scoreOrDash(s.proposal_strength, s.status)) + '</td>' +
'<td>' + esc(scoreOrDash(s.investor_readiness, s.status)) + '</td>' +
'<td>' + esc(fmtDate(s.submitted_at)) + '</td>' +
'</tr>';
}).join("");
tbody.innerHTML = rows;
Array.prototype.forEach.call(tbody.querySelectorAll("tr[data-review-id]"), function(row){
row.addEventListener("click", function(){
openDetail(row.getAttribute("data-review-id"));
});
});
}
function renderPagination(){
var totalPages = Math.max(1, Math.ceil(state.total / PAGE_SIZE));
var currentPage = state.page + 1;
pageLabel.textContent = currentPage + " of " + totalPages + " (" + state.total + " total)";
prevPageBtn.disabled = state.page <= 0;
nextPageBtn.disabled = currentPage >= totalPages;
}
searchInput.addEventListener("input", function(){
clearTimeout(searchDebounceTimer);
var val = searchInput.value;
searchDebounceTimer = setTimeout(function(){
state.q = val.trim();
state.page = 0;
refreshSubmissions();
}, 300);
});
statusSelect.addEventListener("change", function(){
state.status = statusSelect.value;
state.page = 0;
refreshSubmissions();
});
prevPageBtn.addEventListener("click", function(){
if(state.page > 0){
state.page -= 1;
refreshSubmissions();
}
});
nextPageBtn.addEventListener("click", function(){
var totalPages = Math.max(1, Math.ceil(state.total / PAGE_SIZE));
if(state.page + 1 < totalPages){
state.page += 1;
refreshSubmissions();
}
});
/* ---------------- Detail view ---------------- */
function openDetail(reviewId){
showDetailView();
detailContent.innerHTML = '<div class="loading-row">Loading...</div>';
apiFetch("/api/verdicttank/admin/submissions/" + encodeURIComponent(reviewId)).then(function(data){
renderDetail(data);
}).catch(function(err){
if(err.status === 401 || err.status === 403) return;
detailContent.innerHTML = '<div class="empty-state">Failed to load submission: ' + esc(err.message) + '</div>';
});
}
function verdictBannerClass(verdict, status){
if(!verdict) return "verdict-neutral";
var v = String(verdict).toLowerCase();
if(v.indexOf("pass") !== -1 || v.indexOf("fund") !== -1 || v.indexOf("go") !== -1) return "verdict-pass";
if(v.indexOf("fail") !== -1 || v.indexOf("no-go") !== -1 || v.indexOf("reject") !== -1) return "verdict-fail";
return "verdict-neutral";
}
function listOrEmpty(items, renderer){
if(!items || !items.length){
return '<div class="empty-inline">None recorded.</div>';
}
return '<ul>' + items.map(renderer).join("") + '</ul>';
}
function renderDimensions(dimensions){
if(!dimensions || !dimensions.length){
return '<div class="empty-inline">No dimension scores recorded.</div>';
}
return dimensions.map(function(d){
var score = (d.score === null || d.score === undefined) ? null : d.score;
var pct = score === null ? 0 : Math.max(0, Math.min(100, (score / 10) * 100));
return '<div class="dim-row">' +
'<div class="dim-label">' + esc(d.label || d.key || "-") + '</div>' +
'<div class="dim-bar-track"><div class="dim-bar-fill" style="width:' + pct + '%"></div></div>' +
'<div class="dim-score">' + esc(score === null ? "-" : score + "/10") + '</div>' +
'</div>';
}).join("");
}
function renderPanelAudit(panelAudit){
if(!panelAudit || !panelAudit.length){
return '<div class="empty-inline">No panel audit data recorded.</div>';
}
return panelAudit.map(function(entry, idx){
if(entry === null || typeof entry !== "object"){
return '<table class="audit-table"><tr><th>Entry ' + (idx+1) + '</th><td>' + esc(entry) + '</td></tr></table>';
}
var rows = Object.keys(entry).map(function(k){
var v = entry[k];
if(v !== null && typeof v === "object") v = JSON.stringify(v);
return '<tr><th>' + esc(k) + '</th><td>' + esc(v === null || v === undefined ? "-" : v) + '</td></tr>';
}).join("");
return '<table class="audit-table">' + rows + '</table>';
}).join('<div style="height:12px"></div>');
}
function renderDetail(d){
var strength = scoreOrDash(d.proposal_strength, d.status);
var readiness = scoreOrDash(d.investor_readiness, d.status);
var composite = scoreOrDash(d.composite, d.status);
var divergence = (d.divergence === null || d.divergence === undefined) ? emptyOr(null, d.status) : d.divergence;
var actionsHtml = "";
if(d.report_url){
actionsHtml += '<a class="btn btn-ghost btn-sm" href="' + esc(window.location.origin + d.report_url) + '" target="_blank" rel="noopener">Download Report</a>';
}
if(d.receipt_url){
actionsHtml += '<a class="btn btn-ghost btn-sm" href="' + esc(window.location.origin + d.receipt_url) + '" target="_blank" rel="noopener">Download Receipt</a>';
}
var verdictHtml = "";
if(d.verdict || d.verdict_rationale){
verdictHtml = '<div class="verdict-banner ' + verdictBannerClass(d.verdict, d.status) + '">' +
'<div class="verdict-label">Verdict</div>' +
'<div class="verdict-title">' + esc(emptyOr(d.verdict, d.status)) + '</div>' +
(d.verdict_rationale ? '<div class="verdict-rationale">' + esc(d.verdict_rationale) + '</div>' : '') +
'</div>';
} else if(d.status === "queued" || d.status === "processing"){
verdictHtml = '<div class="verdict-banner verdict-neutral"><div class="verdict-label">Verdict</div><div class="verdict-title">Pending</div></div>';
} else if(d.status === "failed"){
verdictHtml = '<div class="verdict-banner verdict-fail"><div class="verdict-label">Verdict</div><div class="verdict-title">Review Failed</div>' +
(d.error ? '<div class="verdict-rationale">' + esc(d.error) + '</div>' : '') + '</div>';
}
var html =
'<div class="detail-header">' +
'<div>' +
'<h2>' + esc(emptyOr(d.proposal_name)) + '</h2>' +
'<div class="detail-sub">' +
'<span class="mono">' + esc(d.review_id) + '</span>' +
'<span>&bull;</span>' +
'<span>' + esc(emptyOr(d.tier_label || d.tier)) + '</span>' +
'<span>&bull;</span>' +
statusPillHtml(d.status) +
'</div>' +
'</div>' +
'<div class="detail-actions">' + actionsHtml + '</div>' +
'</div>' +
verdictHtml +
'<div class="score-row">' +
'<div class="score-card"><div class="label">Proposal Strength</div><div class="score-value">' + esc(strength) + '<span class="score-max">' + (strength !== "-" && strength !== "Pending" ? "/100" : "") + '</span></div></div>' +
'<div class="score-card"><div class="label">Investor Readiness</div><div class="score-value">' + esc(readiness) + '<span class="score-max">' + (readiness !== "-" && readiness !== "Pending" ? "/100" : "") + '</span></div></div>' +
'</div>' +
'<div class="meta-row">' +
'<div class="meta-card"><div class="label">Composite</div><div class="value">' + esc(composite) + '</div></div>' +
'<div class="meta-card"><div class="label">Divergence</div><div class="value">' + esc(divergence) + '</div></div>' +
'<div class="meta-card"><div class="label">Submitted</div><div class="value" style="font-size:15px">' + esc(fmtDate(d.submitted_at)) + '</div></div>' +
'<div class="meta-card"><div class="label">Completed</div><div class="value" style="font-size:15px">' + esc(d.completed_at ? fmtDate(d.completed_at) : emptyOr(null, d.status)) + '</div></div>' +
'</div>' +
'<div class="meta-row">' +
'<div class="meta-card"><div class="label">Submitter</div><div class="value" style="font-size:15px">' + esc(emptyOr(d.name)) + '</div></div>' +
'<div class="meta-card"><div class="label">Email</div><div class="value" style="font-size:15px">' + esc(emptyOr(d.email)) + '</div></div>' +
'<div class="meta-card"><div class="label">User ID</div><div class="value mono" style="font-size:13px">' + esc(emptyOr(d.user_id)) + '</div></div>' +
'</div>' +
(d.executive_summary ?
'<div class="section-card"><h3>Executive Summary</h3><p>' + esc(d.executive_summary) + '</p></div>' : '') +
'<div class="section-card"><h3>Dimension Scores</h3>' + renderDimensions(d.dimensions) + '</div>' +
'<div class="two-col">' +
'<div class="section-card"><h3>Fatal Flaws</h3>' + listOrEmpty(d.fatal_flaws, function(x){ return '<li>' + esc(x) + '</li>'; }) + '</div>' +
'<div class="section-card"><h3>Action Plan</h3>' + listOrEmpty(d.action_plan, function(x){ return '<li>' + esc(x) + '</li>'; }) + '</div>' +
'</div>' +
'<div class="two-col">' +
'<div class="section-card"><h3>Kill Criteria</h3>' + listOrEmpty(d.kill_criteria, function(x){ return '<li>' + esc(x) + '</li>'; }) + '</div>' +
'<div class="section-card"><h3>Consensus</h3>' + listOrEmpty(d.consensus, function(x){ return '<li>' + esc(x) + '</li>'; }) + '</div>' +
'</div>' +
'<details class="section-card">' +
'<summary>Panel Audit</summary>' +
'<div class="audit-body">' + renderPanelAudit(d.panel_audit) + '</div>' +
'</details>';
detailContent.innerHTML = html;
}
backBtn.addEventListener("click", function(){
showListView();
refreshSubmissions();
});
/* ---------------- Boot ---------------- */
function boot(){
var session = loadSession();
if(session){
state.token = session.token;
state.user = session.user;
showApp();
} else {
showLogin();
}
}
boot();
})();
</script>
</body>
</html>