Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
A project is a class with facts (description, status, kind, party, dates, links, notes) in one project.set event behind the format feature projects. Four tools (books.add_project, update_project, projects, project); documents and decisions link to projects; B-37: on-spec costs are expensed as incurred, repayment is revenue, and the unrecovered cost is never below zero (Kani). Viewer: a Projects list and a project page with its figures, by month and by account. The sidebar measures its footer instead of reserving a fixed 172 px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
71 files changed
+7446
-217
No files matched your search
@@ -385,6 +385,16 @@ pub(crate) fn row(
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
if click {
|
||||
// Named by its main cell, for assistive tech (and the tests).
|
||||
let name = cells
|
||||
.iter()
|
||||
.find(|c| c.1 == 0.0)
|
||||
.or(cells.first())
|
||||
.map(|c| c.0.clone())
|
||||
.unwrap_or_default();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
}
|
||||
if click && resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
|
||||
@@ -135,6 +135,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
.map(|(s, _, i, o)| BarGroup {
|
||||
label: s.format("%b").to_string(),
|
||||
values: vec![*i, *o],
|
||||
year: None,
|
||||
})
|
||||
.collect();
|
||||
charts::bar_chart(
|
||||
|
||||
@@ -50,7 +50,7 @@ fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) {
|
||||
|
||||
/// How the books are kept, as the owners decided: conventions, decisions,
|
||||
/// and what is open (each only when there is something to show).
|
||||
fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
fn kept_cards(ui: &mut Ui, b: &Books, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let kept = b.ledger.conventions();
|
||||
let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect();
|
||||
@@ -95,6 +95,27 @@ fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
RichText::new(&d.new.decision_md).font(theme::regular(12.5)),
|
||||
&d.new.decision_md,
|
||||
);
|
||||
if !d.new.projects.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for &c in &d.new.projects {
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!(
|
||||
"{} {}",
|
||||
icon::BRIEFCASE,
|
||||
b.project_label(c)
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.accent),
|
||||
)
|
||||
.on_hover_text("Open this project")
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Project(c));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
@@ -132,6 +153,7 @@ fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let mut kept_go: Option<Route> = None;
|
||||
page(ui, "company", |ui| {
|
||||
let c = &b.settings.company;
|
||||
widgets::page_header(
|
||||
@@ -192,7 +214,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
);
|
||||
},
|
||||
);
|
||||
kept_cards(ui, b);
|
||||
kept_cards(ui, b, &mut kept_go);
|
||||
}
|
||||
_ => {
|
||||
widgets::titled_card(
|
||||
@@ -332,6 +354,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
recurring(ui, b);
|
||||
parties(ui, b, shell);
|
||||
});
|
||||
if let Some(r) = kept_go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
|
||||
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
|
||||
@@ -371,6 +371,7 @@ pub fn subject_icon(s: Subject) -> &'static str {
|
||||
Subject::Account(_) => icon::TREE_STRUCTURE,
|
||||
Subject::Asset(_) => icon::PACKAGE,
|
||||
Subject::Party(_) => icon::ADDRESS_BOOK,
|
||||
Subject::Project(_) => icon::BRIEFCASE,
|
||||
Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT,
|
||||
Subject::Period(_) => icon::CALENDAR_BLANK,
|
||||
Subject::PayrollRun(_) => icon::USERS,
|
||||
|
||||
@@ -239,13 +239,19 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
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(
|
||||
if let Some((cid, c)) = line
|
||||
.class_id
|
||||
.and_then(|c| class_names.get(&c).map(|n| (c, n)))
|
||||
&& widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("{} {c}", icon::TAG))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
)
|
||||
.on_hover_text("Open this project")
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Project(cid));
|
||||
}
|
||||
if let (Some(cur), Some(fa)) =
|
||||
(&line.foreign_currency, line.foreign_amount)
|
||||
|
||||
@@ -479,7 +479,26 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
.map_or(Route::Company, Route::Party),
|
||||
),
|
||||
),
|
||||
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
|
||||
"class.added" => ev(
|
||||
icon::BRIEFCASE,
|
||||
format!("Added project {}", s(p, "name")),
|
||||
u32::try_from(id).ok().map(Route::Project),
|
||||
),
|
||||
"project.set" => {
|
||||
let status = ledger_core::ProjectStatus::parse(s(p, "status"))
|
||||
.map_or("", |x| x.words())
|
||||
.to_lowercase();
|
||||
let kind = match p.get("kind").and_then(|v| v.as_str()) {
|
||||
Some(k) => ledger_core::ProjectKind::parse(k)
|
||||
.map_or(k.to_string(), |x| x.words().to_lowercase()),
|
||||
None => "kind not recorded".into(),
|
||||
};
|
||||
ev(
|
||||
icon::BRIEFCASE,
|
||||
format!("Project {}: {status}, {kind}", s(p, "name")),
|
||||
u32::try_from(id).ok().map(Route::Project),
|
||||
)
|
||||
}
|
||||
"party.added" => {
|
||||
let roles: Vec<String> = p
|
||||
.get("roles")
|
||||
@@ -1114,8 +1133,8 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
|
||||
.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());
|
||||
.map(|c| format!("project {}", c.name))
|
||||
.unwrap_or_else(|| "no project".into());
|
||||
Event {
|
||||
icon: icon::TAG,
|
||||
text: format!(
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod journal;
|
||||
pub mod overview;
|
||||
pub mod parties;
|
||||
pub mod profit;
|
||||
pub mod projects;
|
||||
pub mod reconciliation;
|
||||
pub mod reports;
|
||||
pub mod transactions;
|
||||
@@ -61,6 +62,8 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
|
||||
Route::Asset(id) => assets::show_asset(ui, b, *id, shell),
|
||||
Route::Parties => parties::show_list(ui, b, shell),
|
||||
Route::Party(id) => parties::show_party(ui, b, *id, shell),
|
||||
Route::Projects => projects::show_list(ui, b, shell),
|
||||
Route::Project(id) => projects::show_project(ui, b, *id, shell),
|
||||
Route::Documents => documents::show(ui, b, None, shell),
|
||||
Route::Document(id) => documents::show(ui, b, Some(*id), shell),
|
||||
Route::Company => company::show(ui, b, shell),
|
||||
|
||||
@@ -397,6 +397,7 @@ fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
.map(|m| BarGroup {
|
||||
label: m.start.format("%b").to_string(),
|
||||
values: vec![m.revenue, m.expenses, m.net()],
|
||||
year: None,
|
||||
})
|
||||
.collect();
|
||||
let clicked = charts::bar_chart(
|
||||
|
||||
@@ -322,7 +322,7 @@ fn paid_cell(paid: Decimal, p: &theme::Palette) -> (String, egui::Color32) {
|
||||
}
|
||||
|
||||
/// A filter chip.
|
||||
fn chip(ui: &mut Ui, text: &str, on: bool) -> bool {
|
||||
pub(crate) fn chip(ui: &mut Ui, text: &str, on: bool) -> bool {
|
||||
let p = pal(ui.ctx());
|
||||
ui.add(
|
||||
egui::Button::new(
|
||||
@@ -642,7 +642,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::copyable(
|
||||
widgets::copyable_truncated(
|
||||
ui,
|
||||
RichText::new(party.label()).font(theme::bold(26.0)),
|
||||
party.label(),
|
||||
@@ -654,7 +654,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
widgets::copyable(ui, RichText::new(&party.name), &party.name);
|
||||
widgets::copyable_truncated(ui, RichText::new(&party.name), &party.name);
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
@@ -841,6 +841,11 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
}
|
||||
});
|
||||
|
||||
let projects = b.ledger.projects_of_party(id);
|
||||
if !projects.is_empty() {
|
||||
projects_card(ui, b, &projects, &mut go);
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
entries_card(ui, b, &info, &mut go);
|
||||
ui.add_space(14.0);
|
||||
if !info.invoices.is_empty() || !info.bills.is_empty() {
|
||||
@@ -879,26 +884,9 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
for &i in &info.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));
|
||||
if widgets::link_truncated(
|
||||
ui,
|
||||
RichText::new(&ev.text).font(theme::regular(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::JournalEvent(line.seq));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"event {} · {}",
|
||||
line.seq,
|
||||
fmt::timestamp(&line.ts)
|
||||
))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
});
|
||||
if widgets::history_row(ui, ev.icon, &ev.text, line.seq, &line.ts).clicked() {
|
||||
go = Some(Route::JournalEvent(line.seq));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -919,14 +907,14 @@ fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option<Route>) {
|
||||
facts(ui, |ui, k| {
|
||||
let text = |ui: &mut Ui, v: &Option<String>| match v {
|
||||
Some(t) => {
|
||||
widgets::copyable(ui, RichText::new(t), t);
|
||||
widgets::copyable_truncated(ui, RichText::new(t), t);
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new("not recorded").color(p.faint));
|
||||
}
|
||||
};
|
||||
k(ui, "Name");
|
||||
widgets::copyable(ui, RichText::new(&party.name), &party.name);
|
||||
widgets::copyable_truncated(ui, RichText::new(&party.name), &party.name);
|
||||
ui.end_row();
|
||||
k(ui, "Display name");
|
||||
text(ui, &party.display_name);
|
||||
@@ -1223,6 +1211,59 @@ fn records_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mu
|
||||
);
|
||||
}
|
||||
|
||||
/// The projects for or with the party (rules.md B-37), by name: status
|
||||
/// and net to date; a row opens its project.
|
||||
fn projects_card(ui: &mut Ui, b: &Books, ids: &[ledger_core::ClassId], go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Projects",
|
||||
|ui| {
|
||||
widgets::pill(ui, &ids.len().to_string(), p.muted, p.hover);
|
||||
},
|
||||
|ui| {
|
||||
let amt = widgets::AMOUNT_MIN + 24.0;
|
||||
let w = ui.available_width().max(100.0 + amt + 180.0);
|
||||
widgets::min_width(ui, "party_projects", w, |ui| {
|
||||
for &id in ids {
|
||||
let name = b.project_label(id);
|
||||
let f = b.ledger.project_facts(id).unwrap_or_default();
|
||||
let net = ledger_core::project_to_date(&b.ledger, id, b.today)
|
||||
.ok()
|
||||
.map(|t| t.net());
|
||||
let (net_t, net_c) = match net {
|
||||
Some(n) if n.is_zero() => ("—".to_string(), p.faint),
|
||||
Some(n) if n < Decimal::ZERO => (fmt::accounting(n), p.negative),
|
||||
Some(n) => (fmt::accounting(n), p.text),
|
||||
None => (fmt::CANT_COMPUTE.to_string(), p.faint),
|
||||
};
|
||||
let sub = format!("{} · net to date", super::projects::kind_words(f.kind));
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(name, 0.0, false, p.text),
|
||||
(
|
||||
f.status.words().to_string(),
|
||||
100.0,
|
||||
false,
|
||||
super::projects::status_colors(f.status, &p).0,
|
||||
),
|
||||
(net_t, amt, true, net_c),
|
||||
],
|
||||
Some((sub.as_str(), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open this project").clicked() {
|
||||
*go = Some(Route::Project(id));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Every entry tagged with the party, newest first.
|
||||
fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
@@ -1608,15 +1649,12 @@ fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate)
|
||||
let groups: Vec<crate::charts::BarGroup> = shown
|
||||
.iter()
|
||||
.map(|m| crate::charts::BarGroup {
|
||||
label: if multi_year && m.month.month() == 1 {
|
||||
m.month.format("%b %y").to_string()
|
||||
} else {
|
||||
m.month.format("%b").to_string()
|
||||
},
|
||||
label: m.month.format("%b").to_string(),
|
||||
values: vec![m.paid, m.received],
|
||||
year: multi_year.then(|| m.month.year()),
|
||||
})
|
||||
.collect();
|
||||
crate::charts::bar_chart(
|
||||
crate::charts::bar_chart_view(
|
||||
ui,
|
||||
&groups,
|
||||
&[("Paid out", p.series[2]), ("Received in", p.series[1])],
|
||||
|
||||
@@ -18,10 +18,15 @@ 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> {
|
||||
pub(crate) fn pct(part: Decimal, whole: Decimal) -> Option<Decimal> {
|
||||
part.checked_div(whole)?
|
||||
.checked_mul(Decimal::from(100))
|
||||
.map(|x| x.round_dp(1))
|
||||
.map(|x| {
|
||||
// One place always, so 100% prints as "100.0", as 64.0 does.
|
||||
let mut x = x.round_dp(1);
|
||||
x.rescale(1);
|
||||
x
|
||||
})
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
@@ -74,6 +79,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
.map(|m| BarGroup {
|
||||
label: m.start.format("%b").to_string(),
|
||||
values: vec![m.revenue, m.expenses, m.net()],
|
||||
year: None,
|
||||
})
|
||||
.collect();
|
||||
if let Some(i) = charts::bar_chart(
|
||||
@@ -160,10 +166,14 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
spending_changes(ui, b, shell);
|
||||
let mut go = None;
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => by_customer(ui, b),
|
||||
_ => by_class(ui, b),
|
||||
_ => by_class(ui, b, &mut go),
|
||||
});
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -359,46 +369,52 @@ fn rows_total(by: &BTreeMap<String, Decimal>) -> Option<Decimal> {
|
||||
.try_fold(Decimal::ZERO, |t, v| t.checked_add(*v))
|
||||
}
|
||||
|
||||
fn by_class(ui: &mut Ui, b: &Books) {
|
||||
/// Each project's net income this year, a bar each; a bar opens its
|
||||
/// project.
|
||||
fn by_class(ui: &mut Ui, b: &Books, go: &mut Option<Route>) {
|
||||
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",
|
||||
"Net income by project, 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.",
|
||||
icon::BRIEFCASE,
|
||||
"No projects",
|
||||
"Tag lines with a project to see profit by each.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut rows: Vec<(String, Decimal)> = classes
|
||||
let mut rows: Vec<(ledger_core::ClassId, 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))
|
||||
.then(|| (c.id, c.name.clone(), net))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.2));
|
||||
if rows.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::TAG,
|
||||
"No classified income or spending this year",
|
||||
icon::BRIEFCASE,
|
||||
"No project income or spending this year",
|
||||
"",
|
||||
);
|
||||
return;
|
||||
}
|
||||
charts::hbars(ui, &rows, p.series[0]);
|
||||
let ids: Vec<ledger_core::ClassId> = rows.iter().map(|r| r.0).collect();
|
||||
let bars: Vec<(String, Decimal)> = rows.into_iter().map(|r| (r.1, r.2)).collect();
|
||||
if let Some(i) = charts::hbars_signed(ui, &bars, p.series[0], p.negative) {
|
||||
*go = Some(Route::Project(ids[i]));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new("Only lines tagged with a class count here.")
|
||||
RichText::new("Only lines tagged with a project count here.")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
@@ -418,6 +434,9 @@ mod tests {
|
||||
assert_eq!(pct(cents(2_500), cents(20_000)), Some(Decimal::new(125, 1)));
|
||||
assert_eq!(pct(cents(1), cents(3)), Some(Decimal::new(333, 1)));
|
||||
assert_eq!(pct(cents(1), Decimal::ZERO), None);
|
||||
// One place whatever the value.
|
||||
assert_eq!(pct(cents(500), cents(500)).unwrap().to_string(), "100.0");
|
||||
assert_eq!(pct(cents(-100), cents(200)).unwrap().to_string(), "-50.0");
|
||||
assert_eq!(pct(Decimal::MAX, Decimal::new(1, 2)), None);
|
||||
}
|
||||
|
||||
@@ -440,6 +459,6 @@ mod tests {
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, _) = render(books, Route::Profit, |_| {});
|
||||
find(&texts, "Invoiced by customer, this year");
|
||||
find(&texts, "Acme is 100% of invoiced income.");
|
||||
find(&texts, "Acme is 100.0% of invoiced income.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
//! Projects: every class, with its facts (what it is, where it stands, who
|
||||
//! it is for), and one project completely: what it earned and cost, by
|
||||
//! month and by account, its entries, documents, decisions and history
|
||||
//! (rules.md B-37). Every figure is ledger-core's (`project_figures`,
|
||||
//! `project_to_date`, `project_entries`), the same the server's
|
||||
//! `books.projects` and `books.project` return; this module only arranges
|
||||
//! them, and never writes.
|
||||
|
||||
use super::assets::{facts, row};
|
||||
use super::page;
|
||||
use super::parties::chip;
|
||||
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::{Datelike, NaiveDate};
|
||||
use egui::{RichText, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// The list's filter, kept across navigation.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// Only projects with this status (None: every one).
|
||||
pub status: Option<ProjectStatus>,
|
||||
}
|
||||
|
||||
/// One project on the list: its id, name, facts and net to date.
|
||||
pub struct Listed {
|
||||
pub id: ClassId,
|
||||
pub name: String,
|
||||
pub facts: ProjectFacts,
|
||||
pub to_date: Option<ledger_core::ProjectToDate>,
|
||||
}
|
||||
|
||||
/// Every project (every class), by name ignoring capitals, with its
|
||||
/// figures to date through today.
|
||||
pub fn listed(b: &Books) -> Vec<Listed> {
|
||||
let mut out: Vec<Listed> = b
|
||||
.ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.map(|c| Listed {
|
||||
id: c.id,
|
||||
name: c.name.clone(),
|
||||
facts: b.ledger.project_facts(c.id).unwrap_or_default(),
|
||||
to_date: ledger_core::project_to_date(&b.ledger, c.id, b.today).ok(),
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|a| (a.name.to_lowercase(), a.id));
|
||||
out
|
||||
}
|
||||
|
||||
/// A status's colors: (text, fill).
|
||||
pub(crate) fn status_colors(
|
||||
s: ProjectStatus,
|
||||
p: &theme::Palette,
|
||||
) -> (egui::Color32, egui::Color32) {
|
||||
match s {
|
||||
ProjectStatus::Active => (p.positive, p.positive_soft),
|
||||
ProjectStatus::Paused => (p.warning, p.warning_soft),
|
||||
ProjectStatus::Finished => (p.muted, p.hover),
|
||||
ProjectStatus::Abandoned => (p.faint, p.hover),
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind in words, or that it isn't recorded.
|
||||
pub fn kind_words(k: Option<ProjectKind>) -> &'static str {
|
||||
k.map_or("Kind not recorded", ProjectKind::words)
|
||||
}
|
||||
|
||||
/// The list's sentence: how many in each status, and the net across them.
|
||||
fn summary(rows: &[Listed]) -> String {
|
||||
if rows.is_empty() {
|
||||
return "No projects yet.".into();
|
||||
}
|
||||
let parts: Vec<String> = ProjectStatus::ALL
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
let n = rows.iter().filter(|r| r.facts.status == s).count();
|
||||
(n > 0).then(|| format!("{n} {}", s.key()))
|
||||
})
|
||||
.collect();
|
||||
let net: Option<Decimal> = rows
|
||||
.iter()
|
||||
.map(|r| r.to_date.as_ref().map(|t| t.net()))
|
||||
.try_fold(Decimal::ZERO, |t, n| t.checked_add(n?));
|
||||
let counts = if parts.len() == 1 && rows.len() > 1 {
|
||||
format!(
|
||||
"{}, all {}",
|
||||
fmt::count(rows.len(), "project", "projects"),
|
||||
rows[0].facts.status.key()
|
||||
)
|
||||
} else if rows.len() == 1 {
|
||||
format!("1 project, {}", rows[0].facts.status.key())
|
||||
} else {
|
||||
format!(
|
||||
"{}: {}",
|
||||
fmt::count(rows.len(), "project", "projects"),
|
||||
parts.join(", ")
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"{counts}. Net to date across them: {}.",
|
||||
fmt::dollars_or(net)
|
||||
)
|
||||
}
|
||||
|
||||
/// The projects.
|
||||
pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "projects", |ui| {
|
||||
let all = listed(b);
|
||||
widgets::page_header(ui, "Projects", &summary(&all));
|
||||
if all.is_empty() {
|
||||
widgets::card(ui, |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::BRIEFCASE,
|
||||
"No projects yet",
|
||||
"The agent adds them (books.add_project). Tag lines with a project to see what it earns and costs.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
let st = &mut shell.state.projects;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
|
||||
if chip(ui, &format!("All {}", all.len()), st.status.is_none()) {
|
||||
st.status = None;
|
||||
}
|
||||
for s in ProjectStatus::ALL {
|
||||
let n = all.iter().filter(|r| r.facts.status == s).count();
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let on = st.status == Some(s);
|
||||
if chip(ui, &format!("{} {n}", s.words()), on) {
|
||||
st.status = if on { None } else { Some(s) };
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let rows: Vec<&Listed> = all
|
||||
.iter()
|
||||
.filter(|r| st.status.is_none_or(|s| r.facts.status == s))
|
||||
.collect();
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Projects",
|
||||
|ui| {
|
||||
widgets::pill(ui, &rows.len().to_string(), p.muted, p.hover);
|
||||
},
|
||||
|ui| {
|
||||
let amt = widgets::AMOUNT_MIN + 24.0;
|
||||
let avail = ui.available_width();
|
||||
let status_w = 100.0;
|
||||
let kind_w = if avail >= 760.0 { 140.0 } else { 0.0 };
|
||||
let party_w = if avail >= 900.0 {
|
||||
(avail * 0.18).clamp(140.0, 220.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let w = avail.max(status_w + kind_w + party_w + amt + 180.0);
|
||||
let cells = |c: [(String, f32, bool, egui::Color32); 5]| -> Vec<_> {
|
||||
c.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| (*i != 2 || kind_w > 0.0) && (*i != 3 || party_w > 0.0))
|
||||
.map(|(_, c)| c)
|
||||
.collect()
|
||||
};
|
||||
widgets::min_width(ui, "project_list", w, |ui| {
|
||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&cells([
|
||||
head("Name", 0.0, false),
|
||||
head("Status", status_w, false),
|
||||
head("Kind", kind_w, false),
|
||||
head("Party", party_w, false),
|
||||
head("Net to date", amt, true),
|
||||
]),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for r in &rows {
|
||||
let (sc, _) = status_colors(r.facts.status, &p);
|
||||
let party = r
|
||||
.facts
|
||||
.party
|
||||
.map(|x| b.party_label(x))
|
||||
.unwrap_or_else(|| "—".into());
|
||||
let (net_t, net_c) = match r.to_date.as_ref().map(|t| t.net()) {
|
||||
Some(n) if n.is_zero() => ("—".to_string(), p.faint),
|
||||
Some(n) if n < Decimal::ZERO => (fmt::accounting(n), p.negative),
|
||||
Some(n) => (fmt::accounting(n), p.text),
|
||||
None => (fmt::CANT_COMPUTE.to_string(), p.faint),
|
||||
};
|
||||
let sub = r
|
||||
.facts
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Project {}", r.id));
|
||||
let sub = sub.lines().next().unwrap_or("").to_string();
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&cells([
|
||||
(r.name.clone(), 0.0, false, p.text),
|
||||
(r.facts.status.words().to_string(), status_w, false, sc),
|
||||
(kind_words(r.facts.kind).to_string(), kind_w, false, p.muted),
|
||||
(party, party_w, false, p.muted),
|
||||
(net_t, amt, true, net_c),
|
||||
]),
|
||||
Some((sub.as_str(), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let resp = resp.on_hover_text("Open this project");
|
||||
resp.context_menu(|ui| {
|
||||
widgets::copy_item(ui, "Copy name", &r.name);
|
||||
widgets::copy_item(ui, "Copy project id", &r.id.to_string());
|
||||
});
|
||||
if resp.clicked() {
|
||||
go = Some(r.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!(
|
||||
"Net to date: every posted revenue and expense line tagged to the project through {}, closing entries left out; revenue less expenses.",
|
||||
fmt::date(b.today)
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Project(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The journal events naming a project: its class and project records,
|
||||
/// links of files to it, and decisions naming it (indexes into
|
||||
/// [`Books::journal`], oldest first).
|
||||
pub fn project_events(b: &Books, id: ClassId) -> Vec<usize> {
|
||||
let id64 = u64::from(id);
|
||||
let u = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_u64());
|
||||
b.journal
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| {
|
||||
let p = &line.payload;
|
||||
match line.line_type.as_str() {
|
||||
"class.added" | "project.set" => u(p, "id") == Some(id64),
|
||||
"attachment.linked" | "attachment.unlinked" => {
|
||||
p.get("subject")
|
||||
.and_then(|s| s.get("kind"))
|
||||
.and_then(|k| k.as_str())
|
||||
== Some("project")
|
||||
&& p.get("subject").and_then(|s| u(s, "id")) == Some(id64)
|
||||
}
|
||||
"decision.recorded" => p
|
||||
.get("projects")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|a| a.iter().any(|x| x.as_u64() == Some(id64))),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A number tile: (label, value, how it is computed, the line under it).
|
||||
type Tile = (
|
||||
String,
|
||||
Option<Decimal>,
|
||||
String,
|
||||
Option<(String, egui::Color32)>,
|
||||
);
|
||||
|
||||
/// One project, completely.
|
||||
pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let (Some(class), Some(f)) = (b.ledger.get_class(id), b.ledger.project_facts(id)) else {
|
||||
page(ui, "project", |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::BRIEFCASE,
|
||||
"No such project",
|
||||
"It isn't in these books.",
|
||||
);
|
||||
});
|
||||
return;
|
||||
};
|
||||
let mut go: Option<Route> = None;
|
||||
let today = b.today;
|
||||
let to_date = ledger_core::project_to_date(&b.ledger, id, today).ok();
|
||||
let entries = ledger_core::project_entries(&b.ledger, id);
|
||||
page(ui, "project", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("{} Projects", icon::ARROW_LEFT))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::Projects);
|
||||
}
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(format!("· Project {id}"))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
&id.to_string(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::copyable_truncated(
|
||||
ui,
|
||||
RichText::new(&class.name).font(theme::bold(26.0)),
|
||||
&class.name,
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let (fg, bg) = status_colors(f.status, &p);
|
||||
widgets::pill(ui, f.status.words(), fg, bg);
|
||||
match f.kind {
|
||||
Some(ProjectKind::OnSpec) => {
|
||||
widgets::pill(ui, ProjectKind::OnSpec.words(), p.accent, p.accent_soft)
|
||||
.on_hover_text(
|
||||
"Work done hoping to be paid: its costs are expenses as they are spent, and a repayment is income only when it arrives.",
|
||||
);
|
||||
}
|
||||
k => {
|
||||
widgets::pill(ui, kind_words(k), p.muted, p.hover);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(d) = &f.description {
|
||||
ui.add_space(6.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(d).font(theme::regular(15.0)).color(p.muted));
|
||||
});
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
|
||||
// What it earned and cost, to date.
|
||||
let span = format!(
|
||||
"Every posted line tagged to this project through {}, closing entries left out.",
|
||||
fmt::date(today)
|
||||
);
|
||||
let mut tiles: Vec<Tile> = vec![
|
||||
(
|
||||
"Income to date".into(),
|
||||
to_date.as_ref().map(|t| t.revenue),
|
||||
format!("{span} Revenue lines."),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Spending to date".into(),
|
||||
to_date.as_ref().map(|t| t.expenses),
|
||||
format!("{span} Expense lines."),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Net to date".into(),
|
||||
to_date.as_ref().map(|t| t.net()),
|
||||
format!("{span} Income less spending."),
|
||||
to_date.as_ref().map(|t| {
|
||||
let n = t.net();
|
||||
if n < Decimal::ZERO {
|
||||
("a loss so far".to_string(), p.negative)
|
||||
} else if n.is_zero() {
|
||||
("even".to_string(), p.muted)
|
||||
} else {
|
||||
("a profit so far".to_string(), p.positive)
|
||||
}
|
||||
}),
|
||||
),
|
||||
];
|
||||
if let Some(u) = to_date.as_ref().and_then(|t| t.unrecovered_cost) {
|
||||
tiles.push((
|
||||
"Unrecovered cost".into(),
|
||||
Some(u),
|
||||
format!(
|
||||
"{span} Spending less income, never below zero: what it would take to be repaid in full. A hoped-for payment isn't income or a receivable until it arrives (ASC 450-30-25-1), so this is a figure, not something owed to the company."
|
||||
),
|
||||
Some(("if repaid: not a receivable".into(), p.muted)),
|
||||
));
|
||||
}
|
||||
if f.kind == Some(ProjectKind::Client)
|
||||
&& let Some(t) = &to_date
|
||||
&& !t.revenue.is_zero()
|
||||
{
|
||||
tiles.push((
|
||||
"Margin to date".into(),
|
||||
None,
|
||||
format!("{span} Net as a share of income."),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let margin = to_date
|
||||
.as_ref()
|
||||
.and_then(|t| super::profit::pct(t.net(), t.revenue));
|
||||
let n = tiles.len();
|
||||
super::grid(ui, n, 170.0, 6, |i, ui| {
|
||||
let (label, value, how, sub) = &tiles[i];
|
||||
let text = if label == "Margin to date" {
|
||||
margin.map_or_else(|| "—".into(), |m| format!("{m}%"))
|
||||
} else {
|
||||
fmt::dollars_or(*value)
|
||||
};
|
||||
let r = widgets::kpi(
|
||||
ui,
|
||||
widgets::Kpi {
|
||||
label,
|
||||
value: text,
|
||||
sub: sub.clone(),
|
||||
spark: None,
|
||||
how,
|
||||
},
|
||||
);
|
||||
if let Some(v) = value {
|
||||
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string()));
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
|
||||
// By month and by account.
|
||||
let first = entries
|
||||
.iter()
|
||||
.filter_map(|(e, _)| b.ledger.get_entry(*e))
|
||||
.filter(|e| e.status() == ledger_core::EntryStatus::Posted)
|
||||
.map(|e| e.date())
|
||||
.min();
|
||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
months_card(ui, b, id, first);
|
||||
} else {
|
||||
accounts_card(ui, b, id, &mut go);
|
||||
}
|
||||
});
|
||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
facts_card(ui, b, id, &class.name, &f, &mut go);
|
||||
} else {
|
||||
decisions_card(ui, b, id, &mut go);
|
||||
}
|
||||
});
|
||||
entries_card(ui, b, &entries, &mut go);
|
||||
ui.add_space(14.0);
|
||||
let here = [ledger_store::Subject::Project(u64::from(id))];
|
||||
super::documents::documents_card_with(
|
||||
ui,
|
||||
b,
|
||||
"Documents",
|
||||
&here,
|
||||
&here,
|
||||
"No documents linked to it (its contract, spec or pitch).",
|
||||
&mut go,
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
let events = project_events(b, id);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"History",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if events.is_empty() {
|
||||
ui.label(RichText::new("No journal events name it.").color(p.muted));
|
||||
}
|
||||
for &i in &events {
|
||||
let line = &b.journal[i];
|
||||
let ev = super::journal::describe_any(b, line);
|
||||
if widgets::history_row(ui, ev.icon, &ev.text, line.seq, &line.ts).clicked() {
|
||||
go = Some(Route::JournalEvent(line.seq));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Income, spending and net by month: from the month of its first posted
|
||||
/// tagged entry through this month, at most the last 24.
|
||||
fn months_card(ui: &mut Ui, b: &Books, id: ClassId, first: Option<NaiveDate>) {
|
||||
let p = pal(ui.ctx());
|
||||
let span = first.map(|f| {
|
||||
let start = ledger_core::analysis::interval_start(
|
||||
f.min(b.today),
|
||||
ledger_core::analysis::Interval::Month,
|
||||
);
|
||||
crate::books::Books::last_months((start, b.today), 24)
|
||||
});
|
||||
let months = span
|
||||
.and_then(|(s, e)| ledger_core::project_figures(&b.ledger, id, s, e).ok())
|
||||
.map(|f| f.months)
|
||||
.unwrap_or_default();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"By month",
|
||||
|ui| {
|
||||
if let Some((s, _)) = span {
|
||||
ui.label(
|
||||
RichText::new(format!("since {}", fmt::month(s)))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if months.is_empty() {
|
||||
widgets::empty(ui, icon::CHART_BAR, "No tagged lines yet", "");
|
||||
return;
|
||||
}
|
||||
let multi_year =
|
||||
months.first().map(|m| m.start.year()) != months.last().map(|m| m.start.year());
|
||||
let groups: Vec<BarGroup> = months
|
||||
.iter()
|
||||
.map(|m| BarGroup {
|
||||
label: m.start.format("%b").to_string(),
|
||||
values: vec![m.revenue, m.expenses, m.net()],
|
||||
year: multi_year.then(|| m.start.year()),
|
||||
})
|
||||
.collect();
|
||||
// Hover shows a month's figures; a click opens nothing, since
|
||||
// the reports are the whole company's, not the project's.
|
||||
charts::bar_chart_view(
|
||||
ui,
|
||||
&groups,
|
||||
&[
|
||||
("Income", p.series[1]),
|
||||
("Spending", p.series[2]),
|
||||
("Net", p.series[0]),
|
||||
],
|
||||
200.0,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Its revenue then expense accounts to date, with a total.
|
||||
fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
// The to-date statement alone: project_figures would build every
|
||||
// month's too, each frame.
|
||||
let pl = ledger_core::reports::profit_and_loss_for_period(
|
||||
&b.ledger,
|
||||
ledger_core::calendar::FIRST_DAY.min(b.today),
|
||||
b.today,
|
||||
Some(id),
|
||||
)
|
||||
.ok();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"By account, to date",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let Some(pl) = pl.filter(|pl| !pl.revenue.is_empty() || !pl.expenses.is_empty()) else {
|
||||
widgets::empty(ui, icon::TREE_STRUCTURE, "No income or spending tagged", "");
|
||||
return;
|
||||
};
|
||||
let amt = widgets::amount_col_w(ui.available_width(), 1, 160.0, 140.0);
|
||||
let w = ui.available_width();
|
||||
let mut line = |ui: &mut Ui, l: &ledger_core::StatementLine, expense: bool| {
|
||||
let shown = if expense { -l.amount } else { l.amount };
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(format!("{} {}", l.code, l.name), 0.0, false, p.text),
|
||||
(
|
||||
fmt::accounting(shown),
|
||||
amt,
|
||||
true,
|
||||
if shown < Decimal::ZERO {
|
||||
p.negative
|
||||
} else {
|
||||
p.text
|
||||
},
|
||||
),
|
||||
],
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open this account's register").clicked()
|
||||
&& let Some(a) = b.ledger.chart().find_by_code(&l.code)
|
||||
{
|
||||
*go = Some(Route::Account(a.id));
|
||||
}
|
||||
};
|
||||
for l in &pl.revenue {
|
||||
line(ui, l, false);
|
||||
}
|
||||
for l in &pl.expenses {
|
||||
line(ui, l, true);
|
||||
}
|
||||
widgets::rule(ui);
|
||||
let net = pl.net_income();
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
("Net".to_string(), 0.0, false, p.text),
|
||||
(
|
||||
fmt::accounting(net),
|
||||
amt,
|
||||
true,
|
||||
if net < Decimal::ZERO {
|
||||
p.negative
|
||||
} else {
|
||||
p.text
|
||||
},
|
||||
),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Spending shows in parentheses; only revenue and expense lines count.",
|
||||
)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// What it is: kind, status, party, dates, links, ids, description, notes.
|
||||
fn facts_card(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
id: ClassId,
|
||||
name: &str,
|
||||
f: &ProjectFacts,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Facts",
|
||||
|_| {},
|
||||
|ui| {
|
||||
facts(ui, |ui, k| {
|
||||
let none = |ui: &mut Ui| {
|
||||
ui.label(RichText::new("not recorded").color(p.faint));
|
||||
};
|
||||
k(ui, "Name");
|
||||
widgets::copyable_truncated(ui, RichText::new(name), name);
|
||||
ui.end_row();
|
||||
k(ui, "Kind");
|
||||
match f.kind {
|
||||
Some(kind) => {
|
||||
ui.label(kind.words());
|
||||
}
|
||||
None => none(ui),
|
||||
}
|
||||
ui.end_row();
|
||||
k(ui, "Status");
|
||||
ui.label(f.status.words());
|
||||
ui.end_row();
|
||||
k(ui, "Party");
|
||||
match f.party {
|
||||
Some(pid) => {
|
||||
if widgets::link(ui, RichText::new(b.party_label(pid)).color(p.accent))
|
||||
.on_hover_text("Open this party")
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Party(pid));
|
||||
}
|
||||
}
|
||||
None => none(ui),
|
||||
}
|
||||
ui.end_row();
|
||||
k(ui, "Started");
|
||||
match f.started_on {
|
||||
Some(d) => {
|
||||
ui.label(fmt::date(d));
|
||||
}
|
||||
None => none(ui),
|
||||
}
|
||||
ui.end_row();
|
||||
if f.ended_on.is_some() || f.status.is_over() {
|
||||
k(ui, "Ended");
|
||||
match f.ended_on {
|
||||
Some(d) => {
|
||||
ui.label(fmt::date(d));
|
||||
}
|
||||
None => none(ui),
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
k(ui, "Links");
|
||||
if f.links.is_empty() {
|
||||
none(ui);
|
||||
} else {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for l in &f.links {
|
||||
widgets::copyable_truncated(ui, RichText::new(l), l);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
k(ui, "Project id");
|
||||
widgets::copyable(ui, RichText::new(id.to_string()), &id.to_string());
|
||||
ui.end_row();
|
||||
});
|
||||
if let Some(n) = &f.notes {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Notes")
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add(egui::Label::new(RichText::new(n).font(theme::regular(13.0))).wrap());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The decisions naming it, newest first; replaced ones muted.
|
||||
fn decisions_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let decisions: Vec<_> = b
|
||||
.ledger
|
||||
.conventions()
|
||||
.decisions()
|
||||
.filter(|d| d.new.projects.contains(&id))
|
||||
.collect();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Decisions",
|
||||
|ui| {
|
||||
if !decisions.is_empty() {
|
||||
widgets::pill(ui, &decisions.len().to_string(), p.muted, p.hover);
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if decisions.is_empty() {
|
||||
ui.label(RichText::new("No decision names this project.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
for (i, d) in decisions.iter().rev().enumerate() {
|
||||
if i > 0 {
|
||||
ui.add_space(4.0);
|
||||
widgets::rule(ui);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
let replaced = d.superseded_by.is_some();
|
||||
let text = if replaced { p.muted } else { p.text };
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(&d.new.subject)
|
||||
.font(theme::semibold(13.0))
|
||||
.color(text),
|
||||
)
|
||||
.on_hover_text("Open it in the journal")
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::JournalEvent(d.seq));
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(&d.new.decision_md)
|
||||
.font(theme::regular(12.5))
|
||||
.color(if replaced { p.muted } else { p.text }),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
let mut by = format!(
|
||||
"{} · {}",
|
||||
d.new.decided_by.join(" and "),
|
||||
fmt::date(d.new.decided_on)
|
||||
);
|
||||
if let Some(n) = d.superseded_by {
|
||||
by.push_str(&format!(" · replaced by #{n}"));
|
||||
}
|
||||
ui.label(RichText::new(by).font(theme::regular(11.0)).color(p.faint));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Every entry with a line tagged to it, newest first, with its project
|
||||
/// amount.
|
||||
fn entries_card(ui: &mut Ui, b: &Books, entries: &[(u64, Decimal)], go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Entries",
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::count(entries.len(), "entry", "entries"))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
|ui| {
|
||||
if entries.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("No line in the books is tagged with it yet.").color(p.muted),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 24.0;
|
||||
let w = ui.available_width().max(104.0 + 64.0 + 150.0 + amt + 200.0);
|
||||
widgets::min_width(ui, "project_entries", w, |ui| {
|
||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
head("Date", 104.0, false),
|
||||
head("Entry", 64.0, false),
|
||||
head("Description", 0.0, false),
|
||||
head("Kind", 150.0, false),
|
||||
head("Project amount", amt, true),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for (eid, amount) in entries {
|
||||
let Some(e) = b.entry(*eid) else {
|
||||
continue;
|
||||
};
|
||||
let draft = e.status != ledger_core::EntryStatus::Posted;
|
||||
let text = if draft { p.muted } else { p.text };
|
||||
let kind = if draft {
|
||||
format!("{} · draft", e.origin)
|
||||
} else {
|
||||
e.origin.to_string()
|
||||
};
|
||||
let mut sub: Vec<String> = Vec::new();
|
||||
if e.amount != Some(amount.abs()) {
|
||||
sub.push(format!("of {}", fmt::dollars_or(e.amount)));
|
||||
}
|
||||
if !e.receipts.is_empty() {
|
||||
sub.push(fmt::count(e.receipts.len(), "receipt", "receipts"));
|
||||
}
|
||||
let accounts: Vec<String> =
|
||||
e.accounts.iter().map(|a| b.account_name(*a)).collect();
|
||||
sub.push(accounts.join(" → "));
|
||||
let sub = sub.join(" · ");
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(fmt::date(e.date), 104.0, false, text),
|
||||
(format!("#{}", e.id), 64.0, false, p.muted),
|
||||
(e.description.clone(), 0.0, false, text),
|
||||
(kind, 150.0, false, if draft { p.warning } else { p.muted }),
|
||||
(
|
||||
fmt::accounting(*amount),
|
||||
amt,
|
||||
true,
|
||||
if *amount < Decimal::ZERO {
|
||||
p.negative
|
||||
} else {
|
||||
text
|
||||
},
|
||||
),
|
||||
],
|
||||
Some((sub.as_str(), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
resp.context_menu(|ui| {
|
||||
widgets::copy_item(ui, "Copy entry number", &format!("#{}", e.id));
|
||||
widgets::copy_item(ui, "Copy project amount", &amount.to_string());
|
||||
});
|
||||
if resp.on_hover_text("Open this entry").clicked() {
|
||||
*go = Some(Route::Entry(e.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Project amount: the entry's tagged income less its tagged spending; a tagged balance-sheet line counts nothing.",
|
||||
)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user