admin: compliance-orders dashboard (view, approve-to-file, re-arm intake)
The admin SPA only managed formation_orders; compliance service orders (telecom/DOT/healthcare) had no admin surface, so you couldn't see what was paid, what was stuck on intake, or approve a prepared filing for submission. API (api/src/routes/admin.ts), all requireAdmin: GET /api/v1/admin/compliance-orders list, grouped by batch, filters GET /api/v1/admin/compliance-orders/stats queue overview counts GET /api/v1/admin/compliance-orders/:id full detail + audit log POST /api/v1/admin/compliance-orders/:id/approve approve ready_to_file + dispatch worker POST /api/v1/admin/compliance-orders/:id/rearm-intake clear reminder stamp so daily nudge resumes UI: new static page /admin/compliance-orders/ (self-contained, CSP-safe inline CSS, no external JS framework) reusing the existing pw_admin_token session. Cards group multi-service batches, flag paid+intake-incomplete in red, show reminder counts, and expose Approve & Re-arm buttons. Linked from the main /admin top bar. Every approve/re-arm writes an order_audit_log entry.
This commit is contained in:
parent
b48d0cb799
commit
2296566e85
3 changed files with 669 additions and 1 deletions
|
|
@ -301,4 +301,283 @@ router.get("/api/v1/admin/audit", requireAdmin, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// Compliance Orders (telecom / DOT / healthcare service fulfillment)
|
||||
//
|
||||
// The legacy admin SPA only manages formation_orders. Compliance service
|
||||
// orders (compliance_orders) had no admin surface at all -- you couldn't see
|
||||
// what was paid, what was stuck on intake, or approve a prepared filing for
|
||||
// submission. These endpoints back the /admin/compliance-orders page.
|
||||
// =====================================================================
|
||||
|
||||
/** GET /api/v1/admin/compliance-orders — list, grouped by batch, with filters. */
|
||||
router.get("/api/v1/admin/compliance-orders", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const payment = (req.query.payment as string) || "";
|
||||
const fulfillment = (req.query.fulfillment as string) || "";
|
||||
const intake = (req.query.intake as string) || ""; // 'incomplete' | 'complete'
|
||||
const q = ((req.query.q as string) || "").trim().toLowerCase();
|
||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 200, 500);
|
||||
|
||||
const where: string[] = ["1=1"];
|
||||
const params: any[] = [];
|
||||
let i = 1;
|
||||
|
||||
if (payment) { where.push(`co.payment_status = $${i++}`); params.push(payment); }
|
||||
if (fulfillment === "none") { where.push(`co.fulfillment_status IS NULL`); }
|
||||
else if (fulfillment) { where.push(`co.fulfillment_status = $${i++}`); params.push(fulfillment); }
|
||||
if (intake === "incomplete") { where.push(`COALESCE(co.intake_data_validated, FALSE) = FALSE`); }
|
||||
else if (intake === "complete") { where.push(`co.intake_data_validated = TRUE`); }
|
||||
if (q) {
|
||||
where.push(`(lower(co.customer_email) LIKE $${i} OR lower(co.customer_name) LIKE $${i} OR lower(co.order_number) LIKE $${i} OR lower(COALESCE(co.batch_id,'')) LIKE $${i})`);
|
||||
params.push(`%${q}%`); i++;
|
||||
}
|
||||
|
||||
params.push(limit);
|
||||
const { rows } = await pool.query(
|
||||
`SELECT co.order_number, co.batch_id, co.service_slug, co.service_name,
|
||||
co.customer_email, co.customer_name, co.customer_phone,
|
||||
co.payment_status, co.payment_method, co.paid_at,
|
||||
co.service_fee_cents, co.gov_fee_cents, co.surcharge_cents, co.discount_cents,
|
||||
co.fulfillment_status, co.fulfillment_status_at,
|
||||
COALESCE(co.intake_data_validated, FALSE) AS intake_data_validated,
|
||||
co.intake_reminder_count, co.intake_reminder_last_at,
|
||||
co.erpnext_sales_order, co.created_at
|
||||
FROM compliance_orders co
|
||||
WHERE ${where.join(" AND ")}
|
||||
ORDER BY co.created_at DESC
|
||||
LIMIT $${i}`,
|
||||
params,
|
||||
);
|
||||
|
||||
// Group multi-service batches (batch_id) into a single card; standalone
|
||||
// orders (no batch_id) become their own one-item group keyed by order_number.
|
||||
type Grp = {
|
||||
group_id: string;
|
||||
is_batch: boolean;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
customer_phone: string | null;
|
||||
payment_status: string;
|
||||
payment_method: string | null;
|
||||
paid_at: string | null;
|
||||
created_at: string;
|
||||
total_cents: number;
|
||||
intake_all_complete: boolean;
|
||||
intake_any_incomplete: boolean;
|
||||
max_reminder_count: number;
|
||||
last_reminded_at: string | null;
|
||||
services: any[];
|
||||
};
|
||||
const groups = new Map<string, Grp>();
|
||||
for (const r of rows as any[]) {
|
||||
const key = r.batch_id || r.order_number;
|
||||
let g = groups.get(key);
|
||||
if (!g) {
|
||||
g = {
|
||||
group_id: key,
|
||||
is_batch: !!r.batch_id,
|
||||
customer_name: r.customer_name,
|
||||
customer_email: r.customer_email,
|
||||
customer_phone: r.customer_phone,
|
||||
payment_status: r.payment_status,
|
||||
payment_method: r.payment_method,
|
||||
paid_at: r.paid_at,
|
||||
created_at: r.created_at,
|
||||
total_cents: 0,
|
||||
intake_all_complete: true,
|
||||
intake_any_incomplete: false,
|
||||
max_reminder_count: 0,
|
||||
last_reminded_at: null,
|
||||
services: [],
|
||||
};
|
||||
groups.set(key, g);
|
||||
}
|
||||
g.total_cents += Number(r.service_fee_cents || 0) + Number(r.gov_fee_cents || 0)
|
||||
+ Number(r.surcharge_cents || 0) - Number(r.discount_cents || 0);
|
||||
if (!r.intake_data_validated) { g.intake_all_complete = false; g.intake_any_incomplete = true; }
|
||||
g.max_reminder_count = Math.max(g.max_reminder_count, Number(r.intake_reminder_count || 0));
|
||||
if (r.intake_reminder_last_at && (!g.last_reminded_at || r.intake_reminder_last_at > g.last_reminded_at)) {
|
||||
g.last_reminded_at = r.intake_reminder_last_at;
|
||||
}
|
||||
g.services.push({
|
||||
order_number: r.order_number,
|
||||
service_slug: r.service_slug,
|
||||
service_name: r.service_name || r.service_slug,
|
||||
fulfillment_status: r.fulfillment_status,
|
||||
fulfillment_status_at: r.fulfillment_status_at,
|
||||
intake_data_validated: r.intake_data_validated,
|
||||
erpnext_sales_order: r.erpnext_sales_order,
|
||||
ready_to_approve: r.fulfillment_status === "ready_to_file",
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ groups: Array.from(groups.values()) });
|
||||
} catch (err) {
|
||||
console.error("[admin/compliance-orders] Error:", err);
|
||||
res.status(500).json({ error: "Could not load compliance orders." });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/v1/admin/compliance-orders/stats — queue overview counts. */
|
||||
router.get("/api/v1/admin/compliance-orders/stats", requireAdmin, async (_req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(`
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE payment_status = 'paid') AS paid,
|
||||
COUNT(*) FILTER (WHERE payment_status = 'pending_payment') AS pending_payment,
|
||||
COUNT(*) FILTER (WHERE payment_status = 'paid' AND COALESCE(intake_data_validated, FALSE) = FALSE) AS paid_intake_incomplete,
|
||||
COUNT(*) FILTER (WHERE fulfillment_status = 'ready_to_file') AS ready_to_file,
|
||||
COUNT(*) FILTER (WHERE fulfillment_status = 'awaiting_intake') AS awaiting_intake,
|
||||
COUNT(*) FILTER (WHERE fulfillment_status = 'completed') AS completed,
|
||||
COUNT(*) AS total
|
||||
FROM compliance_orders
|
||||
`);
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error("[admin/compliance-orders/stats] Error:", err);
|
||||
res.status(500).json({ error: "Could not load stats." });
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/v1/admin/compliance-orders/:order_number — single order full detail. */
|
||||
router.get("/api/v1/admin/compliance-orders/:order_number", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT co.*, te.legal_name AS entity_name, te.frn AS entity_frn
|
||||
FROM compliance_orders co
|
||||
LEFT JOIN telecom_entities te ON te.id = co.telecom_entity_id
|
||||
WHERE co.order_number = $1`,
|
||||
[req.params.order_number],
|
||||
);
|
||||
if (rows.length === 0) { res.status(404).json({ error: "Order not found." }); return; }
|
||||
const order = rows[0];
|
||||
const audit = await pool.query(
|
||||
`SELECT * FROM order_audit_log
|
||||
WHERE order_number = $1 AND order_type IN ('compliance', 'compliance_batch')
|
||||
ORDER BY created_at DESC`,
|
||||
[order.order_number],
|
||||
);
|
||||
res.json({ order, audit_log: audit.rows });
|
||||
} catch (err) {
|
||||
console.error("[admin/compliance-orders/:id] Error:", err);
|
||||
res.status(500).json({ error: "Could not load order." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/compliance-orders/:order_number/approve
|
||||
* Approve a prepared filing held at fulfillment_status='ready_to_file' and
|
||||
* dispatch the worker to actually submit it to the government system.
|
||||
*/
|
||||
router.post("/api/v1/admin/compliance-orders/:order_number/approve", requireAdmin, async (req, res) => {
|
||||
const id = req.params.order_number;
|
||||
try {
|
||||
const { rows } = await pool.query(
|
||||
`SELECT order_number, service_slug, fulfillment_status
|
||||
FROM compliance_orders WHERE order_number = $1`,
|
||||
[id],
|
||||
);
|
||||
const order = rows[0];
|
||||
if (!order) { res.status(404).json({ error: "Order not found." }); return; }
|
||||
if (order.fulfillment_status !== "ready_to_file") {
|
||||
res.status(409).json({
|
||||
error: `Order is not awaiting submission approval (status=${order.fulfillment_status ?? "none"}).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
`UPDATE compliance_orders
|
||||
SET fulfillment_status = 'authorization_signed', fulfillment_status_at = now(), updated_at = now()
|
||||
WHERE order_number = $1`,
|
||||
[id],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, from_status, to_status, actor_type, actor_id, actor_name, note)
|
||||
VALUES ('compliance', 0, $1, 'approved_for_submission', 'ready_to_file', 'authorization_signed', 'admin', $2, $3, $4)`,
|
||||
[id, req.admin!.id, req.admin!.username, (req.body?.note as string) || "Approved + dispatched for government submission"],
|
||||
);
|
||||
|
||||
const workerUrl = process.env.WORKER_URL || "http://workers:8090";
|
||||
let dispatched = false;
|
||||
try {
|
||||
const r = await fetch(`${workerUrl}/jobs`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
action: "process_compliance_service",
|
||||
order_name: id,
|
||||
order_number: id,
|
||||
service_slug: order.service_slug,
|
||||
admin_approved: true,
|
||||
}),
|
||||
});
|
||||
dispatched = r.ok;
|
||||
} catch (err) {
|
||||
console.error(`[admin/compliance-orders] approve dispatch failed for ${id}:`, err);
|
||||
}
|
||||
res.json({ success: true, order_number: id, dispatched });
|
||||
} catch (err) {
|
||||
console.error(`[admin/compliance-orders] approve error for ${id}:`, err);
|
||||
res.status(500).json({ error: "Approve failed." });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/v1/admin/compliance-orders/:order_number/rearm-intake
|
||||
* Re-arm the daily intake reminder for a paid+incomplete order whose reminders
|
||||
* went quiet (clears intake_reminder_last_at so the next daily run nudges it).
|
||||
* Optionally resets the count back to 0 with { reset_count: true }.
|
||||
*/
|
||||
router.post("/api/v1/admin/compliance-orders/:order_number/rearm-intake", requireAdmin, async (req, res) => {
|
||||
const id = req.params.order_number;
|
||||
try {
|
||||
const resetCount = req.body?.reset_count === true;
|
||||
const { rows } = await pool.query(
|
||||
`SELECT order_number, batch_id, payment_status,
|
||||
COALESCE(intake_data_validated, FALSE) AS intake_data_validated
|
||||
FROM compliance_orders WHERE order_number = $1`,
|
||||
[id],
|
||||
);
|
||||
const order = rows[0];
|
||||
if (!order) { res.status(404).json({ error: "Order not found." }); return; }
|
||||
if (order.payment_status !== "paid") {
|
||||
res.status(409).json({ error: "Only paid orders can be re-armed." });
|
||||
return;
|
||||
}
|
||||
if (order.intake_data_validated) {
|
||||
res.status(409).json({ error: "Intake is already complete for this order." });
|
||||
return;
|
||||
}
|
||||
|
||||
// Re-arm the whole batch when this order belongs to one, so a multi-service
|
||||
// customer gets a single consolidated nudge (matches the worker grouping).
|
||||
const filter = order.batch_id
|
||||
? { clause: "batch_id = $1", val: order.batch_id }
|
||||
: { clause: "order_number = $1", val: id };
|
||||
const updated = await pool.query(
|
||||
`UPDATE compliance_orders
|
||||
SET intake_reminder_last_at = NULL
|
||||
${resetCount ? ", intake_reminder_count = 0" : ""},
|
||||
updated_at = now()
|
||||
WHERE ${filter.clause}
|
||||
AND payment_status = 'paid'
|
||||
AND COALESCE(intake_data_validated, FALSE) = FALSE
|
||||
RETURNING order_number`,
|
||||
[filter.val],
|
||||
);
|
||||
await pool.query(
|
||||
`INSERT INTO order_audit_log (order_type, order_id, order_number, action, actor_type, actor_id, actor_name, note)
|
||||
VALUES ('compliance', 0, $1, 'intake_reminder_rearmed', 'admin', $2, $3, $4)`,
|
||||
[id, req.admin!.id, req.admin!.username,
|
||||
`Re-armed intake reminder for ${updated.rowCount} order(s)${resetCount ? " (count reset to 0)" : ""}`],
|
||||
);
|
||||
res.json({ success: true, rearmed: updated.rowCount });
|
||||
} catch (err) {
|
||||
console.error(`[admin/compliance-orders] rearm-intake error for ${id}:`, err);
|
||||
res.status(500).json({ error: "Re-arm failed." });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
|||
389
site/public/admin/compliance-orders/index.html
Normal file
389
site/public/admin/compliance-orders/index.html
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
<!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)}" 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"))));
|
||||
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) {
|
||||
if (!confirm(`Approve "${svc}" (${orderNumber}) and dispatch it for government submission?\n\nThis cannot be undone.`)) return;
|
||||
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.");
|
||||
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" ? `<button id="drawer-approve" class="btn btn-blue wfull" style="margin-top:16px;">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">Audit log</div>${auditHtml}`;
|
||||
const da = $("drawer-approve");
|
||||
if (da) da.addEventListener("click", () => approveOrder(order.order_number, order.service_name || order.service_slug));
|
||||
const dr = $("drawer-rearm");
|
||||
if (dr) dr.addEventListener("click", () => rearmIntake(order.order_number));
|
||||
} catch (e) { $("drawer-body").innerHTML = `<div style="color:#b91c1c;">Failed to load: ${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>
|
||||
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue