envelope_cli/tui/dialogs/
adjustment.rs

1//! Adjustment transaction dialog
2//!
3//! Dialog to confirm creating an adjustment transaction during reconciliation.
4
5use ratatui::{
6    layout::{Constraint, Direction, Layout},
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, Borders, Clear, Paragraph},
10    Frame,
11};
12
13use crate::models::{CategoryId, Money};
14use crate::tui::app::App;
15use crate::tui::layout::centered_rect_fixed;
16
17/// State for the adjustment dialog
18#[derive(Debug, Clone, Default)]
19pub struct AdjustmentDialogState {
20    /// The adjustment amount needed
21    pub adjustment_amount: Money,
22    /// Category input for the adjustment
23    pub category_input: String,
24    /// Selected category ID
25    pub selected_category: Option<CategoryId>,
26    /// List of categories for selection
27    pub categories: Vec<(CategoryId, String)>,
28    /// Selected category index
29    pub selected_index: usize,
30    /// Whether in category selection mode
31    pub selecting_category: bool,
32}
33
34impl AdjustmentDialogState {
35    pub fn new(adjustment_amount: Money) -> Self {
36        Self {
37            adjustment_amount,
38            category_input: String::new(),
39            selected_category: None,
40            categories: Vec::new(),
41            selected_index: 0,
42            selecting_category: false,
43        }
44    }
45
46    /// Load categories from storage
47    pub fn load_categories(&mut self, categories: Vec<(CategoryId, String)>) {
48        self.categories = categories;
49    }
50
51    /// Move selection up
52    pub fn move_up(&mut self) {
53        if self.selecting_category && self.selected_index > 0 {
54            self.selected_index -= 1;
55        }
56    }
57
58    /// Move selection down
59    pub fn move_down(&mut self) {
60        if self.selecting_category && self.selected_index < self.categories.len().saturating_sub(1)
61        {
62            self.selected_index += 1;
63        }
64    }
65
66    /// Get filtered categories based on input
67    pub fn filtered_categories(&self) -> Vec<&(CategoryId, String)> {
68        if self.category_input.is_empty() {
69            self.categories.iter().collect()
70        } else {
71            let search = self.category_input.to_lowercase();
72            self.categories
73                .iter()
74                .filter(|(_, name)| name.to_lowercase().contains(&search))
75                .collect()
76        }
77    }
78
79    /// Select the current category
80    pub fn select_current(&mut self) {
81        // Clone to avoid borrow issues
82        let selection: Option<(CategoryId, String)> = {
83            let filtered = self.filtered_categories();
84            filtered
85                .get(self.selected_index)
86                .map(|(id, name)| (*id, name.clone()))
87        };
88
89        if let Some((id, name)) = selection {
90            self.selected_category = Some(id);
91            self.category_input = name;
92            self.selecting_category = false;
93        }
94    }
95}
96
97/// Render the adjustment dialog
98pub fn render(frame: &mut Frame, app: &App) {
99    let area = centered_rect_fixed(60, 14, frame.area());
100
101    // Clear the background
102    frame.render_widget(Clear, area);
103
104    let state = &app.adjustment_dialog_state;
105
106    let block = Block::default()
107        .title(" Create Adjustment Transaction ")
108        .title_style(
109            Style::default()
110                .fg(Color::Yellow)
111                .add_modifier(Modifier::BOLD),
112        )
113        .borders(Borders::ALL)
114        .border_style(Style::default().fg(Color::Yellow));
115
116    let inner = block.inner(area);
117    frame.render_widget(block, area);
118
119    let chunks = Layout::default()
120        .direction(Direction::Vertical)
121        .constraints([
122            Constraint::Length(1), // Spacer
123            Constraint::Length(2), // Message
124            Constraint::Length(2), // Amount
125            Constraint::Length(2), // Category input
126            Constraint::Length(3), // Category list (if selecting)
127            Constraint::Length(2), // Instructions
128        ])
129        .split(inner);
130
131    // Message
132    let message = Paragraph::new(Line::from(vec![Span::styled(
133        "Your cleared balance doesn't match the statement balance.",
134        Style::default().fg(Color::White),
135    )]));
136    frame.render_widget(message, chunks[1]);
137
138    // Amount
139    let amount_style = if state.adjustment_amount.is_negative() {
140        Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
141    } else {
142        Style::default()
143            .fg(Color::Green)
144            .add_modifier(Modifier::BOLD)
145    };
146
147    let amount_text = Paragraph::new(Line::from(vec![
148        Span::styled("Adjustment needed: ", Style::default().fg(Color::Yellow)),
149        Span::styled(format!("{}", state.adjustment_amount), amount_style),
150    ]));
151    frame.render_widget(amount_text, chunks[2]);
152
153    // Category input
154    let category_style = if state.selecting_category {
155        Style::default()
156            .fg(Color::Yellow)
157            .add_modifier(Modifier::BOLD)
158    } else {
159        Style::default().fg(Color::White)
160    };
161
162    let category_display = if state.category_input.is_empty() {
163        "(Optional) Enter category...".to_string()
164    } else {
165        state.category_input.clone()
166    };
167
168    let category_text = Paragraph::new(Line::from(vec![
169        Span::styled("Category: ", Style::default().fg(Color::Yellow)),
170        Span::styled(category_display, category_style),
171    ]));
172    frame.render_widget(category_text, chunks[3]);
173
174    // Category selection list (if selecting)
175    if state.selecting_category {
176        let filtered = state.filtered_categories();
177        let items: Vec<Line> = filtered
178            .iter()
179            .enumerate()
180            .take(3)
181            .map(|(i, (_, name))| {
182                let style = if i == state.selected_index {
183                    Style::default().bg(Color::DarkGray).fg(Color::White)
184                } else {
185                    Style::default().fg(Color::Yellow)
186                };
187                Line::from(Span::styled(format!("  {}", name), style))
188            })
189            .collect();
190
191        let list = Paragraph::new(items);
192        frame.render_widget(list, chunks[4]);
193    }
194
195    // Instructions
196    let instructions = Paragraph::new(Line::from(vec![
197        Span::styled("[Enter]", Style::default().fg(Color::Green)),
198        Span::raw(" Create  "),
199        Span::styled("[Tab]", Style::default().fg(Color::Cyan)),
200        Span::raw(" Select category  "),
201        Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
202        Span::raw(" Cancel"),
203    ]));
204    frame.render_widget(instructions, chunks[5]);
205}
206
207/// Handle key input for the adjustment dialog
208pub fn handle_key(app: &mut App, key: crossterm::event::KeyCode) -> bool {
209    use crossterm::event::KeyCode;
210
211    let state = &mut app.adjustment_dialog_state;
212
213    match key {
214        KeyCode::Tab => {
215            state.selecting_category = !state.selecting_category;
216            true
217        }
218        KeyCode::Up | KeyCode::Char('k') if state.selecting_category => {
219            state.move_up();
220            true
221        }
222        KeyCode::Down | KeyCode::Char('j') if state.selecting_category => {
223            state.move_down();
224            true
225        }
226        KeyCode::Enter if state.selecting_category => {
227            state.select_current();
228            true
229        }
230        KeyCode::Char(c) if !state.selecting_category => {
231            state.category_input.push(c);
232            state.selecting_category = true;
233            state.selected_index = 0;
234            true
235        }
236        KeyCode::Backspace if !state.selecting_category => {
237            state.category_input.pop();
238            true
239        }
240        _ => false,
241    }
242}