File size: 16,405 Bytes
bf8519f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
// ---------------------------------------------------------------------------
// 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;