Prune files not in the deploy set (stale build context)
Browse files
web/src/settings/StatementsPane.tsx
DELETED
|
@@ -1,392 +0,0 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// settings / StatementsPane.tsx — the statement-of-account sender (EXIT-6).
|
| 3 |
-
//
|
| 4 |
-
// Ported off `app.py::_collections_statements` when Streamlit was deleted. Owner
|
| 5 |
-
// ruling wave-17 item 15 retired the Collections DASHBOARD (it is the shared
|
| 6 |
-
// "Collections" view on the Customer grid) but explicitly left this workflow
|
| 7 |
-
// untouched — so it is the last live surface that had nowhere else to go.
|
| 8 |
-
//
|
| 9 |
-
// ⛔ THIS IS THE ONE SANCTIONED ODOO WRITER. Queueing these emails is the only
|
| 10 |
-
// write the product performs, so the original's shape is preserved deliberately
|
| 11 |
-
// rather than modernised: review → tick → preview → test → CONFIRM → send. The
|
| 12 |
-
// confirm step is a product requirement, not a formality, and the send button is
|
| 13 |
-
// disabled while SAFE_MODE is on exactly as the Streamlit page had it.
|
| 14 |
-
//
|
| 15 |
-
// ⚠ THE GUARDRAIL IS THE SERVER'S. Everything this file does with `safeMode` is
|
| 16 |
-
// presentation: disabling a control and explaining why. `collections_send`
|
| 17 |
-
// refuses an out-of-allow-list recipient in the data layer, which is what makes
|
| 18 |
-
// the guarantee real. Never read the disabled button as the enforcement.
|
| 19 |
-
// ---------------------------------------------------------------------------
|
| 20 |
-
import { useCallback, useEffect, useMemo, useState } from "react";
|
| 21 |
-
|
| 22 |
-
import {
|
| 23 |
-
loadStatements,
|
| 24 |
-
previewStatement,
|
| 25 |
-
sendStatements,
|
| 26 |
-
type SendOutcome,
|
| 27 |
-
type StatementRow,
|
| 28 |
-
type StatementTemplates,
|
| 29 |
-
type StatementsPayload,
|
| 30 |
-
} from "./statementsApi";
|
| 31 |
-
|
| 32 |
-
const money = (n: number) =>
|
| 33 |
-
`$${Math.round(Number(n) || 0).toLocaleString("en-US")}`;
|
| 34 |
-
|
| 35 |
-
/** The tiers the original defaulted ON. "Monitor" = open receivable but nothing
|
| 36 |
-
* overdue, and the Streamlit page excluded it by default for that reason. */
|
| 37 |
-
const DEFAULT_TIERS = ["A-Urgent", "B-Active", "C-Light"];
|
| 38 |
-
|
| 39 |
-
export function StatementsPane() {
|
| 40 |
-
const [data, setData] = useState<StatementsPayload | null>(null);
|
| 41 |
-
const [err, setErr] = useState("");
|
| 42 |
-
const [busy, setBusy] = useState(false);
|
| 43 |
-
const [tiers, setTiers] = useState<string[]>(DEFAULT_TIERS);
|
| 44 |
-
const [emailOnly, setEmailOnly] = useState(true);
|
| 45 |
-
const [search, setSearch] = useState("");
|
| 46 |
-
const [picked, setPicked] = useState<Set<string>>(new Set());
|
| 47 |
-
const [tpl, setTpl] = useState<StatementTemplates | null>(null);
|
| 48 |
-
const [showTpl, setShowTpl] = useState(false);
|
| 49 |
-
const [previewOf, setPreviewOf] = useState("");
|
| 50 |
-
const [preview, setPreview] = useState<{ html: string; to: string; subject: string } | null>(null);
|
| 51 |
-
const [testTo, setTestTo] = useState("");
|
| 52 |
-
const [confirming, setConfirming] = useState(false);
|
| 53 |
-
const [result, setResult] = useState<SendOutcome | null>(null);
|
| 54 |
-
|
| 55 |
-
const reload = useCallback(async (refresh = false) => {
|
| 56 |
-
setBusy(true);
|
| 57 |
-
const r = await loadStatements(refresh);
|
| 58 |
-
setBusy(false);
|
| 59 |
-
if (!r.ok) { setErr(r.message); return; }
|
| 60 |
-
setErr("");
|
| 61 |
-
setData(r.data);
|
| 62 |
-
setTpl((t) => t ?? r.data.templates);
|
| 63 |
-
setTestTo((v) => v || (r.data.safeMode ? (r.data.safeRecipients[0] ?? "") : ""));
|
| 64 |
-
}, []);
|
| 65 |
-
|
| 66 |
-
useEffect(() => { void reload(false); }, [reload]);
|
| 67 |
-
|
| 68 |
-
const view = useMemo(() => {
|
| 69 |
-
if (!data) return [] as StatementRow[];
|
| 70 |
-
const s = search.trim().toLowerCase();
|
| 71 |
-
return data.rows.filter(
|
| 72 |
-
(r) =>
|
| 73 |
-
tiers.includes(r.Tier) &&
|
| 74 |
-
(!emailOnly || !!r.Email) &&
|
| 75 |
-
(!s || r.Customer.toLowerCase().includes(s)),
|
| 76 |
-
);
|
| 77 |
-
}, [data, tiers, emailOnly, search]);
|
| 78 |
-
|
| 79 |
-
// The selection follows the FILTER: a customer ticked and then filtered out
|
| 80 |
-
// must not ride along invisibly into a send. Intersecting here (rather than
|
| 81 |
-
// pruning on every filter change) keeps that true without fighting the user's
|
| 82 |
-
// ticks when they widen the filter again.
|
| 83 |
-
const selected = useMemo(
|
| 84 |
-
() => view.filter((r) => picked.has(r.Customer)),
|
| 85 |
-
[view, picked],
|
| 86 |
-
);
|
| 87 |
-
const sendable = useMemo(() => selected.filter((r) => r.Email), [selected]);
|
| 88 |
-
const noEmail = useMemo(() => selected.filter((r) => !r.Email), [selected]);
|
| 89 |
-
|
| 90 |
-
const previewRow = previewOf || selected[0]?.Customer || view[0]?.Customer || "";
|
| 91 |
-
|
| 92 |
-
useEffect(() => {
|
| 93 |
-
if (!previewRow || !tpl) { setPreview(null); return; }
|
| 94 |
-
let live = true;
|
| 95 |
-
void previewStatement(previewRow, tpl).then((r) => {
|
| 96 |
-
if (live) setPreview(r.ok ? r.data : null);
|
| 97 |
-
});
|
| 98 |
-
return () => { live = false; };
|
| 99 |
-
}, [previewRow, tpl]);
|
| 100 |
-
|
| 101 |
-
const toggle = (name: string) =>
|
| 102 |
-
setPicked((p) => {
|
| 103 |
-
const n = new Set(p);
|
| 104 |
-
if (n.has(name)) n.delete(name); else n.add(name);
|
| 105 |
-
return n;
|
| 106 |
-
});
|
| 107 |
-
|
| 108 |
-
const doSend = async (customers: string[], overrideTo?: string) => {
|
| 109 |
-
if (!tpl || !customers.length) return;
|
| 110 |
-
setBusy(true);
|
| 111 |
-
const r = await sendStatements(customers, tpl, overrideTo);
|
| 112 |
-
setBusy(false);
|
| 113 |
-
setConfirming(false);
|
| 114 |
-
if (!r.ok) { setErr(r.message); return; }
|
| 115 |
-
setErr("");
|
| 116 |
-
setResult(r.data);
|
| 117 |
-
if (!overrideTo) setPicked(new Set());
|
| 118 |
-
};
|
| 119 |
-
|
| 120 |
-
if (!data && !err) return <p className="set-help">Loading the collection list…</p>;
|
| 121 |
-
|
| 122 |
-
return (
|
| 123 |
-
<div className="set-pane">
|
| 124 |
-
<h3 className="set-h">Statements</h3>
|
| 125 |
-
<p className="set-pane-intro set-help">
|
| 126 |
-
Queue per-customer statement-of-account emails. The list is the Odoo follow-up filter
|
| 127 |
-
(Reminders = Automatic, receivable over $1, GIFTWARE excluded), tiered by urgency.
|
| 128 |
-
Statements send as <b>{data?.sender.name}</b> through the Odoo mail queue (Office 365
|
| 129 |
-
relay, ~15 min); every send is logged on the customer chatter in Odoo. Queueing these
|
| 130 |
-
emails is the only write this app can perform, and nothing sends without the confirm step.
|
| 131 |
-
</p>
|
| 132 |
-
|
| 133 |
-
{data?.safeMode ? (
|
| 134 |
-
<div className="stmt-warn">
|
| 135 |
-
<b>Testing guardrail is ON</b> — email can only go to{" "}
|
| 136 |
-
{data.safeRecipients.join(", ")}. Bulk send to customers is disabled. This is enforced
|
| 137 |
-
in the data layer, not by this screen; set the Space secret <code>SAFE_MODE=0</code> to
|
| 138 |
-
go live.
|
| 139 |
-
</div>
|
| 140 |
-
) : null}
|
| 141 |
-
{err ? <div className="set-error">{err}</div> : null}
|
| 142 |
-
|
| 143 |
-
{data ? (
|
| 144 |
-
<>
|
| 145 |
-
<div className="stmt-row">
|
| 146 |
-
{data.tiers.map((t) => (
|
| 147 |
-
<label key={t} className="set-chip">
|
| 148 |
-
<input
|
| 149 |
-
type="checkbox"
|
| 150 |
-
checked={tiers.includes(t)}
|
| 151 |
-
onChange={() =>
|
| 152 |
-
setTiers((cur) =>
|
| 153 |
-
cur.includes(t) ? cur.filter((x) => x !== t) : [...cur, t],
|
| 154 |
-
)
|
| 155 |
-
}
|
| 156 |
-
/>
|
| 157 |
-
{t}
|
| 158 |
-
</label>
|
| 159 |
-
))}
|
| 160 |
-
<label className="set-chip">
|
| 161 |
-
<input
|
| 162 |
-
type="checkbox"
|
| 163 |
-
checked={emailOnly}
|
| 164 |
-
onChange={(e) => setEmailOnly(e.currentTarget.checked)}
|
| 165 |
-
/>
|
| 166 |
-
Has email only
|
| 167 |
-
</label>
|
| 168 |
-
<input
|
| 169 |
-
className="set-input"
|
| 170 |
-
placeholder="Search customer"
|
| 171 |
-
value={search}
|
| 172 |
-
onChange={(e) => setSearch(e.currentTarget.value)}
|
| 173 |
-
/>
|
| 174 |
-
<button className="set-secondary" disabled={busy} onClick={() => void reload(true)}>
|
| 175 |
-
Refresh from Odoo
|
| 176 |
-
</button>
|
| 177 |
-
</div>
|
| 178 |
-
<p className="set-help stmt-small">
|
| 179 |
-
Data as of {data.loadedAt}. “Monitor” customers have open receivables but nothing
|
| 180 |
-
overdue — usually excluded from monthly statements.
|
| 181 |
-
</p>
|
| 182 |
-
|
| 183 |
-
<button className="stmt-link" onClick={() => setShowTpl((v) => !v)}>
|
| 184 |
-
{showTpl ? "Hide" : "Show"} statement template (applies to every send below)
|
| 185 |
-
</button>
|
| 186 |
-
{showTpl && tpl ? (
|
| 187 |
-
<div className="stmt-stack">
|
| 188 |
-
<label className="set-label">
|
| 189 |
-
Subject <span className="set-help">— placeholders: {"{customer} {company} {month}"}</span>
|
| 190 |
-
<input
|
| 191 |
-
className="set-input stmt-wide"
|
| 192 |
-
value={tpl.subject}
|
| 193 |
-
onChange={(e) => setTpl({ ...tpl, subject: e.currentTarget.value })}
|
| 194 |
-
/>
|
| 195 |
-
</label>
|
| 196 |
-
<label className="set-label">
|
| 197 |
-
Intro (HTML ok)
|
| 198 |
-
<textarea
|
| 199 |
-
className="stmt-textarea"
|
| 200 |
-
rows={4}
|
| 201 |
-
value={tpl.intro}
|
| 202 |
-
onChange={(e) => setTpl({ ...tpl, intro: e.currentTarget.value })}
|
| 203 |
-
/>
|
| 204 |
-
</label>
|
| 205 |
-
<label className="set-label">
|
| 206 |
-
Footer (HTML ok)
|
| 207 |
-
<textarea
|
| 208 |
-
className="stmt-textarea"
|
| 209 |
-
rows={4}
|
| 210 |
-
value={tpl.footer}
|
| 211 |
-
onChange={(e) => setTpl({ ...tpl, footer: e.currentTarget.value })}
|
| 212 |
-
/>
|
| 213 |
-
</label>
|
| 214 |
-
</div>
|
| 215 |
-
) : null}
|
| 216 |
-
|
| 217 |
-
<div className="stmt-tablewrap">
|
| 218 |
-
<table className="stmt-table">
|
| 219 |
-
<thead>
|
| 220 |
-
<tr>
|
| 221 |
-
<th style={{ width: 44 }}>Send</th>
|
| 222 |
-
<th>Customer</th>
|
| 223 |
-
<th>Email</th>
|
| 224 |
-
<th>Tier</th>
|
| 225 |
-
<th className="stmt-num">Overdue</th>
|
| 226 |
-
<th className="stmt-num">Receivable</th>
|
| 227 |
-
</tr>
|
| 228 |
-
</thead>
|
| 229 |
-
<tbody>
|
| 230 |
-
{view.map((r) => (
|
| 231 |
-
<tr key={r.Customer} className={picked.has(r.Customer) ? "is-picked" : undefined}>
|
| 232 |
-
<td>
|
| 233 |
-
<input
|
| 234 |
-
type="checkbox"
|
| 235 |
-
aria-label={`Send to ${r.Customer}`}
|
| 236 |
-
checked={picked.has(r.Customer)}
|
| 237 |
-
onChange={() => toggle(r.Customer)}
|
| 238 |
-
/>
|
| 239 |
-
</td>
|
| 240 |
-
<td>{r.Customer}</td>
|
| 241 |
-
<td className={r.Email ? undefined : "set-muted"}>{r.Email || "(no email)"}</td>
|
| 242 |
-
<td>{r.Tier}</td>
|
| 243 |
-
<td className="stmt-num">{money(r.Overdue)}</td>
|
| 244 |
-
<td className="stmt-num">{money(r.Receivable)}</td>
|
| 245 |
-
</tr>
|
| 246 |
-
))}
|
| 247 |
-
</tbody>
|
| 248 |
-
</table>
|
| 249 |
-
{/* Owner rule [[no-unverifiable-aggregates]]: state the denominator, always. */}
|
| 250 |
-
<p className="set-help stmt-small">
|
| 251 |
-
Showing {view.length} of {data.rows.length} customers · {selected.length} selected ·
|
| 252 |
-
combined overdue {money(selected.reduce((a, r) => a + (Number(r.Overdue) || 0), 0))}.
|
| 253 |
-
</p>
|
| 254 |
-
</div>
|
| 255 |
-
|
| 256 |
-
{view.length ? (
|
| 257 |
-
<div className="stmt-stack">
|
| 258 |
-
<label className="set-label">
|
| 259 |
-
Preview customer
|
| 260 |
-
<select
|
| 261 |
-
className="set-input"
|
| 262 |
-
value={previewRow}
|
| 263 |
-
onChange={(e) => setPreviewOf(e.currentTarget.value)}
|
| 264 |
-
>
|
| 265 |
-
{(selected.length ? selected : view).map((r) => (
|
| 266 |
-
<option key={r.Customer} value={r.Customer}>{r.Customer}</option>
|
| 267 |
-
))}
|
| 268 |
-
</select>
|
| 269 |
-
</label>
|
| 270 |
-
{preview ? (
|
| 271 |
-
<>
|
| 272 |
-
<p className="set-help stmt-small">
|
| 273 |
-
From: {data.sender.name} <{data.sender.email}> · reply-to{" "}
|
| 274 |
-
{data.sender.replyTo} · To: {preview.to || "(no email)"} · Subject:{" "}
|
| 275 |
-
{preview.subject}
|
| 276 |
-
</p>
|
| 277 |
-
<details className="stmt-details">
|
| 278 |
-
<summary>Preview statement</summary>
|
| 279 |
-
{/* Server-rendered from our own template + our own Odoo rows — the same
|
| 280 |
-
HTML the send path posts. */}
|
| 281 |
-
<div
|
| 282 |
-
className="stmt-preview"
|
| 283 |
-
dangerouslySetInnerHTML={{ __html: preview.html }}
|
| 284 |
-
/>
|
| 285 |
-
</details>
|
| 286 |
-
</>
|
| 287 |
-
) : null}
|
| 288 |
-
|
| 289 |
-
<div className="stmt-row">
|
| 290 |
-
<label className="set-label">
|
| 291 |
-
Test address (sends the preview customer’s statement here)
|
| 292 |
-
<input
|
| 293 |
-
className="set-input stmt-wide"
|
| 294 |
-
value={testTo}
|
| 295 |
-
disabled={data.safeMode}
|
| 296 |
-
title={data.safeMode ? "Locked to the guardrail address while testing." : undefined}
|
| 297 |
-
onChange={(e) => setTestTo(e.currentTarget.value)}
|
| 298 |
-
/>
|
| 299 |
-
</label>
|
| 300 |
-
<button
|
| 301 |
-
className="set-secondary"
|
| 302 |
-
disabled={busy || !previewRow || !testTo.includes("@")}
|
| 303 |
-
onClick={() => void doSend([previewRow], testTo)}
|
| 304 |
-
>
|
| 305 |
-
Send test email
|
| 306 |
-
</button>
|
| 307 |
-
</div>
|
| 308 |
-
</div>
|
| 309 |
-
) : (
|
| 310 |
-
<p className="set-help">No customers match the current filters.</p>
|
| 311 |
-
)}
|
| 312 |
-
|
| 313 |
-
{noEmail.length ? (
|
| 314 |
-
<div className="stmt-warn">
|
| 315 |
-
{noEmail.length} selected {noEmail.length === 1 ? "customer has" : "customers have"} no
|
| 316 |
-
email and will be skipped: {noEmail.slice(0, 5).map((r) => r.Customer).join(", ")}
|
| 317 |
-
{noEmail.length > 5 ? "…" : ""}
|
| 318 |
-
</div>
|
| 319 |
-
) : null}
|
| 320 |
-
|
| 321 |
-
{result ? (
|
| 322 |
-
<div className={result.failed.length ? "set-warn" : "set-ok"}>
|
| 323 |
-
<b>
|
| 324 |
-
{result.sent.length} statement{result.sent.length === 1 ? "" : "s"} queued
|
| 325 |
-
{result.test ? " (test)" : ""} in Odoo
|
| 326 |
-
</b>{" "}
|
| 327 |
-
— delivery within ~15 min via the Office 365 relay; each send is logged on the
|
| 328 |
-
customer record.
|
| 329 |
-
{result.skipped.length ? (
|
| 330 |
-
<div>Skipped: {result.skipped.map((s) => `${s.customer} (${s.reason})`).join(" · ")}</div>
|
| 331 |
-
) : null}
|
| 332 |
-
{result.failed.length ? (
|
| 333 |
-
<div>Failed: {result.failed.map((f) => `${f.customer}: ${f.error}`).join(" | ")}</div>
|
| 334 |
-
) : null}
|
| 335 |
-
</div>
|
| 336 |
-
) : null}
|
| 337 |
-
|
| 338 |
-
{/* ── the confirm flow ─────────────────────────────────────────── */}
|
| 339 |
-
{!confirming ? (
|
| 340 |
-
<button
|
| 341 |
-
className="set-primary"
|
| 342 |
-
disabled={busy || !sendable.length || data.safeMode}
|
| 343 |
-
title={data.safeMode ? "Disabled while the testing guardrail is on." : undefined}
|
| 344 |
-
onClick={() => { setResult(null); setConfirming(true); }}
|
| 345 |
-
>
|
| 346 |
-
Send {sendable.length} statement{sendable.length === 1 ? "" : "s"}…
|
| 347 |
-
</button>
|
| 348 |
-
) : (
|
| 349 |
-
<div className="stmt-confirm">
|
| 350 |
-
<div className="stmt-warn">
|
| 351 |
-
About to queue <b>{sendable.length}</b> statements as {data.sender.name} (
|
| 352 |
-
{data.sender.email}), replies to {data.sender.replyTo} — combined overdue{" "}
|
| 353 |
-
{money(sendable.reduce((a, r) => a + (Number(r.Overdue) || 0), 0))}.{" "}
|
| 354 |
-
<b>This cannot be undone from here.</b>
|
| 355 |
-
</div>
|
| 356 |
-
<div className="stmt-tablewrap stmt-tablewrap--short">
|
| 357 |
-
<table className="stmt-table">
|
| 358 |
-
<thead>
|
| 359 |
-
<tr><th>Customer</th><th>Email</th><th className="stmt-num">Overdue</th></tr>
|
| 360 |
-
</thead>
|
| 361 |
-
<tbody>
|
| 362 |
-
{sendable.map((r) => (
|
| 363 |
-
<tr key={r.Customer}>
|
| 364 |
-
<td>{r.Customer}</td>
|
| 365 |
-
<td>{r.Email}</td>
|
| 366 |
-
<td className="stmt-num">{money(r.Overdue)}</td>
|
| 367 |
-
</tr>
|
| 368 |
-
))}
|
| 369 |
-
</tbody>
|
| 370 |
-
</table>
|
| 371 |
-
</div>
|
| 372 |
-
<div className="stmt-row">
|
| 373 |
-
<button
|
| 374 |
-
className="set-primary"
|
| 375 |
-
disabled={busy}
|
| 376 |
-
onClick={() => void doSend(sendable.map((r) => r.Customer))}
|
| 377 |
-
>
|
| 378 |
-
{busy ? "Queueing…" : "Confirm and send"}
|
| 379 |
-
</button>
|
| 380 |
-
<button className="set-secondary" disabled={busy} onClick={() => setConfirming(false)}>
|
| 381 |
-
Cancel
|
| 382 |
-
</button>
|
| 383 |
-
</div>
|
| 384 |
-
</div>
|
| 385 |
-
)}
|
| 386 |
-
</>
|
| 387 |
-
) : null}
|
| 388 |
-
</div>
|
| 389 |
-
);
|
| 390 |
-
}
|
| 391 |
-
|
| 392 |
-
export default StatementsPane;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
web/src/settings/statementsApi.ts
DELETED
|
@@ -1,94 +0,0 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// settings / statementsApi.ts — the statement sender's wire (EXIT-6).
|
| 3 |
-
//
|
| 4 |
-
// ⛔ THE ONLY WRITE PATH IN THIS PRODUCT THAT LEAVES THE BUILDING. Everything
|
| 5 |
-
// else AIOS does to Odoo is read-only by hard block; `/admin/statements/send`
|
| 6 |
-
// queues real email to real customers. Three consequences for this file:
|
| 7 |
-
//
|
| 8 |
-
// 1. **No convenience wrapper sends anything.** There is no `sendAll()`, no
|
| 9 |
-
// default argument that could become a send. The caller names every
|
| 10 |
-
// customer explicitly, every time.
|
| 11 |
-
// 2. **SAFE_MODE is REPORTED here, never decided here.** The server's data
|
| 12 |
-
// layer refuses an out-of-allow-list address. If this file ever grows a
|
| 13 |
-
// `if (safeMode) return` it would look like the guardrail while the real
|
| 14 |
-
// one silently rotted — a hidden button is a courtesy, never the check
|
| 15 |
-
// ([[aios-permissioning]]).
|
| 16 |
-
// 3. **Outcomes are per-customer.** `sent` / `failed` / `skipped` come back as
|
| 17 |
-
// lists, not counts, because "37 queued" is unverifiable and this is mail.
|
| 18 |
-
// ---------------------------------------------------------------------------
|
| 19 |
-
|
| 20 |
-
import { API_V1, CREDENTIALS } from "../apiContract";
|
| 21 |
-
|
| 22 |
-
export interface StatementRow {
|
| 23 |
-
Customer: string;
|
| 24 |
-
Email: string;
|
| 25 |
-
Tier: string;
|
| 26 |
-
Overdue: number;
|
| 27 |
-
Receivable: number;
|
| 28 |
-
[k: string]: unknown;
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
export interface StatementTemplates {
|
| 32 |
-
subject: string;
|
| 33 |
-
intro: string;
|
| 34 |
-
footer: string;
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
export interface StatementsPayload {
|
| 38 |
-
rows: StatementRow[];
|
| 39 |
-
loadedAt: string;
|
| 40 |
-
safeMode: boolean;
|
| 41 |
-
safeRecipients: string[];
|
| 42 |
-
sender: { name: string; email: string; replyTo: string; company: string };
|
| 43 |
-
templates: StatementTemplates;
|
| 44 |
-
tiers: string[];
|
| 45 |
-
}
|
| 46 |
-
|
| 47 |
-
export interface SendOutcome {
|
| 48 |
-
sent: Array<{ customer: string; to: string; mailId: number }>;
|
| 49 |
-
failed: Array<{ customer: string; error: string }>;
|
| 50 |
-
skipped: Array<{ customer: string; reason: string }>;
|
| 51 |
-
safeMode: boolean;
|
| 52 |
-
test: boolean;
|
| 53 |
-
}
|
| 54 |
-
|
| 55 |
-
type Res<T> = { ok: true; data: T } | { ok: false; message: string };
|
| 56 |
-
|
| 57 |
-
async function call<T>(path: string, init?: RequestInit): Promise<Res<T>> {
|
| 58 |
-
try {
|
| 59 |
-
const r = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
|
| 60 |
-
const body = await r.json().catch(() => null);
|
| 61 |
-
if (!r.ok) {
|
| 62 |
-
const msg = body?.error?.message || `request failed (${r.status})`;
|
| 63 |
-
return { ok: false, message: msg };
|
| 64 |
-
}
|
| 65 |
-
return { ok: true, data: body as T };
|
| 66 |
-
} catch (e) {
|
| 67 |
-
return { ok: false, message: e instanceof Error ? e.message : "network error" };
|
| 68 |
-
}
|
| 69 |
-
}
|
| 70 |
-
|
| 71 |
-
export function loadStatements(refresh = false) {
|
| 72 |
-
return call<StatementsPayload>(`/admin/statements${refresh ? "?refresh=1" : ""}`);
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
export function previewStatement(customer: string, templates: StatementTemplates) {
|
| 76 |
-
return call<{ html: string; to: string; subject: string }>("/admin/statements/preview", {
|
| 77 |
-
method: "POST",
|
| 78 |
-
headers: { "Content-Type": "application/json" },
|
| 79 |
-
body: JSON.stringify({ customer, templates }),
|
| 80 |
-
});
|
| 81 |
-
}
|
| 82 |
-
|
| 83 |
-
/** Queue statements. `overrideTo` is the TEST path and takes exactly one customer. */
|
| 84 |
-
export function sendStatements(
|
| 85 |
-
customers: string[],
|
| 86 |
-
templates: StatementTemplates,
|
| 87 |
-
overrideTo?: string,
|
| 88 |
-
) {
|
| 89 |
-
return call<SendOutcome>("/admin/statements/send", {
|
| 90 |
-
method: "POST",
|
| 91 |
-
headers: { "Content-Type": "application/json" },
|
| 92 |
-
body: JSON.stringify({ customers, templates, overrideTo: overrideTo || undefined }),
|
| 93 |
-
});
|
| 94 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|