check / check (push) Canceled after 0s
- 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
208 lines
7.3 KiB
Rust
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());
|
|
}
|