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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue