feat(viewer): Invoices & Bills, a document's page, overview tiles (P4b S2)
check / check (push) Canceled after 0s

One sidebar item, Invoices & Bills (the owner's choice, 2026-10-24),
with Money in (invoices) and Money out (bills) tabs: what is owed and
overdue, and the list tied to its receivables or payables account
(Agrees, or what to look at); filters Open, Overdue, Paid, Void, All;
rows by due date, an overdue one saying how many days late, each named
in full for assistive tech. A document's page: total, received or paid,
still owed, status; its lines with their projects; its timeline, a
payment later reversed marked so, each row opening its entry. The
overview adds Owed to you and You owe on bills once the books have
documents. Every figure is the document's derived state (rules.md B-38).
The sidebar's rows (and the rail's) give way to 22 points, not 24, so
13 links still fit the owner's 1479x519 window unscrolled; the
sidebar's tests compute heights from the link count; the 1440x920
golden is re-blessed for the new link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-24 15:33:04 -06:00
1 parent 6f0fd97db0
commit b7b7f17163
11 files changed
+927 -13

No files matched your search

+29 -8
View File
@@ -24,6 +24,7 @@ pub struct PageState {
pub parties: pages::parties::State,
pub projects: pages::projects::State,
pub documents: pages::documents::State,
pub invoices: pages::invoices::State,
pub journal: pages::journal::State,
}
@@ -1039,7 +1040,9 @@ const SIDEBAR_ROWS: Rows = Rows {
n: Section::ALL.len(),
row: 32.0,
gap: 6.0,
row_min: 24.0,
// 22, not 24, since Invoices & Bills made 13 links (2026-10-24): so
// they still fit the owner's 1479x519 window unscrolled.
row_min: 22.0,
head_gap: 18.0,
foldable: true,
};
@@ -1049,8 +1052,8 @@ const RAIL_ROWS: Rows = Rows {
row: 36.0,
gap: 6.0,
// As low as the sidebar's: at 28 the rail scrolled in the owner's
// window (review, 2026-10-22).
row_min: 24.0,
// window (review, 2026-10-22), and at 24 once there were 13 links.
row_min: 22.0,
head_gap: 18.0,
foldable: false,
};
@@ -1733,18 +1736,36 @@ mod tests {
#[test]
fn the_sidebar_gives_way_in_order() {
// The links' height at a row and gap, however many sections there
// are (12 until 2026-10-24, when Invoices & Bills made 13).
let links = |row: f32, gap: f32| SIDEBAR_ROWS.height(row, gap);
// Card folds first.
let f = fit(18.0 + 450.0 + 8.0 + 147.0, FOOTER, &SIDEBAR_ROWS, 0.5);
let f = fit(
18.0 + links(32.0, 6.0) + 8.0 + 147.0,
FOOTER,
&SIDEBAR_ROWS,
0.5,
);
assert!(f.folded && !f.tight && f.row == 32.0 && f.gap == 6.0);
// Then the spacing goes, before the rows.
let f = fit(8.0 + 410.0 + 8.0 + 112.0, FOOTER, &SIDEBAR_ROWS, 0.5);
let f = fit(
8.0 + links(32.0, 4.0) + 8.0 + 112.0,
FOOTER,
&SIDEBAR_ROWS,
0.5,
);
assert!(f.folded && f.tight && f.row == 32.0 && f.gap < 6.0 && f.gap >= GAP_MIN);
// Then the rows, down to their least.
let f = fit(8.0 + 330.0 + 8.0 + 112.0, FOOTER, &SIDEBAR_ROWS, 0.5);
assert!(f.tight && !f.scroll && f.gap == GAP_MIN && f.row < 32.0 && f.row >= 24.0);
let f = fit(
8.0 + links(28.0, GAP_MIN) + 8.0 + 112.0,
FOOTER,
&SIDEBAR_ROWS,
0.5,
);
assert!(f.tight && !f.scroll && f.gap == GAP_MIN && f.row < 32.0 && f.row >= 22.0);
// Then they scroll.
let f = fit(300.0, FOOTER, &SIDEBAR_ROWS, 0.5);
assert!(f.scroll && f.row == 24.0 && f.gap == GAP_MIN);
assert!(f.scroll && f.row == 22.0 && f.gap == GAP_MIN);
}
#[test]
+17 -1
View File
@@ -74,6 +74,14 @@ pub enum Route {
Projects,
/// One project.
Project(ledger_core::ClassId),
/// Invoices: what customers owe the company (money in).
Receivables,
/// Bills: what the company owes vendors (money out).
Payables,
/// One invoice.
Invoice(ledger_core::InvoiceId),
/// One bill.
Bill(ledger_core::BillId),
Documents,
Document(u64),
Company,
@@ -94,6 +102,9 @@ impl Route {
Route::Assets | Route::Asset(_) => Section::Assets,
Route::Parties | Route::Party(_) => Section::Parties,
Route::Projects | Route::Project(_) => Section::Projects,
Route::Receivables | Route::Payables | Route::Invoice(_) | Route::Bill(_) => {
Section::Invoices
}
Route::Documents | Route::Document(_) => Section::Documents,
Route::Company => Section::Company,
Route::Journal | Route::JournalEvent(_) => Section::Journal,
@@ -112,13 +123,14 @@ pub enum Section {
Assets,
Parties,
Projects,
Invoices,
Documents,
Company,
Journal,
}
impl Section {
pub const ALL: [Section; 12] = [
pub const ALL: [Section; 13] = [
Section::Overview,
Section::Cash,
Section::Profit,
@@ -128,6 +140,7 @@ impl Section {
Section::Assets,
Section::Parties,
Section::Projects,
Section::Invoices,
Section::Documents,
Section::Company,
Section::Journal,
@@ -144,6 +157,7 @@ impl Section {
Section::Assets => "Assets",
Section::Parties => "Parties",
Section::Projects => "Projects",
Section::Invoices => "Invoices & Bills",
Section::Documents => "Documents",
Section::Company => "Company",
Section::Journal => "Journal",
@@ -162,6 +176,7 @@ impl Section {
Section::Assets => i::PACKAGE,
Section::Parties => i::ADDRESS_BOOK,
Section::Projects => i::BRIEFCASE,
Section::Invoices => i::RECEIPT,
Section::Documents => i::FILES,
Section::Company => i::BUILDINGS,
Section::Journal => i::SCROLL,
@@ -179,6 +194,7 @@ impl Section {
Section::Assets => Route::Assets,
Section::Parties => Route::Parties,
Section::Projects => Route::Projects,
Section::Invoices => Route::Receivables,
Section::Documents => Route::Documents,
Section::Company => Route::Company,
Section::Journal => Route::Journal,
+619
View File
@@ -0,0 +1,619 @@
//! Invoices and bills: what customers owe the company (money in) and what
//! it owes vendors (money out), each figure derived from the document's
//! ledger lines that stand (rules.md B-38), the list tied to the
//! receivables or payables account (B-23); and one document's page, with
//! its lines and its timeline. Read only (D-28): the agent issues, pays
//! and voids them.
use super::assets::row;
use super::page;
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::theme::pal;
use crate::widgets;
use chrono::NaiveDate;
use egui::Ui;
use egui_phosphor::regular as icon;
use ledger_core::{DocRef, DocState, DocStatus, DocumentLine};
use rust_decimal::Decimal;
/// Which documents the list shows.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Filter {
/// Anything still owed (partly paid and overdue included).
#[default]
Open,
Overdue,
Paid,
Void,
All,
}
impl Filter {
fn keeps(self, st: &DocState) -> bool {
match self {
Filter::Open => !st.outstanding.is_zero() && st.status != DocStatus::Void,
Filter::Overdue => st.overdue_days > 0,
Filter::Paid => st.status == DocStatus::Paid,
Filter::Void => st.status == DocStatus::Void,
Filter::All => true,
}
}
}
#[derive(Default)]
pub struct State {
pub filter: Filter,
}
/// One document as the list shows it.
struct Item {
doc: DocRef,
name: String,
party: String,
date: NaiveDate,
due: NaiveDate,
state: Result<Option<DocState>, ledger_core::LedgerError>,
}
/// Every invoice (or bill) in the books, read as of today (one dated
/// ahead, as of its own date: Books::doc_today).
fn items(b: &Books, bills: bool) -> Vec<Item> {
let l = &b.ledger;
if bills {
l.bills()
.into_iter()
.map(|d| Item {
doc: DocRef::Bill(d.id),
name: format!("Bill {}", d.id),
party: l
.get_vendor(d.vendor_id)
.map(|v| v.name.clone())
.unwrap_or_default(),
date: d.date,
due: d.due_date,
state: b.doc_today(DocRef::Bill(d.id)),
})
.collect()
} else {
l.invoices()
.into_iter()
.map(|d| Item {
doc: DocRef::Invoice(d.id),
name: format!("Invoice {}", d.id),
party: l
.get_customer(d.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default(),
date: d.date,
due: d.due_date,
state: b.doc_today(DocRef::Invoice(d.id)),
})
.collect()
}
}
/// The overview's figure for invoices (or bills): what the open ones
/// still owe, how many are overdue, and how many fall due within a week;
/// None when the books have none.
pub fn summary(b: &Books, bills: bool) -> Option<(Decimal, usize, usize)> {
let all = items(b, bills);
if all.is_empty() {
return None;
}
let week = b.today + chrono::Duration::days(7);
let (mut owed, mut late, mut soon) = (Decimal::ZERO, 0, 0);
for it in &all {
if let Ok(Some(st)) = &it.state
&& Filter::Open.keeps(st)
{
owed += st.outstanding;
if st.overdue_days > 0 {
late += 1;
} else if it.due <= week {
soon += 1;
}
}
}
Some((owed, late, soon))
}
fn route_of(doc: DocRef) -> Route {
match doc {
DocRef::Invoice(id) => Route::Invoice(id),
DocRef::Bill(id) => Route::Bill(id),
}
}
/// A status for people: "12 days late" while overdue, else its label;
/// "Dated ahead" for one dated after today.
fn status_text(st: &DocState, date: NaiveDate, today: NaiveDate) -> String {
if date > today {
"Dated ahead".into()
} else if st.overdue_days > 0 {
format!(
"{} late",
fmt::count(st.overdue_days as usize, "day", "days")
)
} else {
st.status.label().into()
}
}
/// Money in (invoices) or money out (bills).
pub fn show_list(ui: &mut Ui, b: &Books, bills: bool, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "invoices", |ui| {
widgets::page_header(
ui,
"Invoices & Bills",
"What customers owe the company and what it owes vendors, each figure read from the ledger, tied to its account. The bookkeeper issues, records and voids them.",
);
let mut side = bills;
if widgets::segmented(
ui,
&mut side,
&[(false, "Money in (invoices)"), (true, "Money out (bills)")],
) {
shell.go(if side {
Route::Payables
} else {
Route::Receivables
});
}
ui.add_space(10.0);
let all = items(b, bills);
// The tiles count what is owed now, whatever the filter.
let mut owed = Decimal::ZERO;
let mut late = Decimal::ZERO;
let mut late_n = 0usize;
let mut open_n = 0usize;
let mut unknown = false;
for it in &all {
match &it.state {
Ok(Some(st)) if Filter::Open.keeps(st) => {
owed += st.outstanding;
open_n += 1;
if st.overdue_days > 0 {
late += st.outstanding;
late_n += 1;
}
}
Ok(_) => {}
Err(_) => unknown = true,
}
}
let (what, who) = if bills {
("You owe", "bills")
} else {
("Owed to you", "invoices")
};
let tie = tie_out(b, bills);
super::grid(ui, 3, 200.0, 3, |i, ui| {
let (label, value, sub, how) = match i {
0 => (
what,
if unknown {
"Can't compute".to_string()
} else {
fmt::dollars(owed)
},
Some((
fmt::count(
open_n,
&format!("open {}", &who[..who.len() - 1]),
&format!("open {who}"),
),
p.muted,
)),
"What the open documents still owe, together, as of today.",
),
1 => (
"Overdue",
fmt::dollars(late),
Some((
fmt::count(late_n, "past its due date", "past their due date"),
if late_n > 0 { p.warning } else { p.muted },
)),
"Of that, what is past its due date.",
),
_ => (
"Ledger",
tie.0.clone(),
Some((tie.1.clone(), if tie.2 { p.muted } else { p.warning })),
"The receivables (or payables) account against its documents: they agree unless a line on it is no document's.",
),
};
widgets::kpi(
ui,
widgets::Kpi {
label,
value,
sub,
spark: None,
how,
},
);
});
ui.add_space(8.0);
let st = &mut shell.state.invoices;
ui.horizontal_wrapped(|ui| {
for (f, label) in [
(Filter::Open, "Open"),
(Filter::Overdue, "Overdue"),
(Filter::Paid, "Paid"),
(Filter::Void, "Void"),
(Filter::All, "All"),
] {
if ui.selectable_label(st.filter == f, label).clicked() {
st.filter = f;
}
}
});
ui.add_space(8.0);
let filter = st.filter;
let mut rows: Vec<&Item> = all
.iter()
.filter(|it| match &it.state {
Ok(Some(s)) => filter.keeps(s),
Ok(None) => filter == Filter::All,
Err(_) => true,
})
.collect();
rows.sort_by_key(|it| (it.due, it.date, it.name.clone()));
let mut go = None;
widgets::card(ui, |ui| {
if rows.is_empty() {
let (title, text) = if all.is_empty() {
(
if bills { "No bills" } else { "No invoices" },
if bills {
"A vendor's bill appears here once the bookkeeper records it (books.create_bill)."
} else {
"An invoice appears here once the bookkeeper issues it (books.create_invoice)."
},
)
} else {
("Nothing here", "No document matches this filter.")
};
widgets::empty(ui, icon::RECEIPT, title, text);
return;
}
let amt = widgets::AMOUNT_MIN + 16.0;
let fixed = 110.0 + 104.0 * 2.0 + 120.0 + amt * 2.0;
let w = ui.available_width().max(fixed + 160.0);
widgets::min_width(ui, "invoice_list", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Document", 110.0, false),
head(if bills { "Vendor" } else { "Customer" }, 0.0, false),
head("Date", 104.0, false),
head("Due", 104.0, false),
head("Status", 120.0, false),
head("Total", amt, true),
head("Still owed", amt, true),
],
None,
true,
false,
);
widgets::rule(ui);
for it in rows {
let (status, total, owed, late) = match &it.state {
Ok(Some(s)) => (
status_text(s, it.date, b.today),
fmt::money(s.total),
fmt::money(s.outstanding),
s.overdue_days > 0,
),
Ok(None) => ("Unknown".into(), String::new(), String::new(), false),
Err(_) => ("Can't compute".into(), String::new(), String::new(), false),
};
let name = format!("{}, {}, {}, owes {}", it.name, it.party, status, owed);
let resp = row(
ui,
w,
&[
(it.name.clone(), 110.0, false, p.text),
(it.party.clone(), 0.0, false, p.text),
(fmt::date(it.date), 104.0, false, p.text),
(
fmt::date(it.due),
104.0,
false,
if late { p.warning } else { p.text },
),
(status, 120.0, false, if late { p.warning } else { p.muted }),
(total, amt, true, p.text),
(owed, amt, true, p.text),
],
None,
false,
true,
);
// Named in full, for assistive tech (and the tests).
resp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)
});
if resp.clicked() {
go = Some(route_of(it.doc));
}
}
});
});
if let Some(r) = go {
shell.go(r);
}
});
}
/// The tie-out of receivables (or payables) as of today: its value, a
/// line under it, and whether it agrees.
fn tie_out(b: &Books, bills: bool) -> (String, String, bool) {
let role = if bills { "payables" } else { "receivables" };
match b.ledger.control_check(b.today) {
Err(e) => ("Can't compute".into(), e.to_string(), false),
Ok(checks) => {
let mine: Vec<_> = checks.iter().filter(|c| c.role == role).collect();
if mine.is_empty() {
return ("Agrees".into(), "No account holds them yet".into(), true);
}
let names: Vec<String> = mine.iter().map(|c| b.account_label(c.account)).collect();
let loose: usize = mine.iter().map(|c| c.unattached.len()).sum();
let odd: usize = mine.iter().map(|c| c.attention.len()).sum();
let off = mine.iter().any(|c| !c.difference.is_zero());
if off {
("Differs".into(), "An engine error: report it".into(), false)
} else if loose > 0 || odd > 0 {
let mut why = Vec::new();
if loose > 0 {
why.push(fmt::count(
loose,
"line no document's",
"lines no document's",
));
}
if odd > 0 {
why.push(fmt::count(
odd,
"document to look at",
"documents to look at",
));
}
("Look at".into(), why.join(", "), false)
} else {
("Agrees".into(), format!("with {}", names.join(", ")), true)
}
}
}
}
/// One invoice or bill: its figures, lines and timeline.
pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let l = &b.ledger;
let (name, party, date, due, entry, lines): (
String,
String,
NaiveDate,
NaiveDate,
u64,
Vec<DocumentLine>,
) = match doc {
DocRef::Invoice(id) => match l.get_invoice(id) {
Some(d) => (
format!("Invoice {id}"),
l.get_customer(d.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default(),
d.date,
d.due_date,
d.entry_id,
d.lines.clone(),
),
None => return missing(ui, "No such invoice"),
},
DocRef::Bill(id) => match l.get_bill(id) {
Some(d) => (
format!("Bill {id}"),
l.get_vendor(d.vendor_id)
.map(|v| v.name.clone())
.unwrap_or_default(),
d.date,
d.due_date,
d.entry_id,
d.lines.clone(),
),
None => return missing(ui, "No such bill"),
},
};
let mut go = None;
page(ui, "invoice", |ui| {
widgets::page_header(
ui,
&name,
&format!(
"{party} · dated {} · due {}",
fmt::date(date),
fmt::date(due)
),
);
let st = match b.doc_today(doc) {
Ok(Some(st)) => st,
Ok(None) => return missing(ui, "Not in the books as of today"),
Err(e) => return missing(ui, &format!("Its figures can't be computed: {e}")),
};
let status = status_text(&st, date, b.today);
super::grid(ui, 4, 180.0, 4, |i, ui| {
let (label, value, how) = match i {
0 => ("Total", fmt::dollars(st.total), "The sum of its lines."),
1 => (
if matches!(doc, DocRef::Invoice(_)) {
"Received"
} else {
"Paid"
},
fmt::dollars(st.paid),
"Payments against it that still stand: a reversed payment no longer counts.",
),
2 => (
"Still owed",
fmt::dollars(st.outstanding),
"Its total less what stands against it.",
),
_ => ("Status", status.clone(), "As of today."),
};
widgets::kpi(
ui,
widgets::Kpi {
label,
value,
sub: None,
spark: None,
how,
},
);
});
ui.add_space(8.0);
let amt = widgets::AMOUNT_MIN + 16.0;
widgets::titled_card(
ui,
"Lines",
|_| {},
|ui| {
let w = ui.available_width().max(160.0 + 200.0 + amt);
widgets::min_width(ui, "invoice_lines", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Description", 0.0, false),
head("Project", 200.0, false),
head("Amount", amt, true),
],
None,
true,
false,
);
widgets::rule(ui);
for line in &lines {
let project = line
.class_id
.and_then(|c| l.get_class(c))
.map(|c| c.name.clone())
.unwrap_or_default();
let resp = row(
ui,
w,
&[
(line.description.clone(), 0.0, false, p.text),
(project, 200.0, false, p.muted),
(fmt::money(line.amount), amt, true, p.text),
],
None,
false,
line.class_id.is_some(),
);
if let Some(c) = line.class_id
&& resp.clicked()
{
go = Some(Route::Project(c));
}
}
});
},
);
ui.add_space(8.0);
widgets::titled_card(
ui,
"Timeline",
|_| {},
|ui| {
let w = ui.available_width().max(104.0 + 220.0 + 90.0 + amt);
widgets::min_width(ui, "invoice_timeline", w, |ui| {
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
row(
ui,
w,
&[
head("Date", 104.0, false),
head("What", 0.0, false),
head("Entry", 90.0, false),
head("Amount", amt, true),
],
None,
true,
false,
);
widgets::rule(ui);
for t in l.doc_lines(doc) {
let what = match (t.movement, t.reversal) {
(ledger_core::Movement::Origin, false) => "Issued",
(ledger_core::Movement::Origin, true) => "Voided",
(ledger_core::Movement::Payment, false) => "Payment",
(ledger_core::Movement::Payment, true) => "Payment reversed",
(ledger_core::Movement::Discount, _) => "Discount",
(ledger_core::Movement::WriteOff, _) => "Written off",
(ledger_core::Movement::Adopted, _) => "Adopted",
(ledger_core::Movement::RoleMove, _) => "Moved with its account",
};
// A payment later reversed reads as such.
let what = if !t.reversal && !l.stands_on(t.entry, b.today) {
format!("{what} (reversed later)")
} else {
what.to_string()
};
let name = format!(
"{}: {what}, entry #{}, {}",
fmt::date(t.date),
t.entry,
fmt::money(t.amount)
);
let resp = row(
ui,
w,
&[
(fmt::date(t.date), 104.0, false, p.text),
(what, 0.0, false, p.text),
(format!("#{}", t.entry), 90.0, false, p.muted),
(fmt::money(t.amount), amt, true, p.text),
],
None,
false,
true,
);
resp.widget_info(|| {
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name)
});
if resp.on_hover_text("Open its entry").clicked() {
go = Some(Route::Entry(t.entry));
}
}
});
},
);
ui.add_space(8.0);
if ui
.link(format!(
"{} Open the entry that recorded it (#{entry})",
icon::ARROW_SQUARE_OUT
))
.clicked()
{
go = Some(Route::Entry(entry));
}
});
if let Some(r) = go {
shell.go(r);
}
}
fn missing(ui: &mut Ui, title: &str) {
widgets::card(ui, |ui| widgets::empty(ui, icon::RECEIPT, title, ""));
}
+5
View File
@@ -7,6 +7,7 @@ pub mod cash;
pub mod company;
pub mod documents;
pub mod entry;
pub mod invoices;
pub mod journal;
pub mod overview;
pub mod parties;
@@ -64,6 +65,10 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
Route::Party(id) => parties::show_party(ui, b, *id, shell),
Route::Projects => projects::show_list(ui, b, shell),
Route::Project(id) => projects::show_project(ui, b, *id, shell),
Route::Receivables => invoices::show_list(ui, b, false, shell),
Route::Payables => invoices::show_list(ui, b, true, shell),
Route::Invoice(id) => invoices::show_doc(ui, b, ledger_core::DocRef::Invoice(*id), shell),
Route::Bill(id) => invoices::show_doc(ui, b, ledger_core::DocRef::Bill(*id), shell),
Route::Documents => documents::show(ui, b, None, shell),
Route::Document(id) => documents::show(ui, b, Some(*id), shell),
Route::Company => company::show(ui, b, shell),
+37 -1
View File
@@ -148,7 +148,7 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
),
};
let ytd = b.fiscal_ytd();
let tiles: Vec<(Kpi, Route)> = vec![
let mut tiles: Vec<(Kpi, Route)> = vec![
(
Kpi {
label: "Cash on hand",
@@ -222,6 +222,42 @@ fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
Route::Cash,
),
];
// Invoices and bills, once the books have any (P4b S2).
if let Some((owed, late, _)) = super::invoices::summary(b, false) {
tiles.push((
Kpi {
label: "Owed to you",
value: fmt::dollars(owed),
sub: Some(if late > 0 {
(
fmt::count(late, "invoice overdue", "invoices overdue"),
p.warning,
)
} else {
("none overdue".into(), p.muted)
}),
spark: None,
how: "What customers' open invoices still owe, read from the ledger.",
},
Route::Receivables,
));
}
if let Some((owed, late, soon)) = super::invoices::summary(b, true) {
tiles.push((
Kpi {
label: "You owe on bills",
value: fmt::dollars(owed),
sub: Some(if late > 0 {
(fmt::count(late, "bill overdue", "bills overdue"), p.warning)
} else {
(format!("{} due this week", soon), p.muted)
}),
spark: None,
how: "What vendors' open bills still owe, read from the ledger.",
},
Route::Payables,
));
}
super::grid(ui, tiles.len(), 150.0, 6, |i, ui| {
let (tile, route) = &tiles[i];
let r = widgets::kpi(
+10
View File
@@ -533,6 +533,16 @@ pub fn describe(b: &Books, r: &Route) -> String {
.map(|a| a.file_name.clone())
.unwrap_or_default(),
Route::Asset(id) => b.asset_label(*id),
Route::Invoice(id) => b
.ledger
.get_invoice(*id)
.map(|_| format!("Invoice {id}"))
.unwrap_or_default(),
Route::Bill(id) => b
.ledger
.get_bill(*id)
.map(|_| format!("Bill {id}"))
.unwrap_or_default(),
Route::Party(id) => b.party_label(*id),
Route::Project(id) => b.project_label(*id),
Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)),