//! 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 = 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_top(|ui| { ui.label(RichText::new(ev.icon).color(p.muted)); ui.vertical(|ui| { ui.spacing_mut().item_spacing.y = 0.0; let r = ui.add( egui::Label::new(RichText::new(&ev.text).font(theme::regular(12.5))) .wrap() .sense(egui::Sense::click()), ); if r.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } if r.clicked() { shell.go(Route::JournalEvent(line.seq)); } ui.label( RichText::new(format!( "event {} · {}", line.seq, fmt::timestamp(&line.ts) )) .font(theme::regular(11.0)) .color(p.faint), ); }); }); ui.add_space(4.0); } }); } 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); } } } }