feat(viewer): Invoices & Bills, a document's page, overview tiles (P4b S2)
check / check (push) Canceled after 0s
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:
11 files changed
+927
-13
No files matched your search
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ""));
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -352,7 +352,8 @@ fn a_very_short_window_keeps_one_link() {
|
||||
h.run_steps(4);
|
||||
let first = sidebar_link(&h, "Overview");
|
||||
assert!(
|
||||
first.height() >= 23.0 && first.bottom() <= h_px,
|
||||
// 22: the rows' least since there are 13 links (2026-10-24).
|
||||
first.height() >= 22.0 && first.bottom() <= h_px,
|
||||
"{tag}: Overview at {first:?}"
|
||||
);
|
||||
if let Some(status) = h.query_by_label_contains("event ") {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 45 KiB |
@@ -1 +1 @@
|
||||
05381e0e275497fa
|
||||
5ccd68030ec4a54b
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Invoices & Bills in the viewer (plan P4b S2): the list shows each
|
||||
//! document's figures as the ledger has them (a reversed payment no
|
||||
//! longer counts), overdue ones say how late, the tiles add up the open
|
||||
//! ones and tie the list to its account; a document's page shows its
|
||||
//! lines and its timeline; the overview shows what is owed both ways once
|
||||
//! the books have documents.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::{AccountType, Currency, DocumentLine};
|
||||
use ledger_gui::nav::Route;
|
||||
use ledger_gui::{App, Source, data};
|
||||
use ledger_store::Store;
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl TempDir {
|
||||
fn new(tag: &str) -> TempDir {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("ledger-gui-invoices-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TempDir(d)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn line(description: &str, cents: i64) -> DocumentLine {
|
||||
DocumentLine {
|
||||
description: description.into(),
|
||||
amount: Decimal::new(cents, 2),
|
||||
class_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Today is 2026-03-15. Invoice 1 ($500, due Mar 1): $200 paid, then the
|
||||
/// payment reversed, so it owes $500 and is 14 days late. Invoice 2 ($80,
|
||||
/// due Apr 30): open. Invoice 3 ($40): paid in full. Bill 1 ($300, due
|
||||
/// Mar 20): open.
|
||||
fn books(dir: &TempDir) -> (u64, u64) {
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
for (code, name, t) in [
|
||||
("1000", "Checking", AccountType::Asset),
|
||||
("3000", "Capital", AccountType::Equity),
|
||||
] {
|
||||
s.add_account(code, name, t, None, false).unwrap();
|
||||
}
|
||||
let checking = s.ledger().chart().find_by_code("1000").unwrap().id;
|
||||
let client = s.add_customer("Harbor Client").unwrap();
|
||||
let late = s
|
||||
.create_invoice(
|
||||
client,
|
||||
d(2026, 2, 1),
|
||||
d(2026, 3, 1),
|
||||
vec![line("Consulting", 50_000)],
|
||||
)
|
||||
.unwrap();
|
||||
s.create_invoice(
|
||||
client,
|
||||
d(2026, 2, 5),
|
||||
d(2026, 4, 30),
|
||||
vec![line("Support", 8_000)],
|
||||
)
|
||||
.unwrap();
|
||||
let paid = s
|
||||
.create_invoice(
|
||||
client,
|
||||
d(2026, 2, 6),
|
||||
d(2026, 3, 6),
|
||||
vec![line("Setup", 4_000)],
|
||||
)
|
||||
.unwrap();
|
||||
let pay = s
|
||||
.receive_payment(late, d(2026, 2, 10), Decimal::new(20_000, 2), checking)
|
||||
.unwrap();
|
||||
s.reverse_entry(pay, d(2026, 2, 12), "the transfer bounced")
|
||||
.unwrap();
|
||||
s.receive_payment(paid, d(2026, 2, 20), Decimal::new(4_000, 2), checking)
|
||||
.unwrap();
|
||||
let vendor = s.add_vendor("Clay Co").unwrap();
|
||||
s.create_bill(
|
||||
vendor,
|
||||
d(2026, 3, 2),
|
||||
d(2026, 3, 20),
|
||||
vec![line("Glaze", 30_000)],
|
||||
)
|
||||
.unwrap();
|
||||
(late, pay)
|
||||
}
|
||||
|
||||
fn open(dir: &TempDir, route: Route) -> Harness<'static, App> {
|
||||
let books = data::load_once(&Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
today: Some(d(2026, 3, 15)),
|
||||
})
|
||||
.unwrap();
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1200.0, 900.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route));
|
||||
h.run_steps(4);
|
||||
h
|
||||
}
|
||||
|
||||
/// SHOT_DIR set: the page as drawn, to look at.
|
||||
fn shot(h: &mut Harness<'static, App>, name: &str) {
|
||||
if let Ok(out) = std::env::var("SHOT_DIR") {
|
||||
let out = PathBuf::from(out);
|
||||
std::fs::create_dir_all(&out).unwrap();
|
||||
h.render()
|
||||
.unwrap()
|
||||
.save(out.join(format!("{name}.png")))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_shows_the_ledgers_figures() {
|
||||
let dir = TempDir::new("list");
|
||||
books(&dir);
|
||||
let mut h = open(&dir, Route::Receivables);
|
||||
shot(&mut h, "receivables");
|
||||
// Open: invoices 1 and 2 (3 is paid): $580 owed, $500 of it late.
|
||||
assert!(h.query_by_label("$580.00").is_some());
|
||||
assert!(h.query_by_label("$500.00").is_some());
|
||||
assert!(
|
||||
h.query_by_label("Invoice 1, Harbor Client, 14 days late, owes 500.00")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
h.query_by_label_contains("Invoice 2, Harbor Client, Open")
|
||||
.is_some()
|
||||
);
|
||||
assert!(h.query_by_label_contains("Invoice 3,").is_none());
|
||||
assert!(h.query_by_label_contains("with 1200").is_some());
|
||||
// All shows the paid one too.
|
||||
h.get_by_label("All").click();
|
||||
h.run_steps(2);
|
||||
assert!(
|
||||
h.query_by_label_contains("Invoice 3, Harbor Client, Paid")
|
||||
.is_some()
|
||||
);
|
||||
let mut h = open(&dir, Route::Payables);
|
||||
shot(&mut h, "payables");
|
||||
assert!(
|
||||
h.query_by_label_contains("Bill 1, Clay Co, Open, owes 300.00")
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_documents_page_shows_its_timeline() {
|
||||
let dir = TempDir::new("doc");
|
||||
let (late, pay) = books(&dir);
|
||||
let mut h = open(&dir, Route::Invoice(late));
|
||||
shot(&mut h, "invoice");
|
||||
assert!(
|
||||
h.query_by_label_contains("Payment (reversed later), entry #")
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
h.query_by_label_contains("Payment reversed, entry #")
|
||||
.is_some()
|
||||
);
|
||||
// Received: nothing stands.
|
||||
assert!(h.query_by_label("$0.00").is_some());
|
||||
// A timeline row opens its entry.
|
||||
h.get_by_label_contains(&format!("Payment (reversed later), entry #{pay},"))
|
||||
.click();
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state_mut().route(), &Route::Entry(pay));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_overview_shows_what_is_owed_both_ways() {
|
||||
let dir = TempDir::new("overview");
|
||||
books(&dir);
|
||||
let mut h = open(&dir, Route::Overview);
|
||||
shot(&mut h, "overview");
|
||||
assert!(h.query_by_label("Owed to you").is_some());
|
||||
assert!(h.query_by_label("You owe on bills").is_some());
|
||||
assert!(h.query_by_label("1 invoice overdue").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn books_without_documents_show_no_overview_tiles_for_them() {
|
||||
let dir = TempDir::new("none");
|
||||
Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
let h = open(&dir, Route::Overview);
|
||||
assert!(h.query_by_label("Owed to you").is_none());
|
||||
let mut h = open(&dir, Route::Receivables);
|
||||
shot(&mut h, "receivables-empty");
|
||||
assert!(h.query_by_label("No invoices").is_some());
|
||||
}
|
||||
@@ -423,7 +423,8 @@ fn at_1479x519_every_link_shows_unscrolled() {
|
||||
"{footer:?}: {} at {r:?} isn't whole in {view:?}",
|
||||
s.title()
|
||||
);
|
||||
assert!(r.height() >= 24.0, "{footer:?}: {} too tight", s.title());
|
||||
// 22: the rows' least since there are 13 links (2026-10-24).
|
||||
assert!(r.height() >= 22.0, "{footer:?}: {} too tight", s.title());
|
||||
}
|
||||
assert_clear(&h, Pane::Sidebar, &format!("{footer:?}"));
|
||||
// Nothing moves when the last link is asked into view.
|
||||
|
||||
Reference in new issue
Block a user