- Documents now flag is_image and the drawer renders screenshots / confirmation images as inline clickable thumbnails (click to open full size); PDFs keep the View link. Evidence keys are labeled (Filing confirmation screenshot, etc.), the worker-temp screenshot_path (not a MinIO key) is dropped in favor of the durable evidence copy, and non-file evidence (fax_log_id) is skipped. - Wrap approve's status-update + audit-insert in a transaction so a failure can no longer leave an order out of ready_to_file without dispatching (the earlier audit CHECK violation did exactly that to Paul's UCR; it has been reset).
445 lines
26 KiB
HTML
445 lines
26 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<meta name="robots" content="noindex, nofollow" />
|
|
<title>Compliance Orders | Performance West Admin</title>
|
|
<script>
|
|
// Same API resolution the main admin SPA uses.
|
|
window.__PW_API = (function () {
|
|
var h = window.location.hostname;
|
|
if (h === "localhost" || h === "127.0.0.1") return "http://" + h + ":3001";
|
|
if (h === "dev.performancewest.net") return "https://api.dev.performancewest.net";
|
|
return "https://api.performancewest.net";
|
|
})();
|
|
</script>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
<style>
|
|
/* Self-contained CSS — no external CSS frameworks, so it stays within the
|
|
site CSP (style-src 'self' 'unsafe-inline'). Palette mirrors the pw-* brand. */
|
|
:root {
|
|
--pw50:#f0f5fa; --pw100:#dce6f2; --pw300:#8daed3; --pw500:#4a78ad;
|
|
--pw600:#3a6192; --pw700:#2d4e78; --pw800:#213b5c; --pw900:#182c45;
|
|
--gray50:#f9fafb; --gray100:#f3f4f6; --gray200:#e5e7eb; --gray300:#d1d5db;
|
|
--gray400:#9ca3af; --gray500:#6b7280; --gray700:#374151; --gray900:#111827;
|
|
}
|
|
* { box-sizing: border-box; }
|
|
body { margin:0; font-family:'Inter',system-ui,sans-serif; background:var(--gray50); color:var(--gray700); }
|
|
a { color:var(--pw700); }
|
|
.hidden { display:none !important; }
|
|
.wrap { max-width:1500px; margin:0 auto; padding:0 16px; }
|
|
/* login */
|
|
.login-box { min-height:100vh; display:flex; align-items:center; justify-content:center; padding:16px; }
|
|
.login-inner { width:100%; max-width:360px; }
|
|
.center { text-align:center; }
|
|
.muted { color:var(--gray500); font-size:13px; }
|
|
label { display:block; font-size:13px; font-weight:500; color:var(--gray700); margin-bottom:4px; }
|
|
input, select { font-family:inherit; }
|
|
.field { width:100%; padding:8px 12px; border:1px solid var(--gray300); border-radius:8px; font-size:14px; }
|
|
.field:focus { outline:2px solid var(--pw500); border-color:var(--pw500); }
|
|
.btn { font-family:inherit; cursor:pointer; border:none; border-radius:8px; font-size:14px; padding:9px 16px; }
|
|
.btn-primary { background:var(--pw700); color:#fff; }
|
|
.btn-primary:hover { background:var(--pw800); }
|
|
.btn-sm { font-size:12px; padding:4px 10px; border-radius:6px; }
|
|
.btn-blue { background:#2563eb; color:#fff; }
|
|
.btn-blue:hover { background:#1d4ed8; }
|
|
.btn-amber { background:#fff; color:#b45309; border:1px solid #fbbf24; }
|
|
.btn-amber:hover { background:#fffbeb; }
|
|
.btn-ghost { background:var(--pw700); color:#fff; }
|
|
.btn-ghost:hover { background:var(--pw600); }
|
|
.btn-red { background:#b91c1c; color:#fff; }
|
|
.btn-red:hover { background:#dc2626; }
|
|
.btn-outline { background:#fff; border:1px solid var(--gray300); color:var(--gray700); }
|
|
.btn-outline:hover { background:var(--gray100); }
|
|
.err { font-size:13px; color:#b91c1c; background:#fef2f2; border:1px solid #fecaca; border-radius:8px; padding:8px 12px; margin-top:8px; }
|
|
/* topbar */
|
|
.topbar { position:sticky; top:0; z-index:40; background:var(--pw900); color:#fff; }
|
|
.topbar .wrap { display:flex; align-items:center; justify-content:space-between; padding:10px 16px; }
|
|
.topbar .left { display:flex; align-items:center; gap:12px; }
|
|
.topbar .title { font-size:14px; font-weight:600; }
|
|
.topbar a { color:var(--pw300); font-size:12px; }
|
|
.topbar a:hover { color:#fff; }
|
|
.topbar .right { display:flex; align-items:center; gap:10px; }
|
|
.topbar .who { font-size:12px; color:var(--pw300); }
|
|
/* stats */
|
|
.panel { background:#fff; border-bottom:1px solid var(--gray200); }
|
|
.stats { display:grid; grid-template-columns:repeat(2,1fr); gap:8px; padding:12px 0; }
|
|
@media(min-width:640px){ .stats{ grid-template-columns:repeat(4,1fr);} }
|
|
@media(min-width:1024px){ .stats{ grid-template-columns:repeat(7,1fr);} }
|
|
.stat { background:#fff; border:1px solid var(--gray200); border-radius:8px; padding:8px 12px; }
|
|
.stat .n { font-size:18px; font-weight:700; color:var(--pw900); }
|
|
.stat .l { font-size:11px; color:var(--gray500); text-transform:uppercase; letter-spacing:.04em; }
|
|
.ring-amber { box-shadow:0 0 0 2px #fcd34d; }
|
|
.ring-blue { box-shadow:0 0 0 2px #60a5fa; }
|
|
/* filters */
|
|
.filters { background:var(--gray50); border-bottom:1px solid var(--gray200); }
|
|
.filters .wrap { display:flex; flex-wrap:wrap; align-items:center; gap:12px; padding:12px 16px; }
|
|
.filters .field { width:auto; padding:5px 8px; }
|
|
.filters .search { width:260px; }
|
|
/* cards */
|
|
.list { padding:16px 0; }
|
|
.card { background:#fff; border:1px solid var(--gray200); border-radius:8px; padding:16px; margin-bottom:12px; }
|
|
.card-head { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
|
.cust { font-weight:600; color:var(--gray900); }
|
|
.small { font-size:11px; color:var(--gray400); }
|
|
.xs { font-size:12px; color:var(--gray500); }
|
|
.total { font-weight:600; color:var(--pw900); text-align:right; }
|
|
.badges { display:flex; align-items:center; gap:6px; justify-content:flex-end; margin-top:4px; }
|
|
.svc-row { display:flex; align-items:center; justify-content:space-between; padding:6px 0; border-top:1px solid var(--gray100); }
|
|
.svc-left { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
|
.svc-right { display:flex; align-items:center; gap:8px; }
|
|
.linkbtn { background:none; border:none; cursor:pointer; color:var(--pw700); font-weight:500; font-size:14px; padding:0; }
|
|
.linkbtn:hover { text-decoration:underline; }
|
|
.card-foot { margin-top:8px; display:flex; align-items:center; justify-content:space-between; gap:8px; }
|
|
.badge { padding:2px 8px; border-radius:999px; font-size:11px; font-weight:600; white-space:nowrap; }
|
|
.b-green{ background:#dcfce7; color:#166534;} .b-amber{ background:#fef3c7; color:#92400e;}
|
|
.b-red{ background:#fee2e2; color:#991b1b;} .b-gray{ background:#f3f4f6; color:#374151;}
|
|
.b-blue{ background:#dbeafe; color:#1e40af;} .b-indigo{ background:#e0e7ff; color:#3730a3;}
|
|
.b-pw{ background:var(--pw100); color:var(--pw800);} .b-none{ background:#f3f4f6; color:#9ca3af;}
|
|
.ok{ color:#16a34a; font-size:11px;} .no{ color:#ef4444; font-size:11px;}
|
|
.empty { text-align:center; color:var(--gray500); padding:48px 0; font-size:14px; }
|
|
/* drawer */
|
|
.drawer { position:fixed; inset:0; z-index:50; }
|
|
.backdrop { position:absolute; inset:0; background:rgba(0,0,0,.4); }
|
|
.sheet { position:absolute; right:0; top:0; height:100%; width:100%; max-width:680px; background:#fff; box-shadow:-4px 0 24px rgba(0,0,0,.15); overflow-y:auto; }
|
|
.sheet-head { position:sticky; top:0; background:var(--pw900); color:#fff; padding:12px 20px; display:flex; align-items:center; justify-content:space-between; }
|
|
.sheet-body { padding:20px; font-size:14px; }
|
|
.drow { display:flex; justify-content:space-between; padding:5px 0; border-bottom:1px solid var(--gray100); gap:12px; }
|
|
.drow .k { color:var(--gray500); } .drow .v { color:var(--gray900); text-align:right; }
|
|
.section-h { font-size:11px; font-weight:600; color:var(--gray500); text-transform:uppercase; letter-spacing:.05em; margin:20px 0 6px; }
|
|
pre { background:var(--gray50); border:1px solid var(--gray200); border-radius:6px; padding:8px; font-size:11px; overflow-x:auto; }
|
|
.wfull { width:100%; }
|
|
.mt8{ margin-top:8px;} .mt4{ margin-top:4px;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<!-- ─── Login ─── -->
|
|
<div id="login-screen" class="login-box hidden">
|
|
<div class="login-inner">
|
|
<div class="center" style="margin-bottom:24px;">
|
|
<h1 style="font-size:20px;font-weight:700;color:var(--gray900);margin:0;">Admin Login</h1>
|
|
<p class="muted" style="margin:4px 0 0;">Compliance Orders</p>
|
|
</div>
|
|
<form id="login-form">
|
|
<div style="margin-bottom:14px;"><label>Username</label><input id="login-username" class="field" type="text" autocomplete="username" required /></div>
|
|
<div style="margin-bottom:14px;"><label>Password</label><input id="login-password" class="field" type="password" autocomplete="current-password" required /></div>
|
|
<div id="login-error" class="err hidden"></div>
|
|
<button type="submit" class="btn btn-primary wfull" style="margin-top:8px;">Sign In</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Dashboard ─── -->
|
|
<div id="dashboard-screen" class="hidden">
|
|
<div class="topbar">
|
|
<div class="wrap">
|
|
<div class="left">
|
|
<span class="title">Compliance Orders</span>
|
|
<a href="/admin/">← Main dashboard</a>
|
|
</div>
|
|
<div class="right">
|
|
<span id="admin-user" class="who"></span>
|
|
<button id="refresh-btn" class="btn btn-ghost btn-sm">Refresh</button>
|
|
<button id="logout-btn" class="btn btn-red btn-sm">Logout</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel"><div class="wrap"><div id="stats-bar" class="stats"></div></div></div>
|
|
|
|
<div class="filters">
|
|
<div class="wrap">
|
|
<input id="filter-q" class="field search" type="text" placeholder="Search name / email / order…" />
|
|
<select id="filter-payment" class="field">
|
|
<option value="">All payments</option>
|
|
<option value="paid">Paid</option>
|
|
<option value="pending_payment">Pending payment</option>
|
|
<option value="refunded">Refunded</option>
|
|
<option value="cancelled">Cancelled</option>
|
|
</select>
|
|
<select id="filter-intake" class="field">
|
|
<option value="">Any intake</option>
|
|
<option value="incomplete">Intake incomplete</option>
|
|
<option value="complete">Intake complete</option>
|
|
</select>
|
|
<select id="filter-fulfillment" class="field">
|
|
<option value="">Any fulfillment</option>
|
|
<option value="ready_to_file">Ready to file (approve!)</option>
|
|
<option value="awaiting_intake">Awaiting intake</option>
|
|
<option value="authorization_signed">Authorization signed</option>
|
|
<option value="filed_waiting_state">Filed, waiting on state</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="none">No status</option>
|
|
</select>
|
|
<button id="apply-filters" class="btn btn-primary btn-sm">Apply</button>
|
|
<button id="clear-filters" class="btn btn-outline btn-sm">Clear</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="wrap list">
|
|
<div id="orders-list"></div>
|
|
<div id="orders-empty" class="empty hidden">No orders match these filters.</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ─── Drawer ─── -->
|
|
<div id="drawer" class="drawer hidden">
|
|
<div id="drawer-backdrop" class="backdrop"></div>
|
|
<div class="sheet">
|
|
<div class="sheet-head">
|
|
<span id="drawer-title" class="title">Order detail</span>
|
|
<button id="drawer-close" class="btn btn-ghost btn-sm">Close</button>
|
|
</div>
|
|
<div id="drawer-body" class="sheet-body"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API = window.__PW_API;
|
|
const TOKEN_KEY = "pw_admin_token";
|
|
const USER_KEY = "pw_admin_user";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const token = () => localStorage.getItem(TOKEN_KEY);
|
|
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g, (c) =>
|
|
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
const money = (cents) => "$" + (Number(cents || 0) / 100).toFixed(2);
|
|
const fmtDate = (s) => s ? new Date(s).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" }) : "—";
|
|
const fmtDay = (s) => s ? new Date(s).toLocaleDateString("en-US", { dateStyle: "medium" }) : "—";
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(API + path, {
|
|
...opts,
|
|
headers: { "Content-Type": "application/json", Authorization: "Bearer " + token(), ...(opts.headers || {}) },
|
|
});
|
|
if (res.status === 401) { showLogin(); throw new Error("Unauthorized"); }
|
|
const data = await res.json().catch(() => ({}));
|
|
if (!res.ok) throw new Error(data.error || ("HTTP " + res.status));
|
|
return data;
|
|
}
|
|
|
|
function showLogin() { $("dashboard-screen").classList.add("hidden"); $("login-screen").classList.remove("hidden"); }
|
|
function showDashboard() {
|
|
$("login-screen").classList.add("hidden");
|
|
$("dashboard-screen").classList.remove("hidden");
|
|
const u = JSON.parse(localStorage.getItem(USER_KEY) || "{}");
|
|
$("admin-user").textContent = u.display_name || u.username || "";
|
|
refresh();
|
|
}
|
|
$("login-form").addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
$("login-error").classList.add("hidden");
|
|
try {
|
|
const res = await fetch(API + "/api/v1/admin/login", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ username: $("login-username").value, password: $("login-password").value }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) throw new Error(data.error || "Login failed");
|
|
localStorage.setItem(TOKEN_KEY, data.token);
|
|
localStorage.setItem(USER_KEY, JSON.stringify(data.user));
|
|
showDashboard();
|
|
} catch (err) { $("login-error").textContent = err.message; $("login-error").classList.remove("hidden"); }
|
|
});
|
|
$("logout-btn").addEventListener("click", () => {
|
|
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); showLogin();
|
|
});
|
|
|
|
const payBadge = (s) => {
|
|
const m = { paid: "b-green", pending_payment: "b-amber", refunded: "b-gray", cancelled: "b-gray" };
|
|
return `<span class="badge ${m[s] || "b-gray"}">${esc(s)}</span>`;
|
|
};
|
|
const fulfillBadge = (s) => {
|
|
if (!s) return `<span class="badge b-none">no status</span>`;
|
|
const m = { ready_to_file: "b-blue", awaiting_intake: "b-amber", completed: "b-green", filed_waiting_state: "b-indigo" };
|
|
return `<span class="badge ${m[s] || "b-pw"}">${esc(s.replace(/_/g, " "))}</span>`;
|
|
};
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const s = await api("/api/v1/admin/compliance-orders/stats");
|
|
const cell = (l, v, cls) => `<div class="stat ${cls || ""}"><div class="n">${v ?? 0}</div><div class="l">${l}</div></div>`;
|
|
$("stats-bar").innerHTML =
|
|
cell("Total", s.total) + cell("Paid", s.paid) + cell("Pending pay", s.pending_payment) +
|
|
cell("Paid · intake ✗", s.paid_intake_incomplete, Number(s.paid_intake_incomplete) > 0 ? "ring-amber" : "") +
|
|
cell("Ready to file", s.ready_to_file, Number(s.ready_to_file) > 0 ? "ring-blue" : "") +
|
|
cell("Awaiting intake", s.awaiting_intake) + cell("Completed", s.completed);
|
|
} catch (e) {}
|
|
}
|
|
|
|
async function loadOrders() {
|
|
const p = new URLSearchParams();
|
|
if ($("filter-q").value.trim()) p.set("q", $("filter-q").value.trim());
|
|
if ($("filter-payment").value) p.set("payment", $("filter-payment").value);
|
|
if ($("filter-intake").value) p.set("intake", $("filter-intake").value);
|
|
if ($("filter-fulfillment").value) p.set("fulfillment", $("filter-fulfillment").value);
|
|
const data = await api("/api/v1/admin/compliance-orders?" + p.toString());
|
|
renderOrders(data.groups || []);
|
|
}
|
|
|
|
function renderOrders(groups) {
|
|
$("orders-empty").classList.toggle("hidden", groups.length > 0);
|
|
$("orders-list").innerHTML = groups.map((g) => {
|
|
const intakeChip = g.payment_status === "paid"
|
|
? (g.intake_all_complete ? `<span class="badge b-green">intake complete</span>` : `<span class="badge b-red">intake incomplete</span>`)
|
|
: "";
|
|
const reminders = (g.payment_status === "paid" && !g.intake_all_complete)
|
|
? `<span class="small">${g.max_reminder_count} reminder(s)${g.last_reminded_at ? ", last " + fmtDay(g.last_reminded_at) : ", none sent"}</span>` : "";
|
|
const services = g.services.map((s) => {
|
|
const approve = s.ready_to_approve
|
|
? `<button data-approve="${esc(s.order_number)}" data-svc="${esc(s.service_name)}" data-intake="${s.intake_data_validated ? 1 : 0}" class="btn btn-blue btn-sm">Approve & file</button>` : "";
|
|
return `<div class="svc-row"><div class="svc-left">
|
|
<button data-detail="${esc(s.order_number)}" class="linkbtn">${esc(s.service_name)}</button>
|
|
<span class="small">${esc(s.order_number)}</span>
|
|
${s.intake_data_validated ? '<span class="ok">✓ intake</span>' : '<span class="no">✗ intake</span>'}
|
|
</div><div class="svc-right">${fulfillBadge(s.fulfillment_status)}${approve}</div></div>`;
|
|
}).join("");
|
|
const rearm = (g.payment_status === "paid" && !g.intake_all_complete)
|
|
? `<button data-rearm="${esc(g.services[0].order_number)}" class="btn btn-amber btn-sm">Re-arm intake reminder</button>` : "";
|
|
return `<div class="card">
|
|
<div class="card-head">
|
|
<div>
|
|
<div class="cust">${esc(g.customer_name || "—")} <span class="small">${esc(g.group_id)}${g.is_batch ? " · batch" : ""}</span></div>
|
|
<div class="xs"><a href="mailto:${esc(g.customer_email)}">${esc(g.customer_email)}</a>${g.customer_phone ? " · " + esc(g.customer_phone) : ""}</div>
|
|
</div>
|
|
<div><div class="total">${money(g.total_cents)}</div><div class="badges">${payBadge(g.payment_status)}${intakeChip}</div></div>
|
|
</div>
|
|
<div class="mt8">${services}</div>
|
|
<div class="card-foot">
|
|
<div class="small">${g.payment_method ? esc(g.payment_method) + " · " : ""}${g.paid_at ? "paid " + fmtDay(g.paid_at) : "created " + fmtDay(g.created_at)}</div>
|
|
<div class="svc-right">${reminders}${rearm}</div>
|
|
</div>
|
|
</div>`;
|
|
}).join("");
|
|
|
|
document.querySelectorAll("[data-approve]").forEach((b) => b.addEventListener("click", () => approveOrder(b.getAttribute("data-approve"), b.getAttribute("data-svc"), b.getAttribute("data-intake") === "1")));
|
|
document.querySelectorAll("[data-rearm]").forEach((b) => b.addEventListener("click", () => rearmIntake(b.getAttribute("data-rearm"))));
|
|
document.querySelectorAll("[data-detail]").forEach((b) => b.addEventListener("click", () => openDetail(b.getAttribute("data-detail"))));
|
|
}
|
|
|
|
async function approveOrder(orderNumber, svc, intakeOk) {
|
|
// Hard warn when intake isn't complete — the filing may be missing data or
|
|
// have no prepared document to review.
|
|
if (intakeOk === false) {
|
|
if (!confirm(`⚠️ Intake is NOT complete for "${svc}" (${orderNumber}).\n\n`
|
|
+ `The prepared filing may be missing required data, and there may be no `
|
|
+ `document to review. Filing now could submit an incomplete/incorrect `
|
|
+ `form to the government.\n\nProceed ANYWAY (override)?`)) return;
|
|
}
|
|
if (!confirm(`Approve "${svc}" (${orderNumber}) and dispatch it for government submission?\n\nThis cannot be undone.`)) return;
|
|
const body = JSON.stringify(intakeOk === false ? { force: true } : {});
|
|
try {
|
|
const r = await api("/api/v1/admin/compliance-orders/" + encodeURIComponent(orderNumber) + "/approve", { method: "POST", body });
|
|
alert(r.dispatched ? "Approved and dispatched to the worker." : "Approved, but worker dispatch did not confirm — check worker logs.");
|
|
$("drawer").classList.add("hidden");
|
|
refresh();
|
|
} catch (e) { alert("Approve failed: " + e.message); }
|
|
}
|
|
async function rearmIntake(orderNumber) {
|
|
if (!confirm("Re-arm the intake reminder for this customer's paid order(s)?\nThey'll be nudged again on the next daily run.")) return;
|
|
try {
|
|
const r = await api("/api/v1/admin/compliance-orders/" + encodeURIComponent(orderNumber) + "/rearm-intake", { method: "POST", body: "{}" });
|
|
alert(`Re-armed ${r.rearmed} order(s). They'll be reminded on the next daily run.`);
|
|
refresh();
|
|
} catch (e) { alert("Re-arm failed: " + e.message); }
|
|
}
|
|
|
|
async function openDetail(orderNumber) {
|
|
$("drawer").classList.remove("hidden");
|
|
$("drawer-title").textContent = orderNumber;
|
|
$("drawer-body").innerHTML = '<div class="muted">Loading…</div>';
|
|
try {
|
|
const { order, audit_log } = await api("/api/v1/admin/compliance-orders/" + encodeURIComponent(orderNumber));
|
|
const row = (k, v) => `<div class="drow"><span class="k">${esc(k)}</span><span class="v">${v}</span></div>`;
|
|
const intake = order.intake_data && typeof order.intake_data === "object" ? order.intake_data : {};
|
|
const auditHtml = (audit_log || []).map((a) =>
|
|
`<div class="drow" style="display:block;">
|
|
<div style="font-size:12px;"><b>${esc(a.action)}</b> ${a.from_status ? `<span class="small">${esc(a.from_status)} → ${esc(a.to_status)}</span>` : ""}</div>
|
|
<div class="small">${esc(a.actor_name || a.actor_type)} · ${fmtDate(a.created_at)}${a.note ? " · " + esc(a.note) : ""}</div>
|
|
</div>`).join("") || '<div class="muted" style="font-size:12px;">No audit entries.</div>';
|
|
$("drawer-body").innerHTML =
|
|
row("Service", esc(order.service_name || order.service_slug)) +
|
|
row("Customer", esc(order.customer_name) + "<br><span class='xs'>" + esc(order.customer_email) + "</span>") +
|
|
row("Payment", payBadge(order.payment_status) + (order.payment_method ? " " + esc(order.payment_method) : "")) +
|
|
row("Paid at", fmtDate(order.paid_at)) +
|
|
row("Fulfillment", fulfillBadge(order.fulfillment_status)) +
|
|
row("Intake validated", order.intake_data_validated ? "✓ yes" : "✗ no") +
|
|
row("Intake reminders", (order.intake_reminder_count || 0) + (order.intake_reminder_last_at ? " · last " + fmtDay(order.intake_reminder_last_at) : "")) +
|
|
row("ERPNext SO", esc(order.erpnext_sales_order || "—")) +
|
|
(order.batch_id ? row("Batch", esc(order.batch_id)) : "") +
|
|
row("Created", fmtDate(order.created_at)) +
|
|
(order.fulfillment_status === "ready_to_file"
|
|
? ((!order.intake_data_validated
|
|
? `<div style="margin-top:16px;padding:8px 12px;border:1px solid #fbbf24;background:#fffbeb;border-radius:6px;font-size:12px;color:#92400e;">⚠️ Intake is not complete — review documents before filing. Approving will require an override.</div>`
|
|
: "")
|
|
+ `<button id="drawer-approve" class="btn btn-blue wfull" style="margin-top:8px;">Approve & file this order</button>`)
|
|
: "") +
|
|
((order.payment_status === "paid" && !order.intake_data_validated) ? `<button id="drawer-rearm" class="btn btn-amber wfull" style="margin-top:8px;">Re-arm intake reminder</button>` : "") +
|
|
`<div class="section-h">Intake data</div><pre>${esc(JSON.stringify(intake, null, 2))}</pre>` +
|
|
`<div class="section-h">Documents</div><div id="drawer-docs"><div class="muted" style="font-size:12px;">Loading documents…</div></div>` +
|
|
`<div class="section-h">Audit log</div>${auditHtml}`;
|
|
const da = $("drawer-approve");
|
|
if (da) da.addEventListener("click", () => approveOrder(order.order_number, order.service_name || order.service_slug, !!order.intake_data_validated));
|
|
const dr = $("drawer-rearm");
|
|
if (dr) dr.addEventListener("click", () => rearmIntake(order.order_number));
|
|
loadDocuments(order.order_number);
|
|
} catch (e) { $("drawer-body").innerHTML = `<div style="color:#b91c1c;">Failed to load: ${esc(e.message)}</div>`; }
|
|
}
|
|
async function loadDocuments(orderNumber) {
|
|
const box = $("drawer-docs");
|
|
if (!box) return;
|
|
try {
|
|
const { documents } = await api("/api/v1/admin/compliance-orders/" + encodeURIComponent(orderNumber) + "/documents");
|
|
if (!documents || !documents.length) {
|
|
box.innerHTML = '<div class="muted" style="font-size:12px;">No documents on file. Admin-assisted services (e.g. UCR, MC authority) are filed manually and have no generated form; form-based filings appear here once intake is complete.</div>';
|
|
return;
|
|
}
|
|
// Stream endpoint takes the JWT via ?token= so it opens in a new tab.
|
|
const tok = encodeURIComponent(token());
|
|
box.innerHTML = documents.map((d) => {
|
|
const u = API + "/api/v1/admin/compliance-orders/" + encodeURIComponent(orderNumber)
|
|
+ "/document?key=" + encodeURIComponent(d.key) + "&token=" + tok;
|
|
// Render screenshots/images as an inline clickable thumbnail so you can
|
|
// see the filing confirmation at a glance; PDFs stay as a View link.
|
|
if (d.is_image) {
|
|
return `<div style="padding:8px 0;border-top:1px solid var(--gray100);">
|
|
<div class="svc-left" style="margin-bottom:6px;">
|
|
<span style="font-size:13px;">${esc(d.label)}</span>
|
|
<span class="small">${esc(d.key.split("/").pop())}</span>
|
|
</div>
|
|
<a href="${u}" target="_blank" rel="noopener" title="Open full size">
|
|
<img src="${u}" alt="${esc(d.label)}" loading="lazy"
|
|
style="max-width:100%;border:1px solid var(--gray200);border-radius:6px;display:block;" />
|
|
</a>
|
|
</div>`;
|
|
}
|
|
return `<div class="svc-row"><div class="svc-left">
|
|
<span style="font-size:13px;">${esc(d.label)}</span>
|
|
<span class="small">${esc(d.key.split("/").pop())}</span>
|
|
</div><div class="svc-right">
|
|
<a href="${u}" target="_blank" rel="noopener" class="btn btn-blue btn-sm" style="text-decoration:none;">View</a>
|
|
</div></div>`;
|
|
}).join("");
|
|
} catch (e) {
|
|
box.innerHTML = `<div style="color:#b91c1c;font-size:12px;">Could not load documents: ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
$("drawer-close").addEventListener("click", () => $("drawer").classList.add("hidden"));
|
|
$("drawer-backdrop").addEventListener("click", () => $("drawer").classList.add("hidden"));
|
|
|
|
function refresh() { loadStats(); loadOrders().catch(() => {}); }
|
|
$("refresh-btn").addEventListener("click", refresh);
|
|
$("apply-filters").addEventListener("click", () => loadOrders().catch(() => {}));
|
|
$("clear-filters").addEventListener("click", () => {
|
|
$("filter-q").value = ""; $("filter-payment").value = "";
|
|
$("filter-intake").value = ""; $("filter-fulfillment").value = "";
|
|
loadOrders().catch(() => {});
|
|
});
|
|
$("filter-q").addEventListener("keydown", (e) => { if (e.key === "Enter") loadOrders().catch(() => {}); });
|
|
|
|
if (token()) showDashboard(); else showLogin();
|
|
</script>
|
|
</body>
|
|
</html>
|