Files
wanderbooks/crates/ledger-gui/src/pages/mod.rs
T
swaitsandClaude Opus 5.5 ee7e205f5c
check / check (push) Canceled after 0s
Fixed assets, fully featured: tags, serials, acquisition links, label sheets, the fixed asset register, and an Assets section in the viewer; fix a flaky verify
- core: asset tags (generated PREFIX-0001 or the human's own; unique ignoring case; never reused by another asset) and serial numbers (rules.md B-24); a journaled link from an asset to the entry that acquired it (posted, unreversed, debiting the cost account); fixed_asset_register(as_of), reconciled to the GL (unposted depreciation, unregistered cost)
- store: asset.tagged, asset.serial_set, asset.acquisition_linked journaled, replayed, projected (schema v9); differs_from now also compares control roles and tag history
- config: [books] asset_tag_prefix, default the company's initials without legal-form words
- import: Avery 5160/8160 label sheets with QR codes (geometry vendored from Avery, docs/spec/sources/avery-5160.md); the register as XLSX and PDF
- mcp: register_asset tags (and links) automatically; books.label_asset, books.asset_labels; asset tools take a tag; books.show asset / assets page / register report; export fixed_asset_register (json, csv, xlsx, pdf) and in the CPA package
- viewer: Assets page (register, reconciliation), asset page (identity, schedule posted vs not, entries, documents), Fixed asset register report, global search by tag/serial/description, journal text, needs-attention items
- fix: verify compared snapshot amounts as strings; a zero total summed in hash-map order could print "0" or "0.00", so agreeing books failed verify at random. Now by value (regression test)
- tests: spec-blind suites for tags, register, prefix, store, MCP; label and register PDF/XLSX tests; viewer tests

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

208 lines
7.3 KiB
Rust

//! One module per screen.
pub mod accounts;
pub mod assets;
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)) {
// Pinned to the window: something too wide is clipped (and found in
// review) rather than widening the page past its edge, which would
// carry over to the next frame and push everything else off screen.
let width = ui.available_width();
let side: i8 = if width < 560.0 { 16 } else { 32 };
egui::ScrollArea::vertical()
.id_salt(id)
.max_width(width)
.auto_shrink(false)
.show(ui, |ui| {
egui::Frame::new()
.inner_margin(Margin {
left: side,
right: side,
top: 24,
bottom: 40,
})
.show(ui, |ui| {
ui.set_width((width - 2.0 * side as f32).max(0.0));
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::Assets => assets::show_register(ui, b, shell),
Route::Asset(id) => assets::show_asset(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);
}
const SPLIT_MIN_LIST: f32 = 380.0;
const SPLIT_MIN_DETAIL: f32 = 360.0;
/// Tiles in rows: as many columns as fit at `min_width` each (up to
/// `max`), evened out across the rows, the tiles stretched to fill. `add(i, ui)` fills tile `i`.
pub fn grid(
ui: &mut Ui,
n: usize,
min_width: f32,
max: usize,
mut add: impl FnMut(usize, &mut Ui),
) {
let fit = crate::widgets::fit_columns(ui.available_width(), min_width, 16.0, max);
// Balanced rows: six tiles that fit five across go three and three,
// not five and one.
let rows = n.max(1).div_ceil(fit);
let per_row = n.max(1).div_ceil(rows);
let idx: Vec<usize> = (0..n).collect();
for row in idx.chunks(per_row) {
columns(ui, 0.0, &vec![1.0; per_row], |i, ui| {
if let Some(&t) = row.get(i) {
add(t, ui);
}
});
}
}
/// A list on the left and a detail pane on the right, split by a
/// draggable divider. The split is remembered per `id`. Too narrow for
/// both, the detail takes the whole width (its close button goes back to
/// the list).
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();
if rect.width() < SPLIT_MIN_LIST + SPLIT_MIN_DETAIL {
let inner = rect.shrink2(egui::vec2(16.0, 16.0));
ui.painter().rect_filled(rect, 0.0, p.surface);
let mut r = ui.new_child(
egui::UiBuilder::new()
.max_rect(inner)
.layout(egui::Layout::top_down(egui::Align::Min)),
);
r.set_clip_rect(rect);
right(&mut r, state);
ui.allocate_rect(rect, egui::Sense::hover());
return;
}
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(SPLIT_MIN_DETAIL, rect.width() - SPLIT_MIN_LIST);
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());
}