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
Generated
+3498
-76
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli"]
|
members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|||||||
@@ -15,13 +15,21 @@ through MCP. Self-hosted, no rent.
|
|||||||
append-only, hash-chained JSONL journal; SQLite is a disposable
|
append-only, hash-chained JSONL journal; SQLite is a disposable
|
||||||
projection rebuilt from it; receipts live in a content-addressed blob
|
projection rebuilt from it; receipts live in a content-addressed blob
|
||||||
store. Drafts are durable, corrupt projections self-heal.
|
store. Drafts are durable, corrupt projections self-heal.
|
||||||
- **`ledger-mcp`** — the AI-first interface. 27 MCP tools over stdio or
|
- **`ledger-mcp`** — the AI-first interface. 64 MCP tools over stdio or
|
||||||
Streamable HTTP: draft → preview → post workflow, server-side receipt
|
Streamable HTTP: draft → preview → post workflow, server-side receipt
|
||||||
policy (>$25), anti-splitting detection, asset management, digest for
|
policy (>$25), anti-splitting detection, asset management, digest for
|
||||||
human review, plus read-only resources for dashboards.
|
human review, plus read-only resources for dashboards.
|
||||||
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
|
- **`ledger-import`** — QuickBooks interop. Import bank/credit-card CSVs
|
||||||
and chart-of-accounts CSVs; export IIF journal entries (for QuickBooks
|
and chart-of-accounts CSVs; export IIF journal entries (for QuickBooks
|
||||||
Desktop) and XLSX reports.
|
Desktop) and XLSX reports.
|
||||||
|
- **`ledger-config`** — the company settings in a books dir's
|
||||||
|
`wanderbooks.toml`.
|
||||||
|
- **`ledger-gui`** — the read-only desktop view for owners and their
|
||||||
|
accountant (egui). Answers first (cash, runway, profit, what needs
|
||||||
|
attention), then every number drills down to its lines, its entry, the
|
||||||
|
rendered receipt, and the journal history. Follows the journal live:
|
||||||
|
a change the agent makes appears in milliseconds. `--demo` opens a
|
||||||
|
realistic demo company.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
@@ -51,6 +59,7 @@ See [`plan/STATUS.md`](plan/STATUS.md) for where things stand and
|
|||||||
cargo test # whole suite
|
cargo test # whole suite
|
||||||
cargo run -p ledger-cli -- init ~/books/acme # scaffold a books dir: mise.toml + AGENT.md
|
cargo run -p ledger-cli -- init ~/books/acme # scaffold a books dir: mise.toml + AGENT.md
|
||||||
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
|
cargo run -p ledger-mcp -- --data-dir /tmp/books # MCP server over stdio
|
||||||
|
cargo run -p ledger-gui -- --demo # the desktop view, on a demo company
|
||||||
```
|
```
|
||||||
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
|
Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
|
||||||
`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md.
|
`mise run mcp-add`, and start `claude` — the agent onboards from AGENT.md.
|
||||||
@@ -60,8 +69,10 @@ Then `cd ~/books/acme`: `mise trust && mise install`, `mise run build`,
|
|||||||
```
|
```
|
||||||
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
|
crates/ledger-core/ # engine: money, accounts, journal, ledger, reports
|
||||||
crates/ledger-store/ # journal + SQLite projection + blob store
|
crates/ledger-store/ # journal + SQLite projection + blob store
|
||||||
crates/ledger-mcp/ # MCP server: 27 tools, resources, receipt policy
|
crates/ledger-mcp/ # MCP server: 64 tools, resources, receipt policy
|
||||||
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
crates/ledger-import/ # QBO CSV import, IIF/XLSX export
|
||||||
|
crates/ledger-config/ # wanderbooks.toml company settings
|
||||||
|
crates/ledger-gui/ # read-only live desktop view (egui)
|
||||||
docs/ # requirements, architecture, oracle, parity, glossary
|
docs/ # requirements, architecture, oracle, parity, glossary
|
||||||
plan/ # STATUS.md, roadmap.md
|
plan/ # STATUS.md, roadmap.md
|
||||||
AGENTS.md # operating manual for agents working here
|
AGENTS.md # operating manual for agents working here
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
[package]
|
||||||
|
name = "ledger-config"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Company settings for wanderbooks: the books dir's wanderbooks.toml."
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ledger-core = { path = "../ledger-core" }
|
||||||
|
chrono = "0.4"
|
||||||
|
rust_decimal = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
toml = { version = "1", features = ["preserve_order"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -35,6 +35,9 @@ pub struct Company {
|
|||||||
/// `[books] close_sweep`: equity accounts (draws, dividends,
|
/// `[books] close_sweep`: equity accounts (draws, dividends,
|
||||||
/// distributions) a close zeroes into `close_into`.
|
/// distributions) a close zeroes into `close_into`.
|
||||||
pub close_sweep: Vec<String>,
|
pub close_sweep: Vec<String>,
|
||||||
|
/// Every simple `[company]` setting (name, EIN, formation date, ...),
|
||||||
|
/// in file order, as written: the full profile for display.
|
||||||
|
pub profile: Vec<(String, String)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One member of a multi-member LLC.
|
/// One member of a multi-member LLC.
|
||||||
@@ -61,6 +64,7 @@ impl Default for Company {
|
|||||||
tax_form: None,
|
tax_form: None,
|
||||||
close_into: None,
|
close_into: None,
|
||||||
close_sweep: Vec::new(),
|
close_sweep: Vec::new(),
|
||||||
|
profile: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,6 +93,10 @@ pub struct BooksSettings {
|
|||||||
pub company: Company,
|
pub company: Company,
|
||||||
/// `[books] receipt_threshold`, validated as a decimal string.
|
/// `[books] receipt_threshold`, validated as a decimal string.
|
||||||
pub receipt_threshold: Option<String>,
|
pub receipt_threshold: Option<String>,
|
||||||
|
/// `[books] cash_accounts`: account codes that hold cash (bank,
|
||||||
|
/// savings, cash on hand), for cash reports and dashboards. Empty:
|
||||||
|
/// callers decide (e.g. by account name).
|
||||||
|
pub cash_accounts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -124,6 +132,8 @@ struct BooksSection {
|
|||||||
close_into: Option<String>,
|
close_into: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
close_sweep: Vec<String>,
|
close_sweep: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
cash_accounts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The return a federal tax classification files: "partnership" → 1065,
|
/// The return a federal tax classification files: "partnership" → 1065,
|
||||||
@@ -238,9 +248,27 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
|||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||||
|
if let Some(toml::Value::Table(c)) = table.get("company") {
|
||||||
|
company.profile = c
|
||||||
|
.iter()
|
||||||
|
.filter_map(|(k, v)| {
|
||||||
|
let s = match v {
|
||||||
|
toml::Value::String(s) => s.clone(),
|
||||||
|
toml::Value::Integer(i) => i.to_string(),
|
||||||
|
toml::Value::Float(f) => f.to_string(),
|
||||||
|
toml::Value::Boolean(b) => b.to_string(),
|
||||||
|
toml::Value::Datetime(d) => d.to_string(),
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((k.clone(), s))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
Ok(BooksSettings {
|
Ok(BooksSettings {
|
||||||
company,
|
company,
|
||||||
receipt_threshold,
|
receipt_threshold,
|
||||||
|
cash_accounts: raw.books.cash_accounts,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,6 +334,23 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keeps_the_whole_profile_in_order() {
|
||||||
|
let s = parse(
|
||||||
|
"[company]\nname = \"Acme LLC\"\nein = \"12-3456789\"\nformed = \"2026-10-04\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\n",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.company.profile,
|
||||||
|
[
|
||||||
|
("name".to_string(), "Acme LLC".to_string()),
|
||||||
|
("ein".to_string(), "12-3456789".to_string()),
|
||||||
|
("formed".to_string(), "2026-10-04".to_string()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(s.cash_accounts, ["1000", "1010"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn reads_members_and_basis() {
|
fn reads_members_and_basis() {
|
||||||
let s = parse(
|
let s = parse(
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
//! Time series over the books, for dashboards and interval-column reports.
|
||||||
|
//!
|
||||||
|
//! Statements answer "what was true for this range"; these answer "how did
|
||||||
|
//! it change": a range split into months, quarters, or years; balances of a
|
||||||
|
//! set of accounts on a series of dates; profit and loss per interval. They
|
||||||
|
//! are built from the same posted entries and statements as everything
|
||||||
|
//! else, so a number on a chart always ties to a number on a statement.
|
||||||
|
|
||||||
|
use crate::reports::profit_and_loss_for_period;
|
||||||
|
use crate::{AccountId, AccountType, Ledger, LedgerError, Side};
|
||||||
|
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
/// How to split a date range into columns.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum Interval {
|
||||||
|
/// Calendar months.
|
||||||
|
Month,
|
||||||
|
/// Calendar quarters.
|
||||||
|
Quarter,
|
||||||
|
/// Calendar years.
|
||||||
|
Year,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Interval {
|
||||||
|
fn months(self) -> u32 {
|
||||||
|
match self {
|
||||||
|
Interval::Month => 1,
|
||||||
|
Interval::Quarter => 3,
|
||||||
|
Interval::Year => 12,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first day of the calendar interval containing `d`.
|
||||||
|
pub fn interval_start(d: NaiveDate, interval: Interval) -> NaiveDate {
|
||||||
|
let month0 = (d.month0() / interval.months()) * interval.months();
|
||||||
|
NaiveDate::from_ymd_opt(d.year(), month0 + 1, 1).expect("valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `[start, end]` split on calendar interval boundaries, first and last
|
||||||
|
/// pieces clipped to the range. Empty when `end < start`.
|
||||||
|
pub fn intervals(
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
interval: Interval,
|
||||||
|
) -> Vec<(NaiveDate, NaiveDate)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut from = interval_start(start, interval);
|
||||||
|
while from <= end {
|
||||||
|
let next = from + Months::new(interval.months());
|
||||||
|
let to = (next - Days::new(1)).min(end);
|
||||||
|
out.push((from.max(start), to));
|
||||||
|
from = next;
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The combined normal-side balance of `accounts` at the end of each date
|
||||||
|
/// in `dates` (which must be ascending). Assets and expenses count debits
|
||||||
|
/// as positive; liabilities, equity, and revenue count credits. Mixing
|
||||||
|
/// types is allowed but rarely meaningful.
|
||||||
|
pub fn balance_series(
|
||||||
|
ledger: &Ledger,
|
||||||
|
accounts: &[AccountId],
|
||||||
|
dates: &[NaiveDate],
|
||||||
|
) -> Vec<Decimal> {
|
||||||
|
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||||
|
let mut postings: Vec<(NaiveDate, Decimal)> = Vec::new();
|
||||||
|
for entry in ledger.posted_entries() {
|
||||||
|
for line in entry.lines() {
|
||||||
|
if !set.contains(&line.account) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(account) = ledger.chart().get(line.account) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let signed = if line.side == account.account_type.normal_side() {
|
||||||
|
line.amount.amount
|
||||||
|
} else {
|
||||||
|
-line.amount.amount
|
||||||
|
};
|
||||||
|
postings.push((entry.date(), signed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
postings.sort_by_key(|(d, _)| *d);
|
||||||
|
let mut out = Vec::with_capacity(dates.len());
|
||||||
|
let mut running = Decimal::ZERO;
|
||||||
|
let mut i = 0;
|
||||||
|
for date in dates {
|
||||||
|
while i < postings.len() && postings[i].0 <= *date {
|
||||||
|
running += postings[i].1;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
out.push(running);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every date `accounts` moved, with the combined balance at the end of
|
||||||
|
/// that day: the exact shape of a balance-over-time chart.
|
||||||
|
pub fn balance_history(ledger: &Ledger, accounts: &[AccountId]) -> Vec<(NaiveDate, Decimal)> {
|
||||||
|
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||||
|
let mut dates: Vec<NaiveDate> = ledger
|
||||||
|
.posted_entries()
|
||||||
|
.filter(|e| e.lines().iter().any(|l| set.contains(&l.account)))
|
||||||
|
.map(|e| e.date())
|
||||||
|
.collect();
|
||||||
|
dates.sort();
|
||||||
|
dates.dedup();
|
||||||
|
let balances = balance_series(ledger, accounts, &dates);
|
||||||
|
dates.into_iter().zip(balances).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One interval's profit and loss.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct IntervalPnl {
|
||||||
|
/// First day.
|
||||||
|
pub start: NaiveDate,
|
||||||
|
/// Last day.
|
||||||
|
pub end: NaiveDate,
|
||||||
|
/// Total revenue.
|
||||||
|
pub revenue: Decimal,
|
||||||
|
/// Total expenses.
|
||||||
|
pub expenses: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl IntervalPnl {
|
||||||
|
/// Revenue minus expenses.
|
||||||
|
pub fn net(&self) -> Decimal {
|
||||||
|
self.revenue - self.expenses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Profit and loss per interval across `[start, end]` (closing entries
|
||||||
|
/// excluded, as on every period statement).
|
||||||
|
pub fn pnl_by_interval(
|
||||||
|
ledger: &Ledger,
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
interval: Interval,
|
||||||
|
) -> Result<Vec<IntervalPnl>, LedgerError> {
|
||||||
|
intervals(start, end, interval)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(s, e)| {
|
||||||
|
let pl = profit_and_loss_for_period(ledger, s, e, None)?;
|
||||||
|
Ok(IntervalPnl {
|
||||||
|
start: s,
|
||||||
|
end: e,
|
||||||
|
revenue: pl.total_revenue,
|
||||||
|
expenses: pl.total_expenses,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Money in and out of `accounts` per interval: (inflows, outflows), each
|
||||||
|
/// positive. Each entry counts by its net debit (in) or credit (out) to the
|
||||||
|
/// accounts together: for cash, deposits and payments; a transfer between
|
||||||
|
/// two of them (checking to savings) is neither; with a credit card in the
|
||||||
|
/// set, a charge is money out and paying the card moves nothing.
|
||||||
|
pub fn flows_by_interval(
|
||||||
|
ledger: &Ledger,
|
||||||
|
accounts: &[AccountId],
|
||||||
|
start: NaiveDate,
|
||||||
|
end: NaiveDate,
|
||||||
|
interval: Interval,
|
||||||
|
) -> Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> {
|
||||||
|
let set: HashSet<AccountId> = accounts.iter().copied().collect();
|
||||||
|
let mut out: Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> = intervals(start, end, interval)
|
||||||
|
.into_iter()
|
||||||
|
.map(|(s, e)| (s, e, Decimal::ZERO, Decimal::ZERO))
|
||||||
|
.collect();
|
||||||
|
for entry in ledger.posted_entries() {
|
||||||
|
let d = entry.date();
|
||||||
|
let Some(slot) = out.iter_mut().find(|(s, e, _, _)| *s <= d && d <= *e) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
// Debits in, credits out, for every account in the set: cash
|
||||||
|
// received is a debit to cash, and a charge on a card in the set is
|
||||||
|
// a credit to the card.
|
||||||
|
let mut net = Decimal::ZERO;
|
||||||
|
for line in entry.lines().iter().filter(|l| set.contains(&l.account)) {
|
||||||
|
match line.side {
|
||||||
|
Side::Debit => net += line.amount.amount,
|
||||||
|
Side::Credit => net -= line.amount.amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if net > Decimal::ZERO {
|
||||||
|
slot.2 += net;
|
||||||
|
} else {
|
||||||
|
slot.3 -= net;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accounts of the given types, in chart order.
|
||||||
|
pub fn accounts_of_type(ledger: &Ledger, types: &[AccountType]) -> Vec<AccountId> {
|
||||||
|
ledger
|
||||||
|
.chart()
|
||||||
|
.all()
|
||||||
|
.filter(|a| types.contains(&a.account_type))
|
||||||
|
.map(|a| a.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn intervals_clip_to_the_range() {
|
||||||
|
assert_eq!(
|
||||||
|
intervals(d(2026, 1, 15), d(2026, 3, 10), Interval::Month),
|
||||||
|
[
|
||||||
|
(d(2026, 1, 15), d(2026, 1, 31)),
|
||||||
|
(d(2026, 2, 1), d(2026, 2, 28)),
|
||||||
|
(d(2026, 3, 1), d(2026, 3, 10)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
intervals(d(2026, 2, 1), d(2026, 12, 31), Interval::Quarter),
|
||||||
|
[
|
||||||
|
(d(2026, 2, 1), d(2026, 3, 31)),
|
||||||
|
(d(2026, 4, 1), d(2026, 6, 30)),
|
||||||
|
(d(2026, 7, 1), d(2026, 9, 30)),
|
||||||
|
(d(2026, 10, 1), d(2026, 12, 31)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
intervals(d(2025, 6, 1), d(2026, 2, 1), Interval::Year),
|
||||||
|
[
|
||||||
|
(d(2025, 6, 1), d(2025, 12, 31)),
|
||||||
|
(d(2026, 1, 1), d(2026, 2, 1)),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert!(intervals(d(2026, 2, 1), d(2026, 1, 1), Interval::Month).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
//! (`docs/oracle.md`) possible.
|
//! (`docs/oracle.md`) possible.
|
||||||
|
|
||||||
pub mod account;
|
pub mod account;
|
||||||
|
pub mod analysis;
|
||||||
pub mod arap;
|
pub mod arap;
|
||||||
pub mod assets;
|
pub mod assets;
|
||||||
pub mod classes;
|
pub mod classes;
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
//! Time series. Numbers computed by hand.
|
||||||
|
//!
|
||||||
|
//! Cash 1000 and card 2100. Jan 5: owner puts in 5,000. Jan 20: 300 of
|
||||||
|
//! software on the card. Feb 3: 1,200 of sales into cash. Feb 10: pay the
|
||||||
|
//! card 300. Mar 15: 800 rent from cash.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::analysis::{
|
||||||
|
Interval, balance_history, balance_series, flows_by_interval, pnl_by_interval,
|
||||||
|
};
|
||||||
|
use ledger_core::{AccountType, Currency, Ledger, Money, Side};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
fn d(m: u32, day: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dec(n: i64) -> Decimal {
|
||||||
|
Decimal::from(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn balances_pnl_and_flows_over_time() {
|
||||||
|
let mut l = Ledger::new(Currency::USD);
|
||||||
|
let mut a = |c: &str, t| l.add_account(c, c, t, None, false).unwrap();
|
||||||
|
let cash = a("1000", AccountType::Asset);
|
||||||
|
let card = a("2100", AccountType::Liability);
|
||||||
|
let equity = a("3000", AccountType::Equity);
|
||||||
|
let sales = a("4000", AccountType::Revenue);
|
||||||
|
let software = a("6300", AccountType::Expense);
|
||||||
|
let rent = a("6600", AccountType::Expense);
|
||||||
|
for (date, dr, cr, amt) in [
|
||||||
|
(d(1, 5), cash, equity, 5000),
|
||||||
|
(d(1, 20), software, card, 300),
|
||||||
|
(d(2, 3), cash, sales, 1200),
|
||||||
|
(d(2, 10), card, cash, 300),
|
||||||
|
(d(3, 15), rent, cash, 800),
|
||||||
|
] {
|
||||||
|
let e = l.create_entry(date, "x");
|
||||||
|
let m = Money::new(dec(amt), Currency::USD);
|
||||||
|
l.add_line(e, dr, Side::Debit, m).unwrap();
|
||||||
|
l.add_line(e, cr, Side::Credit, m).unwrap();
|
||||||
|
l.post_entry(e).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
balance_series(&l, &[cash], &[d(1, 1), d(1, 31), d(2, 28), d(3, 31)]),
|
||||||
|
[dec(0), dec(5000), dec(5900), dec(5100)]
|
||||||
|
);
|
||||||
|
// The card is a liability: owed 300, then paid off.
|
||||||
|
assert_eq!(
|
||||||
|
balance_series(&l, &[card], &[d(1, 31), d(2, 28)]),
|
||||||
|
[dec(300), dec(0)]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
balance_history(&l, &[cash]),
|
||||||
|
[
|
||||||
|
(d(1, 5), dec(5000)),
|
||||||
|
(d(2, 3), dec(6200)),
|
||||||
|
(d(2, 10), dec(5900)),
|
||||||
|
(d(3, 15), dec(5100))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let pnl = pnl_by_interval(&l, d(1, 1), d(3, 31), Interval::Month).unwrap();
|
||||||
|
let nets: Vec<Decimal> = pnl.iter().map(|p| p.net()).collect();
|
||||||
|
assert_eq!(nets, [dec(-300), dec(1200), dec(-800)]);
|
||||||
|
let flows = flows_by_interval(&l, &[cash], d(1, 1), d(3, 31), Interval::Month);
|
||||||
|
let io: Vec<(Decimal, Decimal)> = flows.iter().map(|f| (f.2, f.3)).collect();
|
||||||
|
assert_eq!(
|
||||||
|
io,
|
||||||
|
[
|
||||||
|
(dec(5000), dec(0)),
|
||||||
|
(dec(1200), dec(300)),
|
||||||
|
(dec(0), dec(800))
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// Cash and card together: a card charge is money out when it's made,
|
||||||
|
// and paying the card moves nothing out of the pair.
|
||||||
|
let both = flows_by_interval(&l, &[cash, card], d(1, 1), d(2, 28), Interval::Month);
|
||||||
|
let io: Vec<(Decimal, Decimal)> = both.iter().map(|f| (f.2, f.3)).collect();
|
||||||
|
assert_eq!(io, [(dec(5000), dec(300)), (dec(1200), dec(0))]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[package]
|
||||||
|
name = "ledger-gui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
description = "Read-only, live desktop view of wanderbooks books."
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "ledger-gui"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ledger-core = { path = "../ledger-core" }
|
||||||
|
ledger-store = { path = "../ledger-store" }
|
||||||
|
ledger-config = { path = "../ledger-config" }
|
||||||
|
eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] }
|
||||||
|
egui = "0.36"
|
||||||
|
egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] }
|
||||||
|
egui-phosphor = "0.14"
|
||||||
|
hayro = { version = "0.8", features = ["embed-fonts"] }
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
|
||||||
|
notify = "8"
|
||||||
|
jiff = "0.2"
|
||||||
|
chrono = "0.4"
|
||||||
|
rust_decimal = "1"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
chrono = "0.4"
|
||||||
|
egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] }
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION AND CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,442 @@
|
|||||||
|
//! The window: sidebar, top bar, and the current page.
|
||||||
|
|
||||||
|
use crate::books::Books;
|
||||||
|
use crate::data::{Loader, Source, Update};
|
||||||
|
use crate::nav::{Nav, Route, Section};
|
||||||
|
use crate::pages;
|
||||||
|
use crate::receipts::Receipts;
|
||||||
|
use crate::theme::{self, pal};
|
||||||
|
use egui::{
|
||||||
|
Align, Color32, CornerRadius, Frame, Key, Layout, Margin, RichText, Sense, Stroke, Ui, vec2,
|
||||||
|
};
|
||||||
|
use egui_phosphor::regular as icon;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Per-page state that survives navigation.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct PageState {
|
||||||
|
pub transactions: pages::transactions::State,
|
||||||
|
pub reports: pages::reports::State,
|
||||||
|
pub accounts: pages::accounts::State,
|
||||||
|
pub documents: pages::documents::State,
|
||||||
|
pub journal: pages::journal::State,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a page can ask of the shell.
|
||||||
|
pub struct Shell<'a> {
|
||||||
|
pub receipts: &'a mut Receipts,
|
||||||
|
pub state: &'a mut PageState,
|
||||||
|
pub go: Option<Route>,
|
||||||
|
/// Seconds since the last live update arrived (for "new" highlights).
|
||||||
|
pub since_update: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Shell<'_> {
|
||||||
|
pub fn go(&mut self, r: Route) {
|
||||||
|
self.go = Some(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
loader: Option<Loader>,
|
||||||
|
books: Option<Arc<Books>>,
|
||||||
|
error: Option<String>,
|
||||||
|
nav: Nav,
|
||||||
|
receipts: Receipts,
|
||||||
|
dark: bool,
|
||||||
|
state: PageState,
|
||||||
|
palette: crate::palette::Palette,
|
||||||
|
updated_at: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
/// The live app over a books directory.
|
||||||
|
pub fn new(cc: &eframe::CreationContext<'_>, source: Source) -> Self {
|
||||||
|
let dark = cc
|
||||||
|
.storage
|
||||||
|
.and_then(|s| s.get_string("dark"))
|
||||||
|
.map(|v| v == "true")
|
||||||
|
.unwrap_or(cc.egui_ctx.global_style().visuals.dark_mode);
|
||||||
|
let mut app = Self::bare(&cc.egui_ctx, dark);
|
||||||
|
app.loader = Some(Loader::spawn(source, cc.egui_ctx.clone()));
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An app showing fixed books (tests and screenshots).
|
||||||
|
pub fn with_books(ctx: &egui::Context, books: Books, dark: bool, start: Route) -> Self {
|
||||||
|
let mut app = Self::bare(ctx, dark);
|
||||||
|
app.books = Some(Arc::new(books));
|
||||||
|
app.nav = Nav::new(start);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bare(ctx: &egui::Context, dark: bool) -> Self {
|
||||||
|
theme::install_fonts(ctx);
|
||||||
|
theme::apply(ctx, dark);
|
||||||
|
egui_extras::install_image_loaders(ctx);
|
||||||
|
Self {
|
||||||
|
loader: None,
|
||||||
|
books: None,
|
||||||
|
error: None,
|
||||||
|
nav: Nav::new(Route::Overview),
|
||||||
|
receipts: Receipts::new(ctx),
|
||||||
|
dark,
|
||||||
|
state: PageState::default(),
|
||||||
|
palette: Default::default(),
|
||||||
|
updated_at: -1e9,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate (tests).
|
||||||
|
pub fn goto(&mut self, r: Route) {
|
||||||
|
self.nav.go(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until every receipt on screen has rendered (screenshots).
|
||||||
|
pub fn settle(&mut self, ctx: &egui::Context) {
|
||||||
|
self.receipts.settle(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page state (tests).
|
||||||
|
pub fn state_mut(&mut self) -> &mut PageState {
|
||||||
|
&mut self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_keys(&mut self, ui: &Ui) {
|
||||||
|
let (back, fwd, palette) = ui.input(|i| {
|
||||||
|
(
|
||||||
|
(i.modifiers.alt && i.key_pressed(Key::ArrowLeft))
|
||||||
|
|| i.pointer.button_pressed(egui::PointerButton::Extra1),
|
||||||
|
(i.modifiers.alt && i.key_pressed(Key::ArrowRight))
|
||||||
|
|| i.pointer.button_pressed(egui::PointerButton::Extra2),
|
||||||
|
i.modifiers.command && i.key_pressed(Key::K),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if back {
|
||||||
|
self.nav.back();
|
||||||
|
}
|
||||||
|
if fwd {
|
||||||
|
self.nav.forward();
|
||||||
|
}
|
||||||
|
if palette {
|
||||||
|
self.palette.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sidebar(&mut self, ui: &mut Ui) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let books = self.books.clone();
|
||||||
|
egui::Panel::left("sidebar")
|
||||||
|
.exact_size(224.0)
|
||||||
|
.resizable(false)
|
||||||
|
.frame(
|
||||||
|
Frame::new()
|
||||||
|
.fill(p.sidebar)
|
||||||
|
.inner_margin(Margin::symmetric(12, 16)),
|
||||||
|
)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let (r, _) = ui.allocate_exact_size(vec2(28.0, 28.0), Sense::hover());
|
||||||
|
ui.painter().rect_filled(r, CornerRadius::same(8), p.accent);
|
||||||
|
let initial = books
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b.company_name().chars().next())
|
||||||
|
.unwrap_or('W');
|
||||||
|
ui.painter().text(
|
||||||
|
r.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
initial,
|
||||||
|
theme::bold(15.0),
|
||||||
|
Color32::WHITE,
|
||||||
|
);
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
|
let name = books
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| b.company_name().to_string())
|
||||||
|
.unwrap_or_else(|| "wanderbooks".into());
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(RichText::new(name).font(theme::semibold(13.5)))
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
RichText::new("Books")
|
||||||
|
.font(theme::regular(11.5))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.add_space(18.0);
|
||||||
|
let current = self.nav.current.section();
|
||||||
|
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||||
|
for s in Section::ALL {
|
||||||
|
let on = s == current;
|
||||||
|
let (rect, resp) =
|
||||||
|
ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click());
|
||||||
|
if on {
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||||
|
ui.painter().rect_stroke(
|
||||||
|
rect,
|
||||||
|
CornerRadius::same(8),
|
||||||
|
Stroke::new(1.0, p.border),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
} else if resp.hovered() {
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||||
|
}
|
||||||
|
let color = if on { p.text } else { p.muted };
|
||||||
|
ui.painter().text(
|
||||||
|
rect.left_center() + vec2(10.0, 0.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
s.icon(),
|
||||||
|
egui::FontId::proportional(16.0),
|
||||||
|
if on { p.accent } else { p.muted },
|
||||||
|
);
|
||||||
|
ui.painter().text(
|
||||||
|
rect.left_center() + vec2(36.0, 0.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
s.title(),
|
||||||
|
if on {
|
||||||
|
theme::semibold(13.5)
|
||||||
|
} else {
|
||||||
|
theme::regular(13.5)
|
||||||
|
},
|
||||||
|
color,
|
||||||
|
);
|
||||||
|
if s == Section::Overview && attention > 0 {
|
||||||
|
let badge = egui::Rect::from_center_size(
|
||||||
|
rect.right_center() - vec2(18.0, 0.0),
|
||||||
|
vec2(22.0, 18.0),
|
||||||
|
);
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(badge, CornerRadius::same(9), p.warning_soft);
|
||||||
|
ui.painter().text(
|
||||||
|
badge.center(),
|
||||||
|
egui::Align2::CENTER_CENTER,
|
||||||
|
attention.to_string(),
|
||||||
|
theme::semibold(11.0),
|
||||||
|
p.warning,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if resp.clicked() {
|
||||||
|
self.nav.go(s.home());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||||
|
let label = if self.dark {
|
||||||
|
format!("{} Light mode", icon::SUN)
|
||||||
|
} else {
|
||||||
|
format!("{} Dark mode", icon::MOON)
|
||||||
|
};
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
RichText::new(label)
|
||||||
|
.font(theme::regular(12.5))
|
||||||
|
.color(p.muted),
|
||||||
|
)
|
||||||
|
.frame(false),
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.dark = !self.dark;
|
||||||
|
theme::apply(ui.ctx(), self.dark);
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
if let Some(b) = &books {
|
||||||
|
live_status(
|
||||||
|
ui,
|
||||||
|
b,
|
||||||
|
ui.input(|i| i.time) - self.updated_at,
|
||||||
|
self.loader.is_some(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn topbar(&mut self, ui: &mut Ui) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
egui::Panel::top("topbar")
|
||||||
|
.exact_size(52.0)
|
||||||
|
.frame(
|
||||||
|
Frame::new()
|
||||||
|
.fill(p.bg)
|
||||||
|
.inner_margin(Margin::symmetric(20, 10))
|
||||||
|
.stroke(Stroke::new(1.0, p.border)),
|
||||||
|
)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal_centered(|ui| {
|
||||||
|
let nav_btn = |ui: &mut Ui, glyph: &str, enabled: bool, tip: &str| {
|
||||||
|
ui.add_enabled(
|
||||||
|
enabled,
|
||||||
|
egui::Button::new(RichText::new(glyph).size(16.0))
|
||||||
|
.frame(false)
|
||||||
|
.min_size(vec2(28.0, 28.0)),
|
||||||
|
)
|
||||||
|
.on_hover_text(tip)
|
||||||
|
.clicked()
|
||||||
|
};
|
||||||
|
if nav_btn(ui, icon::ARROW_LEFT, self.nav.can_back(), "Back (Alt+←)") {
|
||||||
|
self.nav.back();
|
||||||
|
}
|
||||||
|
if nav_btn(
|
||||||
|
ui,
|
||||||
|
icon::ARROW_RIGHT,
|
||||||
|
self.nav.can_forward(),
|
||||||
|
"Forward (Alt+→)",
|
||||||
|
) {
|
||||||
|
self.nav.forward();
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
let crumb = self
|
||||||
|
.books
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| crate::palette::describe(b, &self.nav.current))
|
||||||
|
.unwrap_or_default();
|
||||||
|
ui.label(
|
||||||
|
RichText::new(self.nav.current.section().title())
|
||||||
|
.font(theme::regular(13.5))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
if !crumb.is_empty() {
|
||||||
|
ui.label(RichText::new(icon::CARET_RIGHT).color(p.faint));
|
||||||
|
ui.add(
|
||||||
|
egui::Label::new(RichText::new(crumb).font(theme::semibold(13.5)))
|
||||||
|
.truncate(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||||
|
let search = egui::Button::new(
|
||||||
|
RichText::new(format!(
|
||||||
|
"{} Search everything… Ctrl K",
|
||||||
|
icon::MAGNIFYING_GLASS
|
||||||
|
))
|
||||||
|
.font(theme::regular(13.0))
|
||||||
|
.color(p.faint),
|
||||||
|
)
|
||||||
|
.fill(p.surface)
|
||||||
|
.stroke(Stroke::new(1.0, p.border))
|
||||||
|
.corner_radius(CornerRadius::same(8))
|
||||||
|
.min_size(vec2(260.0, 30.0));
|
||||||
|
if ui.add(search).clicked() {
|
||||||
|
self.palette.open();
|
||||||
|
}
|
||||||
|
if let Some(b) = &self.books {
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!("Today {}", crate::fmt::date(b.today)))
|
||||||
|
.font(theme::regular(12.5))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
Frame::new()
|
||||||
|
.fill(p.surface)
|
||||||
|
.stroke(Stroke::new(1.0, p.border))
|
||||||
|
.corner_radius(CornerRadius::same(8))
|
||||||
|
.inner_margin(Margin::same(10))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let (r, _) = ui.allocate_exact_size(vec2(8.0, 8.0), Sense::hover());
|
||||||
|
let pulse = (since_update < 2.0) as u8 as f32;
|
||||||
|
let color = if live { p.positive } else { p.faint };
|
||||||
|
ui.painter().circle_filled(r.center(), 4.0 + pulse, color);
|
||||||
|
ui.label(
|
||||||
|
RichText::new(if live { "Live" } else { "Snapshot" })
|
||||||
|
.font(theme::semibold(12.0)),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!("· event {}", b.seq()))
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!("{} Journal chain verified", icon::SEAL_CHECK))
|
||||||
|
.font(theme::regular(11.5))
|
||||||
|
.color(p.muted),
|
||||||
|
)
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"Every journal event's hash chains to the one before it.\nHead: {}\nData: {}",
|
||||||
|
b.head,
|
||||||
|
b.data_dir.display()
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
impl eframe::App for App {
|
||||||
|
fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) {
|
||||||
|
if let Some(loader) = &self.loader {
|
||||||
|
match loader.poll() {
|
||||||
|
Some(Update::Books(b)) => {
|
||||||
|
if self.books.is_some() {
|
||||||
|
self.updated_at = ui.input(|i| i.time);
|
||||||
|
}
|
||||||
|
self.books = Some(b);
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Some(Update::Error(e)) => self.error = Some(e),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.receipts.pump(ui.ctx());
|
||||||
|
self.handle_keys(ui);
|
||||||
|
self.sidebar(ui);
|
||||||
|
self.topbar(ui);
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let since_update = ui.input(|i| i.time) - self.updated_at;
|
||||||
|
if since_update < 6.0 {
|
||||||
|
ui.ctx()
|
||||||
|
.request_repaint_after(std::time::Duration::from_millis(250));
|
||||||
|
}
|
||||||
|
egui::CentralPanel::no_frame()
|
||||||
|
.frame(Frame::new().fill(p.bg))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if let Some(err) = &self.error {
|
||||||
|
Frame::new()
|
||||||
|
.fill(p.negative_soft)
|
||||||
|
.inner_margin(Margin::symmetric(20, 8))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.label(
|
||||||
|
RichText::new(format!("{} {err}", icon::WARNING)).color(p.negative),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let Some(books) = self.books.clone() else {
|
||||||
|
ui.centered_and_justified(|ui| {
|
||||||
|
ui.label(RichText::new("Opening the books…").color(p.muted));
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut shell = Shell {
|
||||||
|
receipts: &mut self.receipts,
|
||||||
|
state: &mut self.state,
|
||||||
|
go: None,
|
||||||
|
since_update,
|
||||||
|
};
|
||||||
|
let route = self.nav.current.clone();
|
||||||
|
pages::show(ui, &books, &route, &mut shell);
|
||||||
|
if let Some(to) = shell.go.take() {
|
||||||
|
self.nav.go(to);
|
||||||
|
}
|
||||||
|
if let Some(to) = self.palette.show(ui.ctx(), &books) {
|
||||||
|
self.nav.go(to);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save(&mut self, storage: &mut dyn eframe::Storage) {
|
||||||
|
storage.set_string("dark", self.dark.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
//! What the books need: drafts left unposted, missing receipts, schedules
|
||||||
|
//! and recurring entries due, open past periods, uncategorized tax lines,
|
||||||
|
//! 1099 vendors without a W-9, overdue bills and invoices, money owed to
|
||||||
|
//! owners. Each item says what's wrong, where to look, and the prompt that
|
||||||
|
//! asks the agent to fix it (the GUI never changes the books itself).
|
||||||
|
|
||||||
|
use crate::books::{Attention, Books, Severity};
|
||||||
|
use crate::fmt;
|
||||||
|
use crate::nav::Route;
|
||||||
|
use chrono::Datelike;
|
||||||
|
use ledger_core::reports::profit_and_loss_for_period;
|
||||||
|
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
pub fn find(b: &Books) -> Vec<Attention> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let today = b.today;
|
||||||
|
|
||||||
|
for e in b.entries.iter().filter(|e| e.status == EntryStatus::Draft) {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Todo,
|
||||||
|
title: format!("Draft not posted: {}", e.description),
|
||||||
|
detail: format!(
|
||||||
|
"#{} · {} · {}. Drafts appear in no report until posted.",
|
||||||
|
e.id,
|
||||||
|
fmt::date(e.date),
|
||||||
|
fmt::dollars(e.amount)
|
||||||
|
),
|
||||||
|
target: Some(Route::Entry(e.id)),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, review draft entry {} (\"{}\", {}): attach its receipt if it needs one, preview it, and post it if it's right.",
|
||||||
|
e.id,
|
||||||
|
e.description,
|
||||||
|
fmt::dollars(e.amount)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let missing: Vec<_> = b
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.status == EntryStatus::Posted && e.missing_receipt)
|
||||||
|
.collect();
|
||||||
|
if missing.len() > 3 {
|
||||||
|
let total: Decimal = missing.iter().map(|e| e.amount).sum();
|
||||||
|
let list: Vec<String> = missing
|
||||||
|
.iter()
|
||||||
|
.map(|e| format!("{} ({}, {})", e.id, e.description, e.date))
|
||||||
|
.collect();
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Problem,
|
||||||
|
title: format!("{} without receipts", fmt::count(missing.len(), "expense", "expenses")),
|
||||||
|
detail: format!(
|
||||||
|
"{} of spending over the {} threshold has no receipt attached.",
|
||||||
|
fmt::dollars(total),
|
||||||
|
fmt::dollars(b.receipt_threshold)
|
||||||
|
),
|
||||||
|
target: Some(Route::Transactions),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, these entries have no receipt: {}. Help me find each receipt and attach it with books.attach_receipt.",
|
||||||
|
list.join("; ")
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for e in missing.iter().filter(|_| missing.len() <= 3) {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Problem,
|
||||||
|
title: format!("No receipt: {}", e.description),
|
||||||
|
detail: format!(
|
||||||
|
"#{} · {} · {} is over the {} receipt threshold.",
|
||||||
|
e.id,
|
||||||
|
fmt::date(e.date),
|
||||||
|
fmt::dollars(e.amount),
|
||||||
|
fmt::dollars(b.receipt_threshold)
|
||||||
|
),
|
||||||
|
target: Some(Route::Entry(e.id)),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, entry {} (\"{}\", {} on {}) has no receipt. Find it and attach it with books.attach_receipt.",
|
||||||
|
e.id,
|
||||||
|
e.description,
|
||||||
|
fmt::dollars(e.amount),
|
||||||
|
e.date
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for r in b.ledger.recurring_entries() {
|
||||||
|
let due = r.due(today);
|
||||||
|
if !due.is_empty() {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Todo,
|
||||||
|
title: format!("Recurring \"{}\" is due", r.description),
|
||||||
|
detail: format!(
|
||||||
|
"{} since {}.",
|
||||||
|
fmt::count(due.len(), "occurrence", "occurrences"),
|
||||||
|
fmt::date(due[0])
|
||||||
|
),
|
||||||
|
target: Some(Route::Company),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, run books.run_recurring through {today}, then attach each draft's receipt and post them."
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for s in b.ledger.schedules() {
|
||||||
|
let due = s.due(today);
|
||||||
|
if !due.is_empty() {
|
||||||
|
let total: Decimal = due.iter().map(|(_, a)| *a).sum();
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Todo,
|
||||||
|
title: format!("Amortization due: {}", s.description),
|
||||||
|
detail: format!(
|
||||||
|
"{} totaling {} not yet posted.",
|
||||||
|
fmt::count(due.len(), "month", "months"),
|
||||||
|
fmt::dollars(total)
|
||||||
|
),
|
||||||
|
target: Some(Route::Company),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, preview books.run_schedules through {today}, then post it if it's right."
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for p in b.ledger.list_periods() {
|
||||||
|
if p.status == PeriodStatus::Open && p.end < today {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Todo,
|
||||||
|
title: format!("Period {} ended but is open", p.name),
|
||||||
|
detail: format!(
|
||||||
|
"{} – {}. Closing locks it and moves its earnings to equity.",
|
||||||
|
fmt::date(p.start),
|
||||||
|
fmt::date(p.end)
|
||||||
|
),
|
||||||
|
target: Some(Route::Company),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, review period {} and, if everything for it is recorded and reconciled, preview and close it.",
|
||||||
|
p.name
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (fy_start, _) = b.fiscal_ytd();
|
||||||
|
if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) {
|
||||||
|
let uncategorized: Vec<String> = pl
|
||||||
|
.revenue
|
||||||
|
.iter()
|
||||||
|
.chain(&pl.expenses)
|
||||||
|
.filter(|l| {
|
||||||
|
b.ledger
|
||||||
|
.chart()
|
||||||
|
.find_by_code(&l.code)
|
||||||
|
.is_some_and(|a| a.tax_category.is_none())
|
||||||
|
})
|
||||||
|
.map(|l| format!("{} {}", l.code, l.name))
|
||||||
|
.collect();
|
||||||
|
if !uncategorized.is_empty() {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Info,
|
||||||
|
title: format!(
|
||||||
|
"{} without a tax category",
|
||||||
|
fmt::count(uncategorized.len(), "account", "accounts")
|
||||||
|
),
|
||||||
|
detail: format!(
|
||||||
|
"{}. The tax summary lists them as uncategorized.",
|
||||||
|
uncategorized.join(", ")
|
||||||
|
),
|
||||||
|
target: Some(Route::Accounts),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.",
|
||||||
|
uncategorized.join(", ")
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut years = vec![today.year()];
|
||||||
|
if today.month() == 1 {
|
||||||
|
years.insert(0, today.year() - 1);
|
||||||
|
}
|
||||||
|
for year in years {
|
||||||
|
let r = vendor_1099_report(&b.ledger, year, None);
|
||||||
|
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Problem,
|
||||||
|
title: format!("{} needs a W-9", v.name),
|
||||||
|
detail: format!(
|
||||||
|
"Paid {} in {year}, over the {} 1099 threshold.",
|
||||||
|
fmt::dollars(v.paid),
|
||||||
|
fmt::dollars(r.threshold)
|
||||||
|
),
|
||||||
|
target: Some(Route::Company),
|
||||||
|
prompt: format!(
|
||||||
|
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
|
||||||
|
v.name
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let overdue = |rows: Vec<ledger_core::AgingRow>| -> (usize, Decimal) {
|
||||||
|
let late: Vec<Decimal> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.days_30 + r.days_60 + r.days_90 + r.days_90_plus)
|
||||||
|
.filter(|d| !d.is_zero())
|
||||||
|
.collect();
|
||||||
|
(late.len(), late.iter().sum())
|
||||||
|
};
|
||||||
|
let (n, total) = overdue(b.ledger.ap_aging(today));
|
||||||
|
if n > 0 {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Problem,
|
||||||
|
title: format!("{} overdue to pay", fmt::dollars(total)),
|
||||||
|
detail: format!(
|
||||||
|
"{} with bills past due.",
|
||||||
|
fmt::count(n, "vendor", "vendors")
|
||||||
|
),
|
||||||
|
target: Some(Route::Reports(crate::nav::ReportKind::ApAging)),
|
||||||
|
prompt: "In wanderbooks, show me the A/P aging and which bills are past due.".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let (n, total) = overdue(b.ledger.ar_aging(today));
|
||||||
|
if n > 0 {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Problem,
|
||||||
|
title: format!("{} overdue to collect", fmt::dollars(total)),
|
||||||
|
detail: format!(
|
||||||
|
"{} with invoices past due.",
|
||||||
|
fmt::count(n, "customer", "customers")
|
||||||
|
),
|
||||||
|
target: Some(Route::Reports(crate::nav::ReportKind::ArAging)),
|
||||||
|
prompt:
|
||||||
|
"In wanderbooks, show me the A/R aging and draft reminders for past-due invoices."
|
||||||
|
.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for a in b.ledger.chart().all().filter(|a| {
|
||||||
|
a.account_type == AccountType::Liability && {
|
||||||
|
let n = a.name.to_lowercase();
|
||||||
|
n.contains("due to") || n.contains("owed to")
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
let bal = b.balance_on(a.id, today);
|
||||||
|
if bal > Decimal::ZERO {
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Info,
|
||||||
|
title: format!("{} owed on {}", fmt::dollars(bal), a.name),
|
||||||
|
detail: "Expenses someone paid personally, not yet reimbursed.".into(),
|
||||||
|
target: Some(Route::Account(a.id)),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, list what makes up the {} balance on {} {} and who is owed what.",
|
||||||
|
fmt::dollars(bal),
|
||||||
|
a.code,
|
||||||
|
a.name
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.sort_by_key(|a| a.severity);
|
||||||
|
out
|
||||||
|
}
|
||||||
@@ -0,0 +1,519 @@
|
|||||||
|
//! The view model: one immutable picture of the books, rebuilt whenever the
|
||||||
|
//! journal grows. Built off the UI thread from a [`Snapshot`]; the UI only
|
||||||
|
//! reads it. Every number here comes from `ledger-core`; this module only
|
||||||
|
//! indexes and arranges.
|
||||||
|
|
||||||
|
use chrono::{Datelike, NaiveDate};
|
||||||
|
use ledger_config::BooksSettings;
|
||||||
|
use ledger_core::analysis::{balance_history, balance_series};
|
||||||
|
use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period};
|
||||||
|
use ledger_core::{AccountId, AccountType, EntryId, EntryStatus, Ledger, Side};
|
||||||
|
use ledger_store::{Attachment, JournalLine, Snapshot};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// One entry, indexed for listing and detail.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct EntryInfo {
|
||||||
|
pub id: EntryId,
|
||||||
|
pub date: NaiveDate,
|
||||||
|
pub description: String,
|
||||||
|
pub status: EntryStatus,
|
||||||
|
/// Total debits.
|
||||||
|
pub amount: Decimal,
|
||||||
|
/// Accounts touched, debits first: "6300 Software".
|
||||||
|
pub accounts: Vec<AccountId>,
|
||||||
|
/// Indexes into [`Books::attachments`] of linked, current receipts.
|
||||||
|
pub receipts: Vec<usize>,
|
||||||
|
/// How the entry came to be, in words.
|
||||||
|
pub origin: &'static str,
|
||||||
|
/// Indexes into [`Books::journal`] of every event that touched it.
|
||||||
|
pub events: Vec<usize>,
|
||||||
|
pub vendor: Option<String>,
|
||||||
|
pub reversal_of: Option<EntryId>,
|
||||||
|
pub reversed_by: Option<EntryId>,
|
||||||
|
/// Spending over the receipt threshold with no receipt.
|
||||||
|
pub missing_receipt: bool,
|
||||||
|
/// Net effect on cash accounts: positive in, negative out, zero if the
|
||||||
|
/// entry doesn't touch cash.
|
||||||
|
pub cash_effect: Decimal,
|
||||||
|
/// The customer or vendor, from invoices, bills, and vendor tags.
|
||||||
|
pub party: Option<String>,
|
||||||
|
/// Lower-cased text for search: description, accounts, vendor, amount.
|
||||||
|
pub haystack: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Something the books need, with how to fix it.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Attention {
|
||||||
|
pub severity: Severity,
|
||||||
|
pub title: String,
|
||||||
|
pub detail: String,
|
||||||
|
pub target: Option<crate::nav::Route>,
|
||||||
|
/// A prompt to paste into the agent session that fixes it.
|
||||||
|
pub prompt: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||||
|
pub enum Severity {
|
||||||
|
Problem,
|
||||||
|
Todo,
|
||||||
|
Info,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The whole picture.
|
||||||
|
pub struct Books {
|
||||||
|
pub ledger: Ledger,
|
||||||
|
pub settings: BooksSettings,
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub attachments: Vec<Attachment>,
|
||||||
|
pub journal: Vec<JournalLine>,
|
||||||
|
pub head: String,
|
||||||
|
pub today: NaiveDate,
|
||||||
|
pub loaded_at: chrono::DateTime<chrono::Utc>,
|
||||||
|
/// Newest first (date, then id).
|
||||||
|
pub entries: Vec<EntryInfo>,
|
||||||
|
pub entry_index: HashMap<EntryId, usize>,
|
||||||
|
pub cash_accounts: Vec<AccountId>,
|
||||||
|
pub card_accounts: Vec<AccountId>,
|
||||||
|
/// Entries created or changed by the most recent refresh.
|
||||||
|
pub fresh: HashSet<EntryId>,
|
||||||
|
/// Journal seq before the most recent refresh.
|
||||||
|
pub fresh_from_seq: u64,
|
||||||
|
pub receipt_threshold: Decimal,
|
||||||
|
pub attention: Vec<Attention>,
|
||||||
|
pub kpis: Kpis,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The headline numbers.
|
||||||
|
#[derive(Clone, Debug, Default)]
|
||||||
|
pub struct Kpis {
|
||||||
|
pub cash: Decimal,
|
||||||
|
pub cash_30d_ago: Decimal,
|
||||||
|
pub liabilities: Decimal,
|
||||||
|
pub receivables: Decimal,
|
||||||
|
pub revenue_mtd: Decimal,
|
||||||
|
pub expenses_mtd: Decimal,
|
||||||
|
/// Last month over the same days as this month so far.
|
||||||
|
pub revenue_prev_month: Decimal,
|
||||||
|
pub expenses_prev_month: Decimal,
|
||||||
|
pub net_ytd: Decimal,
|
||||||
|
/// Average monthly net loss over the last three full months, if losing.
|
||||||
|
pub monthly_burn: Option<Decimal>,
|
||||||
|
pub first_entry: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Books {
|
||||||
|
/// Build the picture from a snapshot. `prev` is the previous picture,
|
||||||
|
/// to mark what's new.
|
||||||
|
pub fn build(
|
||||||
|
snap: &Snapshot,
|
||||||
|
settings: BooksSettings,
|
||||||
|
today: NaiveDate,
|
||||||
|
prev_seq: Option<u64>,
|
||||||
|
) -> Result<Books, String> {
|
||||||
|
let ledger = snap.ledger().clone();
|
||||||
|
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
||||||
|
let journal = snap.journal().to_vec();
|
||||||
|
let receipt_threshold = settings
|
||||||
|
.receipt_threshold
|
||||||
|
.as_deref()
|
||||||
|
.and_then(|t| t.parse().ok())
|
||||||
|
.unwrap_or(Decimal::new(25, 0));
|
||||||
|
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||||
|
let card_accounts: Vec<AccountId> = ledger
|
||||||
|
.chart()
|
||||||
|
.all()
|
||||||
|
.filter(|a| {
|
||||||
|
a.account_type == AccountType::Liability && a.name.to_lowercase().contains("card")
|
||||||
|
})
|
||||||
|
.map(|a| a.id)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Journal events per entry.
|
||||||
|
let mut events: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
||||||
|
for (i, line) in journal.iter().enumerate() {
|
||||||
|
for id in entry_refs(line) {
|
||||||
|
events.entry(id).or_default().push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut receipts: HashMap<EntryId, Vec<usize>> = HashMap::new();
|
||||||
|
for (i, a) in attachments.iter().enumerate() {
|
||||||
|
if let (Some(e), None) = (a.entry_id, &a.withdrawn_at) {
|
||||||
|
receipts.entry(e).or_default().push(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64);
|
||||||
|
let parties = parties(&ledger, &journal);
|
||||||
|
let mut fresh = HashSet::new();
|
||||||
|
let closings = ledger.closing_entry_ids();
|
||||||
|
let mut entries = Vec::new();
|
||||||
|
let mut reversed_by: HashMap<EntryId, EntryId> = HashMap::new();
|
||||||
|
for id in 1..ledger.next_entry_id() {
|
||||||
|
if let Some(e) = ledger.get_entry(id)
|
||||||
|
&& let Some(orig) = e.reversal_of()
|
||||||
|
{
|
||||||
|
reversed_by.insert(orig, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in 1..ledger.next_entry_id() {
|
||||||
|
let Some(e) = ledger.get_entry(id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let evs = events.remove(&id).unwrap_or_default();
|
||||||
|
if evs.iter().any(|&i| journal[i].seq > fresh_from_seq) {
|
||||||
|
fresh.insert(id);
|
||||||
|
}
|
||||||
|
let origin = origin(&evs, &journal, closings.contains(&id));
|
||||||
|
let mut accounts: Vec<AccountId> = Vec::new();
|
||||||
|
for side in [Side::Debit, Side::Credit] {
|
||||||
|
for l in e.lines().iter().filter(|l| l.side == side) {
|
||||||
|
if !accounts.contains(&l.account) {
|
||||||
|
accounts.push(l.account);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let amount: Decimal = e
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| l.side == Side::Debit)
|
||||||
|
.map(|l| l.amount.amount)
|
||||||
|
.sum();
|
||||||
|
let rec = receipts.remove(&id).unwrap_or_default();
|
||||||
|
let vendor = ledger
|
||||||
|
.entry_vendor(id)
|
||||||
|
.and_then(|v| ledger.get_vendor(v))
|
||||||
|
.map(|v| v.name.clone());
|
||||||
|
// Receipts prove spending: hand-made entries (and recurring
|
||||||
|
// drafts) that debit an expense or buy an asset from outside.
|
||||||
|
// Invoices, payments, transfers, draws, closes, and schedules
|
||||||
|
// carry their own evidence.
|
||||||
|
let spends = e.lines().iter().any(|l| {
|
||||||
|
l.side == Side::Debit
|
||||||
|
&& ledger.chart().get(l.account).is_some_and(|a| {
|
||||||
|
a.account_type == AccountType::Expense
|
||||||
|
|| (a.account_type == AccountType::Asset
|
||||||
|
&& !cash_accounts.contains(&a.id)
|
||||||
|
&& !a.name.to_lowercase().contains("receivable"))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
|
||||||
|
&& spends
|
||||||
|
&& rec.is_empty()
|
||||||
|
&& amount > receipt_threshold;
|
||||||
|
let cash_effect: Decimal = e
|
||||||
|
.lines()
|
||||||
|
.iter()
|
||||||
|
.filter(|l| cash_accounts.contains(&l.account))
|
||||||
|
.map(|l| match l.side {
|
||||||
|
Side::Debit => l.amount.amount,
|
||||||
|
Side::Credit => -l.amount.amount,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
let party = vendor.clone().or_else(|| parties.get(&id).cloned());
|
||||||
|
let mut haystack = format!(
|
||||||
|
"{} {} #{} {}",
|
||||||
|
e.description(),
|
||||||
|
crate::fmt::money(amount),
|
||||||
|
id,
|
||||||
|
amount
|
||||||
|
);
|
||||||
|
for a in &accounts {
|
||||||
|
if let Some(acct) = ledger.chart().get(*a) {
|
||||||
|
haystack.push_str(&format!(" {} {}", acct.code, acct.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(v) = &party {
|
||||||
|
haystack.push(' ');
|
||||||
|
haystack.push_str(v);
|
||||||
|
}
|
||||||
|
entries.push(EntryInfo {
|
||||||
|
id,
|
||||||
|
date: e.date(),
|
||||||
|
description: e.description().to_string(),
|
||||||
|
status: e.status(),
|
||||||
|
amount,
|
||||||
|
accounts,
|
||||||
|
receipts: rec,
|
||||||
|
origin,
|
||||||
|
events: evs,
|
||||||
|
vendor,
|
||||||
|
reversal_of: e.reversal_of(),
|
||||||
|
reversed_by: reversed_by.get(&id).copied(),
|
||||||
|
missing_receipt,
|
||||||
|
cash_effect,
|
||||||
|
party,
|
||||||
|
haystack: haystack.to_lowercase(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.id.cmp(&a.id)));
|
||||||
|
let entry_index = entries.iter().enumerate().map(|(i, e)| (e.id, i)).collect();
|
||||||
|
let mut books = Books {
|
||||||
|
ledger,
|
||||||
|
settings,
|
||||||
|
data_dir: snap.data_dir().to_path_buf(),
|
||||||
|
attachments,
|
||||||
|
journal,
|
||||||
|
head: snap.head_hash().to_string(),
|
||||||
|
today,
|
||||||
|
loaded_at: chrono::Utc::now(),
|
||||||
|
entries,
|
||||||
|
entry_index,
|
||||||
|
cash_accounts,
|
||||||
|
card_accounts,
|
||||||
|
fresh,
|
||||||
|
fresh_from_seq,
|
||||||
|
receipt_threshold,
|
||||||
|
attention: Vec::new(),
|
||||||
|
kpis: Kpis::default(),
|
||||||
|
};
|
||||||
|
books.kpis = books.compute_kpis();
|
||||||
|
books.attention = crate::attention::find(&books);
|
||||||
|
Ok(books)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn seq(&self) -> u64 {
|
||||||
|
self.journal.len() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn entry(&self, id: EntryId) -> Option<&EntryInfo> {
|
||||||
|
self.entry_index.get(&id).map(|&i| &self.entries[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_label(&self, id: AccountId) -> String {
|
||||||
|
self.ledger
|
||||||
|
.chart()
|
||||||
|
.get(id)
|
||||||
|
.map(|a| format!("{} {}", a.code, a.name))
|
||||||
|
.unwrap_or_else(|| format!("account {id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn account_name(&self, id: AccountId) -> String {
|
||||||
|
self.ledger
|
||||||
|
.chart()
|
||||||
|
.get(id)
|
||||||
|
.map(|a| a.name.clone())
|
||||||
|
.unwrap_or_else(|| format!("account {id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn company_name(&self) -> &str {
|
||||||
|
&self.settings.company.name
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cash balance at the end of each day it changed.
|
||||||
|
pub fn cash_history(&self) -> Vec<(NaiveDate, Decimal)> {
|
||||||
|
balance_history(&self.ledger, &self.cash_accounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normal-side balance of one account at the end of `date`.
|
||||||
|
pub fn balance_on(&self, account: AccountId, date: NaiveDate) -> Decimal {
|
||||||
|
balance_series(&self.ledger, &[account], &[date])[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First day of the current month.
|
||||||
|
pub fn month_start(&self) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(self.today.year(), self.today.month(), 1).expect("valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Range of the last `n` months, ending with the current one.
|
||||||
|
pub fn trailing_months(&self, n: u32) -> (NaiveDate, NaiveDate) {
|
||||||
|
let start = self.month_start() - chrono::Months::new(n.saturating_sub(1));
|
||||||
|
(start, self.today)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fiscal year to date.
|
||||||
|
pub fn fiscal_ytd(&self) -> (NaiveDate, NaiveDate) {
|
||||||
|
let (start, _) = self.settings.company.fiscal_year_containing(self.today);
|
||||||
|
(start, self.today)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compute_kpis(&self) -> Kpis {
|
||||||
|
let l = &self.ledger;
|
||||||
|
let today = self.today;
|
||||||
|
let cash = balance_series(
|
||||||
|
l,
|
||||||
|
&self.cash_accounts,
|
||||||
|
&[today - chrono::Days::new(30), today],
|
||||||
|
);
|
||||||
|
let bs = balance_sheet_as_of(l, today).ok();
|
||||||
|
let receivables: Decimal = l
|
||||||
|
.chart()
|
||||||
|
.all()
|
||||||
|
.filter(|a| {
|
||||||
|
a.account_type == AccountType::Asset && a.name.to_lowercase().contains("receivable")
|
||||||
|
})
|
||||||
|
.map(|a| self.balance_on(a.id, today))
|
||||||
|
.sum();
|
||||||
|
let ms = self.month_start();
|
||||||
|
let prev_start = ms - chrono::Months::new(1);
|
||||||
|
let prev_end = ms - chrono::Days::new(1);
|
||||||
|
let pl = |s, e| profit_and_loss_for_period(l, s, e, None).ok();
|
||||||
|
let mtd = pl(ms, today);
|
||||||
|
// Same days of last month: a fair comparison mid-month.
|
||||||
|
let same_day = (prev_start + chrono::Days::new(u64::from(today.day() - 1))).min(prev_end);
|
||||||
|
let prev = pl(prev_start, same_day);
|
||||||
|
let (fy_start, _) = self.fiscal_ytd();
|
||||||
|
let ytd = pl(fy_start, today);
|
||||||
|
let three = pl(ms - chrono::Months::new(3), prev_end);
|
||||||
|
let first_entry = self
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.status == EntryStatus::Posted)
|
||||||
|
.map(|e| e.date)
|
||||||
|
.min();
|
||||||
|
let monthly_burn = three.and_then(|p| {
|
||||||
|
let loss = p.total_expenses - p.total_revenue;
|
||||||
|
// Only months the books cover count toward the average.
|
||||||
|
let months = match first_entry {
|
||||||
|
Some(f) if f > ms - chrono::Months::new(3) => {
|
||||||
|
let m = (prev_end.year() * 12 + prev_end.month() as i32)
|
||||||
|
- (f.year() * 12 + f.month() as i32)
|
||||||
|
+ 1;
|
||||||
|
m.max(0)
|
||||||
|
}
|
||||||
|
Some(_) => 3,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
(loss > Decimal::ZERO && months > 0).then(|| (loss / Decimal::from(months)).round_dp(2))
|
||||||
|
});
|
||||||
|
Kpis {
|
||||||
|
cash: cash[1],
|
||||||
|
cash_30d_ago: cash[0],
|
||||||
|
liabilities: bs.as_ref().map(|b| b.total_liabilities).unwrap_or_default(),
|
||||||
|
receivables,
|
||||||
|
revenue_mtd: mtd.as_ref().map(|p| p.total_revenue).unwrap_or_default(),
|
||||||
|
expenses_mtd: mtd.as_ref().map(|p| p.total_expenses).unwrap_or_default(),
|
||||||
|
revenue_prev_month: prev.as_ref().map(|p| p.total_revenue).unwrap_or_default(),
|
||||||
|
expenses_prev_month: prev.as_ref().map(|p| p.total_expenses).unwrap_or_default(),
|
||||||
|
net_ytd: ytd
|
||||||
|
.map(|p| p.total_revenue - p.total_expenses)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
monthly_burn,
|
||||||
|
first_entry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blob store path for an attachment.
|
||||||
|
pub fn blob_root(&self) -> PathBuf {
|
||||||
|
self.data_dir.join("blobs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Customer or vendor names for entries created by invoices and bills and
|
||||||
|
/// their payments, from the journal.
|
||||||
|
fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap<EntryId, String> {
|
||||||
|
let mut out = HashMap::new();
|
||||||
|
let customer = |id: Option<u64>| {
|
||||||
|
id.and_then(|c| ledger.get_customer(c))
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
};
|
||||||
|
let vendor = |id: Option<u64>| {
|
||||||
|
id.and_then(|v| ledger.get_vendor(v))
|
||||||
|
.map(|v| v.name.clone())
|
||||||
|
};
|
||||||
|
let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64());
|
||||||
|
for line in journal {
|
||||||
|
let p = &line.payload;
|
||||||
|
let Some(entry) = u(p, "entry_id") else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let name = match line.line_type.as_str() {
|
||||||
|
"invoice.created" => customer(u(p, "customer_id")),
|
||||||
|
"invoice.payment_received" => customer(
|
||||||
|
u(p, "invoice_id")
|
||||||
|
.and_then(|i| ledger.get_invoice(i))
|
||||||
|
.map(|i| i.customer_id),
|
||||||
|
),
|
||||||
|
"bill.created" => vendor(u(p, "vendor_id")),
|
||||||
|
"bill.paid" => vendor(
|
||||||
|
u(p, "bill_id")
|
||||||
|
.and_then(|b| ledger.get_bill(b))
|
||||||
|
.map(|b| b.vendor_id),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
if let Some(n) = name {
|
||||||
|
out.insert(entry, n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Entry ids a journal line refers to.
|
||||||
|
pub fn entry_refs(line: &JournalLine) -> Vec<EntryId> {
|
||||||
|
let p = &line.payload;
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let mut push = |v: Option<&Value>| {
|
||||||
|
if let Some(id) = v.and_then(|v| v.as_u64())
|
||||||
|
&& !out.contains(&id)
|
||||||
|
{
|
||||||
|
out.push(id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
push(p.get("entry_id"));
|
||||||
|
push(p.get("closing_entry"));
|
||||||
|
push(p.get("entry").and_then(|e| e.get("id")));
|
||||||
|
if line.line_type.starts_with("entry.") {
|
||||||
|
push(p.get("id"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static str {
|
||||||
|
if closing {
|
||||||
|
return "Period close";
|
||||||
|
}
|
||||||
|
let has = |t: &str| events.iter().any(|&i| journal[i].line_type == t);
|
||||||
|
if has("entry.reversed") {
|
||||||
|
"Reversal"
|
||||||
|
} else if has("schedule.posted") {
|
||||||
|
"Amortization"
|
||||||
|
} else if has("asset.depreciation_posted") {
|
||||||
|
"Depreciation"
|
||||||
|
} else if has("asset.disposed") {
|
||||||
|
"Asset disposal"
|
||||||
|
} else if has("bill.created") {
|
||||||
|
"Bill"
|
||||||
|
} else if has("bill.paid") {
|
||||||
|
"Bill payment"
|
||||||
|
} else if has("invoice.created") {
|
||||||
|
"Invoice"
|
||||||
|
} else if has("invoice.payment_received") {
|
||||||
|
"Customer payment"
|
||||||
|
} else if has("payroll.run") {
|
||||||
|
"Payroll"
|
||||||
|
} else if has("payroll.remitted") || has("payroll.taxes_remitted") {
|
||||||
|
"Payroll taxes"
|
||||||
|
} else if has("recurring.generated") {
|
||||||
|
"Recurring"
|
||||||
|
} else {
|
||||||
|
"Journal entry"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cash accounts: from `[books] cash_accounts`, else asset accounts whose
|
||||||
|
/// names say they hold cash.
|
||||||
|
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
|
||||||
|
if !settings.cash_accounts.is_empty() {
|
||||||
|
return settings
|
||||||
|
.cash_accounts
|
||||||
|
.iter()
|
||||||
|
.filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id))
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
ledger
|
||||||
|
.chart()
|
||||||
|
.all()
|
||||||
|
.filter(|a| {
|
||||||
|
let n = a.name.to_lowercase();
|
||||||
|
a.account_type == AccountType::Asset
|
||||||
|
&& !a.placeholder
|
||||||
|
&& ["cash", "bank", "checking", "savings", "money market"]
|
||||||
|
.iter()
|
||||||
|
.any(|w| n.contains(w))
|
||||||
|
})
|
||||||
|
.map(|a| a.id)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,387 @@
|
|||||||
|
//! Charts, painted directly: a balance line (step-shaped, because a
|
||||||
|
//! balance holds until the next transaction), grouped bars, ranked
|
||||||
|
//! horizontal bars, and sparklines. Every point answers hover with its
|
||||||
|
//! exact value; bars report clicks so they can drill down.
|
||||||
|
|
||||||
|
use crate::fmt;
|
||||||
|
use crate::theme::{self, pal};
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use egui::{
|
||||||
|
Align2, Color32, CornerRadius, Painter, Pos2, Rect, Sense, Shape, Stroke, Ui, pos2, vec2,
|
||||||
|
};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
fn f(d: Decimal) -> f64 {
|
||||||
|
d.try_into().unwrap_or(0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Nice" axis ticks covering [lo, hi].
|
||||||
|
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
|
||||||
|
let span = (hi - lo).max(1e-9);
|
||||||
|
let raw = span / n as f64;
|
||||||
|
let mag = 10f64.powf(raw.log10().floor());
|
||||||
|
let step = [1.0, 2.0, 2.5, 5.0, 10.0]
|
||||||
|
.iter()
|
||||||
|
.map(|m| m * mag)
|
||||||
|
.find(|s| span / s <= n as f64)
|
||||||
|
.unwrap_or(10.0 * mag);
|
||||||
|
let start = (lo / step).floor() * step;
|
||||||
|
let mut v = vec![start];
|
||||||
|
// Up to the first tick at or above the top, so the data never pokes
|
||||||
|
// past the last gridline.
|
||||||
|
while *v.last().expect("non-empty") < hi - step * 1e-9 {
|
||||||
|
let next = v.last().expect("non-empty") + step;
|
||||||
|
v.push(next);
|
||||||
|
}
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
fn money_label(v: f64) -> String {
|
||||||
|
fmt::compact(Decimal::from_f64_retain(v).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A balance over time as a step line with a soft fill. `points` are
|
||||||
|
/// (date, balance at end of day), ascending; the line runs to `until`.
|
||||||
|
pub fn balance_line(ui: &mut Ui, points: &[(NaiveDate, Decimal)], until: NaiveDate, height: f32) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::hover());
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
if points.is_empty() {
|
||||||
|
painter.text(
|
||||||
|
rect.center(),
|
||||||
|
Align2::CENTER_CENTER,
|
||||||
|
"No cash activity yet",
|
||||||
|
theme::regular(12.5),
|
||||||
|
p.faint,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let first = points[0].0;
|
||||||
|
let last = until.max(points[points.len() - 1].0);
|
||||||
|
let days = ((last - first).num_days().max(1)) as f64;
|
||||||
|
let vals: Vec<f64> = points.iter().map(|(_, v)| f(*v)).collect();
|
||||||
|
let lo = vals.iter().cloned().fold(0.0f64, f64::min);
|
||||||
|
let hi = vals.iter().cloned().fold(f64::MIN, f64::max).max(lo + 1.0);
|
||||||
|
let ts = ticks(lo, hi, 4);
|
||||||
|
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
|
||||||
|
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(8.0, 22.0));
|
||||||
|
let x =
|
||||||
|
|d: NaiveDate| plot.left() + ((d - first).num_days() as f64 / days) as f32 * plot.width();
|
||||||
|
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
|
||||||
|
for t in &ts {
|
||||||
|
let yy = y(*t);
|
||||||
|
painter.line_segment(
|
||||||
|
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
|
||||||
|
Stroke::new(1.0, p.border),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
pos2(plot.left() - 8.0, yy),
|
||||||
|
Align2::RIGHT_CENTER,
|
||||||
|
money_label(*t),
|
||||||
|
theme::regular(11.0),
|
||||||
|
p.faint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// X labels: first, last, and a few between.
|
||||||
|
let n_labels = 4;
|
||||||
|
for i in 0..=n_labels {
|
||||||
|
let d = first + chrono::Days::new(((days * i as f64) / n_labels as f64).round() as u64);
|
||||||
|
let align = match i {
|
||||||
|
0 => Align2::LEFT_TOP,
|
||||||
|
i if i == n_labels => Align2::RIGHT_TOP,
|
||||||
|
_ => Align2::CENTER_TOP,
|
||||||
|
};
|
||||||
|
painter.text(
|
||||||
|
pos2(x(d), plot.bottom() + 6.0),
|
||||||
|
align,
|
||||||
|
fmt::date_short(d),
|
||||||
|
theme::regular(11.0),
|
||||||
|
p.faint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Step path.
|
||||||
|
let mut line: Vec<Pos2> = Vec::new();
|
||||||
|
for (i, (d, v)) in points.iter().enumerate() {
|
||||||
|
let px = x(*d);
|
||||||
|
if i > 0 {
|
||||||
|
line.push(pos2(px, line.last().unwrap().y));
|
||||||
|
}
|
||||||
|
line.push(pos2(px, y(f(*v))));
|
||||||
|
}
|
||||||
|
line.push(pos2(x(last), line.last().unwrap().y));
|
||||||
|
let base = y(0.0f64.clamp(lo, hi));
|
||||||
|
let mut fill = line.clone();
|
||||||
|
fill.push(pos2(x(last), base));
|
||||||
|
fill.push(pos2(x(first), base));
|
||||||
|
let soft = Color32::from_rgba_unmultiplied(p.accent.r(), p.accent.g(), p.accent.b(), 28);
|
||||||
|
// Fill: one mesh of strips under each flat step (the outline may be
|
||||||
|
// non-convex). A mesh has no anti-aliased edges, so strips meet without
|
||||||
|
// seams.
|
||||||
|
let mut mesh = egui::Mesh::default();
|
||||||
|
for w in line.windows(2) {
|
||||||
|
if (w[0].y - w[1].y).abs() < 0.01 && w[1].x > w[0].x {
|
||||||
|
mesh.add_colored_rect(
|
||||||
|
Rect::from_min_max(
|
||||||
|
pos2(w[0].x, w[0].y.min(base)),
|
||||||
|
pos2(w[1].x, w[0].y.max(base)),
|
||||||
|
),
|
||||||
|
soft,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
painter.add(Shape::mesh(mesh));
|
||||||
|
let _ = fill;
|
||||||
|
painter.add(Shape::line(line.clone(), Stroke::new(2.0, p.accent)));
|
||||||
|
// Hover: the balance on the hovered day.
|
||||||
|
if let Some(hp) = resp.hover_pos()
|
||||||
|
&& plot.x_range().contains(hp.x)
|
||||||
|
{
|
||||||
|
let frac = ((hp.x - plot.left()) / plot.width()) as f64;
|
||||||
|
let day = first + chrono::Days::new((frac * days).round() as u64);
|
||||||
|
let bal = points
|
||||||
|
.iter()
|
||||||
|
.take_while(|(d, _)| *d <= day)
|
||||||
|
.last()
|
||||||
|
.map(|(_, v)| *v);
|
||||||
|
if let Some(b) = bal {
|
||||||
|
let hx = x(day);
|
||||||
|
painter.line_segment(
|
||||||
|
[pos2(hx, plot.top()), pos2(hx, plot.bottom())],
|
||||||
|
Stroke::new(1.0, p.faint),
|
||||||
|
);
|
||||||
|
painter.circle_filled(pos2(hx, y(f(b))), 4.0, p.accent);
|
||||||
|
tooltip(
|
||||||
|
&painter,
|
||||||
|
pos2(hx, plot.top()),
|
||||||
|
&fmt::date(day),
|
||||||
|
&fmt::dollars(b),
|
||||||
|
p.surface,
|
||||||
|
p.border,
|
||||||
|
p.text,
|
||||||
|
p.muted,
|
||||||
|
rect,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn tooltip(
|
||||||
|
painter: &Painter,
|
||||||
|
at: Pos2,
|
||||||
|
title: &str,
|
||||||
|
value: &str,
|
||||||
|
bg: Color32,
|
||||||
|
border: Color32,
|
||||||
|
fg: Color32,
|
||||||
|
muted: Color32,
|
||||||
|
bounds: Rect,
|
||||||
|
) {
|
||||||
|
let t1 = painter.layout_no_wrap(title.to_string(), theme::regular(11.5), muted);
|
||||||
|
let t2 = painter.layout_no_wrap(value.to_string(), theme::semibold(13.0), fg);
|
||||||
|
let w = t1.size().x.max(t2.size().x) + 16.0;
|
||||||
|
let h = t1.size().y + t2.size().y + 12.0;
|
||||||
|
let mut r = Rect::from_min_size(pos2(at.x + 8.0, at.y), vec2(w, h));
|
||||||
|
if r.right() > bounds.right() {
|
||||||
|
r = r.translate(vec2(-w - 16.0, 0.0));
|
||||||
|
}
|
||||||
|
painter.rect(
|
||||||
|
r,
|
||||||
|
CornerRadius::same(6),
|
||||||
|
bg,
|
||||||
|
Stroke::new(1.0, border),
|
||||||
|
egui::StrokeKind::Inside,
|
||||||
|
);
|
||||||
|
painter.galley(r.min + vec2(8.0, 6.0), t1.clone(), muted);
|
||||||
|
painter.galley(r.min + vec2(8.0, 6.0 + t1.size().y), t2, fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One group of bars on a [`bar_chart`].
|
||||||
|
pub struct BarGroup {
|
||||||
|
pub label: String,
|
||||||
|
pub values: Vec<Decimal>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grouped bars (e.g. income and spending per month), with a legend.
|
||||||
|
/// Returns the clicked group, if any.
|
||||||
|
pub fn bar_chart(
|
||||||
|
ui: &mut Ui,
|
||||||
|
groups: &[BarGroup],
|
||||||
|
series: &[(&str, Color32)],
|
||||||
|
height: f32,
|
||||||
|
) -> Option<usize> {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (name, color) in series {
|
||||||
|
let (r, _) = ui.allocate_exact_size(vec2(10.0, 10.0), Sense::hover());
|
||||||
|
ui.painter().rect_filled(r, CornerRadius::same(3), *color);
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(*name)
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
if groups.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let all: Vec<f64> = groups
|
||||||
|
.iter()
|
||||||
|
.flat_map(|g| g.values.iter().map(|v| f(*v)))
|
||||||
|
.collect();
|
||||||
|
let lo = all.iter().cloned().fold(0.0f64, f64::min);
|
||||||
|
let hi = all.iter().cloned().fold(0.0f64, f64::max).max(lo + 1.0);
|
||||||
|
let ts = ticks(lo, hi, 4);
|
||||||
|
let (lo, hi) = (ts[0].min(lo), ts[ts.len() - 1].max(hi));
|
||||||
|
let plot = Rect::from_min_max(rect.min + vec2(52.0, 6.0), rect.max - vec2(4.0, 22.0));
|
||||||
|
let y = |v: f64| plot.bottom() - ((v - lo) / (hi - lo)) as f32 * plot.height();
|
||||||
|
for t in &ts {
|
||||||
|
let yy = y(*t);
|
||||||
|
painter.line_segment(
|
||||||
|
[pos2(plot.left(), yy), pos2(plot.right(), yy)],
|
||||||
|
Stroke::new(1.0, p.border),
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
pos2(plot.left() - 8.0, yy),
|
||||||
|
Align2::RIGHT_CENTER,
|
||||||
|
money_label(*t),
|
||||||
|
theme::regular(11.0),
|
||||||
|
p.faint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let gw = plot.width() / groups.len() as f32;
|
||||||
|
let n = series.len().max(1) as f32;
|
||||||
|
let bw = ((gw * 0.7) / n).min(28.0);
|
||||||
|
let mut clicked = None;
|
||||||
|
let hover = resp.hover_pos();
|
||||||
|
for (gi, g) in groups.iter().enumerate() {
|
||||||
|
let gx = plot.left() + gw * gi as f32;
|
||||||
|
let grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, plot.bottom()));
|
||||||
|
let hovered = hover.is_some_and(|h| grect.contains(h));
|
||||||
|
if hovered {
|
||||||
|
painter.rect_filled(grect, CornerRadius::same(6), p.hover);
|
||||||
|
}
|
||||||
|
let total_w = bw * n + 3.0 * (n - 1.0);
|
||||||
|
let start = gx + (gw - total_w) / 2.0;
|
||||||
|
for (si, v) in g.values.iter().enumerate() {
|
||||||
|
let color = series.get(si).map(|s| s.1).unwrap_or(p.accent);
|
||||||
|
let x0 = start + si as f32 * (bw + 3.0);
|
||||||
|
let (y0, y1) = (y(0.0), y(f(*v)));
|
||||||
|
let r = Rect::from_min_max(
|
||||||
|
pos2(x0, y0.min(y1)),
|
||||||
|
pos2(x0 + bw, y0.max(y1).max(y0.min(y1) + 1.0)),
|
||||||
|
);
|
||||||
|
painter.rect_filled(
|
||||||
|
r,
|
||||||
|
CornerRadius {
|
||||||
|
nw: 3,
|
||||||
|
ne: 3,
|
||||||
|
sw: 0,
|
||||||
|
se: 0,
|
||||||
|
},
|
||||||
|
color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
painter.text(
|
||||||
|
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
|
||||||
|
Align2::CENTER_TOP,
|
||||||
|
&g.label,
|
||||||
|
theme::regular(11.0),
|
||||||
|
p.faint,
|
||||||
|
);
|
||||||
|
if hovered {
|
||||||
|
let lines: Vec<String> = series
|
||||||
|
.iter()
|
||||||
|
.zip(&g.values)
|
||||||
|
.map(|((name, _), v)| format!("{name}: {}", fmt::dollars(*v)))
|
||||||
|
.collect();
|
||||||
|
tooltip(
|
||||||
|
&painter,
|
||||||
|
pos2(gx + gw / 2.0, plot.top()),
|
||||||
|
&g.label,
|
||||||
|
&lines.join("\n"),
|
||||||
|
p.surface,
|
||||||
|
p.border,
|
||||||
|
p.text,
|
||||||
|
p.muted,
|
||||||
|
rect,
|
||||||
|
);
|
||||||
|
if resp.clicked() {
|
||||||
|
clicked = Some(gi);
|
||||||
|
}
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clicked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ranked horizontal bars with labels and exact amounts. Returns the
|
||||||
|
/// clicked row.
|
||||||
|
pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<usize> {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let max = rows
|
||||||
|
.iter()
|
||||||
|
.map(|(_, v)| f(*v).abs())
|
||||||
|
.fold(0.0, f64::max)
|
||||||
|
.max(1e-9);
|
||||||
|
let mut clicked = None;
|
||||||
|
for (i, (label, v)) in rows.iter().enumerate() {
|
||||||
|
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
if resp.hovered() {
|
||||||
|
painter.rect_filled(rect, CornerRadius::same(6), p.hover);
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|
}
|
||||||
|
let amount = fmt::dollars(*v);
|
||||||
|
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text);
|
||||||
|
let label_w = (rect.width() * 0.38).min(220.0);
|
||||||
|
let lg = painter.layout(label.clone(), theme::regular(13.0), p.text, label_w);
|
||||||
|
painter.galley(
|
||||||
|
pos2(rect.left() + 6.0, rect.center().y - lg.size().y / 2.0),
|
||||||
|
lg,
|
||||||
|
p.text,
|
||||||
|
);
|
||||||
|
let bar_left = rect.left() + label_w + 14.0;
|
||||||
|
let bar_max = rect.right() - ag.size().x - 18.0 - bar_left;
|
||||||
|
let w = (f(*v).abs() / max) as f32 * bar_max.max(0.0);
|
||||||
|
let bar = Rect::from_min_size(
|
||||||
|
pos2(bar_left, rect.center().y - 6.0),
|
||||||
|
vec2(w.max(2.0), 12.0),
|
||||||
|
);
|
||||||
|
painter.rect_filled(bar, CornerRadius::same(3), color);
|
||||||
|
painter.galley(
|
||||||
|
pos2(
|
||||||
|
rect.right() - ag.size().x - 6.0,
|
||||||
|
rect.center().y - ag.size().y / 2.0,
|
||||||
|
),
|
||||||
|
ag,
|
||||||
|
p.text,
|
||||||
|
);
|
||||||
|
if resp.clicked() {
|
||||||
|
clicked = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clicked
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tiny trend line.
|
||||||
|
pub fn sparkline(painter: &Painter, rect: Rect, values: &[f64], color: Color32) {
|
||||||
|
let lo = values.iter().cloned().fold(f64::MAX, f64::min);
|
||||||
|
let hi = values.iter().cloned().fold(f64::MIN, f64::max);
|
||||||
|
let span = (hi - lo).max(1e-9);
|
||||||
|
let n = values.len().max(2) - 1;
|
||||||
|
let pts: Vec<Pos2> = values
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, v)| {
|
||||||
|
pos2(
|
||||||
|
rect.left() + rect.width() * i as f32 / n as f32,
|
||||||
|
rect.bottom() - ((v - lo) / span) as f32 * rect.height(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
painter.add(Shape::line(pts, Stroke::new(1.5, color)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
//! The live loader. A background thread owns the read-only [`Snapshot`],
|
||||||
|
//! watches the data directory, and on every change applies the new journal
|
||||||
|
//! lines and sends a fresh [`Books`] to the UI, then asks for a repaint.
|
||||||
|
//! A change made by the agent in another window shows up within a frame or
|
||||||
|
//! two. Filesystem events are the trigger; a slow poll backs them up in case
|
||||||
|
//! an event is missed (network drives, odd editors).
|
||||||
|
|
||||||
|
use crate::books::Books;
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_config::BooksSettings;
|
||||||
|
use ledger_store::Snapshot;
|
||||||
|
use notify::{RecursiveMode, Watcher};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||||
|
use std::time::{Duration, SystemTime};
|
||||||
|
|
||||||
|
/// Where the books come from.
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct Source {
|
||||||
|
/// The `books-data` directory (journal and blobs).
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
/// The company's `wanderbooks.toml`, if any.
|
||||||
|
pub books_toml: Option<PathBuf>,
|
||||||
|
/// Fix "today" (tests, screenshots); otherwise the local date.
|
||||||
|
pub today: Option<NaiveDate>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum Update {
|
||||||
|
Books(Arc<Books>),
|
||||||
|
Error(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Loader {
|
||||||
|
rx: Receiver<Update>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Loader {
|
||||||
|
pub fn spawn(source: Source, ctx: egui::Context) -> Loader {
|
||||||
|
let (tx, rx) = channel();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name("books-loader".into())
|
||||||
|
.spawn(move || run(source, ctx, tx))
|
||||||
|
.expect("spawn loader");
|
||||||
|
Loader { rx }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The newest update, if any arrived since the last call.
|
||||||
|
pub fn poll(&self) -> Option<Update> {
|
||||||
|
let mut last = None;
|
||||||
|
while let Ok(u) = self.rx.try_recv() {
|
||||||
|
last = Some(u);
|
||||||
|
}
|
||||||
|
last
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Block until the first update (tests, headless).
|
||||||
|
pub fn wait(&self, timeout: Duration) -> Option<Update> {
|
||||||
|
self.rx.recv_timeout(timeout).ok()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load once, synchronously (tests and one-shot tools).
|
||||||
|
pub fn load_once(source: &Source) -> Result<Books, String> {
|
||||||
|
let snap = Snapshot::load(&source.data_dir).map_err(|e| e.to_string())?;
|
||||||
|
Books::build(&snap, settings(source)?, today(source), None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn today(source: &Source) -> NaiveDate {
|
||||||
|
source
|
||||||
|
.today
|
||||||
|
.unwrap_or_else(|| chrono::Local::now().date_naive())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn settings(source: &Source) -> Result<BooksSettings, String> {
|
||||||
|
match &source.books_toml {
|
||||||
|
Some(p) if p.exists() => ledger_config::load(p),
|
||||||
|
_ => Ok(BooksSettings::default()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mtime(p: &Option<PathBuf>) -> Option<SystemTime> {
|
||||||
|
p.as_ref()
|
||||||
|
.and_then(|p| std::fs::metadata(p).ok())
|
||||||
|
.and_then(|m| m.modified().ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(source: Source, ctx: egui::Context, tx: Sender<Update>) {
|
||||||
|
let send = |u: Update| {
|
||||||
|
let _ = tx.send(u);
|
||||||
|
ctx.request_repaint();
|
||||||
|
};
|
||||||
|
let mut snap = loop {
|
||||||
|
match Snapshot::load(&source.data_dir) {
|
||||||
|
Ok(s) => break s,
|
||||||
|
Err(e) => {
|
||||||
|
send(Update::Error(format!(
|
||||||
|
"Can't read the books in {}: {e}",
|
||||||
|
source.data_dir.display()
|
||||||
|
)));
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let build = |snap: &Snapshot, prev: Option<u64>| -> Update {
|
||||||
|
match settings(&source).and_then(|s| Books::build(snap, s, today(&source), prev)) {
|
||||||
|
Ok(b) => Update::Books(Arc::new(b)),
|
||||||
|
Err(e) => Update::Error(e),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
send(build(&snap, None));
|
||||||
|
|
||||||
|
let (ev_tx, ev_rx) = channel();
|
||||||
|
let mut watcher = notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
|
||||||
|
if res.is_ok() {
|
||||||
|
let _ = ev_tx.send(());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
if let Some(w) = watcher.as_mut() {
|
||||||
|
let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive);
|
||||||
|
if let Some(t) = &source.books_toml
|
||||||
|
&& let Some(dir) = t.parent()
|
||||||
|
{
|
||||||
|
let _ = w.watch(dir, RecursiveMode::NonRecursive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut toml_mtime = mtime(&source.books_toml);
|
||||||
|
let mut day = today(&source);
|
||||||
|
loop {
|
||||||
|
match ev_rx.recv_timeout(Duration::from_secs(3)) {
|
||||||
|
Ok(()) => {
|
||||||
|
// Let a burst of writes settle, then take them all at once.
|
||||||
|
std::thread::sleep(Duration::from_millis(30));
|
||||||
|
while ev_rx.try_recv().is_ok() {}
|
||||||
|
}
|
||||||
|
Err(RecvTimeoutError::Timeout) => {}
|
||||||
|
Err(RecvTimeoutError::Disconnected) => {
|
||||||
|
std::thread::sleep(Duration::from_secs(3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let before = snap.journal_seq();
|
||||||
|
let grew = match snap.refresh() {
|
||||||
|
Ok(n) => n > 0,
|
||||||
|
Err(e) => {
|
||||||
|
send(Update::Error(format!("Reading the journal failed: {e}")));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let toml_now = mtime(&source.books_toml);
|
||||||
|
let new_day = today(&source);
|
||||||
|
if grew || toml_now != toml_mtime || new_day != day {
|
||||||
|
toml_mtime = toml_now;
|
||||||
|
day = new_day;
|
||||||
|
send(build(&snap, grew.then_some(before)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
//! A demo company, built through the real engine: fifteen months of a
|
||||||
|
//! small design studio (single-member LLC) with client invoices, template
|
||||||
|
//! sales paid out by a platform, contractors (one without a W-9), rent on a
|
||||||
|
//! recurring schedule, a credit card, a laptop on depreciation, prepaid
|
||||||
|
//! insurance, owner draws, a closed year, PDF receipts, and a few loose
|
||||||
|
//! ends for the "needs attention" list. Deterministic for a given `today`.
|
||||||
|
|
||||||
|
use chrono::{Datelike, Days, Months, NaiveDate};
|
||||||
|
use ledger_core::{
|
||||||
|
AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring,
|
||||||
|
NewSchedule, ScheduleKind, Side, arap::DocumentLine,
|
||||||
|
};
|
||||||
|
use ledger_store::{Store, StoreError};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// The demo's `wanderbooks.toml`.
|
||||||
|
pub const BOOKS_TOML: &str = r#"[company]
|
||||||
|
name = "Juniper Studio LLC"
|
||||||
|
entity_type = "LLC, single-member"
|
||||||
|
tax_classification = "disregarded entity" # Schedule C
|
||||||
|
fiscal_year_start = "01-01"
|
||||||
|
formation_state = "OR"
|
||||||
|
formed = "2025-06-02"
|
||||||
|
ein = "00-0000000"
|
||||||
|
address = "1200 NW Everett St, Portland, OR 97209"
|
||||||
|
owner = "Alex Rivera"
|
||||||
|
|
||||||
|
[books]
|
||||||
|
accounting_basis = "accrual"
|
||||||
|
receipt_threshold = "25.00"
|
||||||
|
close_into = "3000"
|
||||||
|
close_sweep = ["3100"]
|
||||||
|
cash_accounts = ["1000", "1010"]
|
||||||
|
"#;
|
||||||
|
|
||||||
|
struct Rng(u64);
|
||||||
|
|
||||||
|
impl Rng {
|
||||||
|
fn next(&mut self) -> u64 {
|
||||||
|
self.0 = self
|
||||||
|
.0
|
||||||
|
.wrapping_mul(6364136223846793005)
|
||||||
|
.wrapping_add(1442695040888963407);
|
||||||
|
self.0 >> 33
|
||||||
|
}
|
||||||
|
/// Uniform in [lo, hi] dollars, to the cent.
|
||||||
|
fn amount(&mut self, lo: u64, hi: u64) -> Decimal {
|
||||||
|
let cents = lo * 100 + self.next() % ((hi - lo) * 100 + 1);
|
||||||
|
Decimal::new(cents as i64, 2)
|
||||||
|
}
|
||||||
|
fn pick(&mut self, n: u64) -> u64 {
|
||||||
|
self.next() % n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usd(d: Decimal) -> Money {
|
||||||
|
Money::new(d, Currency::USD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dec(n: i64) -> Decimal {
|
||||||
|
Decimal::from(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn day(month_start: NaiveDate, d: u32) -> NaiveDate {
|
||||||
|
let last = (month_start + Months::new(1) - Days::new(1)).day();
|
||||||
|
NaiveDate::from_ymd_opt(month_start.year(), month_start.month(), d.min(last)).expect("valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A one-page PDF receipt with the given lines (Helvetica).
|
||||||
|
pub fn receipt_pdf(title: &str, lines: &[String]) -> Vec<u8> {
|
||||||
|
let esc = |s: &str| {
|
||||||
|
s.replace('\\', "\\\\")
|
||||||
|
.replace('(', "\\(")
|
||||||
|
.replace(')', "\\)")
|
||||||
|
};
|
||||||
|
let mut content = String::new();
|
||||||
|
content.push_str("BT /F2 22 Tf 56 760 Td (");
|
||||||
|
content.push_str(&esc(title));
|
||||||
|
content.push_str(") Tj ET\n");
|
||||||
|
content.push_str("0.85 0.85 0.85 RG 56 742 m 556 742 l S\n");
|
||||||
|
let mut y = 712;
|
||||||
|
for l in lines {
|
||||||
|
let (font, size) = if l.starts_with("TOTAL") {
|
||||||
|
("F2", 13)
|
||||||
|
} else {
|
||||||
|
("F1", 11)
|
||||||
|
};
|
||||||
|
content.push_str(&format!(
|
||||||
|
"BT /{font} {size} Tf 56 {y} Td ({}) Tj ET\n",
|
||||||
|
esc(l)
|
||||||
|
));
|
||||||
|
y -= 20;
|
||||||
|
}
|
||||||
|
let objects = [
|
||||||
|
"<< /Type /Catalog /Pages 2 0 R >>".to_string(),
|
||||||
|
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(),
|
||||||
|
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R /F2 5 0 R >> >> /Contents 6 0 R >>".to_string(),
|
||||||
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(),
|
||||||
|
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold >>".to_string(),
|
||||||
|
format!("<< /Length {} >>\nstream\n{content}endstream", content.len()),
|
||||||
|
];
|
||||||
|
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||||
|
let mut offsets = Vec::new();
|
||||||
|
for (i, o) in objects.iter().enumerate() {
|
||||||
|
offsets.push(pdf.len());
|
||||||
|
pdf.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
|
||||||
|
}
|
||||||
|
let xref = pdf.len();
|
||||||
|
pdf.extend_from_slice(
|
||||||
|
format!("xref\n0 {}\n0000000000 65535 f \n", objects.len() + 1).as_bytes(),
|
||||||
|
);
|
||||||
|
for off in offsets {
|
||||||
|
pdf.extend_from_slice(format!("{off:010} 00000 n \n").as_bytes());
|
||||||
|
}
|
||||||
|
pdf.extend_from_slice(
|
||||||
|
format!(
|
||||||
|
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n",
|
||||||
|
objects.len() + 1
|
||||||
|
)
|
||||||
|
.as_bytes(),
|
||||||
|
);
|
||||||
|
pdf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn receipt(vendor: &str, date: NaiveDate, item: &str, amount: Decimal, paid: &str) -> Vec<u8> {
|
||||||
|
receipt_pdf(
|
||||||
|
vendor,
|
||||||
|
&[
|
||||||
|
format!("Receipt {}", date.format("%B %-d, %Y")),
|
||||||
|
format!(
|
||||||
|
"Order {:08}",
|
||||||
|
(date.num_days_from_ce() as u64 * 7919
|
||||||
|
+ (amount * dec(100)).try_into().unwrap_or(0u64))
|
||||||
|
% 100_000_000
|
||||||
|
),
|
||||||
|
String::new(),
|
||||||
|
item.to_string(),
|
||||||
|
format!("Amount: ${amount}"),
|
||||||
|
String::new(),
|
||||||
|
format!("TOTAL ${amount}"),
|
||||||
|
format!("Paid with {paid}"),
|
||||||
|
String::new(),
|
||||||
|
"Thank you for your business.".into(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the demo books in `dir` (which must not already hold books): the
|
||||||
|
/// journal and blobs under `dir/books-data`, the settings in
|
||||||
|
/// `dir/wanderbooks.toml`.
|
||||||
|
pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||||
|
std::fs::create_dir_all(dir)?;
|
||||||
|
std::fs::write(dir.join("wanderbooks.toml"), BOOKS_TOML)?;
|
||||||
|
let mut s = Store::create(&dir.join("books-data"), Currency::USD)?;
|
||||||
|
let mut rng = Rng(0x5eed_1234);
|
||||||
|
let start =
|
||||||
|
NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid") - Months::new(15);
|
||||||
|
let this_month = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid");
|
||||||
|
|
||||||
|
let acct =
|
||||||
|
|s: &mut Store, code: &str, name: &str, t, tax: Option<&str>| -> Result<u64, StoreError> {
|
||||||
|
let id = s.add_account(code, name, t, None, false)?;
|
||||||
|
if let Some(c) = tax {
|
||||||
|
s.set_account_tax(id, Some(c), None)?;
|
||||||
|
}
|
||||||
|
Ok(id)
|
||||||
|
};
|
||||||
|
use AccountType::*;
|
||||||
|
let checking = acct(&mut s, "1000", "Operating Checking", Asset, None)?;
|
||||||
|
let savings = acct(&mut s, "1010", "Savings", Asset, None)?;
|
||||||
|
let prepaid = acct(&mut s, "1300", "Prepaid Insurance", Asset, None)?;
|
||||||
|
let equipment = acct(&mut s, "1500", "Computer Equipment", Asset, None)?;
|
||||||
|
let accum = acct(&mut s, "1510", "Accumulated Depreciation", Asset, None)?;
|
||||||
|
let card = acct(&mut s, "2100", "Business Credit Card", Liability, None)?;
|
||||||
|
let capital = acct(&mut s, "3000", "Owner's Capital", Equity, None)?;
|
||||||
|
let draws = acct(&mut s, "3100", "Owner's Draws", Equity, None)?;
|
||||||
|
let _services = acct(
|
||||||
|
&mut s,
|
||||||
|
"4000",
|
||||||
|
"Design Services",
|
||||||
|
Revenue,
|
||||||
|
Some("gross_receipts"),
|
||||||
|
)?;
|
||||||
|
let templates = acct(
|
||||||
|
&mut s,
|
||||||
|
"4100",
|
||||||
|
"Template Sales",
|
||||||
|
Revenue,
|
||||||
|
Some("gross_receipts"),
|
||||||
|
)?;
|
||||||
|
let fees = acct(
|
||||||
|
&mut s,
|
||||||
|
"6000",
|
||||||
|
"Platform Fees",
|
||||||
|
Expense,
|
||||||
|
Some("commissions_fees"),
|
||||||
|
)?;
|
||||||
|
let contract = acct(
|
||||||
|
&mut s,
|
||||||
|
"6050",
|
||||||
|
"Contract Labor",
|
||||||
|
Expense,
|
||||||
|
Some("contract_labor"),
|
||||||
|
)?;
|
||||||
|
let software = acct(
|
||||||
|
&mut s,
|
||||||
|
"6100",
|
||||||
|
"Software & Subscriptions",
|
||||||
|
Expense,
|
||||||
|
Some("office_expense"),
|
||||||
|
)?;
|
||||||
|
let rent = acct(
|
||||||
|
&mut s,
|
||||||
|
"6200",
|
||||||
|
"Studio Rent",
|
||||||
|
Expense,
|
||||||
|
Some("rent_property"),
|
||||||
|
)?;
|
||||||
|
let insurance = acct(&mut s, "6300", "Insurance", Expense, Some("insurance"))?;
|
||||||
|
let advertising = acct(&mut s, "6400", "Advertising", Expense, Some("advertising"))?;
|
||||||
|
let meals = acct(&mut s, "6500", "Meals", Expense, Some("meals"))?;
|
||||||
|
let travel = acct(&mut s, "6600", "Travel", Expense, Some("travel"))?;
|
||||||
|
let supplies = acct(&mut s, "6700", "Office Supplies", Expense, Some("supplies"))?;
|
||||||
|
let depreciation = acct(
|
||||||
|
&mut s,
|
||||||
|
"6800",
|
||||||
|
"Depreciation",
|
||||||
|
Expense,
|
||||||
|
Some("depreciation"),
|
||||||
|
)?;
|
||||||
|
let bank_fees = acct(&mut s, "6900", "Bank Fees", Expense, None)?;
|
||||||
|
let client_class = s.add_class("Client work")?;
|
||||||
|
let template_class = s.add_class("Templates")?;
|
||||||
|
|
||||||
|
let sam = s.add_vendor("Sam Okafor")?;
|
||||||
|
s.set_vendor_1099(sam, Some(Form1099::Nec), true)?;
|
||||||
|
let riley = s.add_vendor("Riley Chen")?;
|
||||||
|
s.set_vendor_1099(riley, Some(Form1099::Nec), false)?;
|
||||||
|
let landlord = s.add_vendor("Everett Lofts LLC")?;
|
||||||
|
s.set_vendor_1099(landlord, Some(Form1099::Misc), true)?;
|
||||||
|
let customers: Vec<u64> = [
|
||||||
|
"Fern & Field Coffee",
|
||||||
|
"Bramble Outdoor Co.",
|
||||||
|
"Northwind Clinics",
|
||||||
|
"Tidepool Records",
|
||||||
|
"Larkspur Bakery",
|
||||||
|
]
|
||||||
|
.iter()
|
||||||
|
.map(|n| s.add_customer(n))
|
||||||
|
.collect::<Result<_, _>>()?;
|
||||||
|
|
||||||
|
// Formation documents.
|
||||||
|
s.file_document(
|
||||||
|
"formation",
|
||||||
|
"Articles of Organization.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
&receipt_pdf(
|
||||||
|
"Articles of Organization",
|
||||||
|
&[
|
||||||
|
"Juniper Studio LLC".into(),
|
||||||
|
"Oregon Secretary of State".into(),
|
||||||
|
format!("Filed {}", start.format("%B %-d, %Y")),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Some("Filed with the state"),
|
||||||
|
)?;
|
||||||
|
s.file_document(
|
||||||
|
"tax",
|
||||||
|
"IRS EIN confirmation.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
&receipt_pdf(
|
||||||
|
"EIN Assignment",
|
||||||
|
&[
|
||||||
|
"Juniper Studio LLC".into(),
|
||||||
|
"Employer Identification Number assigned".into(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Some("CP 575"),
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Helper: post a two-line entry with an optional receipt and vendor.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn post(
|
||||||
|
s: &mut Store,
|
||||||
|
date: NaiveDate,
|
||||||
|
desc: &str,
|
||||||
|
dr: u64,
|
||||||
|
cr: u64,
|
||||||
|
amount: Decimal,
|
||||||
|
receipt: Option<(&str, Vec<u8>)>,
|
||||||
|
vendor: Option<u64>,
|
||||||
|
class: Option<u32>,
|
||||||
|
) -> Result<u64, StoreError> {
|
||||||
|
let e = s.create_entry(date, desc)?;
|
||||||
|
s.add_line(e, dr, Side::Debit, usd(amount))?;
|
||||||
|
s.add_line(e, cr, Side::Credit, usd(amount))?;
|
||||||
|
if let Some(c) = class {
|
||||||
|
s.set_line_class(e, 0, Some(c))?;
|
||||||
|
s.set_line_class(e, 1, Some(c))?;
|
||||||
|
}
|
||||||
|
if let Some((name, bytes)) = receipt {
|
||||||
|
s.attach_receipt(Some(e), name, "application/pdf", &bytes, None)?;
|
||||||
|
}
|
||||||
|
s.post_entry(e)?;
|
||||||
|
if vendor.is_some() {
|
||||||
|
s.set_entry_vendor(e, vendor)?;
|
||||||
|
}
|
||||||
|
Ok(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
day(start, 2),
|
||||||
|
"Owner contribution — startup capital",
|
||||||
|
checking,
|
||||||
|
capital,
|
||||||
|
dec(15000),
|
||||||
|
Some((
|
||||||
|
"bank-transfer.pdf",
|
||||||
|
receipt(
|
||||||
|
"Transfer confirmation",
|
||||||
|
day(start, 2),
|
||||||
|
"Transfer from Alex Rivera personal checking",
|
||||||
|
dec(15000),
|
||||||
|
"ACH",
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Rent: a recurring entry, posted every month with its invoice; this
|
||||||
|
// month's is left as a draft.
|
||||||
|
s.register_recurring(NewRecurring {
|
||||||
|
description: "Studio rent — Everett Lofts".into(),
|
||||||
|
lines: vec![
|
||||||
|
(rent, Side::Debit, usd(dec(1800))),
|
||||||
|
(checking, Side::Credit, usd(dec(1800))),
|
||||||
|
],
|
||||||
|
frequency: Frequency::Monthly,
|
||||||
|
start: day(start, 1),
|
||||||
|
end: None,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Laptop and its depreciation.
|
||||||
|
let laptop_date = day(start + Months::new(1), 9);
|
||||||
|
let laptop = dec(3199);
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
laptop_date,
|
||||||
|
"MacBook Pro 16\" — studio workstation",
|
||||||
|
equipment,
|
||||||
|
card,
|
||||||
|
laptop,
|
||||||
|
Some((
|
||||||
|
"apple-store.pdf",
|
||||||
|
receipt(
|
||||||
|
"Apple Store",
|
||||||
|
laptop_date,
|
||||||
|
"MacBook Pro 16-inch, M-series",
|
||||||
|
laptop,
|
||||||
|
"Visa ending 4242",
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
let asset = s.register_asset(NewAsset {
|
||||||
|
description: "MacBook Pro 16\"".into(),
|
||||||
|
purchase_date: laptop_date,
|
||||||
|
cost: usd(laptop),
|
||||||
|
salvage_value: usd(dec(400)),
|
||||||
|
useful_life_months: 36,
|
||||||
|
cost_account: equipment,
|
||||||
|
accum_depr_account: accum,
|
||||||
|
expense_account: depreciation,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// Prepaid insurance, spread over a year.
|
||||||
|
let ins_date = day(start + Months::new(2), 15);
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
ins_date,
|
||||||
|
"General liability insurance — annual premium",
|
||||||
|
prepaid,
|
||||||
|
checking,
|
||||||
|
dec(1440),
|
||||||
|
Some((
|
||||||
|
"hiscox-policy.pdf",
|
||||||
|
receipt(
|
||||||
|
"Hiscox",
|
||||||
|
ins_date,
|
||||||
|
"General liability, 12 months",
|
||||||
|
dec(1440),
|
||||||
|
"ACH",
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
s.register_schedule(NewSchedule {
|
||||||
|
description: "General liability insurance".into(),
|
||||||
|
kind: ScheduleKind::PrepaidExpense,
|
||||||
|
total: usd(dec(1440)),
|
||||||
|
start: ins_date,
|
||||||
|
months: 12,
|
||||||
|
balance_account: prepaid,
|
||||||
|
pl_account: insurance,
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let mut open_invoices: Vec<(u64, NaiveDate, Decimal)> = Vec::new();
|
||||||
|
let mut card_balance = Decimal::ZERO;
|
||||||
|
let mut m = start;
|
||||||
|
let mut month_index = 0;
|
||||||
|
while m <= this_month {
|
||||||
|
let current = m == this_month;
|
||||||
|
let in_range = |d: NaiveDate| d <= today;
|
||||||
|
// Software on the card.
|
||||||
|
for (vendor, item, lo, hi, d) in [
|
||||||
|
("Figma", "Figma Professional — monthly", 45, 45, 3),
|
||||||
|
("Adobe", "Creative Cloud All Apps", 59, 60, 7),
|
||||||
|
("Google Workspace", "Business Starter — 2 users", 28, 28, 11),
|
||||||
|
] {
|
||||||
|
let date = day(m, d);
|
||||||
|
if !in_range(date) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let amt = rng.amount(lo, hi);
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
&format!("{vendor} — {item}"),
|
||||||
|
software,
|
||||||
|
card,
|
||||||
|
amt,
|
||||||
|
Some((
|
||||||
|
&format!("{}-{}.pdf", vendor.to_lowercase().replace(' ', "-"), date),
|
||||||
|
receipt(vendor, date, item, amt, "Visa ending 4242"),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
card_balance += amt;
|
||||||
|
}
|
||||||
|
// Advertising some months.
|
||||||
|
if month_index % 3 == 1 {
|
||||||
|
let date = day(m, 14);
|
||||||
|
if in_range(date) {
|
||||||
|
let amt = rng.amount(150, 420);
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Instagram ads — portfolio campaign",
|
||||||
|
advertising,
|
||||||
|
card,
|
||||||
|
amt,
|
||||||
|
Some((
|
||||||
|
"meta-ads.pdf",
|
||||||
|
receipt("Meta Ads", date, "Sponsored posts", amt, "Visa ending 4242"),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
card_balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Meals and supplies.
|
||||||
|
let date = day(m, 17);
|
||||||
|
if in_range(date) {
|
||||||
|
let amt = rng.amount(28, 96);
|
||||||
|
let missing = current || month_index == 13;
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Client lunch — project kickoff",
|
||||||
|
meals,
|
||||||
|
card,
|
||||||
|
amt,
|
||||||
|
(!missing).then(|| {
|
||||||
|
(
|
||||||
|
"lunch.pdf",
|
||||||
|
receipt(
|
||||||
|
"Tasty n Alder",
|
||||||
|
date,
|
||||||
|
"Lunch for 2",
|
||||||
|
amt,
|
||||||
|
"Visa ending 4242",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
card_balance += amt;
|
||||||
|
}
|
||||||
|
if month_index % 4 == 2 {
|
||||||
|
let date = day(m, 21);
|
||||||
|
if in_range(date) {
|
||||||
|
let amt = rng.amount(40, 180);
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Paper, ink, and sketchbooks",
|
||||||
|
supplies,
|
||||||
|
card,
|
||||||
|
amt,
|
||||||
|
Some((
|
||||||
|
"blick.pdf",
|
||||||
|
receipt(
|
||||||
|
"Blick Art Materials",
|
||||||
|
date,
|
||||||
|
"Supplies",
|
||||||
|
amt,
|
||||||
|
"Visa ending 4242",
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
card_balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if month_index == 7 {
|
||||||
|
let date = day(m, 9);
|
||||||
|
if in_range(date) {
|
||||||
|
let amt = dec(1186) + rng.amount(0, 90);
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Flights and hotel — client workshop, Austin",
|
||||||
|
travel,
|
||||||
|
card,
|
||||||
|
amt,
|
||||||
|
Some((
|
||||||
|
"alaska-air.pdf",
|
||||||
|
receipt(
|
||||||
|
"Alaska Airlines",
|
||||||
|
date,
|
||||||
|
"PDX-AUS round trip + 2 nights",
|
||||||
|
amt,
|
||||||
|
"Visa ending 4242",
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
card_balance += amt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Pay the card in full on the 25th.
|
||||||
|
let date = day(m, 25);
|
||||||
|
if in_range(date) && card_balance > Decimal::ZERO {
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Credit card payment",
|
||||||
|
card,
|
||||||
|
checking,
|
||||||
|
card_balance,
|
||||||
|
Some((
|
||||||
|
"card-statement.pdf",
|
||||||
|
receipt(
|
||||||
|
"Chase Ink",
|
||||||
|
date,
|
||||||
|
"Statement balance payment",
|
||||||
|
card_balance,
|
||||||
|
"ACH from Operating Checking",
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
card_balance = Decimal::ZERO;
|
||||||
|
}
|
||||||
|
// Client invoices: two or three a month, net 30.
|
||||||
|
for k in 0..(2 + rng.pick(2)) {
|
||||||
|
let date = day(m, 5 + 9 * k as u32);
|
||||||
|
if !in_range(date) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let cust = customers[rng.pick(customers.len() as u64) as usize];
|
||||||
|
let amt = (rng.amount(1800, 7800) / dec(50)).round() * dec(50);
|
||||||
|
let inv = s.create_invoice(
|
||||||
|
cust,
|
||||||
|
date,
|
||||||
|
date + Days::new(30),
|
||||||
|
vec![DocumentLine {
|
||||||
|
description: [
|
||||||
|
"Brand identity",
|
||||||
|
"Website design",
|
||||||
|
"Packaging",
|
||||||
|
"Illustration set",
|
||||||
|
"Design retainer",
|
||||||
|
][rng.pick(5) as usize]
|
||||||
|
.into(),
|
||||||
|
amount: amt,
|
||||||
|
}],
|
||||||
|
)?;
|
||||||
|
let entry = s.ledger().get_invoice(inv).map(|i| i.entry_id);
|
||||||
|
if let Some(e) = entry {
|
||||||
|
let _ = e;
|
||||||
|
}
|
||||||
|
open_invoices.push((inv, date, amt));
|
||||||
|
}
|
||||||
|
// Collect invoices 20-50 days out; leave the latest ones open and one overdue.
|
||||||
|
let mut still_open = Vec::new();
|
||||||
|
for (inv, date, amt) in open_invoices.drain(..) {
|
||||||
|
let paid_on = (date + Days::new(20 + rng.pick(30))).max(day(m, 2));
|
||||||
|
let overdue_one = month_index == 12 && inv % 2 == 0;
|
||||||
|
if paid_on <= today && paid_on < m + Months::new(1) && !overdue_one {
|
||||||
|
s.receive_payment(inv, paid_on, amt, checking)?;
|
||||||
|
} else {
|
||||||
|
still_open.push((inv, date, amt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
open_invoices = still_open;
|
||||||
|
// Template sales, paid out by the platform on the 1st for last month.
|
||||||
|
if month_index > 1 {
|
||||||
|
let date = day(m, 1);
|
||||||
|
if in_range(date) {
|
||||||
|
let gross = rng.amount(450, 1600);
|
||||||
|
let fee = (gross * Decimal::new(10, 2)).round_dp(2) + Decimal::new(30, 2);
|
||||||
|
let net = gross - fee;
|
||||||
|
let e = s.create_entry(date, "Gumroad payout — template sales")?;
|
||||||
|
s.add_line(e, checking, Side::Debit, usd(net))?;
|
||||||
|
s.add_line(e, fees, Side::Debit, usd(fee))?;
|
||||||
|
s.add_line(e, templates, Side::Credit, usd(gross))?;
|
||||||
|
for i in 0..3 {
|
||||||
|
s.set_line_class(e, i, Some(template_class))?;
|
||||||
|
}
|
||||||
|
s.attach_receipt(
|
||||||
|
Some(e),
|
||||||
|
"gumroad-payout.pdf",
|
||||||
|
"application/pdf",
|
||||||
|
&receipt_pdf(
|
||||||
|
"Gumroad payout",
|
||||||
|
&[
|
||||||
|
format!("Payout {}", date.format("%B %-d, %Y")),
|
||||||
|
format!("Gross sales: ${gross}"),
|
||||||
|
format!("Fees: ${fee}"),
|
||||||
|
format!("TOTAL ${net}"),
|
||||||
|
"Deposited to checking ending 0187".into(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
s.post_entry(e)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Contractors.
|
||||||
|
if month_index % 2 == 0 && month_index > 0 {
|
||||||
|
let date = day(m, 28);
|
||||||
|
if in_range(date) {
|
||||||
|
let amt = (rng.amount(900, 2200) / dec(25)).round() * dec(25);
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Sam Okafor — illustration (invoice)",
|
||||||
|
contract,
|
||||||
|
checking,
|
||||||
|
amt,
|
||||||
|
Some((
|
||||||
|
"sam-okafor-invoice.pdf",
|
||||||
|
receipt(
|
||||||
|
"Sam Okafor Illustration",
|
||||||
|
date,
|
||||||
|
"Illustration for client projects",
|
||||||
|
amt,
|
||||||
|
"ACH",
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Some(sam),
|
||||||
|
Some(client_class),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if month_index == 11 || month_index == 13 {
|
||||||
|
let date = day(m, 19);
|
||||||
|
if in_range(date) {
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Riley Chen — front-end development",
|
||||||
|
contract,
|
||||||
|
checking,
|
||||||
|
dec(1400),
|
||||||
|
Some((
|
||||||
|
"riley-chen.pdf",
|
||||||
|
receipt(
|
||||||
|
"Riley Chen",
|
||||||
|
date,
|
||||||
|
"Website build, milestone",
|
||||||
|
dec(1400),
|
||||||
|
"ACH",
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
Some(riley),
|
||||||
|
Some(client_class),
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Owner draws and savings.
|
||||||
|
if month_index >= 3 {
|
||||||
|
let date = day(m, 28);
|
||||||
|
if in_range(date) {
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Owner draw",
|
||||||
|
draws,
|
||||||
|
checking,
|
||||||
|
dec(3500),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if month_index % 3 == 2 {
|
||||||
|
let date = day(m, 26);
|
||||||
|
if in_range(date) {
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Transfer to savings",
|
||||||
|
savings,
|
||||||
|
checking,
|
||||||
|
dec(4000),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let date = day(m, 30);
|
||||||
|
if in_range(date) && month_index % 5 == 4 {
|
||||||
|
post(
|
||||||
|
&mut s,
|
||||||
|
date,
|
||||||
|
"Wire fee",
|
||||||
|
bank_fees,
|
||||||
|
checking,
|
||||||
|
dec(30),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
// Month-end: rent occurrences, depreciation, amortization.
|
||||||
|
let month_end = m + Months::new(1) - Days::new(1);
|
||||||
|
let through = if current { today } else { month_end };
|
||||||
|
for (_, date, entry) in s.run_recurring(through)? {
|
||||||
|
if current {
|
||||||
|
continue; // this month's rent waits as a draft
|
||||||
|
}
|
||||||
|
s.attach_receipt(
|
||||||
|
Some(entry),
|
||||||
|
&format!("rent-{date}.pdf"),
|
||||||
|
"application/pdf",
|
||||||
|
&receipt(
|
||||||
|
"Everett Lofts LLC",
|
||||||
|
date,
|
||||||
|
"Studio 4B rent",
|
||||||
|
dec(1800),
|
||||||
|
"ACH",
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)?;
|
||||||
|
s.post_entry(entry)?;
|
||||||
|
s.set_entry_vendor(entry, Some(landlord))?;
|
||||||
|
}
|
||||||
|
if !current {
|
||||||
|
if m >= laptop_date.with_day(1).expect("valid") {
|
||||||
|
s.run_depreciation(asset, &m.format("%Y-%m").to_string())?;
|
||||||
|
}
|
||||||
|
s.run_schedules(month_end)?;
|
||||||
|
}
|
||||||
|
// Close each finished calendar year into capital, draws swept in.
|
||||||
|
if m.month() == 12 && !current {
|
||||||
|
let ys = NaiveDate::from_ymd_opt(m.year(), 1, 1).expect("valid");
|
||||||
|
let p = s.create_period(&m.year().to_string(), ys.max(start), month_end)?;
|
||||||
|
s.close_period_to(
|
||||||
|
p,
|
||||||
|
&CloseTo::Account {
|
||||||
|
into: capital,
|
||||||
|
sweep: vec![draws],
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
m = m + Months::new(1);
|
||||||
|
month_index += 1;
|
||||||
|
}
|
||||||
|
let _ = (bank_fees, client_class);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! Formatting money, dates, and counts. Amounts are exact decimals in and
|
||||||
|
//! exact strings out: nothing here rounds except `compact`, which says so.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
|
||||||
|
/// Thousands separators, two decimals, a true minus sign: `−1,234.56`.
|
||||||
|
pub fn money(d: Decimal) -> String {
|
||||||
|
let neg = d.is_sign_negative() && !d.is_zero();
|
||||||
|
let s = group(d.abs().round_dp(2));
|
||||||
|
if neg { format!("\u{2212}{s}") } else { s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Accounting style: negatives in parentheses, `(1,234.56)`.
|
||||||
|
pub fn accounting(d: Decimal) -> String {
|
||||||
|
if d.is_sign_negative() && !d.is_zero() {
|
||||||
|
format!("({})", group(d.abs().round_dp(2)))
|
||||||
|
} else {
|
||||||
|
group(d.round_dp(2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With the currency sign: `$1,234.56`, `−$12.00`.
|
||||||
|
pub fn dollars(d: Decimal) -> String {
|
||||||
|
let neg = d.is_sign_negative() && !d.is_zero();
|
||||||
|
let s = format!("${}", group(d.abs().round_dp(2)));
|
||||||
|
if neg { format!("\u{2212}{s}") } else { s }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
|
||||||
|
pub fn compact(d: Decimal) -> String {
|
||||||
|
let f: f64 = d.try_into().unwrap_or(0.0);
|
||||||
|
let (neg, a) = (f < 0.0, f.abs());
|
||||||
|
let s = if a >= 1_000_000.0 {
|
||||||
|
format!("${:.1}M", a / 1_000_000.0)
|
||||||
|
} else if a >= 10_000.0 {
|
||||||
|
format!("${:.0}k", a / 1_000.0)
|
||||||
|
} else if a >= 1_000.0 {
|
||||||
|
format!("${:.1}k", a / 1_000.0)
|
||||||
|
} else {
|
||||||
|
format!("${a:.0}")
|
||||||
|
};
|
||||||
|
if neg { format!("\u{2212}{s}") } else { s }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group(d: Decimal) -> String {
|
||||||
|
let s = format!("{:.2}", d);
|
||||||
|
let (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in int.chars().enumerate() {
|
||||||
|
if i > 0 && (int.len() - i) % 3 == 0 {
|
||||||
|
out.push(',');
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
format!("{out}.{frac}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A percent change from `prev` to `now`, if `prev` isn't zero.
|
||||||
|
pub fn pct_change(prev: Decimal, now: Decimal) -> Option<f64> {
|
||||||
|
if prev.is_zero() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let p: f64 = prev.try_into().ok()?;
|
||||||
|
let n: f64 = now.try_into().ok()?;
|
||||||
|
Some((n - p) / p.abs() * 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Oct 12, 2026`.
|
||||||
|
pub fn date(d: NaiveDate) -> String {
|
||||||
|
d.format("%b %-d, %Y").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Oct 12`.
|
||||||
|
pub fn date_short(d: NaiveDate) -> String {
|
||||||
|
d.format("%b %-d").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Oct 2026`.
|
||||||
|
pub fn month(d: NaiveDate) -> String {
|
||||||
|
d.format("%b %Y").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `1 receipt`, `3 receipts`.
|
||||||
|
pub fn count(n: usize, one: &str, many: &str) -> String {
|
||||||
|
format!("{n} {}", if n == 1 { one } else { many })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An RFC 3339 timestamp from the journal, shown as local-ish UTC text:
|
||||||
|
/// `Oct 11, 2026 18:04 UTC`.
|
||||||
|
pub fn timestamp(ts: &str) -> String {
|
||||||
|
chrono::DateTime::parse_from_rfc3339(ts)
|
||||||
|
.map(|t| t.format("%b %-d, %Y %H:%M UTC").to_string())
|
||||||
|
.unwrap_or_else(|_| ts.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bytes as `12.3 KB`.
|
||||||
|
pub fn bytes(n: u64) -> String {
|
||||||
|
let f = n as f64;
|
||||||
|
if f >= 1_048_576.0 {
|
||||||
|
format!("{:.1} MB", f / 1_048_576.0)
|
||||||
|
} else if f >= 1024.0 {
|
||||||
|
format!("{:.1} KB", f / 1024.0)
|
||||||
|
} else {
|
||||||
|
format!("{n} B")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `snake_case_key` as `Snake case key`, for settings shown as labels,
|
||||||
|
/// keeping acronyms upper case (`ein` → `EIN`).
|
||||||
|
pub fn label(key: &str) -> String {
|
||||||
|
const ACRONYMS: [&str; 9] = [
|
||||||
|
"ein", "duns", "irs", "cpa", "llc", "tin", "ssn", "id", "url",
|
||||||
|
];
|
||||||
|
let words: Vec<String> = key
|
||||||
|
.split(['_', ' '])
|
||||||
|
.filter(|w| !w.is_empty())
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, w)| {
|
||||||
|
if ACRONYMS.contains(&w.to_lowercase().as_str()) {
|
||||||
|
w.to_uppercase()
|
||||||
|
} else if i == 0 {
|
||||||
|
let mut c = w.chars();
|
||||||
|
c.next()
|
||||||
|
.map(|f| f.to_uppercase().collect::<String>() + c.as_str())
|
||||||
|
.unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
w.to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
words.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `CamelCase` enum name as words: `PrepaidExpense` → `Prepaid expense`.
|
||||||
|
pub fn words(debug: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, c) in debug.chars().enumerate() {
|
||||||
|
if c.is_uppercase() && i > 0 {
|
||||||
|
out.push(' ');
|
||||||
|
out.extend(c.to_lowercase());
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tax return a form enum stands for.
|
||||||
|
pub fn tax_form(f: ledger_core::TaxForm) -> &'static str {
|
||||||
|
match f {
|
||||||
|
ledger_core::TaxForm::ScheduleC => "Schedule C (Form 1040)",
|
||||||
|
ledger_core::TaxForm::Form1065 => "Form 1065 (partnership)",
|
||||||
|
ledger_core::TaxForm::Form1120S => "Form 1120-S (S corporation)",
|
||||||
|
ledger_core::TaxForm::Form1120 => "Form 1120 (C corporation)",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn dec(s: &str) -> Decimal {
|
||||||
|
Decimal::from_str(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn money_formats() {
|
||||||
|
assert_eq!(money(dec("1234567.5")), "1,234,567.50");
|
||||||
|
assert_eq!(money(dec("-12")), "\u{2212}12.00");
|
||||||
|
assert_eq!(money(dec("0")), "0.00");
|
||||||
|
assert_eq!(money(dec("-0.00")), "0.00");
|
||||||
|
assert_eq!(accounting(dec("-207.68")), "(207.68)");
|
||||||
|
assert_eq!(dollars(dec("999.999")), "$1,000.00");
|
||||||
|
assert_eq!(compact(dec("1234")), "$1.2k");
|
||||||
|
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
|
||||||
|
assert_eq!(compact(dec("950.4")), "$950");
|
||||||
|
assert_eq!(label("state_entity_number"), "State entity number");
|
||||||
|
assert_eq!(label("ein"), "EIN");
|
||||||
|
assert_eq!(label("irs_name_control"), "IRS name control");
|
||||||
|
assert_eq!(words("PrepaidExpense"), "Prepaid expense");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
//! `ledger-gui`: a read-only, live view of wanderbooks books for the people
|
||||||
|
//! who own them and the accountant who checks them. See `plan/gui.md`.
|
||||||
|
|
||||||
|
#![allow(missing_docs)]
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
|
mod attention;
|
||||||
|
pub mod books;
|
||||||
|
mod charts;
|
||||||
|
pub mod data;
|
||||||
|
pub mod demo;
|
||||||
|
mod fmt;
|
||||||
|
pub mod nav;
|
||||||
|
mod pages;
|
||||||
|
mod palette;
|
||||||
|
mod receipts;
|
||||||
|
mod theme;
|
||||||
|
mod widgets;
|
||||||
|
|
||||||
|
pub use app::App;
|
||||||
|
pub use data::Source;
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]`
|
||||||
|
//!
|
||||||
|
//! Opens the books read-only and follows them live. With no flags it looks
|
||||||
|
//! for `books-data/` and `wanderbooks.toml` in the current directory.
|
||||||
|
//! `--demo` builds a demo company in a temp directory and opens that.
|
||||||
|
|
||||||
|
use ledger_gui::{App, Source};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn main() -> eframe::Result<()> {
|
||||||
|
let mut data_dir: Option<PathBuf> = None;
|
||||||
|
let mut books_toml: Option<PathBuf> = None;
|
||||||
|
let mut today = None;
|
||||||
|
let mut demo = false;
|
||||||
|
let mut args = std::env::args().skip(1);
|
||||||
|
while let Some(a) = args.next() {
|
||||||
|
match a.as_str() {
|
||||||
|
"--data-dir" => data_dir = args.next().map(PathBuf::from),
|
||||||
|
"--books-toml" => books_toml = args.next().map(PathBuf::from),
|
||||||
|
"--today" => today = args.next().and_then(|d| d.parse().ok()),
|
||||||
|
"--demo" => demo = true,
|
||||||
|
"-h" | "--help" => {
|
||||||
|
println!(
|
||||||
|
"usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--today YYYY-MM-DD] [--demo]"
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
eprintln!("unknown argument {other}");
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if demo {
|
||||||
|
let d = today.unwrap_or_else(|| chrono::Local::now().date_naive());
|
||||||
|
let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}"));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
if let Err(e) = ledger_gui::demo::build(&dir, d) {
|
||||||
|
eprintln!("building the demo books failed: {e}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
eprintln!("demo books: {}", dir.display());
|
||||||
|
data_dir = Some(dir.join("books-data"));
|
||||||
|
books_toml = Some(dir.join("wanderbooks.toml"));
|
||||||
|
}
|
||||||
|
let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data"));
|
||||||
|
let books_toml = books_toml.or_else(|| {
|
||||||
|
let guess = data_dir
|
||||||
|
.parent()
|
||||||
|
.unwrap_or(&data_dir)
|
||||||
|
.join("wanderbooks.toml");
|
||||||
|
guess.exists().then_some(guess)
|
||||||
|
});
|
||||||
|
let source = Source {
|
||||||
|
data_dir,
|
||||||
|
books_toml,
|
||||||
|
today,
|
||||||
|
};
|
||||||
|
let options = eframe::NativeOptions {
|
||||||
|
viewport: egui::ViewportBuilder::default()
|
||||||
|
.with_title("wanderbooks")
|
||||||
|
.with_app_id("wanderbooks")
|
||||||
|
.with_inner_size([1440.0, 920.0])
|
||||||
|
.with_min_inner_size([900.0, 600.0]),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
eframe::run_native(
|
||||||
|
"wanderbooks",
|
||||||
|
options,
|
||||||
|
Box::new(move |cc| Ok(Box::new(App::new(cc, source)))),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
//! Where you are and how you got there: a browser-style history.
|
||||||
|
|
||||||
|
use ledger_core::{AccountId, EntryId};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||||
|
pub enum ReportKind {
|
||||||
|
ProfitAndLoss,
|
||||||
|
BalanceSheet,
|
||||||
|
CashFlow,
|
||||||
|
TrialBalance,
|
||||||
|
GeneralLedger,
|
||||||
|
MembersEquity,
|
||||||
|
TaxSummary,
|
||||||
|
ArAging,
|
||||||
|
ApAging,
|
||||||
|
Vendor1099,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReportKind {
|
||||||
|
pub const ALL: [ReportKind; 10] = [
|
||||||
|
ReportKind::ProfitAndLoss,
|
||||||
|
ReportKind::BalanceSheet,
|
||||||
|
ReportKind::CashFlow,
|
||||||
|
ReportKind::MembersEquity,
|
||||||
|
ReportKind::TrialBalance,
|
||||||
|
ReportKind::GeneralLedger,
|
||||||
|
ReportKind::TaxSummary,
|
||||||
|
ReportKind::Vendor1099,
|
||||||
|
ReportKind::ArAging,
|
||||||
|
ReportKind::ApAging,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn title(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
ReportKind::ProfitAndLoss => "Profit and loss",
|
||||||
|
ReportKind::BalanceSheet => "Balance sheet",
|
||||||
|
ReportKind::CashFlow => "Cash flow",
|
||||||
|
ReportKind::TrialBalance => "Trial balance",
|
||||||
|
ReportKind::GeneralLedger => "General ledger",
|
||||||
|
ReportKind::MembersEquity => "Owners' equity",
|
||||||
|
ReportKind::TaxSummary => "Tax summary",
|
||||||
|
ReportKind::ArAging => "Receivables aging",
|
||||||
|
ReportKind::ApAging => "Payables aging",
|
||||||
|
ReportKind::Vendor1099 => "1099 worksheet",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub enum Route {
|
||||||
|
Overview,
|
||||||
|
Cash,
|
||||||
|
Profit,
|
||||||
|
Transactions,
|
||||||
|
Entry(EntryId),
|
||||||
|
Reports(ReportKind),
|
||||||
|
Accounts,
|
||||||
|
Account(AccountId),
|
||||||
|
Documents,
|
||||||
|
Document(u64),
|
||||||
|
Company,
|
||||||
|
Journal,
|
||||||
|
JournalEvent(u64),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Route {
|
||||||
|
/// The sidebar section this route belongs to.
|
||||||
|
pub fn section(&self) -> Section {
|
||||||
|
match self {
|
||||||
|
Route::Overview => Section::Overview,
|
||||||
|
Route::Cash => Section::Cash,
|
||||||
|
Route::Profit => Section::Profit,
|
||||||
|
Route::Transactions | Route::Entry(_) => Section::Transactions,
|
||||||
|
Route::Reports(_) => Section::Reports,
|
||||||
|
Route::Accounts | Route::Account(_) => Section::Accounts,
|
||||||
|
Route::Documents | Route::Document(_) => Section::Documents,
|
||||||
|
Route::Company => Section::Company,
|
||||||
|
Route::Journal | Route::JournalEvent(_) => Section::Journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum Section {
|
||||||
|
Overview,
|
||||||
|
Cash,
|
||||||
|
Profit,
|
||||||
|
Transactions,
|
||||||
|
Reports,
|
||||||
|
Accounts,
|
||||||
|
Documents,
|
||||||
|
Company,
|
||||||
|
Journal,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Section {
|
||||||
|
pub const ALL: [Section; 9] = [
|
||||||
|
Section::Overview,
|
||||||
|
Section::Cash,
|
||||||
|
Section::Profit,
|
||||||
|
Section::Transactions,
|
||||||
|
Section::Reports,
|
||||||
|
Section::Accounts,
|
||||||
|
Section::Documents,
|
||||||
|
Section::Company,
|
||||||
|
Section::Journal,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn title(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Section::Overview => "Overview",
|
||||||
|
Section::Cash => "Cash",
|
||||||
|
Section::Profit => "Profit",
|
||||||
|
Section::Transactions => "Transactions",
|
||||||
|
Section::Reports => "Reports",
|
||||||
|
Section::Accounts => "Accounts",
|
||||||
|
Section::Documents => "Documents",
|
||||||
|
Section::Company => "Company",
|
||||||
|
Section::Journal => "Journal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn icon(self) -> &'static str {
|
||||||
|
use egui_phosphor::regular as i;
|
||||||
|
match self {
|
||||||
|
Section::Overview => i::SQUARES_FOUR,
|
||||||
|
Section::Cash => i::BANK,
|
||||||
|
Section::Profit => i::TREND_UP,
|
||||||
|
Section::Transactions => i::LIST_BULLETS,
|
||||||
|
Section::Reports => i::FILE_TEXT,
|
||||||
|
Section::Accounts => i::TREE_STRUCTURE,
|
||||||
|
Section::Documents => i::FILES,
|
||||||
|
Section::Company => i::BUILDINGS,
|
||||||
|
Section::Journal => i::SCROLL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn home(self) -> Route {
|
||||||
|
match self {
|
||||||
|
Section::Overview => Route::Overview,
|
||||||
|
Section::Cash => Route::Cash,
|
||||||
|
Section::Profit => Route::Profit,
|
||||||
|
Section::Transactions => Route::Transactions,
|
||||||
|
Section::Reports => Route::Reports(ReportKind::ProfitAndLoss),
|
||||||
|
Section::Accounts => Route::Accounts,
|
||||||
|
Section::Documents => Route::Documents,
|
||||||
|
Section::Company => Route::Company,
|
||||||
|
Section::Journal => Route::Journal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Back/forward history.
|
||||||
|
pub struct Nav {
|
||||||
|
back: Vec<Route>,
|
||||||
|
forward: Vec<Route>,
|
||||||
|
pub current: Route,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Nav {
|
||||||
|
pub fn new(start: Route) -> Self {
|
||||||
|
Self {
|
||||||
|
back: Vec::new(),
|
||||||
|
forward: Vec::new(),
|
||||||
|
current: start,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn go(&mut self, to: Route) {
|
||||||
|
if to != self.current {
|
||||||
|
self.back.push(std::mem::replace(&mut self.current, to));
|
||||||
|
self.forward.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_back(&self) -> bool {
|
||||||
|
!self.back.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_forward(&self) -> bool {
|
||||||
|
!self.forward.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn back(&mut self) {
|
||||||
|
if let Some(r) = self.back.pop() {
|
||||||
|
self.forward.push(std::mem::replace(&mut self.current, r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn forward(&mut self) {
|
||||||
|
if let Some(r) = self.forward.pop() {
|
||||||
|
self.back.push(std::mem::replace(&mut self.current, r));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
//! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text,
|
||||||
|
//! amount, or #id), vendors, and documents, ranked by a simple fuzzy match.
|
||||||
|
|
||||||
|
use crate::books::Books;
|
||||||
|
use crate::fmt;
|
||||||
|
use crate::nav::{ReportKind, Route, Section};
|
||||||
|
use crate::theme::{self, pal};
|
||||||
|
use egui::{CornerRadius, Key, Margin, RichText, Sense, Stroke, vec2};
|
||||||
|
use egui_phosphor::regular as icon;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Palette {
|
||||||
|
open: bool,
|
||||||
|
query: String,
|
||||||
|
selected: usize,
|
||||||
|
focus: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Item {
|
||||||
|
icon: &'static str,
|
||||||
|
title: String,
|
||||||
|
detail: String,
|
||||||
|
route: Route,
|
||||||
|
score: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Palette {
|
||||||
|
pub fn open(&mut self) {
|
||||||
|
self.open = true;
|
||||||
|
self.query.clear();
|
||||||
|
self.selected = 0;
|
||||||
|
self.focus = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show the palette if open; returns where to go.
|
||||||
|
pub fn show(&mut self, ctx: &egui::Context, books: &Books) -> Option<Route> {
|
||||||
|
if !self.open {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let p = pal(ctx);
|
||||||
|
let items = search(books, &self.query);
|
||||||
|
let mut go = None;
|
||||||
|
let (up, down, enter, esc) = ctx.input(|i| {
|
||||||
|
(
|
||||||
|
i.key_pressed(Key::ArrowUp),
|
||||||
|
i.key_pressed(Key::ArrowDown),
|
||||||
|
i.key_pressed(Key::Enter),
|
||||||
|
i.key_pressed(Key::Escape),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if down {
|
||||||
|
self.selected = (self.selected + 1).min(items.len().saturating_sub(1));
|
||||||
|
}
|
||||||
|
if up {
|
||||||
|
self.selected = self.selected.saturating_sub(1);
|
||||||
|
}
|
||||||
|
if esc {
|
||||||
|
self.open = false;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if enter && let Some(it) = items.get(self.selected) {
|
||||||
|
self.open = false;
|
||||||
|
return Some(it.route.clone());
|
||||||
|
}
|
||||||
|
let modal = egui::Modal::new(egui::Id::new("palette"))
|
||||||
|
.frame(
|
||||||
|
egui::Frame::new()
|
||||||
|
.fill(p.surface)
|
||||||
|
.stroke(Stroke::new(1.0, p.border))
|
||||||
|
.corner_radius(CornerRadius::same(14))
|
||||||
|
.inner_margin(Margin::same(10)),
|
||||||
|
)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_width(620.0);
|
||||||
|
let edit = egui::TextEdit::singleline(&mut self.query)
|
||||||
|
.hint_text("Search entries, accounts, vendors, documents, reports…")
|
||||||
|
.font(theme::regular(16.0))
|
||||||
|
.frame(egui::Frame::NONE)
|
||||||
|
.desired_width(f32::INFINITY);
|
||||||
|
let r = ui.add(edit);
|
||||||
|
if self.focus {
|
||||||
|
r.request_focus();
|
||||||
|
self.focus = false;
|
||||||
|
}
|
||||||
|
if r.changed() {
|
||||||
|
self.selected = 0;
|
||||||
|
}
|
||||||
|
ui.add_space(6.0);
|
||||||
|
crate::widgets::rule(ui);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(420.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if items.is_empty() {
|
||||||
|
ui.label(RichText::new("Nothing matches.").color(p.muted));
|
||||||
|
}
|
||||||
|
for (i, it) in items.iter().enumerate() {
|
||||||
|
let (rect, resp) = ui.allocate_exact_size(
|
||||||
|
vec2(ui.available_width(), 40.0),
|
||||||
|
Sense::click(),
|
||||||
|
);
|
||||||
|
if i == self.selected || resp.hovered() {
|
||||||
|
ui.painter()
|
||||||
|
.rect_filled(rect, CornerRadius::same(8), p.hover);
|
||||||
|
}
|
||||||
|
if i == self.selected {
|
||||||
|
ui.scroll_to_rect(rect, None);
|
||||||
|
}
|
||||||
|
let painter = ui.painter_at(rect);
|
||||||
|
painter.text(
|
||||||
|
rect.left_center() + vec2(12.0, 0.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
it.icon,
|
||||||
|
egui::FontId::proportional(16.0),
|
||||||
|
p.muted,
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.left_center() + vec2(38.0, -7.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
&it.title,
|
||||||
|
theme::regular(14.0),
|
||||||
|
p.text,
|
||||||
|
);
|
||||||
|
painter.text(
|
||||||
|
rect.left_center() + vec2(38.0, 9.0),
|
||||||
|
egui::Align2::LEFT_CENTER,
|
||||||
|
&it.detail,
|
||||||
|
theme::regular(11.5),
|
||||||
|
p.faint,
|
||||||
|
);
|
||||||
|
if resp.clicked() {
|
||||||
|
go = Some(it.route.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if modal.should_close() {
|
||||||
|
self.open = false;
|
||||||
|
}
|
||||||
|
if go.is_some() {
|
||||||
|
self.open = false;
|
||||||
|
}
|
||||||
|
go
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Subsequence match: every query character in order, rewarding
|
||||||
|
/// contiguous runs and word starts. None if no match.
|
||||||
|
fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||||||
|
if query.is_empty() {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
let hay: Vec<char> = hay.to_lowercase().chars().collect();
|
||||||
|
let mut score = 0;
|
||||||
|
let mut hi = 0;
|
||||||
|
let mut prev_match = false;
|
||||||
|
for qc in query.to_lowercase().chars() {
|
||||||
|
if qc == ' ' {
|
||||||
|
prev_match = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let mut found = false;
|
||||||
|
while hi < hay.len() {
|
||||||
|
let c = hay[hi];
|
||||||
|
hi += 1;
|
||||||
|
if c == qc {
|
||||||
|
score += if prev_match { 6 } else { 1 };
|
||||||
|
if hi == 1 || !hay[hi - 2].is_alphanumeric() {
|
||||||
|
score += 4;
|
||||||
|
}
|
||||||
|
prev_match = true;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prev_match = false;
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(score)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||||
|
let q = query.trim();
|
||||||
|
let mut out: Vec<Item> = Vec::new();
|
||||||
|
let mut add = |icon: &'static str, title: String, detail: String, route: Route, hay: &str| {
|
||||||
|
if let Some(score) = fuzzy(hay, q) {
|
||||||
|
out.push(Item {
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
detail,
|
||||||
|
route,
|
||||||
|
score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for s in Section::ALL {
|
||||||
|
add(
|
||||||
|
s.icon(),
|
||||||
|
s.title().into(),
|
||||||
|
"Page".into(),
|
||||||
|
s.home(),
|
||||||
|
s.title(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for r in ReportKind::ALL {
|
||||||
|
add(
|
||||||
|
icon::FILE_TEXT,
|
||||||
|
r.title().into(),
|
||||||
|
"Report".into(),
|
||||||
|
Route::Reports(r),
|
||||||
|
&format!("report {}", r.title()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if !q.is_empty() {
|
||||||
|
for a in b.ledger.chart().all() {
|
||||||
|
add(
|
||||||
|
icon::TREE_STRUCTURE,
|
||||||
|
format!("{} {}", a.code, a.name),
|
||||||
|
format!("{:?} account", a.account_type),
|
||||||
|
Route::Account(a.id),
|
||||||
|
&format!("{} {}", a.code, a.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let id_query = q.strip_prefix('#').and_then(|s| s.parse::<u64>().ok());
|
||||||
|
for e in &b.entries {
|
||||||
|
let exact = id_query == Some(e.id);
|
||||||
|
let hay = &e.haystack;
|
||||||
|
let score = if exact {
|
||||||
|
Some(1000)
|
||||||
|
} else {
|
||||||
|
fuzzy(hay, q).filter(|_| q.len() >= 2)
|
||||||
|
};
|
||||||
|
if let Some(score) = score {
|
||||||
|
// Prefer substring hits for entries: fuzzy over long text is noisy.
|
||||||
|
let bonus = if hay.contains(&q.to_lowercase()) {
|
||||||
|
50
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
if bonus == 0 && !exact && q.len() < 4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(Item {
|
||||||
|
icon: icon::RECEIPT,
|
||||||
|
title: format!("#{} {}", e.id, e.description),
|
||||||
|
detail: format!("{} · {}", fmt::date(e.date), fmt::dollars(e.amount)),
|
||||||
|
route: Route::Entry(e.id),
|
||||||
|
score: score + bonus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for v in b.ledger.list_vendors() {
|
||||||
|
if let Some(score) = fuzzy(&v.name, q) {
|
||||||
|
out.push(Item {
|
||||||
|
icon: icon::STOREFRONT,
|
||||||
|
title: v.name.clone(),
|
||||||
|
detail: "Vendor".into(),
|
||||||
|
route: Route::Company,
|
||||||
|
score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for a in &b.attachments {
|
||||||
|
let hay = format!("{} {}", a.file_name, a.description.as_deref().unwrap_or(""));
|
||||||
|
if let Some(score) = fuzzy(&hay, q) {
|
||||||
|
out.push(Item {
|
||||||
|
icon: icon::FILE,
|
||||||
|
title: a.file_name.clone(),
|
||||||
|
detail: format!(
|
||||||
|
"{} · {}",
|
||||||
|
fmt::label(&a.category),
|
||||||
|
a.description.as_deref().unwrap_or("")
|
||||||
|
),
|
||||||
|
route: Route::Document(a.id),
|
||||||
|
score,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort_by_key(|i| std::cmp::Reverse(i.score));
|
||||||
|
out.truncate(40);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A short description of a route for the breadcrumb.
|
||||||
|
pub fn describe(b: &Books, r: &Route) -> String {
|
||||||
|
match r {
|
||||||
|
Route::Entry(id) => b
|
||||||
|
.entry(*id)
|
||||||
|
.map(|e| format!("#{} {}", e.id, e.description))
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Route::Reports(k) => k.title().into(),
|
||||||
|
Route::Account(id) => b.account_label(*id),
|
||||||
|
Route::Document(id) => b
|
||||||
|
.attachments
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.id == *id)
|
||||||
|
.map(|a| a.file_name.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
Route::JournalEvent(seq) => format!("Event {seq}"),
|
||||||
|
_ => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
//! Rendering receipts and documents to textures, off the UI thread.
|
||||||
|
//!
|
||||||
|
//! PDFs are rasterized with `hayro` (pure Rust), images decoded with
|
||||||
|
//! `image`. Results are cached by blob hash: the same bytes never render
|
||||||
|
//! twice, and a hash is the content, so the cache never goes stale. Bytes
|
||||||
|
//! are read through the verifying blob store: a corrupt blob shows as an
|
||||||
|
//! error, never as a picture.
|
||||||
|
|
||||||
|
use egui::{ColorImage, Context, TextureHandle, TextureOptions};
|
||||||
|
use ledger_store::BlobStore;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::{Receiver, Sender, channel};
|
||||||
|
|
||||||
|
pub enum State {
|
||||||
|
Loading,
|
||||||
|
Ready(Vec<TextureHandle>),
|
||||||
|
Failed(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Job {
|
||||||
|
sha: String,
|
||||||
|
mime: String,
|
||||||
|
root: PathBuf,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Done {
|
||||||
|
sha: String,
|
||||||
|
result: Result<Vec<ColorImage>, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Receipts {
|
||||||
|
cache: HashMap<String, State>,
|
||||||
|
jobs: Sender<Job>,
|
||||||
|
done: Receiver<Done>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Receipts {
|
||||||
|
pub fn new(ctx: &Context) -> Self {
|
||||||
|
let (jobs, job_rx) = channel::<Job>();
|
||||||
|
let (done_tx, done) = channel();
|
||||||
|
let job_rx = std::sync::Arc::new(std::sync::Mutex::new(job_rx));
|
||||||
|
let workers = std::thread::available_parallelism().map_or(2, |n| n.get().clamp(2, 4));
|
||||||
|
for i in 0..workers {
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
let job_rx = job_rx.clone();
|
||||||
|
let done_tx = done_tx.clone();
|
||||||
|
std::thread::Builder::new()
|
||||||
|
.name(format!("receipt-render-{i}"))
|
||||||
|
.spawn(move || {
|
||||||
|
loop {
|
||||||
|
let job = match job_rx.lock() {
|
||||||
|
Ok(rx) => rx.recv(),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let Ok(job) = job else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let result = render(&job);
|
||||||
|
let _ = done_tx.send(Done {
|
||||||
|
sha: job.sha,
|
||||||
|
result,
|
||||||
|
});
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.expect("spawn renderer");
|
||||||
|
}
|
||||||
|
Self {
|
||||||
|
cache: HashMap::new(),
|
||||||
|
jobs,
|
||||||
|
done,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rendering of a blob, starting it if needed.
|
||||||
|
pub fn get(&mut self, sha: &str, mime: &str, root: PathBuf) -> &State {
|
||||||
|
if !self.cache.contains_key(sha) {
|
||||||
|
self.cache.insert(sha.to_string(), State::Loading);
|
||||||
|
let _ = self.jobs.send(Job {
|
||||||
|
sha: sha.to_string(),
|
||||||
|
mime: mime.to_string(),
|
||||||
|
root,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
&self.cache[sha]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn finished renders into textures. Call once per frame.
|
||||||
|
pub fn pump(&mut self, ctx: &Context) {
|
||||||
|
while let Ok(done) = self.done.try_recv() {
|
||||||
|
self.accept(ctx, done);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accept(&mut self, ctx: &Context, done: Done) {
|
||||||
|
let state = match done.result {
|
||||||
|
Ok(images) => State::Ready(
|
||||||
|
images
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, img)| {
|
||||||
|
ctx.load_texture(
|
||||||
|
format!("blob-{}-{i}", done.sha),
|
||||||
|
img,
|
||||||
|
TextureOptions::LINEAR,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
Err(e) => State::Failed(e),
|
||||||
|
};
|
||||||
|
self.cache.insert(done.sha, state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wait until nothing is rendering (tests and screenshots).
|
||||||
|
pub fn settle(&mut self, ctx: &Context) {
|
||||||
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||||
|
while self.cache.values().any(|s| matches!(s, State::Loading))
|
||||||
|
&& std::time::Instant::now() < deadline
|
||||||
|
{
|
||||||
|
if let Ok(done) = self
|
||||||
|
.done
|
||||||
|
.recv_timeout(std::time::Duration::from_millis(100))
|
||||||
|
{
|
||||||
|
self.accept(ctx, done);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(job: &Job) -> Result<Vec<ColorImage>, String> {
|
||||||
|
let bytes = BlobStore::open_read_only(&job.root)
|
||||||
|
.get(&job.sha)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let is_pdf = job.mime == "application/pdf" || bytes.starts_with(b"%PDF");
|
||||||
|
if is_pdf {
|
||||||
|
render_pdf(bytes)
|
||||||
|
} else if job.mime.starts_with("image/") {
|
||||||
|
let img = image::load_from_memory(&bytes).map_err(|e| e.to_string())?;
|
||||||
|
Ok(vec![to_color_image(img.to_rgba8())])
|
||||||
|
} else {
|
||||||
|
Err(format!("{} can't be previewed", job.mime))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_pdf(bytes: Vec<u8>) -> Result<Vec<ColorImage>, String> {
|
||||||
|
use hayro::hayro_interpret::InterpreterSettings;
|
||||||
|
use hayro::hayro_syntax::Pdf;
|
||||||
|
use hayro::vello_cpu::color::palette::css::WHITE;
|
||||||
|
use hayro::{PixmapSettings, RenderCache, RenderSettings, render};
|
||||||
|
let pdf = Pdf::new(bytes).map_err(|e| format!("not a readable PDF: {e:?}"))?;
|
||||||
|
let cache = RenderCache::new();
|
||||||
|
let interp = InterpreterSettings::default();
|
||||||
|
let settings = RenderSettings::default();
|
||||||
|
let px = PixmapSettings {
|
||||||
|
x_scale: 2.0,
|
||||||
|
y_scale: 2.0,
|
||||||
|
bg_color: WHITE,
|
||||||
|
};
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for page in pdf.pages().iter().take(30) {
|
||||||
|
let pixmap = render(page, &cache, &interp, &settings, &px);
|
||||||
|
let png = pixmap.into_png().map_err(|e| format!("{e:?}"))?;
|
||||||
|
let img = image::load_from_memory(&png).map_err(|e| e.to_string())?;
|
||||||
|
out.push(to_color_image(img.to_rgba8()));
|
||||||
|
}
|
||||||
|
if out.is_empty() {
|
||||||
|
return Err("the PDF has no pages".into());
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_color_image(img: image::RgbaImage) -> ColorImage {
|
||||||
|
let size = [img.width() as usize, img.height() as usize];
|
||||||
|
ColorImage::from_rgba_unmultiplied(size, img.as_raw())
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
//! Look and feel: one palette per mode, Inter with tabular figures, and
|
||||||
|
//! the spacing the whole app shares.
|
||||||
|
|
||||||
|
use egui::{
|
||||||
|
Color32, Context, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Margin, Stroke,
|
||||||
|
TextStyle, Visuals,
|
||||||
|
};
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
/// Colors for one mode. Every color in the app comes from here.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct Palette {
|
||||||
|
pub bg: Color32,
|
||||||
|
pub surface: Color32,
|
||||||
|
pub sidebar: Color32,
|
||||||
|
pub hover: Color32,
|
||||||
|
pub selected: Color32,
|
||||||
|
pub border: Color32,
|
||||||
|
pub text: Color32,
|
||||||
|
pub muted: Color32,
|
||||||
|
pub faint: Color32,
|
||||||
|
pub accent: Color32,
|
||||||
|
pub accent_soft: Color32,
|
||||||
|
pub positive: Color32,
|
||||||
|
pub negative: Color32,
|
||||||
|
pub warning: Color32,
|
||||||
|
pub warning_soft: Color32,
|
||||||
|
pub negative_soft: Color32,
|
||||||
|
pub positive_soft: Color32,
|
||||||
|
pub new_highlight: Color32,
|
||||||
|
/// Chart series, in order.
|
||||||
|
pub series: [Color32; 6],
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const LIGHT: Palette = Palette {
|
||||||
|
bg: Color32::from_rgb(0xF6, 0xF6, 0xF4),
|
||||||
|
surface: Color32::from_rgb(0xFF, 0xFF, 0xFF),
|
||||||
|
sidebar: Color32::from_rgb(0xEF, 0xEF, 0xEC),
|
||||||
|
hover: Color32::from_rgb(0xF2, 0xF2, 0xF0),
|
||||||
|
selected: Color32::from_rgb(0xE8, 0xE9, 0xFB),
|
||||||
|
border: Color32::from_rgb(0xE4, 0xE4, 0xE0),
|
||||||
|
text: Color32::from_rgb(0x1B, 0x1B, 0x1F),
|
||||||
|
muted: Color32::from_rgb(0x67, 0x67, 0x70),
|
||||||
|
faint: Color32::from_rgb(0x9C, 0x9C, 0xA4),
|
||||||
|
accent: Color32::from_rgb(0x4F, 0x46, 0xE5),
|
||||||
|
accent_soft: Color32::from_rgb(0xEE, 0xEE, 0xFD),
|
||||||
|
positive: Color32::from_rgb(0x15, 0x80, 0x3D),
|
||||||
|
negative: Color32::from_rgb(0xB9, 0x1C, 0x1C),
|
||||||
|
warning: Color32::from_rgb(0xB4, 0x53, 0x09),
|
||||||
|
warning_soft: Color32::from_rgb(0xFE, 0xF3, 0xE2),
|
||||||
|
negative_soft: Color32::from_rgb(0xFD, 0xEC, 0xEC),
|
||||||
|
positive_soft: Color32::from_rgb(0xE8, 0xF5, 0xEC),
|
||||||
|
new_highlight: Color32::from_rgb(0xFF, 0xF7, 0xD6),
|
||||||
|
series: [
|
||||||
|
Color32::from_rgb(0x4F, 0x46, 0xE5),
|
||||||
|
Color32::from_rgb(0x0D, 0x94, 0x88),
|
||||||
|
Color32::from_rgb(0xD9, 0x77, 0x06),
|
||||||
|
Color32::from_rgb(0xDB, 0x27, 0x77),
|
||||||
|
Color32::from_rgb(0x25, 0x63, 0xEB),
|
||||||
|
Color32::from_rgb(0x64, 0x74, 0x8B),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const DARK: Palette = Palette {
|
||||||
|
bg: Color32::from_rgb(0x11, 0x12, 0x14),
|
||||||
|
surface: Color32::from_rgb(0x19, 0x1A, 0x1D),
|
||||||
|
sidebar: Color32::from_rgb(0x15, 0x16, 0x18),
|
||||||
|
hover: Color32::from_rgb(0x22, 0x23, 0x27),
|
||||||
|
selected: Color32::from_rgb(0x26, 0x27, 0x45),
|
||||||
|
border: Color32::from_rgb(0x2A, 0x2B, 0x30),
|
||||||
|
text: Color32::from_rgb(0xEC, 0xEC, 0xEE),
|
||||||
|
muted: Color32::from_rgb(0xA3, 0xA3, 0xAC),
|
||||||
|
faint: Color32::from_rgb(0x6E, 0x6E, 0x78),
|
||||||
|
accent: Color32::from_rgb(0x8B, 0x93, 0xFA),
|
||||||
|
accent_soft: Color32::from_rgb(0x24, 0x25, 0x45),
|
||||||
|
positive: Color32::from_rgb(0x4A, 0xDE, 0x80),
|
||||||
|
negative: Color32::from_rgb(0xF8, 0x71, 0x71),
|
||||||
|
warning: Color32::from_rgb(0xFB, 0xBF, 0x24),
|
||||||
|
warning_soft: Color32::from_rgb(0x33, 0x29, 0x16),
|
||||||
|
negative_soft: Color32::from_rgb(0x3A, 0x1C, 0x1E),
|
||||||
|
positive_soft: Color32::from_rgb(0x16, 0x30, 0x22),
|
||||||
|
new_highlight: Color32::from_rgb(0x36, 0x31, 0x18),
|
||||||
|
series: [
|
||||||
|
Color32::from_rgb(0x8B, 0x93, 0xFA),
|
||||||
|
Color32::from_rgb(0x2D, 0xD4, 0xBF),
|
||||||
|
Color32::from_rgb(0xFB, 0xBF, 0x24),
|
||||||
|
Color32::from_rgb(0xF4, 0x72, 0xB6),
|
||||||
|
Color32::from_rgb(0x60, 0xA5, 0xFA),
|
||||||
|
Color32::from_rgb(0x94, 0xA3, 0xB8),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The palette for the current mode.
|
||||||
|
pub fn pal(ctx: &Context) -> &'static Palette {
|
||||||
|
if ctx.global_style().visuals.dark_mode {
|
||||||
|
&DARK
|
||||||
|
} else {
|
||||||
|
&LIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const SEMIBOLD: &str = "semibold";
|
||||||
|
pub const BOLD: &str = "bold";
|
||||||
|
|
||||||
|
pub fn regular(size: f32) -> FontId {
|
||||||
|
FontId::new(size, FontFamily::Proportional)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn semibold(size: f32) -> FontId {
|
||||||
|
FontId::new(size, FontFamily::Name(SEMIBOLD.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bold(size: f32) -> FontId {
|
||||||
|
FontId::new(size, FontFamily::Name(BOLD.into()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install fonts: Inter with tabular figures frozen in, so every number in
|
||||||
|
/// the app lines up digit for digit. Phosphor icons ride along as a
|
||||||
|
/// fallback in every family.
|
||||||
|
pub fn install_fonts(ctx: &Context) {
|
||||||
|
let mut fonts = FontDefinitions::default();
|
||||||
|
let add = |fonts: &mut FontDefinitions, name: &str, bytes: &'static [u8]| {
|
||||||
|
fonts
|
||||||
|
.font_data
|
||||||
|
.insert(name.to_owned(), Arc::new(FontData::from_static(bytes)));
|
||||||
|
};
|
||||||
|
add(
|
||||||
|
&mut fonts,
|
||||||
|
"InterTab-Regular",
|
||||||
|
include_bytes!("../assets/fonts/InterTab-Regular.ttf"),
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
&mut fonts,
|
||||||
|
"InterTab-SemiBold",
|
||||||
|
include_bytes!("../assets/fonts/InterTab-SemiBold.ttf"),
|
||||||
|
);
|
||||||
|
add(
|
||||||
|
&mut fonts,
|
||||||
|
"InterTab-Bold",
|
||||||
|
include_bytes!("../assets/fonts/InterTab-Bold.ttf"),
|
||||||
|
);
|
||||||
|
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||||
|
let fallbacks: Vec<String> = fonts
|
||||||
|
.families
|
||||||
|
.get(&FontFamily::Proportional)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_default();
|
||||||
|
// Text first, icons as a fallback. The bundled Inter copies have their
|
||||||
|
// private-use glyphs removed, so Phosphor's icons (which live there)
|
||||||
|
// are never shadowed.
|
||||||
|
let family = |primary: &str| {
|
||||||
|
let mut v = vec![primary.to_owned()];
|
||||||
|
v.extend(fallbacks.iter().cloned());
|
||||||
|
v
|
||||||
|
};
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.insert(FontFamily::Proportional, family("InterTab-Regular"));
|
||||||
|
fonts.families.insert(
|
||||||
|
FontFamily::Name(SEMIBOLD.into()),
|
||||||
|
family("InterTab-SemiBold"),
|
||||||
|
);
|
||||||
|
fonts
|
||||||
|
.families
|
||||||
|
.insert(FontFamily::Name(BOLD.into()), family("InterTab-Bold"));
|
||||||
|
ctx.set_fonts(fonts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the app's style for light or dark mode.
|
||||||
|
pub fn apply(ctx: &Context, dark: bool) {
|
||||||
|
let p = if dark { &DARK } else { &LIGHT };
|
||||||
|
let mut visuals = if dark {
|
||||||
|
Visuals::dark()
|
||||||
|
} else {
|
||||||
|
Visuals::light()
|
||||||
|
};
|
||||||
|
visuals.panel_fill = p.bg;
|
||||||
|
visuals.window_fill = p.surface;
|
||||||
|
visuals.extreme_bg_color = p.surface;
|
||||||
|
visuals.faint_bg_color = p.hover;
|
||||||
|
visuals.override_text_color = Some(p.text);
|
||||||
|
visuals.hyperlink_color = p.accent;
|
||||||
|
visuals.selection.bg_fill = p.selected;
|
||||||
|
visuals.selection.stroke = Stroke::new(1.0, p.accent);
|
||||||
|
visuals.window_stroke = Stroke::new(1.0, p.border);
|
||||||
|
visuals.window_corner_radius = CornerRadius::same(12);
|
||||||
|
visuals.menu_corner_radius = CornerRadius::same(10);
|
||||||
|
for w in [
|
||||||
|
&mut visuals.widgets.noninteractive,
|
||||||
|
&mut visuals.widgets.inactive,
|
||||||
|
&mut visuals.widgets.hovered,
|
||||||
|
&mut visuals.widgets.active,
|
||||||
|
&mut visuals.widgets.open,
|
||||||
|
] {
|
||||||
|
w.corner_radius = CornerRadius::same(8);
|
||||||
|
}
|
||||||
|
visuals.widgets.noninteractive.bg_stroke = Stroke::new(1.0, p.border);
|
||||||
|
visuals.widgets.noninteractive.fg_stroke = Stroke::new(1.0, p.text);
|
||||||
|
visuals.widgets.inactive.bg_fill = p.surface;
|
||||||
|
visuals.widgets.inactive.weak_bg_fill = p.surface;
|
||||||
|
visuals.widgets.inactive.bg_stroke = Stroke::new(1.0, p.border);
|
||||||
|
visuals.widgets.inactive.fg_stroke = Stroke::new(1.0, p.text);
|
||||||
|
visuals.widgets.hovered.bg_fill = p.hover;
|
||||||
|
visuals.widgets.hovered.weak_bg_fill = p.hover;
|
||||||
|
visuals.widgets.hovered.bg_stroke = Stroke::new(1.0, p.border);
|
||||||
|
visuals.widgets.hovered.fg_stroke = Stroke::new(1.0, p.text);
|
||||||
|
visuals.widgets.active.bg_fill = p.selected;
|
||||||
|
visuals.widgets.active.weak_bg_fill = p.selected;
|
||||||
|
visuals.widgets.active.fg_stroke = Stroke::new(1.0, p.text);
|
||||||
|
ctx.set_visuals(visuals);
|
||||||
|
ctx.all_styles_mut(|style| {
|
||||||
|
style.text_styles = [
|
||||||
|
(TextStyle::Heading, semibold(20.0)),
|
||||||
|
(TextStyle::Body, regular(14.0)),
|
||||||
|
(TextStyle::Button, regular(14.0)),
|
||||||
|
(TextStyle::Small, regular(12.0)),
|
||||||
|
(TextStyle::Monospace, FontId::monospace(12.5)),
|
||||||
|
]
|
||||||
|
.into();
|
||||||
|
style.spacing.item_spacing = egui::vec2(8.0, 6.0);
|
||||||
|
style.spacing.button_padding = egui::vec2(10.0, 5.0);
|
||||||
|
style.spacing.interact_size.y = 28.0;
|
||||||
|
style.spacing.window_margin = Margin::same(16);
|
||||||
|
style.spacing.scroll.bar_width = 8.0;
|
||||||
|
style.spacing.scroll.floating = true;
|
||||||
|
style.interaction.selectable_labels = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
//! Small building blocks shared by every screen.
|
||||||
|
|
||||||
|
use crate::theme::{self, pal};
|
||||||
|
use egui::{
|
||||||
|
Align, Color32, CornerRadius, Frame, Layout, Margin, Response, RichText, Sense, Stroke, Ui,
|
||||||
|
vec2,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A white (or dark) rounded surface with a hairline border.
|
||||||
|
pub fn card<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
Frame::new()
|
||||||
|
.fill(p.surface)
|
||||||
|
.stroke(Stroke::new(1.0, p.border))
|
||||||
|
.corner_radius(CornerRadius::same(12))
|
||||||
|
.inner_margin(Margin::same(16))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
add(ui)
|
||||||
|
})
|
||||||
|
.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A card with a title row and a right-aligned extra (a link, a pill).
|
||||||
|
/// Returns what `extra` and `add` return.
|
||||||
|
pub fn titled_card<E, R>(
|
||||||
|
ui: &mut Ui,
|
||||||
|
title: &str,
|
||||||
|
extra: impl FnOnce(&mut Ui) -> E,
|
||||||
|
add: impl FnOnce(&mut Ui) -> R,
|
||||||
|
) -> (E, R) {
|
||||||
|
card(ui, |ui| {
|
||||||
|
let e = ui
|
||||||
|
.horizontal(|ui| {
|
||||||
|
ui.label(RichText::new(title).font(theme::semibold(14.0)));
|
||||||
|
ui.with_layout(Layout::right_to_left(Align::Center), extra)
|
||||||
|
.inner
|
||||||
|
})
|
||||||
|
.inner;
|
||||||
|
ui.add_space(8.0);
|
||||||
|
(e, add(ui))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page title and the sentence under it.
|
||||||
|
pub fn page_header(ui: &mut Ui, title: &str, subtitle: &str) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
ui.label(RichText::new(title).font(theme::bold(26.0)));
|
||||||
|
if !subtitle.is_empty() {
|
||||||
|
ui.add_space(2.0);
|
||||||
|
let w = ui.available_width().min(860.0);
|
||||||
|
ui.allocate_ui(vec2(w, 0.0), |ui| {
|
||||||
|
ui.set_max_width(w);
|
||||||
|
ui.label(
|
||||||
|
RichText::new(subtitle)
|
||||||
|
.font(theme::regular(15.0))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ui.add_space(16.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A rounded tag.
|
||||||
|
pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response {
|
||||||
|
Frame::new()
|
||||||
|
.fill(bg)
|
||||||
|
.corner_radius(CornerRadius::same(255))
|
||||||
|
.inner_margin(Margin::symmetric(8, 2))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.label(RichText::new(text).font(theme::semibold(11.5)).color(fg))
|
||||||
|
})
|
||||||
|
.response
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Text that looks like plain text until you hover it, then like a link.
|
||||||
|
pub fn link(ui: &mut Ui, text: RichText) -> Response {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let r = ui.add(egui::Label::new(text.clone()).sense(Sense::click()));
|
||||||
|
if r.hovered() {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|
let rect = r.rect;
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[
|
||||||
|
rect.left_bottom() + vec2(0.0, 1.0),
|
||||||
|
rect.right_bottom() + vec2(0.0, 1.0),
|
||||||
|
],
|
||||||
|
Stroke::new(1.0, p.accent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
r
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An amount, right-aligned in a fixed-width cell, clickable when `drill`.
|
||||||
|
pub fn amount_cell(
|
||||||
|
ui: &mut Ui,
|
||||||
|
text: &str,
|
||||||
|
width: f32,
|
||||||
|
color: Color32,
|
||||||
|
strong: bool,
|
||||||
|
drill: bool,
|
||||||
|
) -> Response {
|
||||||
|
let font = if strong {
|
||||||
|
theme::semibold(14.0)
|
||||||
|
} else {
|
||||||
|
theme::regular(14.0)
|
||||||
|
};
|
||||||
|
let (rect, resp) = ui.allocate_exact_size(
|
||||||
|
vec2(width, 22.0),
|
||||||
|
if drill {
|
||||||
|
Sense::click()
|
||||||
|
} else {
|
||||||
|
Sense::hover()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let hovered = drill && resp.hovered();
|
||||||
|
let galley = ui.painter().layout_no_wrap(
|
||||||
|
text.to_string(),
|
||||||
|
font,
|
||||||
|
if hovered { p.accent } else { color },
|
||||||
|
);
|
||||||
|
let pos = egui::pos2(
|
||||||
|
rect.right() - galley.size().x,
|
||||||
|
rect.center().y - galley.size().y / 2.0,
|
||||||
|
);
|
||||||
|
if hovered {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
|
ui.painter().line_segment(
|
||||||
|
[
|
||||||
|
egui::pos2(pos.x, rect.center().y + galley.size().y / 2.0),
|
||||||
|
egui::pos2(rect.right(), rect.center().y + galley.size().y / 2.0),
|
||||||
|
],
|
||||||
|
Stroke::new(1.0, p.accent),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.painter().galley(pos, galley, color);
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A big number with a label, a change line, and an optional sparkline.
|
||||||
|
pub struct Kpi<'a> {
|
||||||
|
pub label: &'a str,
|
||||||
|
pub value: String,
|
||||||
|
pub sub: Option<(String, Color32)>,
|
||||||
|
pub spark: Option<&'a [f64]>,
|
||||||
|
pub how: &'a str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn kpi(ui: &mut Ui, k: Kpi<'_>) -> Response {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let resp = Frame::new()
|
||||||
|
.fill(p.surface)
|
||||||
|
.stroke(Stroke::new(1.0, p.border))
|
||||||
|
.corner_radius(CornerRadius::same(12))
|
||||||
|
.inner_margin(Margin::same(14))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(ui.available_width());
|
||||||
|
ui.set_min_height(104.0);
|
||||||
|
ui.label(
|
||||||
|
RichText::new(k.label)
|
||||||
|
.font(theme::regular(12.5))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(RichText::new(&k.value).font(theme::semibold(24.0)));
|
||||||
|
if let Some((text, color)) = &k.sub {
|
||||||
|
ui.add_space(2.0);
|
||||||
|
ui.label(RichText::new(text).font(theme::regular(12.0)).color(*color));
|
||||||
|
}
|
||||||
|
if let Some(values) = k.spark
|
||||||
|
&& values.len() > 1
|
||||||
|
{
|
||||||
|
ui.add_space(6.0);
|
||||||
|
let (rect, _) =
|
||||||
|
ui.allocate_exact_size(vec2(ui.available_width(), 26.0), Sense::hover());
|
||||||
|
crate::charts::sparkline(ui.painter(), rect, values, p.accent);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.response;
|
||||||
|
if !k.how.is_empty() {
|
||||||
|
resp.on_hover_text(k.how)
|
||||||
|
} else {
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A friendly placeholder when there's nothing to show yet.
|
||||||
|
pub fn empty(ui: &mut Ui, icon: &str, title: &str, text: &str) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(18.0);
|
||||||
|
ui.label(RichText::new(icon).size(28.0).color(p.faint));
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.label(
|
||||||
|
RichText::new(title)
|
||||||
|
.font(theme::semibold(14.0))
|
||||||
|
.color(p.muted),
|
||||||
|
);
|
||||||
|
ui.label(
|
||||||
|
RichText::new(text)
|
||||||
|
.font(theme::regular(12.5))
|
||||||
|
.color(p.faint),
|
||||||
|
);
|
||||||
|
ui.add_space(18.0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Copy prompt": puts an agent prompt on the clipboard.
|
||||||
|
pub fn copy_prompt(ui: &mut Ui, prompt: &str) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let id = ui.id().with(("copied", prompt));
|
||||||
|
let copied_at: Option<f64> = ui.ctx().data(|d| d.get_temp(id));
|
||||||
|
let now = ui.input(|i| i.time);
|
||||||
|
let recently = copied_at.is_some_and(|t| now - t < 1.5);
|
||||||
|
let label = if recently {
|
||||||
|
format!("{} Copied", egui_phosphor::regular::CHECK)
|
||||||
|
} else {
|
||||||
|
format!("{} Ask the agent", egui_phosphor::regular::SPARKLE)
|
||||||
|
};
|
||||||
|
let r = ui
|
||||||
|
.add(
|
||||||
|
egui::Button::new(
|
||||||
|
RichText::new(label)
|
||||||
|
.font(theme::regular(12.0))
|
||||||
|
.color(p.accent),
|
||||||
|
)
|
||||||
|
.fill(p.accent_soft)
|
||||||
|
.stroke(Stroke::NONE)
|
||||||
|
.corner_radius(CornerRadius::same(6)),
|
||||||
|
)
|
||||||
|
.on_hover_text(format!(
|
||||||
|
"Copies this prompt for your agent session:\n\n{prompt}"
|
||||||
|
));
|
||||||
|
if r.clicked() {
|
||||||
|
ui.ctx().copy_text(prompt.to_string());
|
||||||
|
ui.ctx().data_mut(|d| d.insert_temp(id, now));
|
||||||
|
}
|
||||||
|
if recently {
|
||||||
|
ui.ctx()
|
||||||
|
.request_repaint_after(std::time::Duration::from_millis(300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A horizontal rule in the border color.
|
||||||
|
pub fn rule(ui: &mut Ui) {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let w = ui.available_width();
|
||||||
|
let (rect, _) = ui.allocate_exact_size(vec2(w, 1.0), Sense::hover());
|
||||||
|
ui.painter().rect_filled(rect, 0.0, p.border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selectable segmented control.
|
||||||
|
pub fn segmented<T: PartialEq + Copy>(ui: &mut Ui, value: &mut T, options: &[(T, &str)]) -> bool {
|
||||||
|
let p = pal(ui.ctx());
|
||||||
|
let mut changed = false;
|
||||||
|
Frame::new()
|
||||||
|
.fill(p.hover)
|
||||||
|
.corner_radius(CornerRadius::same(8))
|
||||||
|
.inner_margin(Margin::same(2))
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 2.0;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
for (v, label) in options {
|
||||||
|
let on = *value == *v;
|
||||||
|
let b = egui::Button::new(
|
||||||
|
RichText::new(*label)
|
||||||
|
.font(if on {
|
||||||
|
theme::semibold(12.5)
|
||||||
|
} else {
|
||||||
|
theme::regular(12.5)
|
||||||
|
})
|
||||||
|
.color(if on { p.text } else { p.muted }),
|
||||||
|
)
|
||||||
|
.fill(if on { p.surface } else { Color32::TRANSPARENT })
|
||||||
|
.stroke(if on {
|
||||||
|
Stroke::new(1.0, p.border)
|
||||||
|
} else {
|
||||||
|
Stroke::NONE
|
||||||
|
})
|
||||||
|
.corner_radius(CornerRadius::same(6))
|
||||||
|
.min_size(vec2(0.0, 24.0));
|
||||||
|
if ui.add(b).clicked() && !on {
|
||||||
|
*value = *v;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
changed
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
//! Live updates: a change written to the books (by the MCP server, in
|
||||||
|
//! practice) reaches the GUI's view model on its own, without a restart,
|
||||||
|
//! and the changed entry is marked fresh.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::{AccountType, Currency, Money, Side};
|
||||||
|
use ledger_gui::data::{Loader, Source, Update};
|
||||||
|
use ledger_store::Store;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_change_in_another_process_shows_up_live() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("ledger-gui-live-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let data = dir.join("books-data");
|
||||||
|
let mut store = Store::create(&data, Currency::USD).unwrap();
|
||||||
|
let cash = store
|
||||||
|
.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let equity = store
|
||||||
|
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let loader = Loader::spawn(
|
||||||
|
Source {
|
||||||
|
data_dir: data.clone(),
|
||||||
|
books_toml: None,
|
||||||
|
today: NaiveDate::from_ymd_opt(2026, 10, 12),
|
||||||
|
},
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
let first = match loader.wait(Duration::from_secs(10)) {
|
||||||
|
Some(Update::Books(b)) => b,
|
||||||
|
_ => panic!("no initial load"),
|
||||||
|
};
|
||||||
|
assert_eq!(first.kpis.cash, Decimal::ZERO);
|
||||||
|
|
||||||
|
// The writer (the MCP server's store) posts an entry.
|
||||||
|
let started = Instant::now();
|
||||||
|
let mut store = Store::open(&data).unwrap();
|
||||||
|
let d = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap();
|
||||||
|
let e = store.create_entry(d, "Owner contribution").unwrap();
|
||||||
|
let m = Money::new(Decimal::from(2500), Currency::USD);
|
||||||
|
store.add_line(e, cash, Side::Debit, m).unwrap();
|
||||||
|
store.add_line(e, equity, Side::Credit, m).unwrap();
|
||||||
|
store.post_entry(e).unwrap();
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
// The GUI sees it, without being asked.
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
let mut seen = None;
|
||||||
|
while Instant::now() < deadline {
|
||||||
|
if let Some(Update::Books(b)) = loader.wait(Duration::from_millis(200))
|
||||||
|
&& b.kpis.cash == Decimal::from(2500)
|
||||||
|
{
|
||||||
|
seen = Some(b);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let b = seen.expect("the posted entry never reached the GUI");
|
||||||
|
assert!(b.fresh.contains(&e), "the new entry is highlighted as new");
|
||||||
|
assert!(b.seq() > first.seq());
|
||||||
|
eprintln!("live update arrived in {:?}", started.elapsed());
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
//! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir
|
||||||
|
//! (containing `books-data/` and `wanderbooks.toml`) and `SHOT_DIR` to
|
||||||
|
//! where the PNGs go; without them this test does nothing.
|
||||||
|
|
||||||
|
use egui_kittest::Harness;
|
||||||
|
use ledger_gui::nav::{ReportKind, Route};
|
||||||
|
use ledger_gui::{App, Source, data};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshots() {
|
||||||
|
let Ok(out) = std::env::var("SHOT_DIR") else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let fixed_today: Option<chrono::NaiveDate> = std::env::var("SHOT_TODAY")
|
||||||
|
.ok()
|
||||||
|
.and_then(|d| d.parse().ok());
|
||||||
|
let books = match std::env::var("SHOT_BOOKS") {
|
||||||
|
Ok(b) => PathBuf::from(b),
|
||||||
|
Err(_) => {
|
||||||
|
let d = fixed_today
|
||||||
|
.unwrap_or_else(|| chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap());
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("ledger-gui-shots-demo-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
ledger_gui::demo::build(&dir, d).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let out = PathBuf::from(out);
|
||||||
|
std::fs::create_dir_all(&out).unwrap();
|
||||||
|
let source = Source {
|
||||||
|
data_dir: books.join("books-data"),
|
||||||
|
books_toml: Some(books.join("wanderbooks.toml")),
|
||||||
|
today: fixed_today.or_else(|| {
|
||||||
|
std::env::var("SHOT_BOOKS")
|
||||||
|
.is_err()
|
||||||
|
.then(|| chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap())
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let only = std::env::var("SHOT_ONLY").ok();
|
||||||
|
let dark = std::env::var("SHOT_DARK").is_ok();
|
||||||
|
let first_entry = data::load_once(&source)
|
||||||
|
.unwrap()
|
||||||
|
.entries
|
||||||
|
.last()
|
||||||
|
.map(|e| e.id)
|
||||||
|
.unwrap_or(1);
|
||||||
|
let routes: Vec<(&str, Route)> = vec![
|
||||||
|
("overview", Route::Overview),
|
||||||
|
("transactions", Route::Transactions),
|
||||||
|
(
|
||||||
|
"entry",
|
||||||
|
Route::Entry(
|
||||||
|
std::env::var("SHOT_ENTRY")
|
||||||
|
.ok()
|
||||||
|
.and_then(|e| e.parse().ok())
|
||||||
|
.unwrap_or(first_entry),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("cash", Route::Cash),
|
||||||
|
("profit", Route::Profit),
|
||||||
|
("pnl", Route::Reports(ReportKind::ProfitAndLoss)),
|
||||||
|
("balance", Route::Reports(ReportKind::BalanceSheet)),
|
||||||
|
("accounts", Route::Accounts),
|
||||||
|
("documents", Route::Documents),
|
||||||
|
("company", Route::Company),
|
||||||
|
("journal", Route::Journal),
|
||||||
|
];
|
||||||
|
for (name, route) in routes {
|
||||||
|
if only
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|o| !o.split(',').any(|x| x == name))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let books = data::load_once(&source).unwrap();
|
||||||
|
let mut harness = Harness::builder()
|
||||||
|
.with_size(egui::vec2(1440.0, 920.0))
|
||||||
|
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, dark, route.clone()));
|
||||||
|
harness.run_steps(4);
|
||||||
|
let ctx = harness.ctx.clone();
|
||||||
|
harness.state_mut().settle(&ctx);
|
||||||
|
harness.run_steps(4);
|
||||||
|
let img = harness.render().unwrap();
|
||||||
|
img.save(out.join(format!("{name}.png"))).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ path = "src/main.rs"
|
|||||||
ledger-core = { path = "../ledger-core" }
|
ledger-core = { path = "../ledger-core" }
|
||||||
ledger-store = { path = "../ledger-store" }
|
ledger-store = { path = "../ledger-store" }
|
||||||
ledger-import = { path = "../ledger-import" }
|
ledger-import = { path = "../ledger-import" }
|
||||||
|
ledger-config = { path = "../ledger-config" }
|
||||||
base64 = "0.22"
|
base64 = "0.22"
|
||||||
mime_guess = "2"
|
mime_guess = "2"
|
||||||
rust_decimal = "1"
|
rust_decimal = "1"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
//! receipt threshold) come from the books dir's `wanderbooks.toml`, by
|
||||||
//! default the one next to the data dir.
|
//! default the one next to the data dir.
|
||||||
|
|
||||||
mod company;
|
use ledger_config as company;
|
||||||
mod policy;
|
mod policy;
|
||||||
mod server;
|
mod server;
|
||||||
mod types;
|
mod types;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
//! The MCP server: narrow typed tools over the store, with the receipt
|
//! The MCP server: narrow typed tools over the store, with the receipt
|
||||||
//! policy enforced server-side.
|
//! policy enforced server-side.
|
||||||
|
|
||||||
use crate::company::Company;
|
|
||||||
use crate::policy::ReceiptPolicy;
|
use crate::policy::ReceiptPolicy;
|
||||||
use crate::types::*;
|
use crate::types::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use ledger_config::Company;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
AccountId, AccountType, AssetStatus, Currency, DocumentLine, DocumentStatus, JournalEntry,
|
||||||
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
Ledger, Money, NewAsset, PeriodStatus, Side, accumulated_depreciation, book_value,
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ impl BlobStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Open an existing blob store for reading, creating nothing. A
|
||||||
|
/// missing directory just means no blobs yet.
|
||||||
|
pub fn open_read_only(root: &Path) -> Self {
|
||||||
|
Self {
|
||||||
|
root: root.to_path_buf(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn path_for(&self, sha_hex: &str) -> PathBuf {
|
fn path_for(&self, sha_hex: &str) -> PathBuf {
|
||||||
self.root
|
self.root
|
||||||
.join(&sha_hex[0..2])
|
.join(&sha_hex[0..2])
|
||||||
|
|||||||
@@ -22,11 +22,13 @@ pub mod blob;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod journal;
|
pub mod journal;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
|
pub mod snapshot;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
|
|
||||||
pub use blob::BlobStore;
|
pub use blob::BlobStore;
|
||||||
pub use error::StoreError;
|
pub use error::StoreError;
|
||||||
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||||
|
pub use snapshot::Snapshot;
|
||||||
pub use store::{
|
pub use store::{
|
||||||
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE,
|
||||||
PayrollLineInput, Store,
|
PayrollLineInput, Store,
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
//! [`Snapshot`]: a read-only view of the books, for viewers.
|
||||||
|
//!
|
||||||
|
//! A snapshot never writes to the data directory. It reads the journal,
|
||||||
|
//! verifies the hash chain, replays it into its own [`Ledger`], and projects
|
||||||
|
//! it into a private in-memory SQLite database with exactly the code the
|
||||||
|
//! writer uses, so attachments and events read the same as through
|
||||||
|
//! [`crate::Store`]. Blobs are read (and verified) straight from the blob
|
||||||
|
//! store.
|
||||||
|
//!
|
||||||
|
//! [`Snapshot::refresh`] follows a live journal: it reads only the bytes
|
||||||
|
//! appended since the last read, verifies they chain onto what it has, and
|
||||||
|
//! applies them. A line the writer is still in the middle of appending
|
||||||
|
//! (no newline yet) is left for the next refresh, never treated as
|
||||||
|
//! corruption. If the file shrank or no longer chains, the snapshot reloads
|
||||||
|
//! from scratch.
|
||||||
|
|
||||||
|
use crate::blob::BlobStore;
|
||||||
|
use crate::error::StoreError;
|
||||||
|
use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex};
|
||||||
|
use crate::store::{
|
||||||
|
Attachment, BLOBS_DIR, JOURNAL_FILE, Store, apply_line_to_sqlite, query_attachments,
|
||||||
|
read_attachment_bytes, replay_line_into_ledger,
|
||||||
|
};
|
||||||
|
use ledger_core::{Currency, EntryId, Ledger};
|
||||||
|
use rusqlite::Connection;
|
||||||
|
use std::io::{Read, Seek, SeekFrom};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
/// A read-only, live-refreshable view of a data directory.
|
||||||
|
pub struct Snapshot {
|
||||||
|
data_dir: PathBuf,
|
||||||
|
conn: Connection,
|
||||||
|
blobs: BlobStore,
|
||||||
|
ledger: Ledger,
|
||||||
|
base_currency: Currency,
|
||||||
|
lines: Vec<JournalLine>,
|
||||||
|
/// Bytes of the journal consumed (complete lines only).
|
||||||
|
offset: u64,
|
||||||
|
/// Hash of the last line read: what the next line must chain onto.
|
||||||
|
head: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Snapshot {
|
||||||
|
/// Load the books in `data_dir`, read-only.
|
||||||
|
pub fn load(data_dir: &Path) -> Result<Self, StoreError> {
|
||||||
|
let journal_path = data_dir.join(JOURNAL_FILE);
|
||||||
|
if !journal_path.exists() {
|
||||||
|
return Err(StoreError::NotFound(data_dir.display().to_string()));
|
||||||
|
}
|
||||||
|
let conn = Connection::open_in_memory()?;
|
||||||
|
Store::migrate(&conn)?;
|
||||||
|
let mut snap = Self {
|
||||||
|
data_dir: data_dir.to_path_buf(),
|
||||||
|
conn,
|
||||||
|
blobs: BlobStore::open_read_only(&data_dir.join(BLOBS_DIR)),
|
||||||
|
ledger: Ledger::new(Currency::USD),
|
||||||
|
base_currency: Currency::USD,
|
||||||
|
lines: Vec::new(),
|
||||||
|
offset: 0,
|
||||||
|
head: GENESIS_PREV.to_string(),
|
||||||
|
};
|
||||||
|
snap.read_new()?;
|
||||||
|
Ok(snap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply whatever the writer appended since the last read. Returns the
|
||||||
|
/// number of new journal lines (0 when nothing changed).
|
||||||
|
pub fn refresh(&mut self) -> Result<usize, StoreError> {
|
||||||
|
let len = std::fs::metadata(self.data_dir.join(JOURNAL_FILE))?.len();
|
||||||
|
if len < self.offset {
|
||||||
|
// Rewritten underneath us (e.g. restored from a backup).
|
||||||
|
*self = Self::load(&self.data_dir)?;
|
||||||
|
return Ok(self.lines.len());
|
||||||
|
}
|
||||||
|
match self.read_new() {
|
||||||
|
Ok(n) => Ok(n),
|
||||||
|
Err(StoreError::JournalCorrupt(_)) => {
|
||||||
|
// No longer chains onto what we have: start over, and let a
|
||||||
|
// real corruption surface from the full load.
|
||||||
|
*self = Self::load(&self.data_dir)?;
|
||||||
|
Ok(self.lines.len())
|
||||||
|
}
|
||||||
|
Err(e) => Err(e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_new(&mut self) -> Result<usize, StoreError> {
|
||||||
|
let mut file = std::fs::File::open(self.data_dir.join(JOURNAL_FILE))?;
|
||||||
|
file.seek(SeekFrom::Start(self.offset))?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
file.read_to_end(&mut buf)?;
|
||||||
|
// Complete lines only: a torn tail is a write still in progress.
|
||||||
|
let Some(last_newline) = buf.iter().rposition(|&b| b == b'\n') else {
|
||||||
|
return Ok(0);
|
||||||
|
};
|
||||||
|
let complete = &buf[..=last_newline];
|
||||||
|
let mut added = 0;
|
||||||
|
for raw in complete.split(|&b| b == b'\n').filter(|l| !l.is_empty()) {
|
||||||
|
let line: JournalLine = serde_json::from_slice(raw).map_err(|e| {
|
||||||
|
StoreError::JournalCorrupt(format!(
|
||||||
|
"seq {}: unparseable line: {e}",
|
||||||
|
self.lines.len() + 1
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let want_seq = self.lines.len() as u64 + 1;
|
||||||
|
if line.seq != want_seq {
|
||||||
|
return Err(StoreError::JournalCorrupt(format!(
|
||||||
|
"seq gap: expected {want_seq}, found {}",
|
||||||
|
line.seq
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if line.prev != self.head {
|
||||||
|
return Err(StoreError::JournalCorrupt(format!(
|
||||||
|
"hash chain broken at seq {}",
|
||||||
|
line.seq
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if line.seq == 1 {
|
||||||
|
let code = line.payload["base_currency"].as_str().unwrap_or("USD");
|
||||||
|
self.base_currency = Currency::new(code)
|
||||||
|
.map_err(|_| StoreError::JournalCorrupt(format!("bad base_currency {code}")))?;
|
||||||
|
self.ledger = Ledger::new(self.base_currency);
|
||||||
|
}
|
||||||
|
replay_line_into_ledger(&mut self.ledger, &line, self.base_currency)?;
|
||||||
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
|
self.head = sha256_hex(line.to_canonical().as_bytes());
|
||||||
|
self.lines.push(line);
|
||||||
|
added += 1;
|
||||||
|
}
|
||||||
|
self.offset += complete.len() as u64;
|
||||||
|
Ok(added)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The replayed ledger: the source for every report.
|
||||||
|
pub fn ledger(&self) -> &Ledger {
|
||||||
|
&self.ledger
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The books' base currency.
|
||||||
|
pub fn base_currency(&self) -> Currency {
|
||||||
|
self.base_currency
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The data directory.
|
||||||
|
pub fn data_dir(&self) -> &Path {
|
||||||
|
&self.data_dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every journal line read so far, in order: the complete history,
|
||||||
|
/// with timestamps and hashes.
|
||||||
|
pub fn journal(&self) -> &[JournalLine] {
|
||||||
|
&self.lines
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sequence number of the last journal line read.
|
||||||
|
pub fn journal_seq(&self) -> u64 {
|
||||||
|
self.lines.len() as u64
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hash of the last journal line: the head of the verified chain.
|
||||||
|
pub fn head_hash(&self) -> &str {
|
||||||
|
&self.head
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attachments (receipts and documents, withdrawn ones included),
|
||||||
|
/// optionally only one entry's.
|
||||||
|
pub fn attachments(&self, entry_id: Option<EntryId>) -> Result<Vec<Attachment>, StoreError> {
|
||||||
|
query_attachments(&self.conn, entry_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An attachment's bytes, verified against its hash.
|
||||||
|
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||||
|
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,7 @@ use std::path::{Path, PathBuf};
|
|||||||
pub const JOURNAL_FILE: &str = "journal.jsonl";
|
pub const JOURNAL_FILE: &str = "journal.jsonl";
|
||||||
/// File names inside the data directory.
|
/// File names inside the data directory.
|
||||||
pub const DB_FILE: &str = "wanderbooks.db";
|
pub const DB_FILE: &str = "wanderbooks.db";
|
||||||
const BLOBS_DIR: &str = "blobs";
|
pub(crate) const BLOBS_DIR: &str = "blobs";
|
||||||
|
|
||||||
/// One row of an entry listing.
|
/// One row of an entry listing.
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
@@ -283,7 +283,7 @@ impl Store {
|
|||||||
matches!(check, Ok(s) if s == "ok")
|
matches!(check, Ok(s) if s == "ok")
|
||||||
}
|
}
|
||||||
|
|
||||||
fn migrate(conn: &Connection) -> Result<(), StoreError> {
|
pub(crate) fn migrate(conn: &Connection) -> Result<(), StoreError> {
|
||||||
let mut v = Self::current_version(conn)?;
|
let mut v = Self::current_version(conn)?;
|
||||||
if v > schema::SCHEMA_VERSION {
|
if v > schema::SCHEMA_VERSION {
|
||||||
return Err(StoreError::SchemaTooNew(v, schema::SCHEMA_VERSION));
|
return Err(StoreError::SchemaTooNew(v, schema::SCHEMA_VERSION));
|
||||||
@@ -2106,36 +2106,12 @@ impl Store {
|
|||||||
&self,
|
&self,
|
||||||
entry_id: Option<EntryId>,
|
entry_id: Option<EntryId>,
|
||||||
) -> Result<Vec<Attachment>, StoreError> {
|
) -> Result<Vec<Attachment>, StoreError> {
|
||||||
let mut sql = String::from(
|
query_attachments(&self.conn, entry_id)
|
||||||
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
|
|
||||||
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
|
|
||||||
FROM attachments a \
|
|
||||||
JOIN blobs b ON b.sha256 = a.blob_sha",
|
|
||||||
);
|
|
||||||
if entry_id.is_some() {
|
|
||||||
sql.push_str(" WHERE a.entry_id = ?");
|
|
||||||
}
|
|
||||||
sql.push_str(" ORDER BY a.id");
|
|
||||||
let mut stmt = self.conn.prepare(&sql)?;
|
|
||||||
let rows: Vec<Attachment> = match entry_id {
|
|
||||||
Some(eid) => stmt
|
|
||||||
.query_map(params![eid as i64], row_to_attachment)?
|
|
||||||
.collect::<Result<_, _>>()?,
|
|
||||||
None => stmt
|
|
||||||
.query_map([], row_to_attachment)?
|
|
||||||
.collect::<Result<_, _>>()?,
|
|
||||||
};
|
|
||||||
Ok(rows)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read attachment bytes, verifying the SHA-256.
|
/// Read attachment bytes, verifying the SHA-256.
|
||||||
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
pub fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||||
let sha: String = self.conn.query_row(
|
read_attachment_bytes(&self.conn, &self.blobs, attachment_id)
|
||||||
"SELECT blob_sha FROM attachments WHERE id = ?",
|
|
||||||
params![attachment_id as i64],
|
|
||||||
|r| r.get(0),
|
|
||||||
)?;
|
|
||||||
self.blobs.get(&sha)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -2795,7 +2771,7 @@ fn get_str<'a>(p: &'a Value, key: &str) -> Result<&'a str, StoreError> {
|
|||||||
/// Replay one journal line into a ledger, asserting recorded ids. Used by
|
/// Replay one journal line into a ledger, asserting recorded ids. Used by
|
||||||
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
|
/// hydration, `verify`, and `rebuild`: the journal is the script, the engine
|
||||||
/// is the actor.
|
/// is the actor.
|
||||||
fn replay_line_into_ledger(
|
pub(crate) fn replay_line_into_ledger(
|
||||||
ledger: &mut Ledger,
|
ledger: &mut Ledger,
|
||||||
line: &JournalLine,
|
line: &JournalLine,
|
||||||
base_currency: Currency,
|
base_currency: Currency,
|
||||||
@@ -3376,7 +3352,10 @@ fn check_replayed_entry(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), StoreError> {
|
pub(crate) fn apply_line_to_sqlite(
|
||||||
|
conn: &Connection,
|
||||||
|
line: &JournalLine,
|
||||||
|
) -> Result<(), StoreError> {
|
||||||
let p = &line.payload;
|
let p = &line.payload;
|
||||||
match line.line_type.as_str() {
|
match line.line_type.as_str() {
|
||||||
"store.created" => {}
|
"store.created" => {}
|
||||||
@@ -3795,6 +3774,47 @@ fn apply_line_to_sqlite(conn: &Connection, line: &JournalLine) -> Result<(), Sto
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attachments in the projection, optionally only one entry's, by id.
|
||||||
|
pub(crate) fn query_attachments(
|
||||||
|
conn: &Connection,
|
||||||
|
entry_id: Option<EntryId>,
|
||||||
|
) -> Result<Vec<Attachment>, StoreError> {
|
||||||
|
let mut sql = String::from(
|
||||||
|
"SELECT a.id, a.blob_sha, a.entry_id, a.category, b.original_name, b.mime_type, \
|
||||||
|
b.size_bytes, a.description, a.created_at, a.withdrawn_at, a.withdrawn_reason \
|
||||||
|
FROM attachments a \
|
||||||
|
JOIN blobs b ON b.sha256 = a.blob_sha",
|
||||||
|
);
|
||||||
|
if entry_id.is_some() {
|
||||||
|
sql.push_str(" WHERE a.entry_id = ?");
|
||||||
|
}
|
||||||
|
sql.push_str(" ORDER BY a.id");
|
||||||
|
let mut stmt = conn.prepare(&sql)?;
|
||||||
|
let rows: Vec<Attachment> = match entry_id {
|
||||||
|
Some(eid) => stmt
|
||||||
|
.query_map(params![eid as i64], row_to_attachment)?
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
None => stmt
|
||||||
|
.query_map([], row_to_attachment)?
|
||||||
|
.collect::<Result<_, _>>()?,
|
||||||
|
};
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An attachment's bytes, verified against its SHA-256.
|
||||||
|
pub(crate) fn read_attachment_bytes(
|
||||||
|
conn: &Connection,
|
||||||
|
blobs: &BlobStore,
|
||||||
|
attachment_id: u64,
|
||||||
|
) -> Result<Vec<u8>, StoreError> {
|
||||||
|
let sha: String = conn.query_row(
|
||||||
|
"SELECT blob_sha FROM attachments WHERE id = ?",
|
||||||
|
params![attachment_id as i64],
|
||||||
|
|r| r.get(0),
|
||||||
|
)?;
|
||||||
|
blobs.get(&sha)
|
||||||
|
}
|
||||||
|
|
||||||
fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
fn row_to_attachment(r: &rusqlite::Row) -> rusqlite::Result<Attachment> {
|
||||||
Ok(Attachment {
|
Ok(Attachment {
|
||||||
id: r.get::<_, i64>(0)? as u64,
|
id: r.get::<_, i64>(0)? as u64,
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
//! Snapshot: a read-only view that follows a live writer.
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::reports::snapshot;
|
||||||
|
use ledger_core::{AccountType, Currency, Money, Side};
|
||||||
|
use ledger_store::{JOURNAL_FILE, Snapshot, Store};
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::io::Write;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn test_dir(name: &str) -> PathBuf {
|
||||||
|
let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id()));
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
dir
|
||||||
|
}
|
||||||
|
|
||||||
|
fn usd(n: i64) -> Money {
|
||||||
|
Money::new(Decimal::from(n), Currency::USD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn post(store: &mut Store, day: u32, dr: u64, cr: u64, amount: i64) -> u64 {
|
||||||
|
let d = NaiveDate::from_ymd_opt(2026, 10, day).unwrap();
|
||||||
|
let e = store.create_entry(d, "x").unwrap();
|
||||||
|
store.add_line(e, dr, Side::Debit, usd(amount)).unwrap();
|
||||||
|
store.add_line(e, cr, Side::Credit, usd(amount)).unwrap();
|
||||||
|
store.post_entry(e).unwrap();
|
||||||
|
e
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_snapshot_follows_the_writer_and_never_writes() {
|
||||||
|
let dir = test_dir("follow");
|
||||||
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let cash = store
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let equity = store
|
||||||
|
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let e = post(&mut store, 1, cash, equity, 1000);
|
||||||
|
store
|
||||||
|
.attach_receipt(Some(e), "deposit.png", "image/png", b"png-bytes", None)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let listing = |dir: &std::path::Path| {
|
||||||
|
let mut v: Vec<_> = walk(dir);
|
||||||
|
v.sort();
|
||||||
|
v
|
||||||
|
};
|
||||||
|
let before = listing(&dir);
|
||||||
|
let mut snap = Snapshot::load(&dir).unwrap();
|
||||||
|
assert_eq!(listing(&dir), before, "loading writes nothing");
|
||||||
|
assert_eq!(snap.journal_seq(), store.journal_seq());
|
||||||
|
assert_eq!(
|
||||||
|
snapshot(snap.ledger()).unwrap(),
|
||||||
|
snapshot(store.ledger()).unwrap()
|
||||||
|
);
|
||||||
|
let atts = snap.attachments(Some(e)).unwrap();
|
||||||
|
assert_eq!(atts.len(), 1);
|
||||||
|
assert_eq!(snap.attachment_bytes(atts[0].id).unwrap(), b"png-bytes");
|
||||||
|
|
||||||
|
// The writer keeps going; refresh picks up exactly the new lines.
|
||||||
|
assert_eq!(snap.refresh().unwrap(), 0);
|
||||||
|
let seq = store.journal_seq();
|
||||||
|
post(&mut store, 2, cash, equity, 250);
|
||||||
|
let added = snap.refresh().unwrap();
|
||||||
|
assert_eq!(added as u64, store.journal_seq() - seq);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot(snap.ledger()).unwrap(),
|
||||||
|
snapshot(store.ledger()).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_half_written_line_waits_for_the_next_refresh() {
|
||||||
|
let dir = test_dir("torn");
|
||||||
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
store
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
let mut snap = Snapshot::load(&dir).unwrap();
|
||||||
|
let seq = snap.journal_seq();
|
||||||
|
// Simulate the writer mid-append: half a line, no newline yet.
|
||||||
|
let journal = dir.join(JOURNAL_FILE);
|
||||||
|
let full = std::fs::read(&journal).unwrap();
|
||||||
|
let mut store = Store::open(&dir).unwrap();
|
||||||
|
store
|
||||||
|
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
drop(store);
|
||||||
|
let grown = std::fs::read(&journal).unwrap();
|
||||||
|
let new_line = &grown[full.len()..];
|
||||||
|
std::fs::write(&journal, &full).unwrap();
|
||||||
|
let mut f = std::fs::OpenOptions::new()
|
||||||
|
.append(true)
|
||||||
|
.open(&journal)
|
||||||
|
.unwrap();
|
||||||
|
f.write_all(&new_line[..new_line.len() / 2]).unwrap();
|
||||||
|
f.flush().unwrap();
|
||||||
|
assert_eq!(snap.refresh().unwrap(), 0, "torn tail is not corruption");
|
||||||
|
f.write_all(&new_line[new_line.len() / 2..]).unwrap();
|
||||||
|
f.flush().unwrap();
|
||||||
|
assert_eq!(snap.refresh().unwrap(), 1);
|
||||||
|
assert_eq!(snap.journal_seq(), seq + 1);
|
||||||
|
assert!(snap.ledger().chart().find_by_code("3000").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_rewritten_journal_reloads() {
|
||||||
|
let dir = test_dir("rewrite");
|
||||||
|
let mut store = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let cash = store
|
||||||
|
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let equity = store
|
||||||
|
.add_account("3000", "Equity", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let journal = dir.join(JOURNAL_FILE);
|
||||||
|
let early = std::fs::read(&journal).unwrap();
|
||||||
|
post(&mut store, 1, cash, equity, 10);
|
||||||
|
drop(store);
|
||||||
|
let mut snap = Snapshot::load(&dir).unwrap();
|
||||||
|
let later = snap.journal_seq();
|
||||||
|
// Restored from an earlier backup: shorter file.
|
||||||
|
std::fs::write(&journal, &early).unwrap();
|
||||||
|
snap.refresh().unwrap();
|
||||||
|
assert!(snap.journal_seq() < later);
|
||||||
|
assert!(snap.ledger().posted_entries().next().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
fn walk(dir: &std::path::Path) -> Vec<(String, u64)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for e in std::fs::read_dir(dir).unwrap() {
|
||||||
|
let e = e.unwrap();
|
||||||
|
let m = e.metadata().unwrap();
|
||||||
|
if m.is_dir() {
|
||||||
|
out.extend(walk(&e.path()));
|
||||||
|
} else {
|
||||||
|
out.push((e.path().display().to_string(), m.len()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
+10
-1
@@ -58,7 +58,6 @@ The core is a pure domain library and stays that way.
|
|||||||
| `ledger-import` | QuickBooks CSV read-only import (bank transactions, chart of accounts) | `ledger-core` |
|
| `ledger-import` | QuickBooks CSV read-only import (bank transactions, chart of accounts) | `ledger-core` |
|
||||||
| `ledger-export` | Format renderers: CSV, XLSX, IIF journal entries (QuickBooks Desktop) | `ledger-core` |
|
| `ledger-export` | Format renderers: CSV, XLSX, IIF journal entries (QuickBooks Desktop) | `ledger-core` |
|
||||||
| `ledger-oracle` | Diff harness: our engine vs GnuCash on fixture books | `ledger-core`, `ledger-import` |
|
| `ledger-oracle` | Diff harness: our engine vs GnuCash on fixture books | `ledger-core`, `ledger-import` |
|
||||||
| `books-app` | egui UI (native + WASM), command-driven | `ledger-core`, `ledger-store` |
|
|
||||||
|
|
||||||
Dependency direction is always inward toward `ledger-core`. Nothing in the
|
Dependency direction is always inward toward `ledger-core`. Nothing in the
|
||||||
core may depend on a future crate. UI concerns (formatting, locale, widgets)
|
core may depend on a future crate. UI concerns (formatting, locale, widgets)
|
||||||
@@ -71,3 +70,13 @@ types, and a type system that makes invalid states (mutable posted entries,
|
|||||||
float money) unrepresentable. The storytold pipeline this project is modeled
|
float money) unrepresentable. The storytold pipeline this project is modeled
|
||||||
on is Rust + egui for the same reasons; when the UI crate arrives it will be
|
on is Rust + egui for the same reasons; when the UI crate arrives it will be
|
||||||
one codebase compiling to native and WASM.
|
one codebase compiling to native and WASM.
|
||||||
|
|
||||||
|
## The GUI (`ledger-gui`)
|
||||||
|
|
||||||
|
Read-only by construction: it opens a `ledger_store::Snapshot` (journal
|
||||||
|
verified and replayed into its own `Ledger`, projected into a private
|
||||||
|
in-memory SQLite, blobs read through the verifying store) and never writes
|
||||||
|
to the data directory. A loader thread watches the directory and applies
|
||||||
|
appended journal lines; the UI renders an immutable view model rebuilt per
|
||||||
|
change. Statements and series come from `ledger-core` (`reports`,
|
||||||
|
`analysis`); the GUI only arranges them. See `plan/gui.md`.
|
||||||
@@ -249,3 +249,21 @@ interface is MCP, not a cloned GUI. The first storage + MCP slice is done
|
|||||||
Journal events vendor.1099_set and entry.vendor_set. Schema v7. 64 tools.
|
Journal events vendor.1099_set and entry.vendor_set. Schema v7. 64 tools.
|
||||||
Also: requirements and parity no longer list finished R-FUTURE work as
|
Also: requirements and parity no longer list finished R-FUTURE work as
|
||||||
future (only budgets remain, declined).
|
future (only budgets remain, declined).
|
||||||
|
- 2026-10-12: ledger-gui, the read-only desktop view (egui 0.36, wgpu).
|
||||||
|
Overview (the answer in a sentence, KPI tiles with honest comparisons,
|
||||||
|
cash chart, needs-attention with copyable agent prompts, recent
|
||||||
|
activity), Cash (accounts, in/out, burn and runway, coming up), Profit
|
||||||
|
(by month with margin, spending vs. its recent average, by customer and
|
||||||
|
class), Transactions (search, filters, cash direction, detail with
|
||||||
|
lines, the rendered receipt via hayro, and journal history), Reports
|
||||||
|
(P&L, balance sheet, cash flow, owners' equity, trial balance, GL, tax
|
||||||
|
summary, 1099, agings; date presets, month/quarter/year columns, prior
|
||||||
|
period/year comparison, % of income, drill-down to the account register
|
||||||
|
for exactly those dates, copy as TSV), Accounts and registers,
|
||||||
|
Documents, Company, Journal (every event, hash, payload), Ctrl+K
|
||||||
|
palette, back/forward, light/dark. Live: notify-driven, ~30 ms from
|
||||||
|
write to screen. Engine additions: ledger-store Snapshot (read-only,
|
||||||
|
incremental, torn-tail tolerant), ledger-core analysis (intervals,
|
||||||
|
balance series and history, P&L by interval, flows), ledger-config
|
||||||
|
(extracted from ledger-mcp; full profile, cash_accounts). Fonts: Inter
|
||||||
|
with tnum frozen in and private-use glyphs removed (Phosphor icons).
|
||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
# ledger-gui — design
|
||||||
|
|
||||||
|
Phase 4 of the roadmap. A desktop app (egui/eframe; web later) for the
|
||||||
|
people who own the books and the accountant who checks them. Same
|
||||||
|
audience as QuickBooks, none of its visual language.
|
||||||
|
|
||||||
|
## What it is for
|
||||||
|
|
||||||
|
It answers the questions an owner actually has, then shows the evidence:
|
||||||
|
|
||||||
|
- **How much money do we have, and how long does it last?** Cash today,
|
||||||
|
the trend, burn, runway, what's coming due.
|
||||||
|
- **Are we making money?** Revenue, spending, and profit by month; what
|
||||||
|
changed and why.
|
||||||
|
- **Where does the money go?** Spending by category and vendor, over time.
|
||||||
|
- **Who owes whom?** Payables, receivables, cards, money owed to members.
|
||||||
|
- **Is anything wrong or missing?** Entries without receipts, drafts never
|
||||||
|
posted, unreconciled accounts, uncategorized tax lines, 1099 vendors with
|
||||||
|
no W-9, past periods left open, a journal that fails verification.
|
||||||
|
- **Can I trust this number?** Every number drills down to the lines that
|
||||||
|
make it, each line to its entry, each entry to its rendered receipt and
|
||||||
|
its complete journal history (who did what, when, hash-chained).
|
||||||
|
|
||||||
|
An accountant gets the same data at full depth: statements with interval
|
||||||
|
columns and comparisons, the general ledger, trial balance, the raw
|
||||||
|
journal with its verification status.
|
||||||
|
|
||||||
|
## Principles
|
||||||
|
|
||||||
|
1. **Answer first.** Every screen leads with the answer in words and one
|
||||||
|
big number, then the chart, then the table, then the raw data.
|
||||||
|
2. **Everything drills down.** Any amount is a link. Navigation is a
|
||||||
|
history stack (back/forward, like a browser) with a breadcrumb.
|
||||||
|
3. **All the data, nothing hidden.** Withdrawn attachments, reversals,
|
||||||
|
drafts, every journal event: visible, marked for what they are.
|
||||||
|
4. **Read-only by construction.** The GUI has no write path: it reads the
|
||||||
|
hash-chained journal and the blob store and builds its own in-memory
|
||||||
|
view (`ledger_store::Snapshot`). Changes happen through the agent (MCP).
|
||||||
|
Where the GUI finds something to fix, it offers a ready-to-paste prompt
|
||||||
|
for the agent ("Attach the receipt for entry 12 — Anthropic, $5.82").
|
||||||
|
5. **Live.** It follows the journal: a change made by the agent appears
|
||||||
|
within a second, highlighted as new.
|
||||||
|
6. **Numbers are typography.** Tabular figures everywhere (Inter with
|
||||||
|
`tnum` frozen in), right-aligned, parentheses for negatives in
|
||||||
|
statements, a minus sign elsewhere, never a float.
|
||||||
|
7. **No accounting logic in the GUI.** Statements, balances, and series
|
||||||
|
come from `ledger-core` (new reports land there, where MCP can use
|
||||||
|
them too). The GUI arranges and draws.
|
||||||
|
|
||||||
|
## Screens
|
||||||
|
|
||||||
|
- **Overview** — KPI tiles with sparkline and change vs. the prior period
|
||||||
|
(cash, runway, revenue, spending, net income, owed by / owed to);
|
||||||
|
cash over time; income vs. spending by month; spending by category;
|
||||||
|
needs-attention list; recent activity from the journal.
|
||||||
|
- **Cash** — every cash and card account with balance and trend; inflows
|
||||||
|
and outflows by month; burn and runway; known upcoming outflows
|
||||||
|
(recurring entries, bills due, schedules).
|
||||||
|
- **Profit** — P&L by month as chart and table, spending breakdown with
|
||||||
|
month-over-month change, by class.
|
||||||
|
- **Transactions** — the register of every entry: search, filters
|
||||||
|
(account, vendor, class, date, status, receipt yes/no, amount), and a
|
||||||
|
detail pane with lines, the rendered receipt (PDF pages, images),
|
||||||
|
vendor, reversal links, and the entry's journal history.
|
||||||
|
- **Reports** — P&L, balance sheet, cash flow, trial balance, general
|
||||||
|
ledger, members' equity, tax summary, A/R and A/P aging, 1099
|
||||||
|
worksheet. Date presets, interval columns (total / month / quarter /
|
||||||
|
year), comparison to the prior period or year. Every amount drills down.
|
||||||
|
- **Accounts** — the chart with balances and tax categories; an account
|
||||||
|
opens its register with a running balance and a balance chart.
|
||||||
|
- **Documents** — every receipt and company document, rendered, with its
|
||||||
|
category, entry link, and withdrawal status.
|
||||||
|
- **Company** — profile, members and capital, periods, fixed assets,
|
||||||
|
amortization schedules, recurring entries, vendors and 1099 status.
|
||||||
|
- **Journal** — the raw event log, filterable, each event expandable to
|
||||||
|
its JSON, with the hash-chain verification result.
|
||||||
|
|
||||||
|
Global: command palette (Ctrl+K) to jump to any account, entry, vendor,
|
||||||
|
document, or report; back/forward (Alt+←/→); light and dark themes.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- `ledger-store::Snapshot`: read-only. Verifies the journal chain,
|
||||||
|
replays it into a `Ledger`, projects it into an in-memory SQLite with
|
||||||
|
the same code the writer uses, reads blobs. `refresh()` applies only
|
||||||
|
new journal lines (or reloads if the file was rewritten).
|
||||||
|
- `ledger-config` (moved out of `ledger-mcp`): `wanderbooks.toml`.
|
||||||
|
- `ledger-gui`:
|
||||||
|
- `data` — a loader thread polls the journal and sends new snapshots.
|
||||||
|
- `model` — view models built from a snapshot (pure, unit-tested).
|
||||||
|
- `ui/*` — one module per screen; `widgets` (money, tiles, sparkline,
|
||||||
|
tables, charts); `theme`; `receipts` (PDF via `hayro`, images via
|
||||||
|
`image`; textures cached by blob hash).
|
||||||
|
- Tests: model unit tests, `egui_kittest` screenshots of every screen
|
||||||
|
against a fixture book.
|
||||||
|
- Web build later: the model is plain data, so a server can ship it.
|
||||||
|
|
||||||
|
## Status (2026-10-12)
|
||||||
|
|
||||||
|
Built: every screen above, the palette, history, themes, live updates
|
||||||
|
(tested end to end: a store write reaches the view model in ~30 ms),
|
||||||
|
receipt rendering for PDFs and images, a deterministic demo company
|
||||||
|
(`ledger-gui --demo`; also the screenshot fixture), and a screenshot test
|
||||||
|
(`SHOT_DIR=… cargo test -p ledger-gui --test screens`, optionally
|
||||||
|
`SHOT_BOOKS`, `SHOT_ONLY`, `SHOT_DARK`).
|
||||||
|
|
||||||
|
Next, in rough order: a budget-free cash forecast with a confidence band;
|
||||||
|
reconciliation view (cleared vs. uncleared per bank account); receipt
|
||||||
|
zoom and multi-page navigation; keyboard navigation in lists;
|
||||||
|
virtualized lists for very large books; the web build (ship the view
|
||||||
|
model from a server).
|
||||||
+3
-1
@@ -31,7 +31,9 @@ The primary interface is MCP, not a cloned GUI (Stephen's call, 2026-10-10).
|
|||||||
- Done when: a fixture book produces byte-identical snapshots from both
|
- Done when: a fixture book produces byte-identical snapshots from both
|
||||||
engines with zero manual steps.
|
engines with zero manual steps.
|
||||||
|
|
||||||
## Phase 4 — Read-only GUI
|
## Phase 4 — Read-only GUI (first version done, 2026-10-12)
|
||||||
|
|
||||||
|
`ledger-gui`: see `plan/gui.md` for the design and what's built.
|
||||||
|
|
||||||
- Consumes the `ledger-store` event log: live updates — the answer to
|
- Consumes the `ledger-store` event log: live updates — the answer to
|
||||||
"watch its files to update live."
|
"watch its files to update live."
|
||||||
|
|||||||
Reference in new issue
Block a user