loopable / web /src /settings /StatementsPane.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
bf8519f verified
Raw
History Blame Contribute Delete
16.4 kB
// ---------------------------------------------------------------------------
// settings / StatementsPane.tsx — the statement-of-account sender (EXIT-6).
//
// Ported off `app.py::_collections_statements` when Streamlit was deleted. Owner
// ruling wave-17 item 15 retired the Collections DASHBOARD (it is the shared
// "Collections" view on the Customer grid) but explicitly left this workflow
// untouched — so it is the last live surface that had nowhere else to go.
//
// ⛔ THIS IS THE ONE SANCTIONED ODOO WRITER. Queueing these emails is the only
// write the product performs, so the original's shape is preserved deliberately
// rather than modernised: review → tick → preview → test → CONFIRM → send. The
// confirm step is a product requirement, not a formality, and the send button is
// disabled while SAFE_MODE is on exactly as the Streamlit page had it.
//
// ⚠ THE GUARDRAIL IS THE SERVER'S. Everything this file does with `safeMode` is
// presentation: disabling a control and explaining why. `collections_send`
// refuses an out-of-allow-list recipient in the data layer, which is what makes
// the guarantee real. Never read the disabled button as the enforcement.
// ---------------------------------------------------------------------------
import { useCallback, useEffect, useMemo, useState } from "react";
import {
loadStatements,
previewStatement,
sendStatements,
type SendOutcome,
type StatementRow,
type StatementTemplates,
type StatementsPayload,
} from "./statementsApi";
const money = (n: number) =>
`$${Math.round(Number(n) || 0).toLocaleString("en-US")}`;
/** The tiers the original defaulted ON. "Monitor" = open receivable but nothing
* overdue, and the Streamlit page excluded it by default for that reason. */
const DEFAULT_TIERS = ["A-Urgent", "B-Active", "C-Light"];
export function StatementsPane() {
const [data, setData] = useState<StatementsPayload | null>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
const [tiers, setTiers] = useState<string[]>(DEFAULT_TIERS);
const [emailOnly, setEmailOnly] = useState(true);
const [search, setSearch] = useState("");
const [picked, setPicked] = useState<Set<string>>(new Set());
const [tpl, setTpl] = useState<StatementTemplates | null>(null);
const [showTpl, setShowTpl] = useState(false);
const [previewOf, setPreviewOf] = useState("");
const [preview, setPreview] = useState<{ html: string; to: string; subject: string } | null>(null);
const [testTo, setTestTo] = useState("");
const [confirming, setConfirming] = useState(false);
const [result, setResult] = useState<SendOutcome | null>(null);
const reload = useCallback(async (refresh = false) => {
setBusy(true);
const r = await loadStatements(refresh);
setBusy(false);
if (!r.ok) { setErr(r.message); return; }
setErr("");
setData(r.data);
setTpl((t) => t ?? r.data.templates);
setTestTo((v) => v || (r.data.safeMode ? (r.data.safeRecipients[0] ?? "") : ""));
}, []);
useEffect(() => { void reload(false); }, [reload]);
const view = useMemo(() => {
if (!data) return [] as StatementRow[];
const s = search.trim().toLowerCase();
return data.rows.filter(
(r) =>
tiers.includes(r.Tier) &&
(!emailOnly || !!r.Email) &&
(!s || r.Customer.toLowerCase().includes(s)),
);
}, [data, tiers, emailOnly, search]);
// The selection follows the FILTER: a customer ticked and then filtered out
// must not ride along invisibly into a send. Intersecting here (rather than
// pruning on every filter change) keeps that true without fighting the user's
// ticks when they widen the filter again.
const selected = useMemo(
() => view.filter((r) => picked.has(r.Customer)),
[view, picked],
);
const sendable = useMemo(() => selected.filter((r) => r.Email), [selected]);
const noEmail = useMemo(() => selected.filter((r) => !r.Email), [selected]);
const previewRow = previewOf || selected[0]?.Customer || view[0]?.Customer || "";
useEffect(() => {
if (!previewRow || !tpl) { setPreview(null); return; }
let live = true;
void previewStatement(previewRow, tpl).then((r) => {
if (live) setPreview(r.ok ? r.data : null);
});
return () => { live = false; };
}, [previewRow, tpl]);
const toggle = (name: string) =>
setPicked((p) => {
const n = new Set(p);
if (n.has(name)) n.delete(name); else n.add(name);
return n;
});
const doSend = async (customers: string[], overrideTo?: string) => {
if (!tpl || !customers.length) return;
setBusy(true);
const r = await sendStatements(customers, tpl, overrideTo);
setBusy(false);
setConfirming(false);
if (!r.ok) { setErr(r.message); return; }
setErr("");
setResult(r.data);
if (!overrideTo) setPicked(new Set());
};
if (!data && !err) return <p className="set-help">Loading the collection list…</p>;
return (
<div className="set-pane">
<h3 className="set-h">Statements</h3>
<p className="set-pane-intro set-help">
Queue per-customer statement-of-account emails. The list is the Odoo follow-up filter
(Reminders = Automatic, receivable over $1, GIFTWARE excluded), tiered by urgency.
Statements send as <b>{data?.sender.name}</b> through the Odoo mail queue (Office 365
relay, ~15 min); every send is logged on the customer chatter in Odoo. Queueing these
emails is the only write this app can perform, and nothing sends without the confirm step.
</p>
{data?.safeMode ? (
<div className="stmt-warn">
<b>Testing guardrail is ON</b> — email can only go to{" "}
{data.safeRecipients.join(", ")}. Bulk send to customers is disabled. This is enforced
in the data layer, not by this screen; set the Space secret <code>SAFE_MODE=0</code> to
go live.
</div>
) : null}
{err ? <div className="set-error">{err}</div> : null}
{data ? (
<>
<div className="stmt-row">
{data.tiers.map((t) => (
<label key={t} className="set-chip">
<input
type="checkbox"
checked={tiers.includes(t)}
onChange={() =>
setTiers((cur) =>
cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t],
)
}
/>
{t}
</label>
))}
<label className="set-chip">
<input
type="checkbox"
checked={emailOnly}
onChange={(e) => setEmailOnly(e.currentTarget.checked)}
/>
Has email only
</label>
<input
className="set-input"
placeholder="Search customer"
value={search}
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<button className="set-secondary" disabled={busy} onClick={() => void reload(true)}>
Refresh from Odoo
</button>
</div>
<p className="set-help stmt-small">
Data as of {data.loadedAt}. “Monitor” customers have open receivables but nothing
overdue — usually excluded from monthly statements.
</p>
<button className="stmt-link" onClick={() => setShowTpl((v) => !v)}>
{showTpl ? "Hide" : "Show"} statement template (applies to every send below)
</button>
{showTpl && tpl ? (
<div className="stmt-stack">
<label className="set-label">
Subject <span className="set-help">— placeholders: {"{customer} {company} {month}"}</span>
<input
className="set-input stmt-wide"
value={tpl.subject}
onChange={(e) => setTpl({ ...tpl, subject: e.currentTarget.value })}
/>
</label>
<label className="set-label">
Intro (HTML ok)
<textarea
className="stmt-textarea"
rows={4}
value={tpl.intro}
onChange={(e) => setTpl({ ...tpl, intro: e.currentTarget.value })}
/>
</label>
<label className="set-label">
Footer (HTML ok)
<textarea
className="stmt-textarea"
rows={4}
value={tpl.footer}
onChange={(e) => setTpl({ ...tpl, footer: e.currentTarget.value })}
/>
</label>
</div>
) : null}
<div className="stmt-tablewrap">
<table className="stmt-table">
<thead>
<tr>
<th style={{ width: 44 }}>Send</th>
<th>Customer</th>
<th>Email</th>
<th>Tier</th>
<th className="stmt-num">Overdue</th>
<th className="stmt-num">Receivable</th>
</tr>
</thead>
<tbody>
{view.map((r) => (
<tr key={r.Customer} className={picked.has(r.Customer) ? "is-picked" : undefined}>
<td>
<input
type="checkbox"
aria-label={`Send to ${r.Customer}`}
checked={picked.has(r.Customer)}
onChange={() => toggle(r.Customer)}
/>
</td>
<td>{r.Customer}</td>
<td className={r.Email ? undefined : "set-muted"}>{r.Email || "(no email)"}</td>
<td>{r.Tier}</td>
<td className="stmt-num">{money(r.Overdue)}</td>
<td className="stmt-num">{money(r.Receivable)}</td>
</tr>
))}
</tbody>
</table>
{/* Owner rule [[no-unverifiable-aggregates]]: state the denominator, always. */}
<p className="set-help stmt-small">
Showing {view.length} of {data.rows.length} customers · {selected.length} selected ·
combined overdue {money(selected.reduce((a, r) => a + (Number(r.Overdue) || 0), 0))}.
</p>
</div>
{view.length ? (
<div className="stmt-stack">
<label className="set-label">
Preview customer
<select
className="set-input"
value={previewRow}
onChange={(e) => setPreviewOf(e.currentTarget.value)}
>
{(selected.length ? selected : view).map((r) => (
<option key={r.Customer} value={r.Customer}>{r.Customer}</option>
))}
</select>
</label>
{preview ? (
<>
<p className="set-help stmt-small">
From: {data.sender.name} &lt;{data.sender.email}&gt; · reply-to{" "}
{data.sender.replyTo} · To: {preview.to || "(no email)"} · Subject:{" "}
{preview.subject}
</p>
<details className="stmt-details">
<summary>Preview statement</summary>
{/* Server-rendered from our own template + our own Odoo rows — the same
HTML the send path posts. */}
<div
className="stmt-preview"
dangerouslySetInnerHTML={{ __html: preview.html }}
/>
</details>
</>
) : null}
<div className="stmt-row">
<label className="set-label">
Test address (sends the preview customer’s statement here)
<input
className="set-input stmt-wide"
value={testTo}
disabled={data.safeMode}
title={data.safeMode ? "Locked to the guardrail address while testing." : undefined}
onChange={(e) => setTestTo(e.currentTarget.value)}
/>
</label>
<button
className="set-secondary"
disabled={busy || !previewRow || !testTo.includes("@")}
onClick={() => void doSend([previewRow], testTo)}
>
Send test email
</button>
</div>
</div>
) : (
<p className="set-help">No customers match the current filters.</p>
)}
{noEmail.length ? (
<div className="stmt-warn">
{noEmail.length} selected {noEmail.length === 1 ? "customer has" : "customers have"} no
email and will be skipped: {noEmail.slice(0, 5).map((r) => r.Customer).join(", ")}
{noEmail.length > 5 ? "…" : ""}
</div>
) : null}
{result ? (
<div className={result.failed.length ? "set-warn" : "set-ok"}>
<b>
{result.sent.length} statement{result.sent.length === 1 ? "" : "s"} queued
{result.test ? " (test)" : ""} in Odoo
</b>{" "}
— delivery within ~15 min via the Office 365 relay; each send is logged on the
customer record.
{result.skipped.length ? (
<div>Skipped: {result.skipped.map((s) => `${s.customer} (${s.reason})`).join(" · ")}</div>
) : null}
{result.failed.length ? (
<div>Failed: {result.failed.map((f) => `${f.customer}: ${f.error}`).join(" | ")}</div>
) : null}
</div>
) : null}
{/* ── the confirm flow ─────────────────────────────────────────── */}
{!confirming ? (
<button
className="set-primary"
disabled={busy || !sendable.length || data.safeMode}
title={data.safeMode ? "Disabled while the testing guardrail is on." : undefined}
onClick={() => { setResult(null); setConfirming(true); }}
>
Send {sendable.length} statement{sendable.length === 1 ? "" : "s"}…
</button>
) : (
<div className="stmt-confirm">
<div className="stmt-warn">
About to queue <b>{sendable.length}</b> statements as {data.sender.name} (
{data.sender.email}), replies to {data.sender.replyTo} — combined overdue{" "}
{money(sendable.reduce((a, r) => a + (Number(r.Overdue) || 0), 0))}.{" "}
<b>This cannot be undone from here.</b>
</div>
<div className="stmt-tablewrap stmt-tablewrap--short">
<table className="stmt-table">
<thead>
<tr><th>Customer</th><th>Email</th><th className="stmt-num">Overdue</th></tr>
</thead>
<tbody>
{sendable.map((r) => (
<tr key={r.Customer}>
<td>{r.Customer}</td>
<td>{r.Email}</td>
<td className="stmt-num">{money(r.Overdue)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="stmt-row">
<button
className="set-primary"
disabled={busy}
onClick={() => void doSend(sendable.map((r) => r.Customer))}
>
{busy ? "Queueing…" : "Confirm and send"}
</button>
<button className="set-secondary" disabled={busy} onClick={() => setConfirming(false)}>
Cancel
</button>
</div>
</div>
)}
</>
) : null}
</div>
);
}
export default StatementsPane;