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:
52 files changed
+14050
-113
No files matched your search
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user