crates/monetize-ui-web/src/pages/accounts.rs
//! **ACCOUNTS** (ONE monetize, 5.4) — every (product, tenant) account of the
//! monetize this console speaks to, and one account's rows. Every word is a
//! cell of [`crate::accounts`], tested on the host; this only lays them out.
//! Members never appear: they are the product's, not monetize's.
use leptos::prelude::*;
use leptos_router::hooks::use_query_map;
use crate::accounts::{
account_api, account_cells, account_href, account_summary_line, payment_cells, row_cells,
status_cell, AccountDetailJson, AccountsJson, ACCOUNT_COLUMNS, PAYMENT_COLUMNS, ROW_COLUMNS,
};
use crate::ui::{resolved, Badge, Empty, PageHeader, Panel};
/// `/accounts`: product | tenant | rows | Stripe | status, one line per account.
#[component]
pub fn Accounts() -> impl IntoView {
crate::api::set_estate(None);
let list =
LocalResource::new(|| async { crate::api::get::<AccountsJson>("/api/accounts").await });
view! {
<main class="page" id="main">
<PageHeader title="Accounts" subtitle="Every account, by product and customer."/>
{move || resolved(list.get(), None, |j: AccountsJson| {
if j.accounts.is_empty() {
return view! { <Empty title="No accounts" detail="No accounts yet."/> }.into_any();
}
let vendors = j.vendors.clone();
view! {
<Panel title="Accounts">
<table class="accounts" aria-label="Accounts">
<thead><tr>
{ACCOUNT_COLUMNS.iter().map(|c| view! { <th scope="col">{*c}</th> }).collect_view()}
</tr></thead>
<tbody>
{j.accounts.into_iter().map(|a| {
let [product, tenant, rows, stripe, _] = account_cells(&a, &vendors);
let (tone, status) = status_cell(&a);
let href = account_href(&a.product, &a.tenant);
view! {
<tr>
<td class="mono">{product}</td>
<td class="mono"><a href=href>{tenant}</a></td>
<td>{rows}</td>
<td class="mono">{stripe}</td>
<td><Badge tone=tone label=status/></td>
</tr>
}
}).collect_view()}
</tbody>
</table>
</Panel>
}
.into_any()
})}
</main>
}
}
/// `/account?product=…&tenant=…`: the account's rows (kind | plan / stop |
/// host | last heard | billing | since), then its payments.
#[component]
pub fn AccountDetail() -> impl IntoView {
crate::api::set_estate(None);
let query = use_query_map();
let key = move || {
query.with(|q| {
(
q.get("product").unwrap_or_default(),
q.get("tenant").unwrap_or_default(),
)
})
};
let again = RwSignal::new(0u32);
let reload = Callback::new(move |()| again.update(|n| *n += 1));
let detail = LocalResource::new(move || {
again.get();
let (product, tenant) = key();
async move { crate::api::get::<AccountDetailJson>(&account_api(&product, &tenant)).await }
});
view! {
<main class="page" id="main">
{move || {
let (product, tenant) = key();
view! { <PageHeader title=format!("{product} / {tenant}") subtitle="One account: its rows and its payments."/> }
}}
<p><a href="/accounts">"← Accounts"</a></p>
{move || resolved(detail.get(), None, |j: AccountDetailJson| {
view! {
<div class="stack">
<p class="hint">{account_summary_line(&j.account)}</p>
<Panel title="Rows">
<table class="account-rows" aria-label="Rows">
<thead><tr>
{ROW_COLUMNS.iter().map(|c| view! { <th scope="col">{*c}</th> }).collect_view()}
</tr></thead>
<tbody>
{j.rows.iter().map(|r| {
// ★ UW-3: an owed or waiting private order carries the operator's settles,
// the server's (A32-29): this page draws them.
let settles = r.actions.clone();
let path = crate::accounts::settle_api(&r.id);
let instance = (r.kind == crate::accounts::KIND_PRIVATE).then(|| r.id.clone());
view! {
<tr title=r.words.clone()>
{row_cells(r, crate::pages::now_ms()).into_iter().map(|c| view! { <td>{c}</td> }).collect_view()}
</tr>
{(!settles.is_empty()).then(|| view! {
<tr class="row-actions"><td colspan=ROW_COLUMNS.len().to_string()>
{settles.into_iter().map(|a| view! {
<crate::pages::money::MoneyAction label=a.label confirm_label=a.confirm path=path.clone() base=serde_json::json!({ "action": a.verb }) on_done=reload/>
}).collect_view()}
</td></tr>
})}
{instance.map(|company| view! {
<tr class="row-actions"><td colspan=ROW_COLUMNS.len().to_string()><SetupLog company=company/></td></tr>
})}
}
}).collect_view()}
</tbody>
</table>
</Panel>
<Panel title="Payments">
{if j.payments.is_empty() {
view! { <p class="hint">"No payments on this account."</p> }.into_any()
} else {
view! {
<table class="account-payments" aria-label="Payments">
<thead><tr>
{PAYMENT_COLUMNS.iter().map(|c| view! { <th scope="col">{*c}</th> }).collect_view()}
</tr></thead>
<tbody>
{j.payments.iter().map(|p| view! {
<tr>{payment_cells(p).into_iter().map(|c| view! { <td>{c}</td> }).collect_view()}</tr>
}).collect_view()}
</tbody>
</table>
}.into_any()
}}
</Panel>
</div>
}
.into_any()
})}
</main>
}
}
/// ★ UW-13: **a private instance's setup, as narrated** — the order's phases and the
/// installer's lines, read when opened.
#[component]
fn SetupLog(company: String) -> impl IntoView {
let open = RwSignal::new(false);
let path = crate::accounts::instance_log_api(&company);
let log = LocalResource::new(move || {
let (path, open) = (path.clone(), open.get());
async move {
if !open {
return None;
}
Some(crate::api::get::<serde_json::Value>(&path).await)
}
});
view! {
<button class="btn quiet" on:click=move |_| open.update(|o| *o = !*o)>{move || if open.get() { "Hide the setup log" } else { "Setup log" }}</button>
{move || match log.get().flatten() {
None => None,
Some(Err(f)) => Some(view! { <crate::ui::FailBox fail=f/> }.into_any()),
Some(Ok(v)) => {
let lines: Vec<String> = v["lines"].as_array().map(|a| a.iter().map(crate::accounts::log_line).collect()).unwrap_or_default();
Some(if lines.is_empty() {
view! { <p class="hint">"No details."</p> }.into_any()
} else {
view! { <pre class="setup-log mono">{lines.join("\n")}</pre> }.into_any()
})
}
}}
}
}