- 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
1061 lines
38 KiB
HTML
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">☼</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">← Prev</button>
|
|
<span id="page-label">0 of 0</span>
|
|
<button class="btn btn-ghost btn-sm" id="next-page" type="button">Next →</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">← 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" ? "☽" : "☼";
|
|
}
|
|
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
}
|
|
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>•</span>' +
|
|
'<span>' + esc(emptyOr(d.tier_label || d.tier)) + '</span>' +
|
|
'<span>•</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>
|