Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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>
    }
}