feat: ledger-gui — a read-only, live desktop view of the books

egui/wgpu app for owners and their accountant: Overview, Cash, Profit,
Transactions (with rendered receipts and journal history), Reports
(statements with interval columns, comparisons, drill-down), Accounts,
Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark.
Follows the journal live (~30 ms from write to screen). --demo opens a
deterministic demo company, also used for screenshot tests.

Engine: ledger-store Snapshot (read-only, incremental, tolerant of a
half-written tail); ledger-core analysis (intervals, balance series,
P&L by interval, cash flows); ledger-config extracted from ledger-mcp
with the full company profile and cash_accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 16:20:14 -07:00
1 parent b48addd0e0
commit 105fd52694
52 files changed
+14050 -113

No files matched your search

+350
View File
@@ -0,0 +1,350 @@
//! The chart of accounts with balances, and one account's register: every
//! posting with a running balance and the balance over time.
use super::page;
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use chrono::NaiveDate;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::AccountType;
use ledger_core::analysis::balance_history;
use ledger_core::reports::general_ledger;
use rust_decimal::Decimal;
#[derive(Default)]
pub struct State {
/// Limit the register to these dates (set by drill-downs).
pub range: Option<(NaiveDate, NaiveDate)>,
pub show_empty: bool,
}
pub fn show_chart(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "accounts", |ui| {
widgets::page_header(
ui,
"Accounts",
"Every account in the chart with today's balance. Click one for its register.",
);
ui.checkbox(
&mut shell.state.accounts.show_empty,
"Show accounts with no activity",
);
ui.add_space(10.0);
let used: std::collections::HashSet<u64> = b
.entries
.iter()
.flat_map(|e| e.accounts.iter().copied())
.collect();
for (t, title) in [
(AccountType::Asset, "Assets"),
(AccountType::Liability, "Liabilities"),
(AccountType::Equity, "Equity"),
(AccountType::Revenue, "Income"),
(AccountType::Expense, "Expenses"),
] {
let accounts: Vec<_> = b
.ledger
.chart()
.all()
.filter(|a| {
a.account_type == t && (shell.state.accounts.show_empty || used.contains(&a.id))
})
.collect();
if accounts.is_empty() {
continue;
}
let mut go = None;
widgets::titled_card(
ui,
title,
|_| {},
|ui| {
for a in &accounts {
let bal = b.balance_on(a.id, b.today);
let (r, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(r);
painter.text(
r.left_center() + vec2(8.0, 0.0),
egui::Align2::LEFT_CENTER,
&a.code,
theme::regular(13.0),
p.muted,
);
painter.text(
r.left_center() + vec2(64.0, 0.0),
egui::Align2::LEFT_CENTER,
&a.name,
theme::regular(13.5),
p.text,
);
let mut tags = Vec::new();
if b.cash_accounts.contains(&a.id) {
tags.push("cash".to_string());
}
if let Some(c) = &a.tax_category {
let pct = a
.deductible_percent
.map(|p| format!(" {}%", p.normalize()))
.unwrap_or_default();
tags.push(format!("tax: {}{pct}", c.replace('_', " ")));
} else if matches!(t, AccountType::Revenue | AccountType::Expense) {
tags.push("no tax category".into());
}
if a.placeholder {
tags.push("header".into());
}
painter.text(
r.left_center() + vec2(340.0, 0.0),
egui::Align2::LEFT_CENTER,
tags.join(" · "),
theme::regular(12.0),
p.faint,
);
painter.text(
r.right_center() - vec2(8.0, 0.0),
egui::Align2::RIGHT_CENTER,
fmt::accounting(bal),
theme::semibold(13.5),
p.text,
);
if resp.clicked() {
go = Some(a.id);
}
}
},
);
if let Some(id) = go {
shell.state.accounts.range = None;
shell.go(Route::Account(id));
}
ui.add_space(12.0);
}
});
}
pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(a) = b.ledger.chart().get(id) else {
return;
};
let first = b.kpis.first_entry.unwrap_or(b.today);
let (s, e) = shell
.state
.accounts
.range
.unwrap_or((first.min(b.today), b.today));
page(ui, "account", |ui| {
ui.label(
RichText::new(format!("{:?} account · {}", a.account_type, a.code))
.font(theme::regular(13.0))
.color(p.muted),
);
ui.label(RichText::new(&a.name).font(theme::bold(26.0)));
ui.add_space(4.0);
let bal = b.balance_on(id, b.today);
ui.label(
RichText::new(format!("Balance today {}", fmt::dollars(bal)))
.font(theme::regular(15.0))
.color(p.muted),
);
ui.add_space(14.0);
let hist = balance_history(&b.ledger, &[id]);
if hist.len() > 1 && a.account_type.is_balance_sheet() {
widgets::titled_card(
ui,
"Balance over time",
|_| {},
|ui| crate::charts::balance_line(ui, &hist, b.today, 180.0),
);
ui.add_space(12.0);
}
ui.horizontal(|ui| {
ui.label(
RichText::new(format!(
"{} {} – {}",
icon::CALENDAR_BLANK,
fmt::date(s),
fmt::date(e)
))
.font(theme::semibold(13.5)),
);
if shell.state.accounts.range.is_some() && ui.button("Show all dates").clicked() {
shell.state.accounts.range = None;
}
if ui
.button(format!("{} Find in transactions", icon::LIST_BULLETS))
.clicked()
{
shell.state.transactions.account = Some(id);
shell.go(Route::Transactions);
}
});
ui.add_space(8.0);
let Ok(gl) = general_ledger(&b.ledger, s, e) else {
return;
};
let Some(acct) = gl.accounts.iter().find(|x| x.code == a.code) else {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::EMPTY,
"No activity",
"Nothing posted to this account in this range.",
)
});
return;
};
let mut go = None;
widgets::card(ui, |ui| {
let w = ui.available_width();
let amt = 120.0;
let header = |ui: &mut Ui, vals: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 26.0), Sense::hover());
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[0],
theme::semibold(12.0),
p.muted,
);
ui.painter().text(
r.left_center() + vec2(90.0, 0.0),
egui::Align2::LEFT_CENTER,
vals[1],
theme::semibold(12.0),
p.muted,
);
for (i, v) in vals[2..].iter().enumerate() {
ui.painter().text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*v,
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Date", "Description", "Debit", "Credit", "Balance"]);
widgets::rule(ui);
let line = |ui: &mut Ui,
date: String,
desc: String,
sub: String,
d: Decimal,
c: Decimal,
bal: Decimal,
strong: bool|
-> egui::Response {
let (r, resp) = ui.allocate_exact_size(
vec2(w, if sub.is_empty() { 30.0 } else { 42.0 }),
Sense::click(),
);
if resp.hovered() && !strong {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(r);
let font = if strong {
theme::semibold(13.0)
} else {
theme::regular(13.0)
};
let y = if sub.is_empty() {
r.center().y
} else {
r.center().y - 7.0
};
painter.text(
egui::pos2(r.left() + 4.0, y),
egui::Align2::LEFT_CENTER,
date,
theme::regular(12.5),
p.muted,
);
painter.text(
egui::pos2(r.left() + 90.0, y),
egui::Align2::LEFT_CENTER,
desc,
font.clone(),
p.text,
);
if !sub.is_empty() {
painter.text(
egui::pos2(r.left() + 90.0, r.center().y + 9.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(11.5),
p.faint,
);
}
for (i, v) in [d, c].iter().enumerate() {
if !v.is_zero() {
painter.text(
egui::pos2(r.right() - amt * (2 - i) as f32 - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::money(*v),
font.clone(),
p.text,
);
}
}
painter.text(
egui::pos2(r.right() - 4.0, y),
egui::Align2::RIGHT_CENTER,
fmt::accounting(bal),
font,
p.text,
);
resp
};
line(
ui,
fmt::date_short(s),
"Beginning balance".into(),
String::new(),
Decimal::ZERO,
Decimal::ZERO,
acct.beginning_balance,
true,
);
for post in acct.postings.iter().rev() {
let r = line(
ui,
fmt::date_short(post.date),
post.description.clone(),
format!("#{} · {}", post.entry_id, post.split),
post.debit,
post.credit,
post.balance,
false,
);
if r.clicked() {
go = Some(post.entry_id);
}
}
widgets::rule(ui);
line(
ui,
fmt::date_short(e),
"Ending balance".into(),
String::new(),
acct.total_debits,
acct.total_credits,
acct.ending_balance,
true,
);
});
if let Some(entry) = go {
shell.go(Route::Entry(entry));
}
});
}
+457
View File
@@ -0,0 +1,457 @@
//! Cash: what's in the bank, where it's been going, how long it lasts, and
//! what's coming in and out next.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use chrono::{Days, NaiveDate};
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, balance_history, flows_by_interval, interval_start};
use rust_decimal::Decimal;
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "cash", |ui| {
let k = &b.kpis;
let since = b.today - Days::new(30);
let flows = flows_by_interval(
&b.ledger,
&b.cash_accounts,
since + Days::new(1),
b.today,
Interval::Year,
);
let (cin, cout): (Decimal, Decimal) =
flows.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, f| {
(a.0 + f.2, a.1 + f.3)
});
widgets::page_header(
ui,
"Cash",
&format!(
"{} across {}. In the last 30 days {} came in and {} went out.",
fmt::dollars(k.cash),
fmt::count(b.cash_accounts.len(), "account", "accounts"),
fmt::dollars(cin),
fmt::dollars(cout)
),
);
if b.cash_accounts.is_empty() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::BANK,
"No cash accounts found",
"Name an asset account \"checking\", \"savings\", or \"cash\", or set [books] cash_accounts in wanderbooks.toml.",
)
});
return;
}
// One tile per cash and card account.
let mut tiles: Vec<(u64, bool)> = b.cash_accounts.iter().map(|a| (*a, false)).collect();
tiles.extend(b.card_accounts.iter().map(|a| (*a, true)));
let n = tiles.len().clamp(1, 4);
for row in tiles.chunks(n) {
columns(ui, 0.0, &vec![1.0; n], |i, ui| {
let Some((id, is_card)) = row.get(i) else {
return;
};
let hist = balance_history(&b.ledger, &[*id]);
let spark: Vec<f64> = hist
.iter()
.rev()
.take(40)
.rev()
.map(|(_, v)| (*v).try_into().unwrap_or(0.0))
.collect();
let bal = b.balance_on(*id, b.today);
let ago = b.balance_on(*id, since);
let delta = bal - ago;
let r = widgets::kpi(
ui,
widgets::Kpi {
label: &b.account_label(*id),
value: fmt::dollars(bal),
sub: Some((
if *is_card {
"owed on the card".to_string()
} else if delta.is_zero() {
"no change in 30 days".to_string()
} else {
format!(
"{}{} in 30 days",
if delta > Decimal::ZERO { "+" } else { "" },
fmt::dollars(delta)
)
},
if *is_card || delta.is_zero() {
p.muted
} else if delta > Decimal::ZERO {
p.positive
} else {
p.negative
},
)),
spark: (spark.len() > 1).then_some(&spark[..]),
how: "",
},
);
if r.interact(Sense::click()).clicked() {
shell.state.accounts.range = None;
shell.go(Route::Account(*id));
}
});
}
let first = k.first_entry.unwrap_or(b.today);
let (start, end) = b.trailing_months(12);
let start = start.max(interval_start(first, Interval::Month));
columns(ui, 980.0, &[3.0, 2.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Cash balance",
|_| {},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 240.0),
);
}
_ => {
let months =
flows_by_interval(&b.ledger, &b.cash_accounts, start, end, Interval::Month);
widgets::titled_card(
ui,
"Money in and out by month",
|_| {},
|ui| {
let groups: Vec<BarGroup> = months
.iter()
.map(|(s, _, i, o)| BarGroup {
label: s.format("%b").to_string(),
values: vec![*i, *o],
})
.collect();
charts::bar_chart(
ui,
&groups,
&[("In", p.series[1]), ("Out", p.series[2])],
210.0,
);
},
);
}
});
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => runway(ui, b),
_ => coming_up(ui, b, shell),
});
biggest_outflows(ui, b, shell);
});
}
fn runway(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let ms = b.month_start();
let three = ms - chrono::Months::new(3);
let start = three.max(
b.kpis
.first_entry
.map_or(three, |f| interval_start(f, Interval::Month)),
);
let months = flows_by_interval(
&b.ledger,
&b.cash_accounts,
start,
ms - Days::new(1),
Interval::Month,
);
widgets::titled_card(
ui,
"Burn and runway",
|_| {},
|ui| {
if months.is_empty() {
ui.label(RichText::new("Needs at least one full month of books.").color(p.muted));
return;
}
let net: Decimal = months.iter().map(|(_, _, i, o)| i - o).sum::<Decimal>()
/ Decimal::from(months.len() as u64);
let net = net.round_dp(2);
let big = |ui: &mut Ui, label: &str, value: String, color| {
ui.vertical(|ui| {
ui.label(
RichText::new(label)
.font(theme::regular(12.5))
.color(p.muted),
);
ui.label(
RichText::new(value)
.font(theme::semibold(22.0))
.color(color),
);
});
};
ui.horizontal(|ui| {
big(
ui,
"Average monthly net cash flow",
format!(
"{}{}",
if net > Decimal::ZERO { "+" } else { "" },
fmt::dollars(net)
),
if net >= Decimal::ZERO {
p.positive
} else {
p.negative
},
);
ui.add_space(32.0);
if net < Decimal::ZERO {
let months_left = (b.kpis.cash / -net).round_dp(1);
let out = b.today
+ Days::new((months_left * Decimal::from(30)).try_into().unwrap_or(0u64));
big(ui, "Runway", format!("{months_left} months"), p.text);
ui.add_space(32.0);
big(ui, "Cash runs out around", fmt::month(out), p.warning);
} else {
big(ui, "Runway", "Cash is growing".into(), p.positive);
}
});
ui.add_space(8.0);
ui.label(
RichText::new(format!(
"Cash in minus cash out of {}, averaged over the last {} (owner contributions and draws included: this is what the bank sees).",
b.cash_accounts.iter().map(|a| b.account_name(*a)).collect::<Vec<_>>().join(" and "),
fmt::count(months.len(), "full month", "full months")
))
.font(theme::regular(12.0))
.color(p.faint),
);
},
);
}
struct Upcoming {
date: NaiveDate,
label: String,
amount: Decimal,
route: Option<Route>,
}
fn coming_up(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let horizon = b.today + Days::new(45);
let mut items: Vec<Upcoming> = Vec::new();
for r in b.ledger.recurring_entries() {
let cash: Decimal = r
.lines
.iter()
.filter(|(a, _, _)| b.cash_accounts.contains(a))
.map(|(_, s, m)| {
if *s == ledger_core::Side::Debit {
m.amount
} else {
-m.amount
}
})
.sum();
for d in r
.due(horizon)
.into_iter()
.filter(|d| *d > b.today - Days::new(1))
{
items.push(Upcoming {
date: d,
label: format!("{} (recurring)", r.description),
amount: cash,
route: Some(Route::Company),
});
}
}
for id in 1..b.ledger.next_invoice_id() {
if let Some(inv) = b.ledger.get_invoice(id)
&& !inv.balance().is_zero()
{
let who = b
.ledger
.get_customer(inv.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default();
let late = inv.due_date < b.today;
items.push(Upcoming {
date: inv.due_date,
label: format!(
"Invoice {id} from {who}{}",
if late { " — overdue" } else { "" }
),
amount: inv.balance(),
route: Some(Route::Entry(inv.entry_id)),
});
}
}
for id in 1..b.ledger.next_bill_id() {
if let Some(bill) = b.ledger.get_bill(id)
&& !bill.balance().is_zero()
{
let who = b
.ledger
.get_vendor(bill.vendor_id)
.map(|v| v.name.clone())
.unwrap_or_default();
items.push(Upcoming {
date: bill.due_date,
label: format!("Bill {id} to {who}"),
amount: -bill.balance(),
route: Some(Route::Entry(bill.entry_id)),
});
}
}
items.sort_by_key(|i| i.date);
let mut go = None;
widgets::titled_card(
ui,
"Coming up",
|ui| {
ui.label(
RichText::new("open invoices, bills, recurring")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if items.is_empty() {
widgets::empty(
ui,
icon::CALENDAR_CHECK,
"Nothing scheduled",
"No open invoices, bills, or recurring entries due soon.",
);
return;
}
let (inflow, outflow): (Decimal, Decimal) =
items.iter().fold((Decimal::ZERO, Decimal::ZERO), |a, i| {
if i.amount > Decimal::ZERO {
(a.0 + i.amount, a.1)
} else {
(a.0, a.1 - i.amount)
}
});
ui.label(
RichText::new(format!(
"Expected in {} · expected out {}",
fmt::dollars(inflow),
fmt::dollars(outflow)
))
.font(theme::semibold(13.0)),
);
ui.add_space(6.0);
egui::ScrollArea::vertical()
.id_salt("upcoming")
.max_height(220.0)
.show(ui, |ui| {
for it in &items {
let (r, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
let late = it.date < b.today;
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::date_short(it.date),
theme::regular(12.5),
if late { p.negative } else { p.muted },
);
ui.painter().text(
r.left_center() + vec2(70.0, 0.0),
egui::Align2::LEFT_CENTER,
&it.label,
theme::regular(13.0),
p.text,
);
let (txt, c) = if it.amount > Decimal::ZERO {
(format!("+{}", fmt::money(it.amount)), p.positive)
} else {
(fmt::money(it.amount), p.text)
};
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
txt,
theme::semibold(13.0),
c,
);
if resp.clicked() {
go = it.route.clone();
}
}
});
},
);
if let Some(r) = go {
shell.go(r);
}
}
fn biggest_outflows(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let since = b.today - Days::new(30);
let mut out: Vec<&crate::books::EntryInfo> = b
.entries
.iter()
.filter(|e| e.date > since && e.cash_effect < Decimal::ZERO)
.collect();
out.sort_by_key(|e| e.cash_effect);
out.truncate(8);
let mut go = None;
widgets::titled_card(
ui,
"Biggest payments, last 30 days",
|_| {},
|ui| {
if out.is_empty() {
widgets::empty(ui, icon::ARROW_UP_RIGHT, "No payments", "");
}
for e in &out {
let (r, resp) =
ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(6), p.hover);
}
ui.painter().text(
r.left_center() + vec2(6.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::date_short(e.date),
theme::regular(12.5),
p.muted,
);
ui.painter().text(
r.left_center() + vec2(70.0, 0.0),
egui::Align2::LEFT_CENTER,
&e.description,
theme::regular(13.0),
p.text,
);
ui.painter().text(
r.right_center() - vec2(6.0, 0.0),
egui::Align2::RIGHT_CENTER,
fmt::money(e.cash_effect),
theme::semibold(13.0),
p.text,
);
if resp.clicked() {
go = Some(e.id);
}
}
},
);
if let Some(id) = go {
shell.go(Route::Entry(id));
}
}
+493
View File
@@ -0,0 +1,493 @@
//! The company: who it is, who owns it, and the standing machinery of the
//! books — periods, fixed assets, amortization schedules, recurring
//! entries, vendors and customers.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::assets::{accumulated_depreciation, book_value};
use ledger_core::{PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
fn kv(ui: &mut Ui, k: &str, v: &str) {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 20.0), Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
k,
theme::regular(13.0),
p.muted,
);
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap());
});
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "company", |ui| {
let c = &b.settings.company;
widgets::page_header(
ui,
&c.name,
"The company's profile and the standing parts of its books.",
);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Profile",
|_| {},
|ui| {
if c.profile.is_empty() {
ui.label(
RichText::new("No [company] settings in wanderbooks.toml.")
.color(p.muted),
);
}
for (k, v) in &c.profile {
kv(ui, &fmt::label(k), v);
}
kv(ui, "Accounting basis", &c.basis);
kv(
ui,
"Tax return",
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
);
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
},
);
}
_ => {
widgets::titled_card(
ui,
"Owners",
|_| {},
|ui| {
if c.members.is_empty() {
// A single owner or a corporation: equity by account.
for a in b
.ledger
.chart()
.all()
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
{
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("{} {}", a.code, a.name))
.font(theme::regular(13.0)),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::accounting(
b.balance_on(a.id, b.today),
))
.font(theme::semibold(13.0)),
);
},
);
});
}
}
for m in &c.members {
let cap = b.ledger.chart().find_by_code(&m.capital_account);
let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
widgets::pill(
ui,
&format!("{}%", (m.share * Decimal::from(100)).normalize()),
p.accent,
p.accent_soft,
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::dollars(bal))
.font(theme::semibold(13.5)),
);
},
);
});
if let Some(a) = cap {
ui.label(
RichText::new(format!("Capital account {} {}", a.code, a.name))
.font(theme::regular(12.0))
.color(p.muted),
);
}
ui.add_space(6.0);
}
},
);
ui.add_space(12.0);
widgets::titled_card(
ui,
"Periods",
|_| {},
|ui| {
let periods = b.ledger.list_periods();
if periods.is_empty() {
ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted));
}
for per in periods {
ui.horizontal(|ui| {
let (g, color) = match per.status {
PeriodStatus::Closed => (icon::LOCK, p.muted),
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
};
ui.label(RichText::new(g).color(color));
ui.label(RichText::new(&per.name).font(theme::semibold(13.0)));
ui.label(
RichText::new(format!(
"{} – {}",
fmt::date(per.start),
fmt::date(per.end)
))
.color(p.muted),
);
if let Some(e) = per.closing_entry
&& widgets::link(
ui,
RichText::new(format!("closing entry #{e}"))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(e));
}
});
}
},
);
}
});
assets(ui, b, shell);
schedules(ui, b);
recurring(ui, b);
parties(ui, b);
});
}
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let assets: Vec<_> = b.ledger.registered_assets().collect();
if assets.is_empty() {
return;
}
let mut go = None;
widgets::titled_card(
ui,
"Fixed assets",
|_| {},
|ui| {
for a in &assets {
// What has actually been posted, so this ties to the balance
// sheet; the schedule may run ahead of the books.
let acc: Decimal = b
.journal
.iter()
.filter(|l| {
l.line_type == "asset.depreciation_posted"
&& l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id)
})
.filter_map(|l| {
l.payload
.get("amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<Decimal>().ok())
})
.sum();
let bv = a.cost.amount - acc;
let scheduled = accumulated_depreciation(a, b.today);
let _ = book_value;
let dep = a.cost.amount - a.salvage_value.amount;
let frac: f32 = if dep.is_zero() {
1.0
} else {
(acc / dep).try_into().unwrap_or(0.0)
};
ui.horizontal(|ui| {
if widgets::link(
ui,
RichText::new(&a.description).font(theme::semibold(13.5)),
)
.clicked()
{
go = Some(a.cost_account);
}
ui.label(
RichText::new(format!(
"bought {} · {} months · {}",
fmt::date(a.purchase_date),
a.useful_life_months,
fmt::words(&format!("{:?}", a.status)).to_lowercase()
))
.font(theme::regular(12.0))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!("book value {}", fmt::dollars(bv)))
.font(theme::semibold(13.0)),
);
});
});
let behind = scheduled - acc;
let note = if behind > Decimal::ZERO {
format!(" · {} more scheduled through today", fmt::dollars(behind))
} else {
String::new()
};
progress(
ui,
frac,
&format!(
"{} of {} depreciated{note}",
fmt::dollars(acc),
fmt::dollars(dep)
),
);
ui.add_space(8.0);
}
},
);
if let Some(id) = go {
shell.go(Route::Account(id));
}
ui.add_space(16.0);
}
fn progress(ui: &mut Ui, frac: f32, label: &str) {
let p = pal(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
let mut done = r;
done.set_width(r.width() * frac.clamp(0.0, 1.0));
ui.painter()
.rect_filled(done, CornerRadius::same(4), p.accent);
ui.label(
RichText::new(label)
.font(theme::regular(11.5))
.color(p.faint),
);
}
fn schedules(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let list: Vec<_> = b.ledger.schedules().collect();
if list.is_empty() {
return;
}
widgets::titled_card(
ui,
"Amortization schedules",
|_| {},
|ui| {
for s in list {
let total = s.total.amount;
let remaining = s.remaining();
let frac: f32 = if total.is_zero() {
1.0
} else {
((total - remaining) / total).try_into().unwrap_or(0.0)
};
let due = s.due(b.today);
ui.horizontal(|ui| {
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {} months from {}",
fmt::words(&format!("{:?}", s.kind)),
s.months,
fmt::month(s.start)
))
.font(theme::regular(12.0))
.color(p.muted),
);
if !due.is_empty() {
widgets::pill(ui, &format!("{} due", due.len()), p.warning, p.warning_soft);
}
});
progress(
ui,
frac,
&format!(
"{} of {} recognized · {} left",
fmt::dollars(total - remaining),
fmt::dollars(total),
fmt::dollars(remaining)
),
);
ui.add_space(8.0);
}
},
);
ui.add_space(16.0);
}
fn recurring(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let list: Vec<_> = b.ledger.recurring_entries().collect();
if list.is_empty() {
return;
}
widgets::titled_card(
ui,
"Recurring entries",
|_| {},
|ui| {
for r in list {
let amount: Decimal = r
.lines
.iter()
.filter(|(_, s, _)| *s == ledger_core::Side::Debit)
.map(|(_, _, m)| m.amount)
.sum();
let next = (0..)
.map_while(|k| {
r.frequency
.nth(r.start, k)
.filter(|d| r.end.is_none_or(|e| *d <= e))
})
.find(|d| !r.generated.contains(d));
ui.horizontal(|ui| {
ui.label(RichText::new(&r.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {}",
fmt::words(&format!("{:?}", r.frequency)),
fmt::dollars(amount)
))
.font(theme::regular(12.0))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let text = match next {
Some(d) if d <= b.today => format!("due {}", fmt::date(d)),
Some(d) => format!("next {}", fmt::date(d)),
None => "ended".into(),
};
ui.label(RichText::new(text).font(theme::regular(12.5)).color(
if next.is_some_and(|d| d <= b.today) {
p.warning
} else {
p.muted
},
));
});
});
ui.label(
RichText::new(format!(
"{} generated so far · drafts only; each one is posted with its receipt",
r.generated.len()
))
.font(theme::regular(11.5))
.color(p.faint),
);
ui.add_space(6.0);
}
},
);
ui.add_space(16.0);
}
fn parties(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let year = chrono::Datelike::year(&b.today);
let r1099 = vendor_1099_report(&b.ledger, year, None);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Vendors",
|_| {},
|ui| {
let vendors = b.ledger.list_vendors();
if vendors.is_empty() {
ui.label(RichText::new("No vendors yet.").color(p.muted));
}
for v in vendors {
let paid = r1099
.vendors
.iter()
.find(|x| x.vendor_id == v.id)
.map(|x| x.paid)
.unwrap_or_default();
ui.horizontal(|ui| {
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
if v.tin_on_file {
widgets::pill(ui, "W-9", p.positive, p.positive_soft);
} else {
widgets::pill(ui, "no W-9", p.negative, p.negative_soft);
}
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !paid.is_zero() {
ui.label(
RichText::new(format!(
"{} paid in {year}",
fmt::dollars(paid)
))
.font(theme::regular(12.5))
.color(p.muted),
);
}
},
);
});
}
},
);
}
_ => {
widgets::titled_card(
ui,
"Customers",
|_| {},
|ui| {
let customers = b.ledger.list_customers();
if customers.is_empty() {
ui.label(RichText::new("No customers yet.").color(p.muted));
}
for c in customers {
let owed: Decimal = (1..b.ledger.next_invoice_id())
.filter_map(|i| b.ledger.get_invoice(i))
.filter(|i| i.customer_id == c.id)
.map(|i| i.balance())
.sum();
ui.horizontal(|ui| {
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !owed.is_zero() {
ui.label(
RichText::new(format!("owes {}", fmt::dollars(owed)))
.font(theme::regular(12.5))
.color(p.muted),
);
}
},
);
});
}
},
);
}
});
}
+302
View File
@@ -0,0 +1,302 @@
//! Every receipt and document the books hold, rendered: what it is, what
//! entry it proves, and whether it was withdrawn (and why).
use super::page;
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::receipts::State as Render;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_store::Attachment;
#[derive(Default)]
pub struct State {
pub category: Option<String>,
pub show_withdrawn: bool,
pub query: String,
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
match selected.and_then(|id| b.attachments.iter().find(|a| a.id == id)) {
Some(a) => super::split(
ui,
"documents",
(ui.available_width() * 0.5).max(460.0),
shell,
|ui, shell| grid(ui, b, Some(a.id), shell),
|ui, shell| detail(ui, b, a, shell),
),
None => grid(ui, b, None, shell),
}
}
fn grid(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
page(ui, "documents", |ui| {
let current = b
.attachments
.iter()
.filter(|a| a.withdrawn_at.is_none())
.count();
widgets::page_header(
ui,
"Documents",
&format!(
"{} on file: receipts that prove entries, and the company's records. Bytes are content-addressed and verified on every read.",
fmt::count(current, "document", "documents")
),
);
let mut cats: Vec<String> = b.attachments.iter().map(|a| a.category.clone()).collect();
cats.sort();
cats.dedup();
let st = &mut shell.state.documents;
ui.horizontal_wrapped(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Search file names", icon::MAGNIFYING_GLASS))
.desired_width(260.0),
);
if ui.selectable_label(st.category.is_none(), "All").clicked() {
st.category = None;
}
for c in &cats {
if ui
.selectable_label(st.category.as_deref() == Some(c.as_str()), fmt::label(c))
.clicked()
{
st.category = Some(c.clone());
}
}
ui.checkbox(&mut st.show_withdrawn, "Show withdrawn");
});
ui.add_space(12.0);
let q = st.query.to_lowercase();
let docs: Vec<&Attachment> = b
.attachments
.iter()
.rev()
.filter(|a| st.show_withdrawn || a.withdrawn_at.is_none())
.filter(|a| st.category.as_ref().is_none_or(|c| &a.category == c))
.filter(|a| {
q.is_empty()
|| a.file_name.to_lowercase().contains(&q)
|| a.description
.as_deref()
.unwrap_or("")
.to_lowercase()
.contains(&q)
})
.collect();
if docs.is_empty() {
widgets::card(ui, |ui| widgets::empty(ui, icon::FILES, "No documents", ""));
return;
}
let tile_w = 200.0;
let per_row = ((ui.available_width() + 16.0) / (tile_w + 16.0))
.floor()
.max(1.0) as usize;
let mut go = None;
for row in docs.chunks(per_row) {
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
for a in row {
if tile(ui, b, a, tile_w, selected == Some(a.id), shell).clicked() {
go = Some(a.id);
}
}
});
ui.add_space(16.0);
}
if let Some(id) = go {
shell.go(Route::Document(id));
}
});
}
fn tile(
ui: &mut Ui,
b: &Books,
a: &Attachment,
w: f32,
on: bool,
shell: &mut Shell<'_>,
) -> egui::Response {
let p = pal(ui.ctx());
let h = 250.0;
let (rect, resp) = ui.allocate_exact_size(vec2(w, h), Sense::click());
let fill = if on { p.selected } else { p.surface };
ui.painter().rect(
rect,
CornerRadius::same(10),
fill,
Stroke::new(1.0, if resp.hovered() { p.accent } else { p.border }),
egui::StrokeKind::Inside,
);
let thumb = egui::Rect::from_min_size(rect.min + vec2(10.0, 10.0), vec2(w - 20.0, 170.0));
ui.painter()
.rect_filled(thumb, CornerRadius::same(6), p.hover);
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
Render::Ready(pages) => {
if let Some(tex) = pages.first() {
// Top of the first page, filling the width.
let size = tex.size_vec2();
let scale = thumb.width() / size.x;
let visible_h = (thumb.height() / scale).min(size.y);
let uv = egui::Rect::from_min_max(
egui::pos2(0.0, 0.0),
egui::pos2(1.0, visible_h / size.y),
);
let img_rect =
egui::Rect::from_min_size(thumb.min, vec2(thumb.width(), visible_h * scale));
ui.painter()
.image(tex.id(), img_rect, uv, egui::Color32::WHITE);
}
}
Render::Loading => {
ui.painter().text(
thumb.center(),
egui::Align2::CENTER_CENTER,
"…",
theme::regular(16.0),
p.faint,
);
}
Render::Failed(_) => {
ui.painter().text(
thumb.center(),
egui::Align2::CENTER_CENTER,
icon::FILE,
egui::FontId::proportional(28.0),
p.faint,
);
}
}
// Title: what it proves, else what it is.
let title = a
.entry_id
.and_then(|e| b.entry(e))
.map(|e| e.description.clone())
.or_else(|| a.description.clone())
.unwrap_or_else(|| a.file_name.clone());
let max_chars = ((w - 20.0) / 7.2) as usize;
let title = if title.chars().count() > max_chars {
title.chars().take(max_chars - 1).collect::<String>() + "…"
} else {
title
};
ui.painter().text(
egui::pos2(rect.left() + 10.0, thumb.bottom() + 18.0),
egui::Align2::LEFT_CENTER,
title,
theme::semibold(12.5),
p.text,
);
let sub = match (a.entry_id, &a.withdrawn_at) {
(_, Some(_)) => format!("Withdrawn · {}", a.file_name),
(Some(e), None) => match b.entry(e) {
Some(x) => format!(
"#{e} · {} · {}",
fmt::date_short(x.date),
fmt::dollars(x.amount)
),
None => format!("#{e}"),
},
(None, None) => format!("{} · {}", fmt::label(&a.category), a.file_name),
};
let sub = if sub.chars().count() > max_chars + 4 {
sub.chars().take(max_chars + 3).collect::<String>() + "…"
} else {
sub
};
ui.painter().text(
egui::pos2(rect.left() + 10.0, thumb.bottom() + 40.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(11.5),
if a.withdrawn_at.is_some() {
p.warning
} else {
p.muted
},
);
resp
}
fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
egui::ScrollArea::vertical()
.id_salt(("doc", a.id))
.auto_shrink(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("Document {}", a.id))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
.clicked()
{
shell.go(Route::Documents);
}
});
});
ui.label(RichText::new(&a.file_name).font(theme::bold(20.0)));
ui.add_space(8.0);
egui::Grid::new(("docfacts", a.id))
.num_columns(2)
.spacing(vec2(20.0, 6.0))
.show(ui, |ui| {
let k = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
};
k(ui, "Category");
ui.label(fmt::label(&a.category));
ui.end_row();
k(ui, "Type");
ui.label(format!("{} · {}", a.mime_type, fmt::bytes(a.size_bytes)));
ui.end_row();
k(ui, "Filed");
ui.label(fmt::timestamp(&a.created_at));
ui.end_row();
k(ui, "SHA-256");
ui.add(
egui::Label::new(RichText::new(&a.blob_sha).monospace().size(11.5)).wrap(),
);
ui.end_row();
if let Some(e) = a.entry_id {
k(ui, "Proves");
let label = b
.entry(e)
.map(|x| {
format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount))
})
.unwrap_or_else(|| format!("#{e}"));
if widgets::link(ui, RichText::new(label).color(p.accent)).clicked() {
shell.go(Route::Entry(e));
}
ui.end_row();
}
});
ui.add_space(10.0);
super::entry::render(ui, b, a, shell, ui.available_width());
if let Some(d) = &a.description {
ui.add_space(6.0);
ui.label(RichText::new(d).color(p.muted));
}
if a.withdrawn_at.is_some() {
ui.add_space(6.0);
widgets::pill(
ui,
&format!("Withdrawn: {}", a.withdrawn_reason.as_deref().unwrap_or("")),
p.warning,
p.warning_soft,
);
}
});
}
+398
View File
@@ -0,0 +1,398 @@
//! One entry, completely: what it is, every line, the receipt itself, and
//! every journal event that touched it.
use crate::app::Shell;
use crate::books::{Books, EntryInfo};
use crate::fmt;
use crate::nav::Route;
use crate::receipts::State as Render;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{EntryStatus, Side};
use ledger_store::Attachment;
use rust_decimal::Decimal;
pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let Some(entry) = b.ledger.get_entry(e.id) else {
return;
};
egui::ScrollArea::vertical()
.id_salt(("entry", e.id))
.auto_shrink(false)
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("Entry #{}", e.id))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(egui::Button::new(RichText::new(icon::X).size(15.0)).frame(false))
.on_hover_text("Close")
.clicked()
{
shell.go(Route::Transactions);
}
});
});
ui.label(RichText::new(&e.description).font(theme::bold(21.0)));
ui.add_space(6.0);
ui.horizontal_wrapped(|ui| {
match e.status {
EntryStatus::Posted => widgets::pill(ui, "Posted", p.positive, p.positive_soft),
EntryStatus::Draft => {
widgets::pill(ui, "Draft — not in any report", p.warning, p.warning_soft)
}
};
widgets::pill(ui, e.origin, p.muted, p.hover);
if let Some(v) = &e.vendor {
widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover);
}
if e.missing_receipt {
widgets::pill(ui, "Missing receipt", p.negative, p.negative_soft);
}
});
if let Some(o) = e.reversal_of {
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!("{} Reverses #{o}", icon::ARROW_COUNTER_CLOCKWISE))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(o));
}
}
if let Some(r) = e.reversed_by {
ui.add_space(4.0);
if widgets::link(
ui,
RichText::new(format!(
"{} Reversed by #{r}",
icon::ARROW_COUNTER_CLOCKWISE
))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(r));
}
}
ui.add_space(16.0);
// Facts.
let created = e.events.first().map(|&i| fmt::timestamp(&b.journal[i].ts));
// Posted by entry.posted, or in one step by whatever created it
// (a schedule, a close, a bill).
let posted = e
.events
.iter()
.find(|&&i| b.journal[i].line_type == "entry.posted")
.or_else(|| e.events.first())
.map(|&i| fmt::timestamp(&b.journal[i].ts));
egui::Grid::new(("facts", e.id))
.num_columns(2)
.spacing(vec2(24.0, 6.0))
.show(ui, |ui| {
let k = |ui: &mut Ui, s: &str| {
ui.label(RichText::new(s).font(theme::regular(12.5)).color(p.muted))
};
k(ui, "Date");
ui.label(fmt::date(e.date));
ui.end_row();
k(ui, "Amount");
ui.label(RichText::new(fmt::dollars(e.amount)).font(theme::semibold(14.0)));
ui.end_row();
if let Some(c) = &created {
k(ui, "Recorded");
ui.label(c);
ui.end_row();
}
if e.status == EntryStatus::Posted
&& let Some(t) = &posted
{
k(ui, "Posted");
ui.label(t);
ui.end_row();
}
});
ui.add_space(16.0);
// Lines.
section(ui, "Lines");
let total_w = ui.available_width();
let amt_w = 104.0;
let class_names: std::collections::HashMap<u32, String> = b
.ledger
.list_classes()
.into_iter()
.map(|c| (c.id, c.name.clone()))
.collect();
egui::Frame::new()
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(10))
.inner_margin(Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.set_width(total_w - 26.0);
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), egui::Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
"Account",
theme::regular(12.0),
p.muted,
);
widgets::amount_cell(ui, "Debit", amt_w, p.muted, false, false);
widgets::amount_cell(ui, "Credit", amt_w, p.muted, false, false);
});
let (mut dr, mut cr) = (Decimal::ZERO, Decimal::ZERO);
for line in entry.lines() {
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
ui.allocate_ui(vec2(w, 22.0), |ui| {
ui.set_width(w);
ui.horizontal(|ui| {
let r = widgets::link(
ui,
RichText::new(b.account_label(line.account))
.font(theme::regular(13.5)),
);
if r.clicked() {
shell.go(Route::Account(line.account));
}
if let Some(c) = line.class_id.and_then(|c| class_names.get(&c))
{
ui.label(
RichText::new(format!("{} {c}", icon::TAG))
.font(theme::regular(11.5))
.color(p.muted),
);
}
if let (Some(cur), Some(fa)) =
(&line.foreign_currency, line.foreign_amount)
{
ui.label(
RichText::new(format!(
"{} {}",
fmt::money(fa),
cur.code()
))
.font(theme::regular(11.5))
.color(p.muted),
);
}
});
});
let a = fmt::money(line.amount.amount);
let (d, c) = match line.side {
Side::Debit => {
dr += line.amount.amount;
(a, String::new())
}
Side::Credit => {
cr += line.amount.amount;
(String::new(), a)
}
};
widgets::amount_cell(ui, &d, amt_w, p.text, false, false);
widgets::amount_cell(ui, &c, amt_w, p.text, false, false);
});
}
widgets::rule(ui);
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w * 2.0 - 16.0;
let balanced = dr == cr;
let (r, _) = ui.allocate_exact_size(vec2(w, 22.0), egui::Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
if balanced {
format!("{} Balanced", icon::CHECK)
} else {
"Out of balance".into()
},
theme::regular(12.0),
if balanced { p.positive } else { p.negative },
);
widgets::amount_cell(ui, &fmt::money(dr), amt_w, p.text, true, false);
widgets::amount_cell(ui, &fmt::money(cr), amt_w, p.text, true, false);
});
});
ui.add_space(18.0);
// Receipts.
let all_for_entry: Vec<&Attachment> = b
.attachments
.iter()
.filter(|a| a.entry_id == Some(e.id))
.collect();
section(
ui,
if all_for_entry.len() == 1 {
"Receipt"
} else {
"Receipts"
},
);
if all_for_entry.is_empty() {
let msg = if e.missing_receipt {
format!(
"None attached, and this entry is over the {} receipt threshold.",
fmt::dollars(b.receipt_threshold)
)
} else {
"None attached.".to_string()
};
ui.label(RichText::new(msg).color(if e.missing_receipt {
p.negative
} else {
p.muted
}));
}
for a in all_for_entry {
attachment_view(ui, b, a, shell, true);
ui.add_space(12.0);
}
ui.add_space(12.0);
// History.
section(ui, "History");
for &i in &e.events {
let line = &b.journal[i];
let ev = super::journal::describe_any(b, line);
ui.horizontal(|ui| {
ui.label(RichText::new(ev.icon).color(p.muted));
let r = widgets::link(ui, RichText::new(&ev.text).font(theme::regular(12.5)));
if r.clicked() {
shell.go(Route::JournalEvent(line.seq));
}
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!("#{} · {}", line.seq, fmt::timestamp(&line.ts)))
.font(theme::regular(11.5))
.color(p.faint),
);
});
});
}
});
}
pub fn section(ui: &mut Ui, title: &str) {
let p = pal(ui.ctx());
ui.label(
RichText::new(title.to_uppercase())
.font(theme::semibold(11.5))
.color(p.faint),
);
ui.add_space(6.0);
}
/// A receipt or document: its name and facts, then the file itself.
pub fn attachment_view(
ui: &mut Ui,
b: &Books,
a: &Attachment,
shell: &mut Shell<'_>,
link_to_doc: bool,
) {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
ui.label(
RichText::new(if a.mime_type == "application/pdf" {
icon::FILE_PDF
} else {
icon::FILE_IMAGE
})
.size(18.0)
.color(p.muted),
);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 1.0;
let name = RichText::new(&a.file_name).font(theme::semibold(13.0));
if link_to_doc {
if widgets::link(ui, name).clicked() {
shell.go(Route::Document(a.id));
}
} else {
ui.label(name);
}
ui.label(
RichText::new(format!(
"{} · {} · filed {}",
fmt::label(&a.category),
fmt::bytes(a.size_bytes),
fmt::timestamp(&a.created_at)
))
.font(theme::regular(11.5))
.color(p.muted),
);
});
});
if let Some(when) = &a.withdrawn_at {
widgets::pill(
ui,
&format!(
"Withdrawn {}: {}",
fmt::timestamp(when),
a.withdrawn_reason.as_deref().unwrap_or("")
),
p.warning,
p.warning_soft,
);
}
if let Some(d) = &a.description {
ui.label(RichText::new(d).font(theme::regular(12.5)).color(p.muted));
}
ui.add_space(6.0);
render(ui, b, a, shell, ui.available_width());
}
/// The file itself, pages stacked, scaled to `width`.
pub fn render(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>, width: f32) {
let p = pal(ui.ctx());
match shell.receipts.get(&a.blob_sha, &a.mime_type, b.blob_root()) {
Render::Loading => {
ui.horizontal(|ui| {
ui.spinner();
ui.label(RichText::new("Rendering…").color(p.muted));
});
}
Render::Failed(err) => {
ui.label(
RichText::new(format!("{} Can't show this file: {err}", icon::WARNING))
.color(p.negative),
);
}
Render::Ready(pages) => {
for (i, tex) in pages.iter().enumerate() {
let size = tex.size_vec2();
let scale = (width / size.x).min(1.0);
let img = egui::Image::new(tex)
.fit_to_exact_size(size * scale)
.corner_radius(CornerRadius::same(4));
egui::Frame::new()
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(6))
.show(ui, |ui| {
ui.add(img);
});
if pages.len() > 1 {
ui.label(
RichText::new(format!("Page {} of {}", i + 1, pages.len()))
.font(theme::regular(11.0))
.color(p.faint),
);
}
ui.add_space(6.0);
}
}
}
}
+482
View File
@@ -0,0 +1,482 @@
//! The journal: every event, in order, as recorded. The audit trail an
//! accountant wants and the raw truth everything else is built from.
use super::page;
use crate::app::Shell;
use crate::books::{Books, entry_refs};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, Margin, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_store::JournalLine;
use serde_json::Value;
#[derive(Default)]
pub struct State {
pub query: String,
pub hide_steps: bool,
}
pub struct Event {
pub icon: &'static str,
pub text: String,
pub route: Option<Route>,
}
fn s<'a>(p: &'a Value, k: &str) -> &'a str {
p.get(k).and_then(|v| v.as_str()).unwrap_or("")
}
fn amount(p: &Value) -> String {
p.get("amount")
.and_then(|v| v.as_str())
.and_then(|a| a.parse().ok())
.map(fmt::dollars)
.unwrap_or_default()
}
fn entry_text(b: &Books, id: u64) -> String {
b.entry(id)
.map(|e| format!("#{} {} ({})", e.id, e.description, fmt::dollars(e.amount)))
.unwrap_or_else(|| format!("#{id}"))
}
fn attachment_name(b: &Books, id: u64) -> String {
b.attachments
.iter()
.find(|a| a.id == id)
.map(|a| a.file_name.clone())
.unwrap_or_else(|| format!("attachment {id}"))
}
/// A sentence for a meaningful event; `None` for the intermediate steps of
/// building an entry (created, line added), which the posted event covers.
pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
let p = &line.payload;
let id = p.get("id").and_then(|v| v.as_u64()).unwrap_or(0);
let entry_id = p.get("entry_id").and_then(|v| v.as_u64());
let att = p.get("attachment_id").and_then(|v| v.as_u64()).unwrap_or(0);
let ev = |icon, text: String, route: Option<Route>| Some(Event { icon, text, route });
match line.line_type.as_str() {
"entry.created" | "entry.line_added" | "entry.line_class_set" => None,
"store.created" => ev(icon::BOOK_OPEN, "Books created".into(), None),
"entry.posted" => ev(
icon::CHECK_CIRCLE,
format!("Posted {}", entry_text(b, id)),
Some(Route::Entry(id)),
),
"entry.reversed" => ev(
icon::ARROW_COUNTER_CLOCKWISE,
format!(
"Reversed #{} with #{id}",
p.get("reverses").and_then(|v| v.as_u64()).unwrap_or(0)
),
Some(Route::Entry(id)),
),
"receipt.attached" => ev(
icon::PAPERCLIP,
match entry_id {
Some(e) => format!("Attached {} to #{e}", s(p, "file_name")),
None => format!("Filed receipt {}", s(p, "file_name")),
},
Some(Route::Document(att)),
),
"document.filed" => ev(
icon::FILE_ARROW_UP,
format!(
"Filed {} ({})",
s(p, "file_name"),
fmt::label(s(p, "category"))
),
Some(Route::Document(att)),
),
"receipt.linked" => ev(
icon::LINK,
format!(
"Linked {} to #{}",
attachment_name(b, att),
entry_id.unwrap_or(0)
),
entry_id.map(Route::Entry),
),
"receipt.unlinked" => ev(
icon::LINK_BREAK,
format!(
"Unlinked {} from #{}: {}",
attachment_name(b, att),
entry_id.unwrap_or(0),
s(p, "reason")
),
entry_id.map(Route::Entry),
),
"attachment.withdrawn" => ev(
icon::ARCHIVE,
format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")),
Some(Route::Document(att)),
),
"account.added" => ev(
icon::PLUS_CIRCLE,
format!("Added account {} {}", s(p, "code"), s(p, "name")),
Some(Route::Account(id)),
),
"account.tax_set" => ev(
icon::TAG,
format!(
"Tax category of {}: {}",
b.account_label(p.get("account_id").and_then(|v| v.as_u64()).unwrap_or(0)),
p.get("tax_category")
.and_then(|v| v.as_str())
.unwrap_or("none")
),
p.get("account_id")
.and_then(|v| v.as_u64())
.map(Route::Account),
),
"vendor.added" => ev(
icon::STOREFRONT,
format!("Added vendor {}", s(p, "name")),
Some(Route::Company),
),
"customer.added" => ev(
icon::USER_PLUS,
format!("Added customer {}", s(p, "name")),
Some(Route::Company),
),
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
"vendor.1099_set" => ev(
icon::IDENTIFICATION_CARD,
"Updated a vendor's 1099 details".into(),
Some(Route::Company),
),
"entry.vendor_set" => ev(
icon::STOREFRONT,
format!("Tagged #{} with a vendor", entry_id.unwrap_or(0)),
entry_id.map(Route::Entry),
),
"invoice.created" => ev(
icon::INVOICE,
format!("Invoice {id} created"),
entry_id.map(Route::Entry),
),
"bill.created" => ev(
icon::RECEIPT,
format!("Bill {id} recorded"),
entry_id.map(Route::Entry),
),
"invoice.payment_received" => ev(
icon::HAND_COINS,
format!("Payment received, {}", amount(p)),
entry_id.map(Route::Entry),
),
"bill.paid" => ev(
icon::MONEY,
format!("Bill paid, {}", amount(p)),
entry_id.map(Route::Entry),
),
"period.created" => ev(
icon::CALENDAR_PLUS,
format!("Period {} created", s(p, "name")),
Some(Route::Company),
),
"period.closed" => ev(
icon::LOCK,
"Period closed".into(),
p.get("closing_entry")
.and_then(|v| v.as_u64())
.map(Route::Entry),
),
"asset.registered" => ev(
icon::PACKAGE,
format!("Registered asset {}", s(p, "description")),
Some(Route::Company),
),
"asset.depreciation_posted" => ev(
icon::TREND_DOWN,
"Depreciation posted".into(),
entry_id.map(Route::Entry),
),
"asset.disposed" => ev(
icon::TRASH,
"Asset disposed".into(),
entry_id.map(Route::Entry),
),
"schedule.registered" => ev(
icon::CALENDAR,
format!("Schedule: {}", s(p, "description")),
Some(Route::Company),
),
"schedule.posted" => ev(
icon::CALENDAR_CHECK,
"Amortization posted".into(),
p.get("entry")
.and_then(|e| e.get("id"))
.and_then(|v| v.as_u64())
.map(Route::Entry),
),
"recurring.registered" => ev(
icon::REPEAT,
format!("Recurring: {}", s(p, "description")),
Some(Route::Company),
),
"recurring.generated" => ev(
icon::REPEAT,
"Recurring draft created".into(),
entry_id.map(Route::Entry),
),
"fx.rate_set" => ev(
icon::CURRENCY_CIRCLE_DOLLAR,
format!("{} rate set", s(p, "currency")),
None,
),
"payroll.run" => ev(
icon::USERS,
"Payroll run".into(),
entry_id.map(Route::Entry),
),
"line.cleared" => ev(
icon::CHECK_SQUARE,
format!("Cleared a line of #{}", entry_id.unwrap_or(0)),
entry_id.map(Route::Entry),
),
other => ev(
icon::DOT,
other.to_string(),
entry_refs(line).first().copied().map(Route::Entry),
),
}
}
/// Like [`describe`], but every event gets a sentence.
pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
if let Some(e) = describe(b, line) {
return e;
}
let p = &line.payload;
let id = p
.get("id")
.and_then(|v| v.as_u64())
.or_else(|| p.get("entry_id").and_then(|v| v.as_u64()))
.unwrap_or(0);
match line.line_type.as_str() {
"entry.created" => Event {
icon: icon::PENCIL_SIMPLE,
text: format!("Drafted #{id} {}", s(p, "description")),
route: Some(Route::Entry(id)),
},
"entry.line_added" => {
let amount = p
.get("amount")
.and_then(|a| a.get("amount").or(Some(a)))
.and_then(|v| v.as_str())
.and_then(|v| v.parse().ok())
.map(fmt::money)
.unwrap_or_default();
Event {
icon: icon::PLUS,
text: format!(
"Line on #{id}: {} {} {amount}",
s(p, "side"),
b.account_label(
p.get("account_id")
.or_else(|| p.get("account"))
.and_then(|v| v.as_u64())
.unwrap_or(0)
),
),
route: Some(Route::Entry(id)),
}
}
"entry.line_class_set" => {
let class = p
.get("class_id")
.and_then(|v| v.as_u64())
.and_then(|c| b.ledger.get_class(c as u32))
.map(|c| c.name.clone())
.unwrap_or_else(|| "no class".into());
Event {
icon: icon::TAG,
text: format!(
"Line {} of #{id} tagged {class}",
p.get("line_index")
.and_then(|v| v.as_u64())
.map_or(0, |i| i + 1)
),
route: Some(Route::Entry(id)),
}
}
t => Event {
icon: icon::DOT,
text: t.to_string(),
route: None,
},
}
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
if let Some(seq) = selected
&& let Some(line) = b.journal.get(seq.saturating_sub(1) as usize)
{
super::split(
ui,
"journal",
480.0,
shell,
|ui, shell| list(ui, b, selected, shell),
|ui, shell| detail(ui, b, line, shell),
);
} else {
list(ui, b, None, shell);
}
}
fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "journal", |ui| {
widgets::page_header(
ui,
"Journal",
&format!(
"Every change to the books, in order: {} events, each hash-chained to the one before. Nothing is ever edited or deleted; corrections are new events.",
b.journal.len()
),
);
let st = &mut shell.state.journal;
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!("{} Filter events", icon::MAGNIFYING_GLASS))
.desired_width(320.0),
);
ui.checkbox(&mut st.hide_steps, "Hide entry-building steps");
});
ui.add_space(10.0);
let q = st.query.to_lowercase();
let hide = st.hide_steps;
let rows: Vec<(&JournalLine, Event)> = b
.journal
.iter()
.rev()
.filter(|l| !(hide && describe(b, l).is_none()))
.map(|l| (l, describe_any(b, l)))
.filter(|(l, e)| {
q.is_empty() || e.text.to_lowercase().contains(&q) || l.line_type.contains(&q)
})
.collect();
widgets::card(ui, |ui| {
let row_h = 34.0;
egui::ScrollArea::vertical()
.id_salt("journal_rows")
.max_height(ui.available_height().max(400.0))
.show_rows(ui, row_h, rows.len(), |ui, range| {
for (line, ev) in &rows[range] {
let (rect, resp) = ui
.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click());
let on = selected == Some(line.seq);
let fresh = line.seq > b.fresh_from_seq && shell.since_update < 8.0;
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.selected);
} else if fresh {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.new_highlight);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(6), p.hover);
}
let painter = ui.painter_at(rect);
let y = rect.center().y;
painter.text(
egui::pos2(rect.left() + 8.0, y),
egui::Align2::LEFT_CENTER,
format!("{}", line.seq),
theme::regular(12.0),
p.faint,
);
painter.text(
egui::pos2(rect.left() + 56.0, y),
egui::Align2::LEFT_CENTER,
ev.icon,
egui::FontId::proportional(15.0),
p.muted,
);
painter.text(
egui::pos2(rect.left() + 80.0, y),
egui::Align2::LEFT_CENTER,
&ev.text,
theme::regular(13.0),
p.text,
);
painter.text(
egui::pos2(rect.right() - 8.0, y),
egui::Align2::RIGHT_CENTER,
fmt::timestamp(&line.ts),
theme::regular(12.0),
p.faint,
);
if resp.clicked() {
shell.go(Route::JournalEvent(line.seq));
}
}
});
});
});
}
fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let ev = describe_any(b, line);
egui::ScrollArea::vertical()
.id_salt("event_detail")
.show(ui, |ui| {
ui.label(
RichText::new(format!("Event {}", line.seq))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.label(RichText::new(&ev.text).font(theme::semibold(17.0)));
ui.add_space(10.0);
let fact = |ui: &mut Ui, k: &str, v: &str| {
ui.horizontal(|ui| {
ui.add_sized(
vec2(90.0, 18.0),
egui::Label::new(
RichText::new(k).font(theme::regular(12.5)).color(p.muted),
),
);
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(12.5))).wrap());
});
};
fact(ui, "Type", &line.line_type);
fact(ui, "Recorded", &fmt::timestamp(&line.ts));
fact(ui, "Previous", &line.prev);
let own = ledger_store::journal::sha256_hex(line.to_canonical().as_bytes());
fact(ui, "This hash", &own);
if let Some(r) = &ev.route {
ui.add_space(6.0);
if ui
.button(format!(
"{} Open {}",
icon::ARROW_SQUARE_OUT,
crate::palette::describe(b, r)
))
.clicked()
{
shell.go(r.clone());
}
}
ui.add_space(12.0);
ui.label(RichText::new("Payload").font(theme::semibold(13.0)));
ui.add_space(4.0);
let json = serde_json::to_string_pretty(&line.payload).unwrap_or_default();
egui::Frame::new()
.fill(p.hover)
.corner_radius(CornerRadius::same(8))
.inner_margin(Margin::same(10))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.add(egui::Label::new(RichText::new(json).monospace().size(12.0)).wrap());
});
});
}
+156
View File
@@ -0,0 +1,156 @@
//! One module per screen.
pub mod accounts;
pub mod cash;
pub mod company;
pub mod documents;
pub mod entry;
pub mod journal;
pub mod overview;
pub mod profit;
pub mod reports;
pub mod transactions;
use crate::app::Shell;
use crate::books::Books;
use crate::nav::Route;
use egui::{Margin, Ui};
/// Standard page body: scrollable, padded.
pub fn page(ui: &mut Ui, id: &str, add: impl FnOnce(&mut Ui)) {
egui::ScrollArea::vertical()
.id_salt(id)
.auto_shrink(false)
.show(ui, |ui| {
egui::Frame::new()
.inner_margin(Margin {
left: 32,
right: 32,
top: 24,
bottom: 40,
})
.show(ui, |ui| {
ui.set_width(ui.available_width());
add(ui)
});
});
}
pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
match route {
Route::Overview => overview::show(ui, b, shell),
Route::Cash => cash::show(ui, b, shell),
Route::Profit => profit::show(ui, b, shell),
Route::Transactions => transactions::show(ui, b, None, shell),
Route::Entry(id) => transactions::show(ui, b, Some(*id), shell),
Route::Reports(kind) => reports::show(ui, b, *kind, shell),
Route::Accounts => accounts::show_chart(ui, b, shell),
Route::Account(id) => accounts::show_account(ui, b, *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),
Route::Journal => journal::show(ui, b, None, shell),
Route::JournalEvent(seq) => journal::show(ui, b, Some(*seq), shell),
}
}
/// Columns sized by `weights` when the page is at least `min_width`
/// wide; stacked otherwise. `add(i, ui)` fills column `i`.
pub fn columns(ui: &mut Ui, min_width: f32, weights: &[f32], mut add: impl FnMut(usize, &mut Ui)) {
let gap = 16.0;
if ui.available_width() < min_width {
for i in 0..weights.len() {
add(i, ui);
ui.add_space(gap);
}
return;
}
let total: f32 = weights.iter().sum();
let avail = ui.available_width() - gap * (weights.len() as f32 - 1.0);
let top = ui.cursor().min;
let mut x = top.x;
let mut bottom = top.y;
for (i, w) in weights.iter().enumerate() {
let width = avail * w / total;
let rect =
egui::Rect::from_min_size(egui::pos2(x, top.y), egui::vec2(width, f32::INFINITY));
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
child.set_width(width);
add(i, &mut child);
bottom = bottom.max(child.min_rect().bottom());
x += width + gap;
}
let used = egui::Rect::from_min_max(top, egui::pos2(top.x + ui.available_width(), bottom));
ui.advance_cursor_after_rect(used);
ui.add_space(gap);
}
/// A list on the left and a detail pane on the right, split by a
/// draggable divider. The split is remembered per `id`.
pub fn split<T: ?Sized>(
ui: &mut Ui,
id: &str,
default_detail: f32,
state: &mut T,
left: impl FnOnce(&mut Ui, &mut T),
right: impl FnOnce(&mut Ui, &mut T),
) {
let p = crate::theme::pal(ui.ctx());
let rect = ui.available_rect_before_wrap();
let key = egui::Id::new(("split", id));
let mut detail_w: f32 = ui
.ctx()
.data_mut(|d| d.get_persisted(key))
.unwrap_or(default_detail);
detail_w = detail_w.clamp(360.0, (rect.width() - 380.0).max(360.0));
let divider_x = rect.right() - detail_w;
let left_rect = egui::Rect::from_min_max(rect.min, egui::pos2(divider_x, rect.bottom()));
let right_rect = egui::Rect::from_min_max(egui::pos2(divider_x + 1.0, rect.top()), rect.max);
ui.painter().rect_filled(right_rect, 0.0, p.surface);
ui.painter().line_segment(
[
egui::pos2(divider_x, rect.top()),
egui::pos2(divider_x, rect.bottom()),
],
egui::Stroke::new(1.0, p.border),
);
let mut l = ui.new_child(
egui::UiBuilder::new()
.max_rect(left_rect)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
l.set_clip_rect(left_rect);
left(&mut l, state);
let inner = right_rect.shrink2(egui::vec2(24.0, 20.0));
let mut r = ui.new_child(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
r.set_clip_rect(right_rect);
right(&mut r, state);
let handle = egui::Rect::from_center_size(
egui::pos2(divider_x, rect.center().y),
egui::vec2(8.0, rect.height()),
);
let resp = ui.interact(handle, key.with("handle"), egui::Sense::drag());
if resp.hovered() || resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
ui.painter().line_segment(
[
egui::pos2(divider_x, rect.top()),
egui::pos2(divider_x, rect.bottom()),
],
egui::Stroke::new(2.0, p.accent),
);
}
if resp.dragged() {
detail_w -= resp.drag_delta().x;
ui.ctx().data_mut(|d| d.insert_persisted(key, detail_w));
}
ui.allocate_rect(rect, egui::Sense::hover());
}
+481
View File
@@ -0,0 +1,481 @@
//! Home: the state of the business in one sentence, the numbers behind
//! it, what needs doing, and what just happened.
use super::{columns, page};
use crate::app::Shell;
use crate::books::{Books, Severity};
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::{ReportKind, Route};
use crate::theme::{self, pal};
use crate::widgets::{self, Kpi};
use egui::{RichText, Ui};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, pnl_by_interval};
use ledger_core::reports::profit_and_loss_for_period;
use rust_decimal::Decimal;
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
page(ui, "overview", |ui| {
let k = &b.kpis;
widgets::page_header(ui, b.company_name(), &headline(b));
kpis(ui, b, shell);
let _ = k;
columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i {
0 => {
let (more, _) = widgets::titled_card(
ui,
"Cash over time",
|ui| {
widgets::link(
ui,
RichText::new("Cash details")
.font(theme::regular(12.5))
.color(pal(ui.ctx()).accent),
)
.clicked()
},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
);
if more {
shell.go(Route::Cash);
}
}
_ => attention(ui, b, shell),
});
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => income_vs_spending(ui, b, shell),
_ => where_it_went(ui, b, shell),
});
recent(ui, b, shell);
});
}
/// The answer, in words.
fn headline(b: &Books) -> String {
let k = &b.kpis;
let Some(first) = k.first_entry else {
return "No transactions yet. Once your agent records some, this page fills in on its own."
.into();
};
let mut s = format!(
"You have {} in cash and owe {}.",
fmt::dollars(k.cash),
fmt::dollars(k.liabilities)
);
if let Some(burn) = k.monthly_burn {
if burn > Decimal::ZERO && k.cash > Decimal::ZERO {
let months = (k.cash / burn).round_dp(1);
s.push_str(&format!(
" Lately you spend {} a month more than you earn: about {} months of runway.",
fmt::dollars(burn),
months
));
}
} else if k.net_ytd > Decimal::ZERO {
s.push_str(&format!(
" You've earned {} more than you've spent this year.",
fmt::dollars(k.net_ytd)
));
} else if k.net_ytd < Decimal::ZERO {
s.push_str(&format!(
" You've spent {} more than you've earned this year.",
fmt::dollars(-k.net_ytd)
));
}
if (b.today - first).num_days() < 60 {
s.push_str(&format!(
" Your books start {}; trends fill in as the months go by.",
fmt::date(first)
));
}
s
}
fn change(
now: Decimal,
prev: Decimal,
up_is_good: bool,
what: &str,
) -> Option<(String, egui::Color32)> {
let pct = fmt::pct_change(prev, now)?;
let good = (pct >= 0.0) == up_is_good;
let p = &theme::LIGHT;
let arrow = if pct >= 0.0 { "▲" } else { "▼" };
Some((
format!("{arrow} {:.0}% vs {what}", pct.abs()),
if pct.abs() < 0.5 {
p.muted
} else if good {
p.positive
} else {
p.negative
},
))
}
fn kpis(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let k = &b.kpis;
let p = pal(ui.ctx());
// Last 12 months of month-end cash for the sparkline.
let (start, end) = b.trailing_months(12);
let start = k.first_entry.map_or(start, |f| {
start.max(ledger_core::analysis::interval_start(f, Interval::Month))
});
let month_ends: Vec<chrono::NaiveDate> =
ledger_core::analysis::intervals(start, end, Interval::Month)
.into_iter()
.map(|(_, e)| e)
.collect();
let cash_spark: Vec<f64> =
ledger_core::analysis::balance_series(&b.ledger, &b.cash_accounts, &month_ends)
.into_iter()
.map(|d| d.try_into().unwrap_or(0.0))
.collect();
let runway = match k.monthly_burn {
Some(burn) if burn > Decimal::ZERO && k.cash > Decimal::ZERO => (
format!("{} mo", (k.cash / burn).round_dp(1)),
Some((format!("burning {}/mo", fmt::compact(burn)), p.warning)),
),
_ if k.first_entry.is_none() => ("—".to_string(), None),
_ if k.first_entry.is_some_and(|f| f >= b.month_start()) => (
"Too early".to_string(),
Some(("needs a full month of books".to_string(), p.muted)),
),
_ => (
"Not burning".to_string(),
Some(("earning ≥ spending".to_string(), p.positive)),
),
};
let ytd = b.fiscal_ytd();
let tiles: Vec<(Kpi, Route)> = vec![
(
Kpi {
label: "Cash on hand",
value: fmt::dollars(k.cash),
sub: change(k.cash, k.cash_30d_ago, true, "30 days ago")
.map(|(t, c)| (t, color_for(c, p))),
spark: Some(&cash_spark),
how: "The balance of every cash account (bank, savings, cash on hand) at the end of today.",
},
Route::Cash,
),
(
Kpi {
label: "Owed by the business",
value: fmt::dollars(k.liabilities),
sub: Some(("bills, cards, owners, taxes".into(), p.muted)),
spark: None,
how: "Total liabilities on today's balance sheet.",
},
Route::Reports(ReportKind::BalanceSheet),
),
(
Kpi {
label: "Spending this month",
value: fmt::dollars(k.expenses_mtd),
sub: change(
k.expenses_mtd,
k.expenses_prev_month,
false,
"same days last month",
)
.map(|(t, c)| (t, color_for(c, p))),
spark: None,
how: "Expenses dated this month (accrual basis: when incurred, not when paid).",
},
Route::Profit,
),
(
Kpi {
label: "Revenue this month",
value: fmt::dollars(k.revenue_mtd),
sub: change(
k.revenue_mtd,
k.revenue_prev_month,
true,
"same days last month",
)
.map(|(t, c)| (t, color_for(c, p))),
spark: None,
how: "Revenue dated this month.",
},
Route::Profit,
),
(
Kpi {
label: "Net income, year to date",
value: fmt::dollars(k.net_ytd),
sub: Some((format!("since {}", fmt::date_short(ytd.0)), p.muted)),
spark: None,
how: "Revenue minus expenses for the fiscal year so far.",
},
Route::Reports(ReportKind::ProfitAndLoss),
),
(
Kpi {
label: "Runway",
value: runway.0,
sub: runway.1,
spark: None,
how: "Cash on hand divided by the average monthly net loss over the last three full months the books cover.",
},
Route::Cash,
),
];
let n = if ui.available_width() > 1180.0 { 6 } else { 3 };
for row in tiles.chunks(n) {
let weights = vec![1.0; n];
columns(ui, 0.0, &weights, |i, ui| {
if let Some((tile, route)) = row.get(i) {
let r = widgets::kpi(
ui,
Kpi {
label: tile.label,
value: tile.value.clone(),
sub: tile.sub.clone(),
spark: tile.spark,
how: tile.how,
},
);
if r.interact(egui::Sense::click()).clicked() {
shell.go(route.clone());
}
}
});
}
}
/// `change` picks from the light palette; map to the current one.
fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 {
let l = &theme::LIGHT;
if c == l.positive {
p.positive
} else if c == l.negative {
p.negative
} else {
p.muted
}
}
fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
widgets::titled_card(
ui,
"Needs attention",
|ui| {
if !b.attention.is_empty() {
widgets::pill(
ui,
&b.attention.len().to_string(),
p.warning,
p.warning_soft,
);
}
},
|ui| {
if b.attention.is_empty() {
widgets::empty(
ui,
icon::CHECK_CIRCLE,
"All clear",
"Nothing is missing or overdue.",
);
return;
}
egui::ScrollArea::vertical()
.id_salt("attention")
.max_height(260.0)
.show(ui, |ui| {
for (i, a) in b.attention.iter().enumerate() {
if i > 0 {
ui.add_space(4.0);
widgets::rule(ui);
ui.add_space(4.0);
}
let (glyph, color) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning),
Severity::Info => (icon::INFO, p.muted),
};
ui.horizontal_top(|ui| {
ui.label(RichText::new(glyph).size(16.0).color(color));
ui.vertical(|ui| {
let t = RichText::new(&a.title).font(theme::semibold(13.0));
let r = widgets::link(ui, t);
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.label(
RichText::new(&a.detail)
.font(theme::regular(12.0))
.color(p.muted),
);
widgets::copy_prompt(ui, &a.prompt);
});
});
}
});
},
);
}
fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let (start, end) = b.trailing_months(12);
let start = b.kpis.first_entry.map_or(start, |f| {
start.max(ledger_core::analysis::interval_start(f, Interval::Month))
});
let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default();
widgets::titled_card(
ui,
"Income and spending by month",
|_| {},
|ui| {
if months.is_empty() {
widgets::empty(ui, icon::CHART_BAR, "No months yet", "");
return;
}
let groups: Vec<BarGroup> = months
.iter()
.map(|m| BarGroup {
label: m.start.format("%b").to_string(),
values: vec![m.revenue, m.expenses, m.net()],
})
.collect();
let clicked = charts::bar_chart(
ui,
&groups,
&[
("Income", p.series[1]),
("Spending", p.series[2]),
("Net", p.series[0]),
],
220.0,
);
if let Some(i) = clicked {
shell.state.reports.range =
crate::pages::reports::Range::Custom(months[i].start, months[i].end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
},
);
}
fn where_it_went(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let (start, end) = b.fiscal_ytd();
widgets::titled_card(
ui,
"Where the money went, this year",
|ui| {
ui.label(
RichText::new(format!(
"{} – {}",
fmt::date_short(start),
fmt::date_short(end)
))
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
let Ok(pl) = profit_and_loss_for_period(&b.ledger, start, end, None) else {
return;
};
let mut rows: Vec<(String, Decimal, String)> = pl
.expenses
.iter()
.map(|l| (l.name.clone(), l.amount, l.code.clone()))
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
rows.truncate(8);
if rows.is_empty() {
widgets::empty(ui, icon::CHART_BAR_HORIZONTAL, "No spending yet", "");
return;
}
let bars: Vec<(String, Decimal)> = rows.iter().map(|r| (r.0.clone(), r.1)).collect();
if let Some(i) = charts::hbars(ui, &bars, p.series[2])
&& let Some(a) = b.ledger.chart().find_by_code(&rows[i].2)
{
shell.go(Route::Account(a.id));
}
},
);
}
fn recent(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let since_update = shell.since_update;
let mut go = None;
let (full, _) = widgets::titled_card(
ui,
"Recent activity",
|ui| {
widgets::link(
ui,
RichText::new("Full journal")
.font(theme::regular(12.5))
.color(p.accent),
)
.clicked()
},
|ui| {
let mut shown = 0;
for (i, line) in b.journal.iter().enumerate().rev() {
let Some(ev) = super::journal::describe(b, line) else {
continue;
};
if shown == 12 {
break;
}
shown += 1;
let fresh = line.seq > b.fresh_from_seq && since_update < 8.0;
let row = egui::Frame::new()
.fill(if fresh {
p.new_highlight
} else {
egui::Color32::TRANSPARENT
})
.corner_radius(egui::CornerRadius::same(6))
.inner_margin(egui::Margin::symmetric(6, 3))
.show(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
ui.label(RichText::new(ev.icon).size(15.0).color(p.muted));
let r = widgets::link(
ui,
RichText::new(&ev.text).font(theme::regular(13.0)),
);
if r.clicked() {
go =
Some(ev.route.clone().unwrap_or(Route::JournalEvent(line.seq)));
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::timestamp(&line.ts))
.font(theme::regular(11.5))
.color(p.faint),
);
},
);
});
});
let _ = (row, i);
}
if shown == 0 {
widgets::empty(ui, icon::CLOCK, "Nothing yet", "");
}
},
);
if full {
go = Some(Route::Journal);
}
if let Some(r) = go {
shell.go(r);
}
}
+377
View File
@@ -0,0 +1,377 @@
//! Profit: are we making money, how is it trending, and what moved.
//! Spending by category this month against the recent average, income by
//! customer and by class.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::{ReportKind, Route};
use crate::theme::{self, pal};
use crate::widgets;
use chrono::{Days, Months};
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, interval_start, pnl_by_interval};
use ledger_core::reports::profit_and_loss_for_period;
use rust_decimal::Decimal;
use std::collections::BTreeMap;
fn pct(part: Decimal, whole: Decimal) -> Option<Decimal> {
(!whole.is_zero()).then(|| (part / whole * Decimal::from(100)).round_dp(1))
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "profit", |ui| {
let (ys, ye) = b.fiscal_ytd();
let ytd = profit_and_loss_for_period(&b.ledger, ys, ye, None).ok();
let (rev, exp) = ytd
.as_ref()
.map(|p| (p.total_revenue, p.total_expenses))
.unwrap_or_default();
let net = rev - exp;
let margin = pct(net, rev);
let sentence = match margin {
Some(m) => format!(
"This year: {} of income, {} of spending, {} {} — a {}% margin.",
fmt::dollars(rev),
fmt::dollars(exp),
fmt::dollars(net.abs()),
if net >= Decimal::ZERO {
"profit"
} else {
"loss"
},
m
),
None if exp.is_zero() => "No income or spending recorded this year yet.".into(),
None => format!(
"This year: {} of spending and no income yet.",
fmt::dollars(exp)
),
};
widgets::page_header(ui, "Profit", &sentence);
let first = b.kpis.first_entry.unwrap_or(b.today);
let (start, end) = b.trailing_months(12);
let start = start.max(interval_start(first, Interval::Month));
let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default();
widgets::titled_card(
ui,
"By month",
|_| {},
|ui| {
if months.is_empty() {
widgets::empty(ui, icon::CHART_BAR, "No months yet", "");
return;
}
let groups: Vec<BarGroup> = months
.iter()
.map(|m| BarGroup {
label: m.start.format("%b").to_string(),
values: vec![m.revenue, m.expenses, m.net()],
})
.collect();
if let Some(i) = charts::bar_chart(
ui,
&groups,
&[
("Income", p.series[1]),
("Spending", p.series[2]),
("Net", p.series[0]),
],
230.0,
) {
shell.state.reports.range =
crate::pages::reports::Range::Custom(months[i].start, months[i].end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
ui.add_space(10.0);
// The same numbers as a table, with the margin.
let w = ui.available_width();
let cw = 130.0;
let header = |ui: &mut Ui, cells: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
cells[0],
theme::semibold(12.0),
p.muted,
);
for (i, c) in cells[1..].iter().enumerate() {
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Month", "Income", "Spending", "Net", "Margin"]);
for m in months.iter().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::month(m.start),
theme::regular(13.0),
p.text,
);
let vals = [
fmt::money(m.revenue),
fmt::money(m.expenses),
fmt::accounting(m.net()),
pct(m.net(), m.revenue)
.map(|x| format!("{x}%"))
.unwrap_or_else(|| "—".into()),
];
for (i, v) in vals.iter().enumerate() {
let color = if i == 2 && m.net() < Decimal::ZERO {
p.negative
} else {
p.text
};
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
v,
theme::regular(13.0),
color,
);
}
if resp.clicked() {
shell.state.reports.range =
crate::pages::reports::Range::Custom(m.start, m.end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
}
},
);
ui.add_space(16.0);
spending_changes(ui, b, shell);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => by_customer(ui, b),
_ => by_class(ui, b),
});
});
}
/// Each expense account: this month so far, last month, the three-month
/// average, and how far this month is running from it.
fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let ms = b.month_start();
let prev =
profit_and_loss_for_period(&b.ledger, ms - Months::new(1), ms - Days::new(1), None).ok();
let three =
profit_and_loss_for_period(&b.ledger, ms - Months::new(3), ms - Days::new(1), None).ok();
let now = profit_and_loss_for_period(&b.ledger, ms, b.today, None).ok();
let mut rows: BTreeMap<String, (String, Decimal, Decimal, Decimal)> = BTreeMap::new();
let mut add = |s: &Option<ledger_core::ProfitAndLossStatement>, slot: usize| {
for l in s.iter().flat_map(|s| &s.expenses) {
let e = rows.entry(l.code.clone()).or_insert((
l.name.clone(),
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
));
match slot {
0 => e.1 = l.amount,
1 => e.2 = l.amount,
_ => e.3 = l.amount / Decimal::from(3),
}
}
};
add(&now, 0);
add(&prev, 1);
add(&three, 2);
let mut rows: Vec<_> = rows.into_iter().collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1.3.max(r.1.1)));
let mut go = None;
widgets::titled_card(
ui,
"Spending by category",
|ui| {
ui.label(
RichText::new("this month so far vs. the last three months")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if rows.is_empty() {
widgets::empty(ui, icon::RECEIPT, "No spending yet", "");
return;
}
let w = ui.available_width();
let cw = 130.0;
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
for (i, c) in ["This month", "Last month", "3-month avg"]
.iter()
.enumerate()
{
ui.painter().text(
egui::pos2(r.right() - cw * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
"Account",
theme::semibold(12.0),
p.muted,
);
for (code, (name, now, last, avg)) in &rows {
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{code} {name}"),
theme::regular(13.0),
p.text,
);
// Flag a category already running well above its average.
let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2);
let vals = [(*now, hot), (*last, false), (*avg, false)];
for (i, (v, flag)) in vals.iter().enumerate() {
let x = r.right() - cw * (2 - i) as f32 - 4.0;
ui.painter().text(
egui::pos2(x, r.center().y),
egui::Align2::RIGHT_CENTER,
fmt::money(v.round_dp(2)),
theme::regular(13.0),
if *flag { p.warning } else { p.text },
);
if *flag {
ui.painter().text(
egui::pos2(x - 86.0, r.center().y),
egui::Align2::RIGHT_CENTER,
icon::ARROW_UP_RIGHT,
egui::FontId::proportional(13.0),
p.warning,
);
}
}
if resp.clicked() {
go = b.ledger.chart().find_by_code(code).map(|a| a.id);
}
}
},
);
if let Some(id) = go {
shell.state.accounts.range = Some((ms - Months::new(3), b.today));
shell.go(Route::Account(id));
}
ui.add_space(16.0);
}
fn by_customer(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let (ys, ye) = b.fiscal_ytd();
let mut by: BTreeMap<String, Decimal> = BTreeMap::new();
for id in 1..b.ledger.next_invoice_id() {
if let Some(inv) = b.ledger.get_invoice(id)
&& inv.date >= ys
&& inv.date <= ye
{
let who = b
.ledger
.get_customer(inv.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default();
*by.entry(who).or_default() += inv.total();
}
}
let mut rows: Vec<(String, Decimal)> = by.into_iter().collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
widgets::titled_card(
ui,
"Invoiced by customer, this year",
|_| {},
|ui| {
if rows.is_empty() {
widgets::empty(
ui,
icon::USERS,
"No invoices this year",
"Income recorded without invoices doesn't show a customer.",
);
return;
}
let total: Decimal = rows.iter().map(|r| r.1).sum();
if let Some((top, v)) = rows.first() {
ui.label(
RichText::new(format!(
"{top} is {}% of invoiced income.",
pct(*v, total).unwrap_or_default()
))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.add_space(4.0);
}
charts::hbars(ui, &rows, p.series[1]);
},
);
}
fn by_class(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let (ys, ye) = b.fiscal_ytd();
let classes = b.ledger.list_classes();
widgets::titled_card(
ui,
"Net income by class, this year",
|_| {},
|ui| {
if classes.is_empty() {
widgets::empty(
ui,
icon::TAG,
"No classes",
"Tag lines with classes (departments, products) to see profit by each.",
);
return;
}
let mut rows: Vec<(String, Decimal)> = classes
.iter()
.filter_map(|c| {
let pl = profit_and_loss_for_period(&b.ledger, ys, ye, Some(c.id)).ok()?;
let net = pl.total_revenue - pl.total_expenses;
(!pl.total_revenue.is_zero() || !pl.total_expenses.is_zero())
.then(|| (c.name.clone(), net))
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
if rows.is_empty() {
widgets::empty(
ui,
icon::TAG,
"No classified income or spending this year",
"",
);
return;
}
charts::hbars(ui, &rows, p.series[0]);
ui.label(
RichText::new("Only lines tagged with a class count here.")
.font(theme::regular(12.0))
.color(p.faint),
);
},
);
}
File diff suppressed because it is too large. Load diff
+279
View File
@@ -0,0 +1,279 @@
//! Every entry, searchable and filterable, with the selected one open
//! beside the list: its lines, its receipt rendered, its history.
use super::page;
use crate::app::Shell;
use crate::books::{Books, EntryInfo};
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::{AccountId, EntryStatus};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Filter {
#[default]
All,
Posted,
Drafts,
NoReceipt,
WithReceipt,
}
#[derive(Default)]
pub struct State {
pub query: String,
pub filter: Filter,
pub account: Option<AccountId>,
}
pub fn matches(e: &EntryInfo, st: &State) -> bool {
let f = match st.filter {
Filter::All => true,
Filter::Posted => e.status == EntryStatus::Posted,
Filter::Drafts => e.status == EntryStatus::Draft,
Filter::NoReceipt => e.receipts.is_empty(),
Filter::WithReceipt => !e.receipts.is_empty(),
};
let a = st.account.is_none_or(|a| e.accounts.contains(&a));
let q = st.query.trim().to_lowercase();
let t = q.is_empty() || q.split_whitespace().all(|w| e.haystack.contains(w));
f && a && t
}
pub fn show(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
match selected.and_then(|id| b.entry(id)) {
Some(e) => {
let w = (ui.available_width() * 0.5).max(420.0);
super::split(
ui,
"transactions",
w,
shell,
|ui, shell| list(ui, b, selected, shell),
|ui, shell| super::entry::detail(ui, b, e, shell),
);
}
None => list(ui, b, None, shell),
}
}
fn list(ui: &mut Ui, b: &Books, selected: Option<u64>, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "transactions", |ui| {
let total = b.entries.len();
widgets::page_header(
ui,
"Transactions",
&format!(
"{} recorded, newest first. Click one to see its lines, its receipt, and its history.",
fmt::count(total, "entry", "entries")
),
);
let st = &mut shell.state.transactions;
let narrow = ui.available_width() < 720.0;
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut st.query)
.hint_text(format!(
"{} Search description, account, vendor, amount",
icon::MAGNIFYING_GLASS
))
.desired_width(if narrow { ui.available_width() } else { 340.0 }),
);
if !narrow {
filters(ui, b, st);
}
});
if narrow {
ui.add_space(6.0);
ui.horizontal(|ui| filters(ui, b, st));
}
ui.add_space(12.0);
let rows: Vec<&EntryInfo> = b.entries.iter().filter(|e| matches(e, st)).collect();
if rows.is_empty() {
widgets::card(ui, |ui| {
widgets::empty(
ui,
icon::MAGNIFYING_GLASS,
"No matching entries",
"Try a different search or filter.",
)
});
return;
}
widgets::card(ui, |ui| {
let mut last_month: Option<(i32, u32)> = None;
for e in rows {
let m = (
chrono::Datelike::year(&e.date),
chrono::Datelike::month(&e.date),
);
if last_month != Some(m) {
if last_month.is_some() {
ui.add_space(10.0);
}
last_month = Some(m);
ui.label(
RichText::new(fmt::month(e.date))
.font(theme::semibold(12.5))
.color(p.muted),
);
ui.add_space(2.0);
}
if row(ui, b, e, selected == Some(e.id), shell.since_update).clicked() {
shell.go(Route::Entry(e.id));
}
}
});
});
}
fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> egui::Response {
let p = pal(ui.ctx());
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 50.0), Sense::click());
let fresh = b.fresh.contains(&e.id) && since_update < 8.0;
if on {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.selected);
} else if fresh {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.new_highlight);
} else if resp.hovered() {
ui.painter()
.rect_filled(rect, CornerRadius::same(8), p.hover);
}
let painter = ui.painter_at(rect);
let left = rect.left() + 10.0;
// Date block.
painter.text(
egui::pos2(left, rect.center().y - 8.0),
egui::Align2::LEFT_CENTER,
e.date.format("%b").to_string().to_uppercase(),
theme::semibold(10.5),
p.faint,
);
painter.text(
egui::pos2(left, rect.center().y + 8.0),
egui::Align2::LEFT_CENTER,
e.date.format("%-d").to_string(),
theme::semibold(16.0),
p.text,
);
// Description and accounts.
let text_left = left + 44.0;
let amount_w = 120.0;
let max_w = (rect.right() - amount_w - 70.0 - text_left).max(60.0);
let desc = painter.layout(e.description.clone(), theme::semibold(13.5), p.text, max_w);
let desc = if desc.rows.len() > 1 {
painter.layout_no_wrap(
truncate(&e.description, (max_w / 7.5) as usize),
theme::semibold(13.5),
p.text,
)
} else {
desc
};
painter.galley(egui::pos2(text_left, rect.center().y - 17.0), desc, p.text);
let accounts: Vec<String> = e.accounts.iter().map(|a| b.account_name(*a)).collect();
let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → "));
if let Some(v) = &e.party {
sub.push_str(&format!(" · {v}"));
}
let sub = truncate(&sub, (max_w / 6.3) as usize);
painter.text(
egui::pos2(text_left, rect.center().y + 10.0),
egui::Align2::LEFT_CENTER,
sub,
theme::regular(12.0),
p.muted,
);
// Amount and badges.
let right = rect.right() - 10.0;
// Cash in reads green with a plus, cash out with a minus; entries that
// don't move cash show their size, muted.
let (amount, color) = if e.cash_effect > rust_decimal::Decimal::ZERO {
(format!("+{}", fmt::money(e.cash_effect)), p.positive)
} else if e.cash_effect < rust_decimal::Decimal::ZERO {
(fmt::money(e.cash_effect), p.text)
} else {
(fmt::money(e.amount), p.muted)
};
painter.text(
egui::pos2(right, rect.center().y - 6.0),
egui::Align2::RIGHT_CENTER,
amount,
theme::semibold(14.0),
color,
);
let mut badges: Vec<(&str, egui::Color32)> = Vec::new();
if e.status == EntryStatus::Draft {
badges.push(("DRAFT", p.warning));
}
if e.reversed_by.is_some() {
badges.push(("REVERSED", p.muted));
}
if e.reversal_of.is_some() {
badges.push(("REVERSAL", p.muted));
}
let mut x = right;
for (text, color) in badges {
let g = painter.layout_no_wrap(text.to_string(), theme::semibold(10.0), color);
x -= g.size().x;
painter.galley(egui::pos2(x, rect.center().y + 6.0), g, color);
x -= 8.0;
}
let clip = if !e.receipts.is_empty() {
Some((icon::PAPERCLIP, p.muted))
} else if e.missing_receipt {
Some((icon::WARNING_CIRCLE, p.negative))
} else {
None
};
if let Some((g, c)) = clip {
painter.text(
egui::pos2(rect.right() - amount_w - 24.0, rect.center().y),
egui::Align2::CENTER_CENTER,
g,
egui::FontId::proportional(16.0),
c,
);
}
resp
}
fn truncate(s: &str, n: usize) -> String {
if s.chars().count() <= n {
s.to_string()
} else {
let t: String = s.chars().take(n.saturating_sub(1)).collect();
format!("{t}…")
}
}
fn filters(ui: &mut Ui, b: &Books, st: &mut State) {
widgets::segmented(
ui,
&mut st.filter,
&[
(Filter::All, "All"),
(Filter::Posted, "Posted"),
(Filter::Drafts, "Drafts"),
(Filter::WithReceipt, "With receipt"),
(Filter::NoReceipt, "No receipt"),
],
);
if let Some(a) = st.account {
ui.add_space(6.0);
let label = format!("{} {} {}", icon::FUNNEL, b.account_label(a), icon::X);
if ui
.button(label)
.on_hover_text("Clear the account filter")
.clicked()
{
st.account = None;
}
}
}