Files
wanderbooks/crates/ledger-gui/src/pages/documents.rs
T
swaitsandClaude Opus 5.5 eba85f9c77
check / check (push) Waiting to run
viewer: click to copy values, amounts, and identifiers
Company details (EIN, state number, address, every profile value), an
entry's number and amount, an account's code and balance, a document's
file name and SHA-256 copy on click: a pointer and copy icon on hover,
a check for a moment after. Amounts copy as their exact plain decimal
(fmt::plain: no "$", commas, or parentheses; "-" for negatives); where a
click already drills down (report cells, register and transaction rows,
general ledger lines), a right-click menu copies the amounts, the entry
number, or the description. The sidebar's chain check copies the
journal head and data folder.

tests/copy.rs: every display style copies back to the exact value
(property), and clicking the EIN, an entry's amount, and its number puts
exactly that on the clipboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
2026-10-14 15:38:05 -06:00

311 lines
11 KiB
Rust

//! 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(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());
}
}
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;
}
// 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;
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);
}
});
});
widgets::copyable(
ui,
RichText::new(&a.file_name).font(theme::bold(20.0)),
&a.file_name,
);
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(
ui,
RichText::new(&a.blob_sha).monospace().size(11.5),
&a.blob_sha,
);
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_truncated(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,
);
}
});
}