Files
wanderbooks/crates/ledger-gui/src/pages/documents.rs
T

739 lines
26 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Every receipt and document the books hold, rendered: what it is,
//! everything it is linked to, and whether it was withdrawn (and why).
//! Also the compact "Documents" card every subject's page shows
//! ([`documents_card`]).
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, Subject};
#[derive(Default)]
pub struct State {
pub category: Option<String>,
/// One subject's files only (plan P6 step 6: grouped by category and
/// subject).
pub subject: Option<Subject>,
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(ui.available_width().min(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());
}
}
// One subject's files (plan P6 step 6): every subject any
// document is evidence of, and the company's own.
let mut subjects: Vec<Subject> = Vec::new();
for a in &b.attachments {
if let Some(e) = a.entry_id {
subjects.push(Subject::Entry(e));
}
subjects.extend(b.links_of(a.id));
}
subjects.sort();
subjects.dedup();
egui::ComboBox::from_id_salt("documents-subject")
.selected_text(match st.subject {
None => "Any subject".to_string(),
Some(s) => b.subject_label(s),
})
.show_ui(ui, |ui| {
if ui
.selectable_label(st.subject.is_none(), "Any subject")
.clicked()
{
st.subject = None;
}
for s in subjects {
if ui
.selectable_label(st.subject == Some(s), b.subject_label(s))
.clicked()
{
st.subject = Some(s);
}
}
});
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| match st.subject {
None => true,
Some(s) => {
a.entry_id.map(Subject::Entry) == Some(s) || b.links_of(a.id).contains(&s)
}
})
.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;
}
// As many 200-point tiles as fit, stretched to fill the row.
let per_row = widgets::fit_columns(ui.available_width(), 200.0, 16.0, usize::MAX);
let tile_w =
((ui.available_width() - 16.0 * (per_row as f32 - 1.0)) / per_row as f32).min(280.0);
let mut go = None;
// Grouped by category unless one is chosen (plan P6 step 6): a
// heading, then its files; the subject filter narrows each group.
let by_category = st.category.is_none();
let mut groups: std::collections::BTreeMap<String, Vec<&Attachment>> =
std::collections::BTreeMap::new();
for a in docs {
groups.entry(a.category.clone()).or_default().push(a);
}
for (category, files) in groups {
if by_category {
ui.label(
RichText::new(fmt::label(&category))
.font(theme::semibold(12.5))
.color(pal(ui.ctx()).muted),
);
ui.add_space(6.0);
}
for row in files.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
.thumb(&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::Text { text, .. } => {
// The first lines of the text, as written, clipped to the thumb.
let head: String = text.lines().take(14).collect::<Vec<_>>().join("\n");
let galley =
ui.painter()
.layout(head, theme::regular(10.5), p.muted, thumb.width() - 16.0);
ui.painter().with_clip_rect(thumb.shrink(4.0)).galley(
thumb.min + vec2(8.0, 8.0),
galley,
p.muted,
);
}
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_or(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);
}
});
});
widgets::copyable(
ui,
RichText::new(&a.file_name).font(theme::bold(20.0)),
&a.file_name,
);
ui.add_space(6.0);
file_actions(ui, b, a);
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");
widgets::copyable_truncated(
ui,
RichText::new(&a.blob_sha).monospace().size(11.5),
&a.blob_sha,
);
ui.end_row();
if let Some(st) = b.statement(a.id) {
k(ui, "Statement");
ui.add(
egui::Label::new(format!(
"{}, {} – {}",
b.account_label(st.account_id),
fmt::date(st.period_start),
fmt::date(st.period_end)
))
.wrap(),
);
ui.end_row();
k(ui, "Opening");
ui.label(fmt::dollars(st.opening_balance));
ui.end_row();
k(ui, "Closing");
ui.label(
RichText::new(fmt::dollars(st.closing_balance))
.font(theme::semibold(13.5)),
);
ui.end_row();
}
});
ui.add_space(10.0);
let mut go = None;
links_section(ui, b, a, &mut go);
if let Some(r) = go {
shell.go(r);
}
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,
);
}
});
}
/// "Save a copy" and "Open" (plan 3.6): a copy in the Downloads folder,
/// never over a file there, opened with the system's app on "Open". The
/// books are only read. What came of it shows under the buttons.
fn file_actions(ui: &mut Ui, b: &Books, a: &Attachment) {
let p = pal(ui.ctx());
let said = ui.id().with(("file-said", a.id));
ui.horizontal(|ui| {
let save = ui
.button(format!("{} Save a copy", icon::FILE_ARROW_UP))
.on_hover_text("A copy in your Downloads folder");
let open = ui
.button(format!("{} Open", icon::ARROW_SQUARE_OUT))
.on_hover_text("A copy in your Downloads folder, opened with its app");
if save.clicked() || open.clicked() {
let result = save_copy(b, a, open.clicked());
ui.data_mut(|d| d.insert_temp(said, result));
}
});
if let Some(result) = ui.data(|d| d.get_temp::<Result<String, String>>(said)) {
let (text, color) = match result {
Ok(path) => (format!("Saved {path}"), p.muted),
Err(why) => (format!("Couldn't save a copy: {why}"), p.warning),
};
ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap());
}
}
/// A copy of `a` in the Downloads folder (opened if `open`): its path.
fn save_copy(b: &Books, a: &Attachment, open: bool) -> Result<String, String> {
let bytes = b.attachment_bytes(a)?;
let (stem, ext) = crate::export::split_name(&a.file_name);
let path = crate::export::save(stem, ext, &bytes)?;
if open {
crate::export::open(&path);
}
Ok(path.display().to_string())
}
/// The icon for a subject's kind.
pub fn subject_icon(s: Subject) -> &'static str {
match s {
Subject::Entry(_) => icon::RECEIPT,
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,
Subject::Schedule(_) => icon::CALENDAR_CHECK,
Subject::Reconciliation(_) => icon::CHECK_SQUARE,
Subject::Company => icon::BUILDINGS,
}
}
/// A small chip naming a subject, which opens it (see
/// [`Books::subject_route`]) when it has somewhere to go.
pub fn subject_chip(ui: &mut Ui, b: &Books, s: Subject, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let route = b.subject_route(s);
// A reconciliation's chip names its date before anything is cut.
let mut label = match s {
Subject::Reconciliation(r) => b.reconciliation(r).map_or_else(
|| b.subject_label(s),
|r| {
format!(
"Reconciliation, {} through {}",
b.account_name(r.account_id),
fmt::date(r.statement_date)
)
},
),
_ => b.subject_label(s),
};
if label.chars().count() > 60 {
label = label.chars().take(59).collect::<String>() + "…";
}
let r = ui.add(
egui::Button::new(
RichText::new(format!("{} {label}", subject_icon(s)))
.font(theme::regular(11.5))
.color(if route.is_some() { p.accent } else { p.muted }),
)
.fill(if route.is_some() {
p.accent_soft
} else {
p.hover
})
.stroke(Stroke::NONE)
.corner_radius(CornerRadius::same(255))
.min_size(vec2(0.0, 22.0)),
);
if let Some(to) = route
&& r.on_hover_text(format!("Open {}", b.subject_label(s)))
.clicked()
{
*go = Some(to);
}
}
/// The compact list of the documents linked to `subjects`: each file's
/// name (opening its document page), category and description, and the
/// other places it is linked as chips that open them.
///
/// ```text
/// GOAL: every subject's page shows the files that are evidence of it,
/// and where else each one is evidence.
/// SOURCE: docs/parties-and-attachments.md (Attachments, Viewer).
/// INVARIANTS:
/// 1. Lists exactly Books::documents_for(subjects), in that order.
/// 2. A file's chips are links_of(file) less `here` (the page's own
/// subject; [`documents_card`] passes `subjects`).
/// 3. Empty: `empty` in muted text (nothing else).
/// ```
pub fn documents_card_with(
ui: &mut Ui,
b: &Books,
title: &str,
subjects: &[Subject],
here: &[Subject],
empty: &str,
go: &mut Option<Route>,
) {
let p = pal(ui.ctx());
let docs = b.documents_for(subjects);
widgets::titled_card(
ui,
title,
|ui| {
if !docs.is_empty() {
widgets::pill(ui, &docs.len().to_string(), p.muted, p.hover);
}
},
|ui| {
if docs.is_empty() {
ui.add(egui::Label::new(RichText::new(empty).color(p.muted)).wrap());
return;
}
for (i, d) in docs.iter().enumerate() {
if i > 0 {
ui.add_space(4.0);
widgets::rule(ui);
ui.add_space(4.0);
}
document_row(ui, b, d, here, go);
}
},
);
}
/// [`documents_card_with`], the chips leaving out `subjects` themselves.
pub fn documents_card(
ui: &mut Ui,
b: &Books,
title: &str,
subjects: &[Subject],
empty: &str,
go: &mut Option<Route>,
) {
documents_card_with(ui, b, title, subjects, subjects, empty, go);
}
/// One file in a [`documents_card_with`].
fn document_row(ui: &mut Ui, b: &Books, d: &Attachment, here: &[Subject], go: &mut Option<Route>) {
let p = pal(ui.ctx());
ui.horizontal_top(|ui| {
ui.label(RichText::new(file_icon(d)).size(18.0).color(p.muted));
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if widgets::link_truncated(ui, RichText::new(&d.file_name).font(theme::semibold(13.0)))
.on_hover_text("Open this document")
.clicked()
{
*go = Some(Route::Document(d.id));
}
let mut sub = vec![];
match b.statement(d.id) {
None => sub.push(fmt::label(&d.category)),
Some(st) => sub.push(format!(
"Statement {} – {}, closing {}",
fmt::date_short(st.period_start),
fmt::date(st.period_end),
fmt::dollars(st.closing_balance)
)),
}
if let Some(desc) = &d.description {
sub.push(desc.clone());
}
ui.add(
egui::Label::new(
RichText::new(sub.join(" · "))
.font(theme::regular(11.5))
.color(p.muted),
)
.wrap(),
);
let elsewhere: Vec<Subject> = b
.links_of(d.id)
.iter()
.copied()
.filter(|s| !here.contains(s))
.collect();
if !elsewhere.is_empty() {
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing = vec2(4.0, 4.0);
ui.label(
RichText::new("Also on")
.font(theme::regular(11.5))
.color(p.faint),
);
for s in elsewhere {
subject_chip(ui, b, s, go);
}
});
}
});
});
}
/// A PDF or an image.
pub fn file_icon(a: &Attachment) -> &'static str {
if a.mime_type == "application/pdf" {
icon::FILE_PDF
} else if a.mime_type.starts_with("image/") {
icon::FILE_IMAGE
} else {
icon::FILE
}
}
/// Everything a document is linked to: a chip per subject (an entry's
/// with its date and amount in the label), opening it; and, for a
/// statement, the reconciliations that used it.
fn links_section(ui: &mut Ui, b: &Books, a: &Attachment, go: &mut Option<Route>) {
let p = pal(ui.ctx());
let links = b.links_of(a.id);
ui.label(
RichText::new("LINKED TO")
.font(theme::semibold(11.5))
.color(p.faint),
);
ui.add_space(4.0);
if links.is_empty() {
ui.label(
RichText::new("Nothing yet: filed on its own.")
.font(theme::regular(12.5))
.color(p.muted),
);
}
for s in links {
ui.horizontal(|ui| {
subject_chip(ui, b, *s, go);
if let Subject::Entry(e) = s
&& let Some(x) = b.entry(*e)
{
ui.label(
RichText::new(format!(
"{} · {}",
fmt::date(x.date),
fmt::dollars_or(x.amount)
))
.font(theme::regular(11.5))
.color(p.muted),
);
}
if let Subject::Reconciliation(r) = s
&& let Some(r) = b.reconciliation(*r)
&& let Some(why) = &r.voided
{
ui.label(
RichText::new(format!("voided: {why}"))
.font(theme::regular(11.5))
.color(p.warning),
);
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{Build, d, find, render};
/// The list groups by category and offers one subject's files (plan P6
/// step 6).
#[test]
fn documents_group_by_category_and_subject() {
let mut b = Build::new("documents-groups");
let (cash, capital) = (b.cash, b.capital);
let e = b.post(d(2026, 1, 2), cash, capital, 100_000);
let (accounting, _, _) =
b.s.file_document(
"accounting",
"ledger.pdf",
"application/pdf",
b"%PDF ledger",
None,
)
.unwrap();
b.s.link_attachment(accounting, ledger_store::Subject::Entry(e))
.unwrap();
b.s.file_document(
"notices",
"notice.pdf",
"application/pdf",
b"%PDF notice",
None,
)
.unwrap();
let (_dir, books) = b.books();
let (texts, _) = render(books, Route::Documents, |_| {});
for group in ["Receipts", "Accounting", "Notices"] {
find(&texts, group);
}
find(&texts, "Any subject");
}
/// A document's page lists what it is linked to: an entry with its date
/// and amount.
#[test]
fn a_document_lists_the_entry_it_is_linked_to() {
let mut b = Build::new("document-links");
let (cash, capital) = (b.cash, b.capital);
let e = b.post(d(2026, 1, 2), cash, capital, 100_000);
let (att, _, _) =
b.s.file_document("other", "note.pdf", "application/pdf", b"%PDF note", None)
.unwrap();
b.s.link_attachment(att, ledger_store::Subject::Entry(e))
.unwrap();
let (_dir, books) = b.books();
let (texts, _) = render(books, Route::Document(att), |_| {});
find(&texts, "LINKED TO");
find(&texts, "Jan 2, 2026 \u{b7} $1,000.00");
}
}