Attachments, statements, reconciliations, TIN vault, counterparty policy
check / check (push) Canceled after 0s

MCP: books.link_attachment, unlink_attachment, attachments; link_to on
attach_receipt and file_document; record_statement, statements;
books.reconcile previews and, confirmed against a recorded statement,
records the reconciliation; void_reconciliation, reconciliations;
import_bank_csv links its source file; digest lists unreconciled
accounts.

TIN vault: books.set_w9 takes the TIN and the form into the encrypted
vault (last four in the books); books.vendor_1099 include_tins and
save_csv_to for the CPA's copy; books.get_w9; books.purge_tin. A leak
test scans every tool output, the journal, the database, and the blobs.

[books] parties = off | suggest | require (default suggest): every
preview names the entry's party or suggests likely ones; require refuses
to post until it names one or is marked as having none (entry.no_party).

Viewer: documents on every subject page; statements and reconciliation
history per account; reconciliation pages; reconcile and
no-counterparty attention items; party money by period (paid out,
received in, net, what for, by month); Needs attention gets its own
full-width section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-15 15:42:37 -06:00
1 parent e265ed4339
commit ae927c3f73
41 files changed
+8463 -465

No files matched your search

+102 -58
View File
@@ -21,28 +21,28 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
widgets::page_header(ui, b.company_name(), &headline(b));
kpis(ui, b, shell);
let _ = k;
columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i {
0 => {
let (more, _) = widgets::titled_card(
// What needs doing gets the page's full width, under the numbers:
// long items wrap in their own room instead of squeezing the chart.
attention(ui, b, shell);
ui.add_space(16.0);
let (more, _) = widgets::titled_card(
ui,
"Cash over time",
|ui| {
widgets::link(
ui,
"Cash over time",
|ui| {
widgets::link(
ui,
RichText::new("Cash details")
.font(theme::regular(12.5))
.color(pal(ui.ctx()).accent),
)
.clicked()
},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
);
if more {
shell.go(Route::Cash);
}
}
_ => attention(ui, b, shell),
});
RichText::new("Cash details")
.font(theme::regular(12.5))
.color(pal(ui.ctx()).accent),
)
.clicked()
},
|ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0),
);
if more {
shell.go(Route::Cash);
}
ui.add_space(16.0);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => income_vs_spending(ui, b, shell),
_ => where_it_went(ui, b, shell),
@@ -253,33 +253,52 @@ fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 {
}
}
/// "Needs attention", full width: a summary of how many problems, to-dos,
/// and notes there are, then each item as a row (its severity, the title
/// that opens where to look, what's wrong, and the prompt for the agent at
/// the right; stacked under the text when narrow). The first few show;
/// the rest wait behind a button that says how many.
fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let count = |s: Severity| b.attention.iter().filter(|a| a.severity == s).count();
widgets::titled_card(
ui,
"Needs attention",
|ui| {
if !b.attention.is_empty() {
widgets::pill(
ui,
&b.attention.len().to_string(),
p.warning,
p.warning_soft,
);
// Laid out right to left: problems end up first, reading on.
for (sev, one, many, fg, bg) in [
(Severity::Info, "note", "notes", p.muted, p.hover),
(Severity::Todo, "to do", "to do", p.warning, p.warning_soft),
(
Severity::Problem,
"problem",
"problems",
p.negative,
p.negative_soft,
),
] {
let n = count(sev);
if n > 0 {
widgets::pill(ui, &fmt::count(n, one, many), fg, bg);
}
}
},
|ui| {
if b.attention.is_empty() {
widgets::empty(
ui,
icon::CHECK_CIRCLE,
"All clear",
"Nothing is missing or overdue.",
);
ui.horizontal(|ui| {
ui.label(
RichText::new(icon::CHECK_CIRCLE)
.size(18.0)
.color(p.positive),
);
ui.label(
RichText::new("All clear: nothing is missing or overdue.")
.font(theme::regular(13.5))
.color(p.muted),
);
});
return;
}
// No inner scrolling: the card grows to fit. Past a handful of
// items, the rest sit behind a button that says how many.
const FIRST: usize = 6;
let id = ui.id().with("attention-expanded");
let mut expanded: bool = ui.ctx().data(|d| d.get_temp(id)).unwrap_or(false);
@@ -288,34 +307,59 @@ fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
} else {
b.attention.len().min(FIRST)
};
let wide = ui.available_width() >= 720.0;
for (i, a) in b.attention.iter().take(shown).enumerate() {
if i > 0 {
ui.add_space(4.0);
ui.add_space(6.0);
widgets::rule(ui);
ui.add_space(4.0);
ui.add_space(6.0);
}
let (glyph, color) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning),
Severity::Info => (icon::INFO, p.muted),
let (glyph, color, word) = match a.severity {
Severity::Problem => (icon::WARNING_CIRCLE, p.negative, "Problem"),
Severity::Todo => (icon::CIRCLE_DASHED, p.warning, "To do"),
Severity::Info => (icon::INFO, p.muted, "Note"),
};
let full = ui.available_width();
let button_w = if wide { 150.0 } else { 0.0 };
ui.horizontal_top(|ui| {
ui.label(RichText::new(glyph).size(16.0).color(color));
ui.vertical(|ui| {
let t = RichText::new(&a.title).font(theme::semibold(13.0));
let r = widgets::link(ui, t);
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.label(
RichText::new(&a.detail)
.font(theme::regular(12.0))
.color(p.muted),
);
widgets::copy_prompt(ui, &a.prompt);
});
ui.label(RichText::new(glyph).size(18.0).color(color))
.on_hover_text(word);
let text_w = (full - 30.0 - button_w).max(120.0);
ui.allocate_ui_with_layout(
egui::vec2(text_w, 0.0),
egui::Layout::top_down(egui::Align::Min),
|ui| {
ui.set_width(text_w);
ui.spacing_mut().item_spacing.y = 2.0;
let t = RichText::new(&a.title).font(theme::semibold(13.5));
let r = ui.add(egui::Label::new(t).wrap().sense(egui::Sense::click()));
if a.target.is_some() && r.hovered() {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if r.clicked()
&& let Some(to) = &a.target
{
shell.go(to.clone());
}
ui.add(
egui::Label::new(
RichText::new(&a.detail)
.font(theme::regular(12.5))
.color(p.muted),
)
.wrap(),
);
if !wide {
ui.add_space(4.0);
widgets::copy_prompt(ui, &a.prompt);
}
},
);
if wide {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| {
widgets::copy_prompt(ui, &a.prompt);
});
}
});
}
let hidden = b.attention.len() - shown;