fsanyoto commited on
Commit
e65eb28
·
verified ·
1 Parent(s): 2bdff0c

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} &lt;{data.sender.email}&gt; · 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
- }