// --------------------------------------------------------------------------- // 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(null); const [err, setErr] = useState(""); const [busy, setBusy] = useState(false); const [tiers, setTiers] = useState(DEFAULT_TIERS); const [emailOnly, setEmailOnly] = useState(true); const [search, setSearch] = useState(""); const [picked, setPicked] = useState>(new Set()); const [tpl, setTpl] = useState(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(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

Loading the collection list…

; return (

Statements

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 {data?.sender.name} 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.

{data?.safeMode ? (
Testing guardrail is ON — 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 SAFE_MODE=0 to go live.
) : null} {err ?
{err}
: null} {data ? ( <>
{data.tiers.map((t) => ( ))} setSearch(e.currentTarget.value)} />

Data as of {data.loadedAt}. “Monitor” customers have open receivables but nothing overdue — usually excluded from monthly statements.

{showTpl && tpl ? (