crates/monetize-ui-web/src/pages/overview.rs
//! **OVERVIEW** — the only page that spans every estate (owner 2026-10-02).
//!
//! One row per estate: its system map as a thumbnail · the estate · machines ·
//! customers · the month's money · which Stripe keys it holds · health. The
//! list comes from `/api/overview`, which asks nobody, and each row then asks
//! its OWN estate (`/api/estates/<estate>/summary`) — so one estate that is
//! slow holds one row, never the page.
//!
//! The total counts only LIVE money, by each payment's own mode; test money is
//! each row's bracket, `(80.00 EUR)`, and stays out of it. Every decision is
//! `crate::estate`'s, tested on the host.
//!
//! OVW-CLEAN (owner 2026-10-29): the rows are the estates that EXIST — installed, or being laid
//! — as monetize classifies them. What ended or was never installed is one quiet line that opens
//! to show them. The last row is the one empty slot, "New estate": a name and a product, and
//! monetize makes it and hands it to the one site order (the answer names where to pay).
use leptos::prelude::*;
use crate::estate::{
asks_summary, count_cell, estate_href, health_cell, laying_cell, may_make, money_set,
new_estate_body, new_pay_link, overview_total, quiet_line, revenue_cell, stripe_word,
summary_path, thumb, total_note, EstateName, NewEstateJson, OverviewJson, SlotJson,
SummaryJson, Thumb, NEW_ESTATE_API,
};
use crate::ui::{resolved, Badge, Empty, PageHeader, Panel, Tone};
type Summary = LocalResource<Result<SummaryJson, crate::api::Fail>>;
/// A row whose own question failed at the HTTP level is still a row: it says
/// what it was told.
fn settled(r: Option<Result<SummaryJson, crate::api::Fail>>) -> Option<SummaryJson> {
r.map(|r| {
r.unwrap_or_else(|f| SummaryJson {
reached: false,
error: Some(format!("{} {}", f.summary(), f.message)),
..Default::default()
})
})
}
#[component]
pub fn Overview() -> impl IntoView {
// Overview names its estates in its paths; nothing it asks is "the
// current estate".
crate::api::set_estate(None);
let reload = RwSignal::new(0u32);
// The slot's form and its answer outlive the list read again after an order.
let form = SlotForm::new();
let list = LocalResource::new(move || {
reload.get();
async { crate::api::get::<OverviewJson>("/api/overview").await }
});
view! {
<main class="page" id="main">
<PageHeader title="Overview" subtitle="Every estate. Pick one to work in it."/>
{move || resolved(list.get(), None, move |o: OverviewJson| {
let stale_secs = o.stale_secs;
let empty = o.estates.is_empty();
// A row the fleet knows asks its own summary; one being laid before any box of
// it reports says monetize's words and asks nobody.
let rows: Vec<(EstateName, Option<Summary>)> = o
.estates
.into_iter()
.map(|e| {
let summary = asks_summary(&e).then(|| {
let path = summary_path(&e.name);
LocalResource::new(move || { let p = path.clone(); async move { crate::api::get::<SummaryJson>(&p).await } })
});
(e, summary)
})
.collect();
let for_total = rows.iter().filter_map(|(e, r)| (*r).map(|r| (e.name.clone(), r))).collect::<Vec<_>>();
let stale = crate::fmt::stale_note(stale_secs);
let ended = o.ended;
let choices = o.slot;
view! {
<div class="stack">
{stale.map(|w| view! { <p class="hint stale" role="status">{w}</p> })}
{empty.then(|| view! { <Empty title="No estates" detail="No estate is installed or being laid. Make one in the New estate row."/> })}
<Panel title="Estates">
<table class="overview" aria-label="Estates">
<thead><tr>
<th scope="col"><span class="vh">"Map"</span></th>
<th scope="col">"Estate"</th>
<th scope="col" class="num">"Machines"</th>
<th scope="col" class="num">"Customers"</th>
<th scope="col" class="num" title="net of VAT, sales started in the last 30 days; test money in brackets">"Revenue/mo"</th>
<th scope="col">"Stripe"</th>
<th scope="col">"Health"</th>
</tr></thead>
<tbody>
{rows.into_iter().map(|(e, summary)| match summary {
Some(summary) => view! { <EstateRow name=e.name control=e.control.unwrap_or_default() summary=summary/> }.into_any(),
None => view! { <LayingRow e=e/> }.into_any(),
}).collect_view()}
<NewEstateSlot choices=choices reload=reload form=form/>
</tbody>
</table>
{quiet_line(&ended).map(|line| view! {
<details class="ended">
<summary class="hint">{line}</summary>
<ul class="ended-list">
{ended.iter().map(|e| view! {
<li data-estate=e.name.clone()><span class="mono">{e.name.clone()}</span>" "<span class="hint">{if e.product.is_empty() { e.words.clone() } else { format!("{} · {}", e.product, e.words) }}</span></li>
}).collect_view()}
</ul>
</details>
})}
</Panel>
{move || {
let got: Vec<(String, Option<SummaryJson>)> = for_total.iter().map(|(n, r)| (n.clone(), settled(r.get()))).collect();
let t = overview_total(got.iter().map(|(n, s)| (n.as_str(), s.as_ref())));
view! {
<section class="total" aria-label="Total">
<span class="hint">"Live revenue, last 30 days"</span>
<strong class="mono">{money_set(&t.live)}</strong>
<p class="hint">{total_note(&t)}</p>
</section>
}
}}
</div>
}.into_any()
})}
</main>
}
}
/// **A row being laid before the fleet knows it**: monetize's words, and the Sites page, where
/// its order stands.
#[component]
fn LayingRow(e: EstateName) -> impl IntoView {
let (tone, words) = laying_cell(&e);
let host = if e.product.is_empty() {
e.domain.clone()
} else {
format!("{} · {}", e.product, e.domain)
};
view! {
<tr class="estate-row laying" data-estate=e.name.clone()>
<td class="thumb-cell" data-label="Map"><a class="thumb placeholder" href="/sites">"being laid"</a></td>
<td data-label="Estate">
<a class="mono estate-link" href="/sites">{e.name.clone()}</a>
<div class="hint mono">{host}</div>
</td>
<td class="num" data-label="Machines">"—"</td>
<td class="num" data-label="Customers">"—"</td>
<td class="num" data-label="Revenue/mo">"—"</td>
<td data-label="Stripe">"—"</td>
<td data-label="Health"><Badge tone=tone label=words/></td>
</tr>
}
}
/// The slot's fields and its last answer, kept by the page (Copy signals).
#[derive(Clone, Copy)]
struct SlotForm {
name: RwSignal<String>,
product: RwSignal<String>,
email: RwSignal<String>,
busy: RwSignal<bool>,
failed: RwSignal<Option<crate::api::Fail>>,
made: RwSignal<Option<NewEstateJson>>,
}
impl SlotForm {
fn new() -> SlotForm {
SlotForm {
name: RwSignal::new(String::new()),
product: RwSignal::new(String::new()),
email: RwSignal::new(String::new()),
busy: RwSignal::new(false),
failed: RwSignal::new(None),
made: RwSignal::new(None),
}
}
}
/// **The one empty slot, "New estate"**: a name (a declared site with no row is offered by its
/// name), a product, the address the ready mail goes to. One press asks monetize; its answer is
/// its own sentence and the vendor's page to pay at.
#[component]
fn NewEstateSlot(choices: SlotJson, reload: RwSignal<u32>, form: SlotForm) -> impl IntoView {
let SlotForm {
name,
product,
email,
busy,
failed,
made,
} = form;
let first = choices.products.first().cloned().unwrap_or_default();
// The product picked, else the first the box can make.
let chosen = move || {
let p = product.get();
if p.is_empty() {
first.clone()
} else {
p
}
};
let is_admin = move || {
crate::app::session()
.get()
.map(|s| s.is_operator())
.unwrap_or(false)
};
let pick = chosen.clone();
let make = move |_| {
if busy.get_untracked() {
return;
}
busy.set(true);
failed.set(None);
made.set(None);
let body = new_estate_body(&name.get_untracked(), &pick(), &email.get_untracked());
leptos::task::spawn_local(async move {
match crate::api::post::<_, NewEstateJson>(NEW_ESTATE_API, &body).await {
Ok(n) => {
made.set(Some(n));
reload.update(|r| *r = r.wrapping_add(1));
}
Err(f) => failed.set(Some(f)),
}
busy.set(false);
});
};
let products = choices
.products
.iter()
.map(|p| view! { <option value=p.clone()>{p.clone()}</option> })
.collect_view();
let declared = choices
.declared
.iter()
.map(|d| view! { <option value=d.clone()></option> })
.collect_view();
let (title_of, armed_of, shown) = (chosen.clone(), chosen.clone(), chosen);
view! {
<tr class="estate-slot">
<td class="thumb-cell" data-label="Map"><span class="thumb placeholder">"New estate"</span></td>
<td colspan="6" data-label="New estate">
<div class="slot-form">
<label>"Name " <input class="slot-name mono" type="text" autocomplete="off" spellcheck="false" list="slot-declared" placeholder="test4-example-com" prop:value=move || name.get() on:input=move |ev| name.set(event_target_value(&ev))/></label>
<datalist id="slot-declared">{declared}</datalist>
<label>"Product " <select class="slot-product" prop:value=shown on:change=move |ev| product.set(event_target_value(&ev))>{products}</select></label>
<label>"Ready mail to " <input class="slot-email" type="email" autocomplete="email" prop:value=move || email.get() on:input=move |ev| email.set(event_target_value(&ev))/></label>
<button class="btn danger slot-make"
title=move || may_make(&name.get(), &title_of(), &email.get(), is_admin()).1
disabled=move || busy.get() || !may_make(&name.get(), &armed_of(), &email.get(), is_admin()).0
on:click=make>"Order"</button>
</div>
{move || failed.get().map(|f| view! { <p class="failure slot-failed" role="alert">{crate::sites::fail_words(f.status, &f.message)}</p> })}
{move || made.get().map(|n| {
let pay = new_pay_link(&n);
view! {
<div class="notice slot-made" role="status">
<p>{n.words.clone()}</p>
{pay.map(|(words, href)| view! { <p><a class="btn danger slot-pay" href=href target="_blank" rel="noopener noreferrer">{words}</a></p> })}
</div>
}
})}
</td>
</tr>
}
}
#[component]
fn EstateRow(name: String, control: String, summary: Summary) -> impl IntoView {
let broken = RwSignal::new(false);
let estate = name.clone();
view! {
<tr class="estate-row" data-estate=name.clone()>
<td class="thumb-cell" data-label="Map">
{move || match thumb(&estate, settled(summary.get()).as_ref()) {
Thumb::Asking => view! { <span class="thumb placeholder asking">"asking …"</span> }.into_any(),
Thumb::Map { href, .. } if broken.get() => view! { <a class="thumb placeholder" href=href>"map did not load"</a> }.into_any(),
Thumb::Map { src, href } => view! {
<a class="thumb" href=href title=format!("Open {}'s map", estate)>
<img src=src alt=format!("{}'s system map", estate) loading="lazy" on:error=move |_| broken.set(true)/>
</a>
}.into_any(),
Thumb::Placeholder { words, href } => view! { <a class="thumb placeholder" href=href>{words}</a> }.into_any(),
}}
</td>
<td data-label="Estate">
<a class="mono estate-link" href=estate_href(&name, "map")>{name.clone()}</a>
<div class="hint mono">{move || {
let products = settled(summary.get()).map(|s| s.products.join(" · ")).unwrap_or_default();
let host = control.trim_start_matches("https://").to_owned();
if products.is_empty() { host } else { format!("{products} · {host}") }
}}</div>
</td>
{move || match settled(summary.get()) {
None => view! { <td colspan="5" class="hint" data-label="">"asking …"</td> }.into_any(),
Some(s) if !s.reached => {
let (tone, words) = health_cell(&s);
view! {
<td class="num" data-label="Machines">"—"</td>
<td class="num" data-label="Customers">"—"</td>
<td class="num" data-label="Revenue/mo">"—"</td>
<td data-label="Stripe">"—"</td>
<td data-label="Health"><span title=s.error.clone().unwrap_or_default()><Badge tone=tone label=words/></span></td>
}.into_any()
}
Some(s) => {
let (tone, words) = health_cell(&s);
let revenue = revenue_cell(s.revenue.as_ref());
let stripe = stripe_word(&s.vendors, &s.vendor_names, &s.site_vendor);
view! {
<td class="num mono" data-label="Machines" title=crate::estate::machines_title(&s)>{count_cell(s.machines)}</td>
<td class="num mono" data-label="Customers">{count_cell(s.customers)}</td>
<td class="num mono" data-label="Revenue/mo">{match revenue {
None => view! { "—" }.into_any(),
Some((live, test, unsaid)) => view! {
<span>{live}</span>
{test.map(|t| view! { " " <span class="test-money hint" title="test money: shown, never in the total">{t}</span> })}
{unsaid.map(|u| view! { <div class="hint">{u}</div> })}
}.into_any(),
}}</td>
<td data-label="Stripe"><Badge tone=if stripe == "live" { Tone::Live } else { Tone::Plain } label=crate::estate::stripe_label(stripe)/></td>
<td data-label="Health"><Badge tone=tone label=words/></td>
}.into_any()
}
}}
</tr>
}
}