| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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")}`; |
|
|
| |
| |
| 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]); |
|
|
| |
| |
| |
| |
| 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} <{data.sender.email}> · 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} |
|
|
| {} |
| {!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; |
|
|