envelope_cli/tui/views/
register.rs

1//! Transaction register view
2//!
3//! Shows transactions for the selected account
4
5use ratatui::{
6    layout::Rect,
7    style::{Color, Modifier, Style},
8    widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState},
9    Frame,
10};
11
12use crate::models::TransactionStatus;
13use crate::tui::app::{App, FocusedPanel};
14use crate::tui::layout::MainPanelLayout;
15
16/// Render the transaction register
17pub fn render(frame: &mut Frame, app: &mut App, area: Rect) {
18    let layout = MainPanelLayout::new(area);
19
20    // Render header with account name
21    render_header(frame, app, layout.header);
22
23    // Render transaction table
24    render_transaction_table(frame, app, layout.content);
25}
26
27/// Render register header
28fn render_header(frame: &mut Frame, app: &mut App, area: Rect) {
29    let account_name = if let Some(account_id) = app.selected_account {
30        app.storage
31            .accounts
32            .get(account_id)
33            .ok()
34            .flatten()
35            .map(|a| a.name.clone())
36            .unwrap_or_else(|| "Unknown".to_string())
37    } else {
38        "No Account Selected".to_string()
39    };
40
41    let title = format!(" {} - Transactions ", account_name);
42    let block = Block::default()
43        .title(title)
44        .title_style(
45            Style::default()
46                .fg(Color::Cyan)
47                .add_modifier(Modifier::BOLD),
48        )
49        .borders(Borders::ALL)
50        .border_style(Style::default().fg(Color::White));
51
52    let hints = if app.multi_select_mode {
53        "Multi-select: SPACE to select, C to categorize, v to exit"
54    } else {
55        "a:Add  e:Edit  c:Clear  v:Multi-select"
56    };
57
58    let paragraph = Paragraph::new(hints)
59        .block(block)
60        .style(Style::default().fg(Color::Yellow));
61
62    frame.render_widget(paragraph, area);
63}
64
65/// Render transaction table
66fn render_transaction_table(frame: &mut Frame, app: &mut App, area: Rect) {
67    let is_focused = app.focused_panel == FocusedPanel::Main;
68    let border_color = if is_focused { Color::Cyan } else { Color::Gray };
69
70    let block = Block::default()
71        .borders(Borders::ALL)
72        .border_style(Style::default().fg(border_color));
73
74    // Get transactions for selected account
75    let transactions = if let Some(account_id) = app.selected_account {
76        let mut txns = app
77            .storage
78            .transactions
79            .get_by_account(account_id)
80            .unwrap_or_default();
81        // Sort by date descending
82        txns.sort_by(|a, b| b.date.cmp(&a.date));
83        txns
84    } else {
85        Vec::new()
86    };
87
88    if transactions.is_empty() {
89        let text = Paragraph::new("No transactions. Press 'a' to add one.")
90            .block(block)
91            .style(Style::default().fg(Color::Yellow));
92        frame.render_widget(text, area);
93        return;
94    }
95
96    // Define column widths
97    let widths = [
98        ratatui::layout::Constraint::Length(2),  // Status
99        ratatui::layout::Constraint::Length(12), // Date
100        ratatui::layout::Constraint::Length(20), // Payee
101        ratatui::layout::Constraint::Length(15), // Category
102        ratatui::layout::Constraint::Length(12), // Amount
103        ratatui::layout::Constraint::Min(10),    // Memo
104    ];
105
106    // Header row
107    let header = Row::new(vec![
108        Cell::from(""),
109        Cell::from("Date").style(Style::default().add_modifier(Modifier::BOLD)),
110        Cell::from("Payee").style(Style::default().add_modifier(Modifier::BOLD)),
111        Cell::from("Category").style(Style::default().add_modifier(Modifier::BOLD)),
112        Cell::from("Amount").style(Style::default().add_modifier(Modifier::BOLD)),
113        Cell::from("Memo").style(Style::default().add_modifier(Modifier::BOLD)),
114    ])
115    .style(Style::default().fg(Color::Yellow))
116    .height(1);
117
118    // Get categories for lookup
119    let categories = app
120        .storage
121        .categories
122        .get_all_categories()
123        .unwrap_or_default();
124
125    // Data rows
126    let rows: Vec<Row> = transactions
127        .iter()
128        .map(|txn| {
129            // Status indicator
130            let status_indicator = match txn.status {
131                TransactionStatus::Pending => "○",
132                TransactionStatus::Cleared => "✓",
133                TransactionStatus::Reconciled => "🔒",
134            };
135            let status_color = match txn.status {
136                TransactionStatus::Pending => Color::Yellow,
137                TransactionStatus::Cleared => Color::Green,
138                TransactionStatus::Reconciled => Color::Blue,
139            };
140
141            // Multi-select indicator
142            let is_selected = app.selected_transactions.contains(&txn.id);
143            let select_indicator = if app.multi_select_mode {
144                if is_selected {
145                    "■ "
146                } else {
147                    "□ "
148                }
149            } else {
150                ""
151            };
152
153            // Category name
154            let category_name = if txn.is_split() {
155                "Split".to_string()
156            } else if txn.is_transfer() {
157                "Transfer".to_string()
158            } else if let Some(cat_id) = txn.category_id {
159                categories
160                    .iter()
161                    .find(|c| c.id == cat_id)
162                    .map(|c| c.name.clone())
163                    .unwrap_or_else(|| "Unknown".to_string())
164            } else {
165                "-".to_string()
166            };
167
168            // Amount styling
169            let amount_style = if txn.amount.is_negative() {
170                Style::default().fg(Color::Red)
171            } else {
172                Style::default().fg(Color::Green)
173            };
174
175            Row::new(vec![
176                Cell::from(format!("{}{}", select_indicator, status_indicator))
177                    .style(Style::default().fg(status_color)),
178                Cell::from(txn.date.format("%Y-%m-%d").to_string()),
179                Cell::from(truncate_string(&txn.payee_name, 20)),
180                Cell::from(truncate_string(&category_name, 15)),
181                Cell::from(format!("{}", txn.amount)).style(amount_style),
182                Cell::from(truncate_string(&txn.memo, 30)),
183            ])
184        })
185        .collect();
186
187    let table = Table::new(rows, widths)
188        .header(header)
189        .block(block)
190        .highlight_style(
191            Style::default()
192                .bg(Color::DarkGray)
193                .add_modifier(Modifier::BOLD),
194        )
195        .highlight_symbol("▶ ");
196
197    let mut state = TableState::default();
198    state.select(Some(app.selected_transaction_index));
199
200    frame.render_stateful_widget(table, area, &mut state);
201}
202
203/// Truncate a string to a maximum length
204fn truncate_string(s: &str, max_len: usize) -> String {
205    if s.len() <= max_len {
206        s.to_string()
207    } else {
208        format!("{}…", &s[..max_len - 1])
209    }
210}