Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- api/main.py +2 -0
- api/routes_statements.py +168 -0
- platform/aios_grid.py +0 -0
- platform/core/grid_events.py +10 -6
- requirements.txt +26 -16
- web/src/index.css +103 -0
- web/src/settings/SettingsModal.tsx +14 -0
- web/src/settings/StatementsPane.tsx +392 -0
- web/src/settings/permsModel.ts +11 -1
- web/src/settings/statementsApi.ts +94 -0
api/main.py
CHANGED
|
@@ -61,6 +61,7 @@ import routes_automation # noqa: E402 (wave 18 C4-AUTO — SESSION D's router,
|
|
| 61 |
import routes_customers # noqa: E402
|
| 62 |
import routes_grid # noqa: E402
|
| 63 |
import routes_keychain # noqa: E402 (wave 18 C7 — keychain + connectors admin surfaces)
|
|
|
|
| 64 |
import routes_nav # noqa: E402
|
| 65 |
import routes_pages # noqa: E402
|
| 66 |
import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane)
|
|
@@ -179,6 +180,7 @@ app.include_router(routes_pages.router)
|
|
| 179 |
app.include_router(routes_admin.router)
|
| 180 |
app.include_router(routes_automation.router)
|
| 181 |
app.include_router(routes_keychain.router)
|
|
|
|
| 182 |
# Wave 19 (owner item 13, R3): the LOOPABLE admin plane — the platform's own cross-tenant view.
|
| 183 |
# Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path
|
| 184 |
# ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a
|
|
|
|
| 61 |
import routes_customers # noqa: E402
|
| 62 |
import routes_grid # noqa: E402
|
| 63 |
import routes_keychain # noqa: E402 (wave 18 C7 — keychain + connectors admin surfaces)
|
| 64 |
+
import routes_statements # noqa: E402 (EXIT-6 — the statement sender, off Streamlit)
|
| 65 |
import routes_nav # noqa: E402
|
| 66 |
import routes_pages # noqa: E402
|
| 67 |
import routes_platform_admin # noqa: E402 (wave 19 R3/R4 — the Loopable cross-tenant plane)
|
|
|
|
| 180 |
app.include_router(routes_admin.router)
|
| 181 |
app.include_router(routes_automation.router)
|
| 182 |
app.include_router(routes_keychain.router)
|
| 183 |
+
app.include_router(routes_statements.router)
|
| 184 |
# Wave 19 (owner item 13, R3): the LOOPABLE admin plane — the platform's own cross-tenant view.
|
| 185 |
# Its own prefix (`/api/v1/platform-admin`), never nested under `/admin`, so there is no path
|
| 186 |
# ambiguity with `routes_admin`'s `{username}` params and no chance of a tenant-admin route and a
|
api/routes_statements.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""routes_statements.py — the statement-of-account sender, ported off Streamlit (EXIT-6).
|
| 2 |
+
|
| 3 |
+
⛔ THIS IS THE ONE SANCTIONED ODOO WRITER IN THE ENTIRE SYSTEM. Everything else in AIOS is
|
| 4 |
+
read-only on Odoo by hard block. `modules/collections_send.py` owns a narrow client that whitelists
|
| 5 |
+
exactly `mail.mail create` (queueing an outbound email) and nothing else; these routes call it and
|
| 6 |
+
add no write of their own.
|
| 7 |
+
|
| 8 |
+
WHY IT EXISTS SEPARATELY FROM THE COLLECTIONS PAGE. Owner ruling wave-17 item 15 retired the
|
| 9 |
+
Collections *dashboard* — the worklist is the shared "Collections" view on the Customer grid, from
|
| 10 |
+
the same reconciled blocks. What that ruling explicitly left "untouched" is this send workflow, so
|
| 11 |
+
when `app.py` is deleted it is the ONLY live Streamlit-only feature, and it moves here rather than
|
| 12 |
+
dying with the host. Ported faithfully: same tiers, same filters, same template placeholders, same
|
| 13 |
+
preview, same test-send, same two-step confirm.
|
| 14 |
+
|
| 15 |
+
THE THREE GUARDRAILS, and where each is enforced:
|
| 16 |
+
1. SAFE_MODE (default ON) — in the DATA LAYER (`collections_send.queue_statement`), so no route,
|
| 17 |
+
payload or UI can bypass it. These routes only REPORT it; they never re-implement the check.
|
| 18 |
+
2. Admin only — `admin_gate` (role, fail-closed), mirroring the Streamlit `if not is_admin()`.
|
| 19 |
+
3. ⭐ TENANT — NEW HERE, and it did not exist in Streamlit because it could not. `cs_mod.Odoo()`
|
| 20 |
+
reads Odoo credentials from the ENVIRONMENT, which after the keychain cutover belongs to
|
| 21 |
+
TENANT #0 ALONE. On the multi-tenant API an unguarded route would let a nurilab or gtmlab
|
| 22 |
+
admin queue mail from Royal Imports' Odoo, as Royal Imports. `_royal_only` closes that; the
|
| 23 |
+
single-tenant Streamlit host never had the exposure, so this is a port that must ADD a wall
|
| 24 |
+
rather than copy one.
|
| 25 |
+
|
| 26 |
+
NOTHING SENDS ON A GET. The send route requires an explicit customer list in the body; there is no
|
| 27 |
+
"send all" parameter, deliberately — the confirm step is a product requirement, not a formality.
|
| 28 |
+
"""
|
| 29 |
+
from fastapi import APIRouter, Body, Depends
|
| 30 |
+
|
| 31 |
+
from deps import Session, err
|
| 32 |
+
from routes_admin import admin_gate
|
| 33 |
+
|
| 34 |
+
router = APIRouter(prefix="/api/v1")
|
| 35 |
+
|
| 36 |
+
#: Cache the Odoo follow-up pull briefly. The Streamlit page used `@st.cache_data(ttl=1800)`; the
|
| 37 |
+
#: list moves slowly (it is a dunning worklist, not a live feed) and the pull is a multi-model read.
|
| 38 |
+
_TTL = 1800
|
| 39 |
+
_cache = {"at": 0.0, "rows": None}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _cs():
|
| 43 |
+
import modules.collections_send as cs
|
| 44 |
+
return cs
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _royal_only(session: Session) -> Session:
|
| 48 |
+
"""⛔ See the module docstring, guardrail 3. The send client is env-credentialed, so it is
|
| 49 |
+
tenant #0's and only tenant #0's. Refuse for anyone else rather than send as the wrong company.
|
| 50 |
+
|
| 51 |
+
Keyed on the runtime, never on a request field: a tenant is a property of the SESSION."""
|
| 52 |
+
if getattr(session.runtime, "key", None) != "royal-imports":
|
| 53 |
+
raise err(404, "not_found", "statements are not configured for this workspace")
|
| 54 |
+
return session
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _gate(session: Session = Depends(admin_gate)) -> Session:
|
| 58 |
+
return _royal_only(session)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _rows(force=False):
|
| 62 |
+
import time
|
| 63 |
+
cs = _cs()
|
| 64 |
+
if force or _cache["rows"] is None or (time.time() - _cache["at"]) > _TTL:
|
| 65 |
+
_cache["rows"] = cs.load_collection_list(cs.Odoo())
|
| 66 |
+
_cache["at"] = time.time()
|
| 67 |
+
return _cache["rows"], time.strftime("%Y-%m-%d %H:%M", time.localtime(_cache["at"]))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _public(row):
|
| 71 |
+
"""Strip the internals the Streamlit grid also hid (`_`-prefixed + partner_id is kept, because
|
| 72 |
+
the client needs a stable row identity that is not the display name)."""
|
| 73 |
+
return {k: v for k, v in row.items() if not str(k).startswith("_")}
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/admin/statements")
|
| 77 |
+
def statements(refresh: int = 0, session: Session = Depends(_gate)):
|
| 78 |
+
"""The worklist + everything the sender UI needs to render itself honestly."""
|
| 79 |
+
cs = _cs()
|
| 80 |
+
try:
|
| 81 |
+
rows, loaded_at = _rows(force=bool(refresh))
|
| 82 |
+
except Exception as e:
|
| 83 |
+
raise err(502, "odoo_unavailable", f"could not load the collection list: {str(e)[:200]}")
|
| 84 |
+
return {
|
| 85 |
+
"rows": [_public(r) for r in rows],
|
| 86 |
+
"loadedAt": loaded_at,
|
| 87 |
+
# The guardrail is reported, never decided, here — the data layer owns it.
|
| 88 |
+
"safeMode": bool(cs.SAFE_MODE),
|
| 89 |
+
"safeRecipients": sorted(cs.SAFE_RECIPIENTS),
|
| 90 |
+
"sender": {"name": cs.SENDER_NAME, "email": cs.SENDER_EMAIL,
|
| 91 |
+
"replyTo": cs.REPLY_TO, "company": cs.COMPANY},
|
| 92 |
+
"templates": {"subject": cs.DEFAULT_SUBJECT, "intro": cs.DEFAULT_INTRO,
|
| 93 |
+
"footer": cs.DEFAULT_FOOTER},
|
| 94 |
+
"tiers": ["A-Urgent", "B-Active", "C-Light", "Monitor"],
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _find(rows, customer):
|
| 99 |
+
return next((r for r in rows if r.get("Customer") == customer), None)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@router.post("/admin/statements/preview")
|
| 103 |
+
def preview(body: dict = Body(default=None), session: Session = Depends(_gate)):
|
| 104 |
+
"""Render ONE customer's statement exactly as the send path would."""
|
| 105 |
+
cs = _cs()
|
| 106 |
+
body = body or {}
|
| 107 |
+
rows, _ = _rows()
|
| 108 |
+
row = _find(rows, body.get("customer"))
|
| 109 |
+
if row is None:
|
| 110 |
+
raise err(404, "not_found", "no such customer on the collection list")
|
| 111 |
+
t = body.get("templates") or {}
|
| 112 |
+
import datetime as dt
|
| 113 |
+
month = dt.date.today().strftime("%B %Y")
|
| 114 |
+
subject = (t.get("subject") or cs.DEFAULT_SUBJECT)
|
| 115 |
+
try:
|
| 116 |
+
subject = subject.format(customer=row["Customer"], company=cs.COMPANY, month=month)
|
| 117 |
+
except (KeyError, IndexError):
|
| 118 |
+
# An unknown placeholder is the user's typo, not a 500. Show the template verbatim so they
|
| 119 |
+
# can see what they typed rather than getting an opaque error.
|
| 120 |
+
pass
|
| 121 |
+
return {
|
| 122 |
+
"html": cs.render_statement_html(row, t.get("intro") or cs.DEFAULT_INTRO,
|
| 123 |
+
t.get("footer") or cs.DEFAULT_FOOTER),
|
| 124 |
+
"to": row.get("Email") or "",
|
| 125 |
+
"subject": subject,
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
@router.post("/admin/statements/send")
|
| 130 |
+
def send(body: dict = Body(default=None), session: Session = Depends(_gate)):
|
| 131 |
+
"""Queue statements. Returns per-customer outcomes — NEVER a bare count.
|
| 132 |
+
|
| 133 |
+
`overrideTo` is the test-send path: one customer, one address. SAFE_MODE still applies (the
|
| 134 |
+
data layer refuses an address outside the allow-list), which is why the route does not check it.
|
| 135 |
+
"""
|
| 136 |
+
cs = _cs()
|
| 137 |
+
body = body or {}
|
| 138 |
+
names = [str(n) for n in (body.get("customers") or []) if str(n).strip()]
|
| 139 |
+
if not names:
|
| 140 |
+
raise err(400, "bad_request", "name at least one customer")
|
| 141 |
+
override = (body.get("overrideTo") or "").strip() or None
|
| 142 |
+
if override and len(names) != 1:
|
| 143 |
+
raise err(400, "bad_request", "a test send takes exactly one customer")
|
| 144 |
+
t = body.get("templates") or {}
|
| 145 |
+
rows, _ = _rows()
|
| 146 |
+
|
| 147 |
+
sent, failed, skipped = [], [], []
|
| 148 |
+
for name in names:
|
| 149 |
+
row = _find(rows, name)
|
| 150 |
+
if row is None:
|
| 151 |
+
failed.append({"customer": name, "error": "not on the current collection list"})
|
| 152 |
+
continue
|
| 153 |
+
if not override and not row.get("Email"):
|
| 154 |
+
# The Streamlit page warned and skipped these. Reporting them SEPARATELY from failures
|
| 155 |
+
# keeps "we could not" distinct from "there was nowhere to send".
|
| 156 |
+
skipped.append({"customer": name, "reason": "no email address on the customer record"})
|
| 157 |
+
continue
|
| 158 |
+
try:
|
| 159 |
+
mid = cs.queue_statement(cs.Odoo(), row, t.get("subject") or cs.DEFAULT_SUBJECT,
|
| 160 |
+
t.get("intro") or cs.DEFAULT_INTRO,
|
| 161 |
+
t.get("footer") or cs.DEFAULT_FOOTER, override_to=override)
|
| 162 |
+
sent.append({"customer": name, "to": override or row.get("Email"), "mailId": mid})
|
| 163 |
+
except Exception as e:
|
| 164 |
+
# SafeModeBlocked lands here too, and that is correct: to the caller a guardrail refusal
|
| 165 |
+
# and an Odoo error are both "this one did not go", each with its own honest message.
|
| 166 |
+
failed.append({"customer": name, "error": str(e)[:200]})
|
| 167 |
+
return {"sent": sent, "failed": failed, "skipped": skipped,
|
| 168 |
+
"safeMode": bool(cs.SAFE_MODE), "test": bool(override)}
|
platform/aios_grid.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
platform/core/grid_events.py
CHANGED
|
@@ -13,13 +13,17 @@ Streamlit couplings the contract names:
|
|
| 13 |
is_admin() (the bare call inside doc_delete, and _cl_table_workspace's admin=None default)
|
| 14 |
-> ctx.admin
|
| 15 |
|
| 16 |
-
There is NO `import streamlit` in this file and there must never be one — `
|
| 17 |
enforces that with a `sys.meta_path` block, which is the only proof that survives refactoring.
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
* the HTTP adapter — `aios-web/api` builds an EventCtx with `fallback_ws=None`. A None
|
| 24 |
fallback with the store unavailable is a **503**, never a silent in-memory workspace: an
|
| 25 |
API request has no durable session dict to fall back to, so pretending to persist would
|
|
|
|
| 13 |
is_admin() (the bare call inside doc_delete, and _cl_table_workspace's admin=None default)
|
| 14 |
-> ctx.admin
|
| 15 |
|
| 16 |
+
There is NO `import streamlit` in this file and there must never be one — `verify_no_streamlit.py`
|
| 17 |
enforces that with a `sys.meta_path` block, which is the only proof that survives refactoring.
|
| 18 |
+
(That gate was `verify_seam.py` until EXIT-6 retired it with `app.py`; the block, its negative
|
| 19 |
+
control and the AST walk moved over intact.)
|
| 20 |
+
|
| 21 |
+
ONE ADAPTER, ONE HANDLER — it was TWO until 2026-08-04:
|
| 22 |
+
* ⛔ the Streamlit adapter is GONE. `app.py::_cl_handle_grid_event` built an EventCtx whose
|
| 23 |
+
`seen_ids` and `fallback_ws` were session-state-backed dicts and applied the Result to session
|
| 24 |
+
state. EXIT-6 deleted it. The host-neutrality this file was written for is what let that
|
| 25 |
+
happen without touching a line of the handler — which is the argument for the design, recorded
|
| 26 |
+
at the moment it paid out.
|
| 27 |
* the HTTP adapter — `aios-web/api` builds an EventCtx with `fallback_ws=None`. A None
|
| 28 |
fallback with the store unavailable is a **503**, never a silent in-memory workspace: an
|
| 29 |
API request has no durable session dict to fall back to, so pretending to persist would
|
requirements.txt
CHANGED
|
@@ -1,16 +1,26 @@
|
|
| 1 |
-
# AIOS web API — the FastAPI backend + the platform data-layer deps it reuses.
|
| 2 |
-
#
|
| 3 |
-
#
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AIOS web API — the FastAPI backend + the platform data-layer deps it reuses.
|
| 2 |
+
#
|
| 3 |
+
# ⛔ STREAMLIT IS GONE (EXIT-6, 2026-08-04) — do not add it back. The line here read
|
| 4 |
+
# `streamlit==1.58.0` with the comment "included only because the shared modules/core may import
|
| 5 |
+
# it at load; it is never served". That stopped being true when the shared layer was cleaned, and
|
| 6 |
+
# it stayed in THIS file — the one the Dockerfile actually installs — while `api/requirements.txt`
|
| 7 |
+
# had already dropped it and a gate reported the omission "verified". ~250 MB of image for a
|
| 8 |
+
# package nothing imported.
|
| 9 |
+
#
|
| 10 |
+
# The lesson is the file, not the package: the PINNED intent and the SHIPPED manifest are two
|
| 11 |
+
# different documents, and checking only the one you wrote is how the other rots.
|
| 12 |
+
# `api/verify_no_streamlit.py` now checks BOTH, and proves the API imports with streamlit,
|
| 13 |
+
# plotly, altair, openpyxl, reportlab and jinja2 all blocked at sys.meta_path.
|
| 14 |
+
# No altair/plotly/openpyxl/reportlab either — those were Streamlit UI/export only.
|
| 15 |
+
fastapi>=0.139
|
| 16 |
+
uvicorn[standard]>=0.30
|
| 17 |
+
python-dotenv>=1.0
|
| 18 |
+
pandas>=2.0
|
| 19 |
+
requests>=2.28
|
| 20 |
+
huggingface_hub>=0.20
|
| 21 |
+
duckdb>=1.0
|
| 22 |
+
pyyaml>=6.0
|
| 23 |
+
pillow>=10.0
|
| 24 |
+
beautifulsoup4>=4.12
|
| 25 |
+
lxml>=5.0
|
| 26 |
+
cryptography>=42.0
|
web/src/index.css
CHANGED
|
@@ -8199,3 +8199,106 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 8199 |
}
|
| 8200 |
}
|
| 8201 |
/* == /W19-D == */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8199 |
}
|
| 8200 |
}
|
| 8201 |
/* == /W19-D == */
|
| 8202 |
+
|
| 8203 |
+
/* == EXIT-6 STATEMENTS == */
|
| 8204 |
+
/* `settings/StatementsPane.tsx` — the statement-of-account sender, ported off
|
| 8205 |
+
`app.py::_collections_statements` when Streamlit was deleted.
|
| 8206 |
+
|
| 8207 |
+
⛔ WHY THE TABLE RULES ARE `stmt-` AND NOT `set-table`. The accounts table was
|
| 8208 |
+
removed in wave 17 (R7) and its rules deliberately went with it, with a note
|
| 8209 |
+
in this file saying a dead rule with a plausible name is worse than no rule
|
| 8210 |
+
because the next thing needing a table adopts decisions nobody made for it.
|
| 8211 |
+
This IS that next thing, so it declares its own, named for its one purpose.
|
| 8212 |
+
|
| 8213 |
+
Quiet by intent: this pane ends in a control that emails real customers, so
|
| 8214 |
+
nothing here competes with the confirm block for attention. */
|
| 8215 |
+
.stmt-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 12px 0 0; }
|
| 8216 |
+
.stmt-row .set-input { width: auto; min-width: 180px; flex: 1 1 180px; }
|
| 8217 |
+
.stmt-small { margin-top: 6px; }
|
| 8218 |
+
.stmt-stack { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
|
| 8219 |
+
.stmt-wide { width: 100%; }
|
| 8220 |
+
.stmt-link {
|
| 8221 |
+
margin-top: 14px;
|
| 8222 |
+
padding: 0;
|
| 8223 |
+
border: 0;
|
| 8224 |
+
background: none;
|
| 8225 |
+
color: var(--lp-blue-deep);
|
| 8226 |
+
font: inherit;
|
| 8227 |
+
font-size: var(--lp-fs-2xs);
|
| 8228 |
+
font-weight: 600;
|
| 8229 |
+
cursor: pointer;
|
| 8230 |
+
text-align: left;
|
| 8231 |
+
}
|
| 8232 |
+
.stmt-textarea {
|
| 8233 |
+
width: 100%;
|
| 8234 |
+
padding: 8px 10px;
|
| 8235 |
+
border: 1px solid var(--lp-line);
|
| 8236 |
+
border-radius: var(--lp-r-sm);
|
| 8237 |
+
background: var(--lp-surface);
|
| 8238 |
+
color: var(--lp-ink);
|
| 8239 |
+
font: inherit;
|
| 8240 |
+
font-size: var(--lp-fs-xs);
|
| 8241 |
+
line-height: var(--lp-lh);
|
| 8242 |
+
resize: vertical;
|
| 8243 |
+
}
|
| 8244 |
+
/* The worklist scrolls INSIDE its own box. A dunning list is hundreds of rows and
|
| 8245 |
+
the settings modal must not grow a second scrollbar for it. */
|
| 8246 |
+
.stmt-tablewrap {
|
| 8247 |
+
margin-top: 12px;
|
| 8248 |
+
max-height: 340px;
|
| 8249 |
+
overflow: auto;
|
| 8250 |
+
border: 1px solid var(--lp-line);
|
| 8251 |
+
border-radius: var(--lp-r-sm);
|
| 8252 |
+
}
|
| 8253 |
+
.stmt-tablewrap--short { max-height: 200px; }
|
| 8254 |
+
.stmt-table { width: 100%; border-collapse: collapse; font-size: var(--lp-fs-2xs); }
|
| 8255 |
+
.stmt-table th,
|
| 8256 |
+
.stmt-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--lp-line); }
|
| 8257 |
+
/* Sticky head: scrolling 400 dunning rows without column labels is unreadable. */
|
| 8258 |
+
.stmt-table thead th {
|
| 8259 |
+
position: sticky;
|
| 8260 |
+
top: 0;
|
| 8261 |
+
z-index: 1;
|
| 8262 |
+
background: var(--lp-surface-2);
|
| 8263 |
+
font-weight: 620;
|
| 8264 |
+
}
|
| 8265 |
+
.stmt-table tbody tr:last-child td { border-bottom: 0; }
|
| 8266 |
+
.stmt-table tbody tr.is-picked { background: var(--lp-blue-tint); }
|
| 8267 |
+
/* Money aligns right, everywhere in this product. */
|
| 8268 |
+
.stmt-num { text-align: right; font-variant-numeric: tabular-nums; }
|
| 8269 |
+
.stmt-table th.stmt-num { text-align: right; }
|
| 8270 |
+
/* ⚠ AMBER, NOT `.set-notice` (green) AND NOT `.set-error` (red). The SAFE_MODE
|
| 8271 |
+
banner is neither success nor failure — it is a live restriction, and dressing
|
| 8272 |
+
a guardrail in green is how somebody reads "ON" as "you are clear to send". */
|
| 8273 |
+
.stmt-warn {
|
| 8274 |
+
margin: 0 0 12px;
|
| 8275 |
+
padding: 9px 12px;
|
| 8276 |
+
border-radius: var(--lp-r-sm);
|
| 8277 |
+
background: var(--lp-yellow-tint);
|
| 8278 |
+
color: var(--lp-yellow-deep);
|
| 8279 |
+
font-size: var(--lp-fs-2xs);
|
| 8280 |
+
line-height: var(--lp-lh);
|
| 8281 |
+
}
|
| 8282 |
+
.stmt-details { margin-top: 10px; font-size: var(--lp-fs-2xs); }
|
| 8283 |
+
.stmt-details summary { cursor: pointer; font-weight: 600; }
|
| 8284 |
+
/* The rendered statement is OUR html from OUR template, but it is still foreign
|
| 8285 |
+
markup inside a pane: box it so its own font sizes cannot reflow the modal. */
|
| 8286 |
+
.stmt-preview {
|
| 8287 |
+
margin-top: 10px;
|
| 8288 |
+
padding: 12px;
|
| 8289 |
+
max-height: 320px;
|
| 8290 |
+
overflow: auto;
|
| 8291 |
+
border: 1px solid var(--lp-line);
|
| 8292 |
+
border-radius: var(--lp-r-sm);
|
| 8293 |
+
background: var(--lp-surface);
|
| 8294 |
+
}
|
| 8295 |
+
.stmt-preview img { max-width: 100%; }
|
| 8296 |
+
/* The confirm block is the loudest thing on the pane, on purpose. */
|
| 8297 |
+
.stmt-confirm {
|
| 8298 |
+
margin-top: 16px;
|
| 8299 |
+
padding: 12px;
|
| 8300 |
+
border: 1px solid var(--lp-line);
|
| 8301 |
+
border-radius: var(--lp-r-sm);
|
| 8302 |
+
background: var(--lp-surface-2);
|
| 8303 |
+
}
|
| 8304 |
+
.stmt-confirm .stmt-row { margin-top: 12px; }
|
web/src/settings/SettingsModal.tsx
CHANGED
|
@@ -56,6 +56,7 @@ import type { ConnectorRow, KeyEntry, UnsyncedInfo } from "./settingsApi";
|
|
| 56 |
import { PermsEditor } from "./PermsEditor";
|
| 57 |
// Wave 19 contract C2 — session D's self-contained plane; see the mount below.
|
| 58 |
import { AdminPane } from "./AdminPane";
|
|
|
|
| 59 |
import { moduleSummary, parseEntry, reachableSection } from "./permsModel";
|
| 60 |
|
| 61 |
// The vocabulary lives beside the rule that gates it (permsModel), and is
|
|
@@ -139,6 +140,14 @@ const IconKey = (
|
|
| 139 |
</RailIcon>
|
| 140 |
);
|
| 141 |
/** Connectors — two links joined: where the data comes from. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
const IconPlug = (
|
| 143 |
<RailIcon>
|
| 144 |
<path d="M6.6 9.4 4.5 11.5a2.6 2.6 0 0 1-3.7-3.7l2.1-2.1" />
|
|
@@ -717,6 +726,10 @@ export function SettingsModal({
|
|
| 717 |
// Wave 18 (C7): the tenant's credential store + data-source status board.
|
| 718 |
["keychains", "Keychains", IconKey],
|
| 719 |
["connectors", "Connectors", IconPlug],
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
] as Array<[SettingsSection, string, ReactNode]>)
|
| 721 |
: []),
|
| 722 |
// Wave 19 (R3 / C2): the Loopable admin plane. Gated on the SERVER's
|
|
@@ -781,6 +794,7 @@ export function SettingsModal({
|
|
| 781 |
|
| 782 |
{active === "keychains" ? <KeychainPane /> : null}
|
| 783 |
{active === "connectors" ? <ConnectorsPane /> : null}
|
|
|
|
| 784 |
{/* Wave 19 contract C2 — the wiring record, verified at the MOUNT SITE
|
| 785 |
(the wave-14 lesson: an occurrence-grep proves an import, not a
|
| 786 |
render): `settings/AdminPane.tsx → SettingsModal.tsx pane switch →
|
|
|
|
| 56 |
import { PermsEditor } from "./PermsEditor";
|
| 57 |
// Wave 19 contract C2 — session D's self-contained plane; see the mount below.
|
| 58 |
import { AdminPane } from "./AdminPane";
|
| 59 |
+
import { StatementsPane } from "./StatementsPane";
|
| 60 |
import { moduleSummary, parseEntry, reachableSection } from "./permsModel";
|
| 61 |
|
| 62 |
// The vocabulary lives beside the rule that gates it (permsModel), and is
|
|
|
|
| 140 |
</RailIcon>
|
| 141 |
);
|
| 142 |
/** Connectors — two links joined: where the data comes from. */
|
| 143 |
+
/** Statements — an envelope. The one room in here that sends something OUT of
|
| 144 |
+
* the building, so it wears the only outbound-shaped mark on the rail. */
|
| 145 |
+
const IconMail = (
|
| 146 |
+
<RailIcon>
|
| 147 |
+
<path d="M2 4.5h12v7H2z" />
|
| 148 |
+
<path d="M2.4 5 8 9l5.6-4" />
|
| 149 |
+
</RailIcon>
|
| 150 |
+
);
|
| 151 |
const IconPlug = (
|
| 152 |
<RailIcon>
|
| 153 |
<path d="M6.6 9.4 4.5 11.5a2.6 2.6 0 0 1-3.7-3.7l2.1-2.1" />
|
|
|
|
| 726 |
// Wave 18 (C7): the tenant's credential store + data-source status board.
|
| 727 |
["keychains", "Keychains", IconKey],
|
| 728 |
["connectors", "Connectors", IconPlug],
|
| 729 |
+
// EXIT-6: the statement sender, ported off app.py. Admin-only, and the
|
| 730 |
+
// server ALSO refuses it to any tenant but Royal (the send client is
|
| 731 |
+
// env-credentialed) — this entry is the courtesy, not the wall.
|
| 732 |
+
["statements", "Statements", IconMail],
|
| 733 |
] as Array<[SettingsSection, string, ReactNode]>)
|
| 734 |
: []),
|
| 735 |
// Wave 19 (R3 / C2): the Loopable admin plane. Gated on the SERVER's
|
|
|
|
| 794 |
|
| 795 |
{active === "keychains" ? <KeychainPane /> : null}
|
| 796 |
{active === "connectors" ? <ConnectorsPane /> : null}
|
| 797 |
+
{active === "statements" ? <StatementsPane /> : null}
|
| 798 |
{/* Wave 19 contract C2 — the wiring record, verified at the MOUNT SITE
|
| 799 |
(the wave-14 lesson: an occurrence-grep proves an import, not a
|
| 800 |
render): `settings/AdminPane.tsx → SettingsModal.tsx pane switch →
|
web/src/settings/StatementsPane.tsx
ADDED
|
@@ -0,0 +1,392 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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/permsModel.ts
CHANGED
|
@@ -44,6 +44,10 @@ export type SettingsSection =
|
|
| 44 |
// Wave 18 (C7): the tenant's credential store and its data-source status board.
|
| 45 |
| "keychains"
|
| 46 |
| "connectors"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
/**
|
| 48 |
* Wave 19 (R3 / contract C2): the Loopable admin plane — the cross-TENANT
|
| 49 |
* console, visible only to a `platform_admin` account.
|
|
@@ -75,7 +79,13 @@ export type SettingsSection =
|
|
| 75 |
export function reachableSection(section: SettingsSection, admin: boolean): SettingsSection {
|
| 76 |
// Wave 18 (C7): keychains + connectors are admin rooms for the same reason users is —
|
| 77 |
// every control inside them would 403 a member.
|
| 78 |
-
const adminOnly =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
return adminOnly && !admin ? "account" : section;
|
| 80 |
}
|
| 81 |
|
|
|
|
| 44 |
// Wave 18 (C7): the tenant's credential store and its data-source status board.
|
| 45 |
| "keychains"
|
| 46 |
| "connectors"
|
| 47 |
+
/** EXIT-6: the statement-of-account sender, ported off `app.py` when Streamlit
|
| 48 |
+
* was deleted. Admin-only for the same reason the Streamlit section was —
|
| 49 |
+
* it is THE one sanctioned Odoo writer in the product. */
|
| 50 |
+
| "statements"
|
| 51 |
/**
|
| 52 |
* Wave 19 (R3 / contract C2): the Loopable admin plane — the cross-TENANT
|
| 53 |
* console, visible only to a `platform_admin` account.
|
|
|
|
| 79 |
export function reachableSection(section: SettingsSection, admin: boolean): SettingsSection {
|
| 80 |
// Wave 18 (C7): keychains + connectors are admin rooms for the same reason users is —
|
| 81 |
// every control inside them would 403 a member.
|
| 82 |
+
const adminOnly =
|
| 83 |
+
section === "users" ||
|
| 84 |
+
section === "keychains" ||
|
| 85 |
+
section === "connectors" ||
|
| 86 |
+
// EXIT-6: every control in the statements room would 403 a member, and the
|
| 87 |
+
// one at the bottom sends customer email. It belongs in this list twice over.
|
| 88 |
+
section === "statements";
|
| 89 |
return adminOnly && !admin ? "account" : section;
|
| 90 |
}
|
| 91 |
|
web/src/settings/statementsApi.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|