envelope_cli/tui/dialogs/
bulk_categorize.rs

1//! Bulk categorize dialog
2//!
3//! Apply category to multiple selected transactions
4
5use ratatui::{
6    layout::{Constraint, Direction, Layout, Rect},
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
10    Frame,
11};
12
13use crate::models::CategoryId;
14use crate::services::CategoryService;
15use crate::tui::app::App;
16use crate::tui::layout::centered_rect;
17
18/// State for the bulk categorize dialog
19#[derive(Debug, Clone, Default)]
20pub struct BulkCategorizeState {
21    /// Selected category
22    pub selected_category: Option<CategoryId>,
23    /// Index in the category list
24    pub category_list_index: usize,
25    /// Search/filter input
26    pub search_input: String,
27    /// Search cursor position
28    pub search_cursor: usize,
29    /// Error message
30    pub error_message: Option<String>,
31    /// Success message
32    pub success_message: Option<String>,
33}
34
35impl BulkCategorizeState {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Reset the state
41    pub fn reset(&mut self) {
42        *self = Self::default();
43    }
44
45    /// Clear error message
46    pub fn clear_error(&mut self) {
47        self.error_message = None;
48    }
49
50    /// Set error message
51    pub fn set_error(&mut self, msg: impl Into<String>) {
52        self.error_message = Some(msg.into());
53        self.success_message = None;
54    }
55
56    /// Set success message
57    pub fn set_success(&mut self, msg: impl Into<String>) {
58        self.success_message = Some(msg.into());
59        self.error_message = None;
60    }
61
62    /// Insert character at cursor
63    pub fn insert_char(&mut self, c: char) {
64        self.search_input.insert(self.search_cursor, c);
65        self.search_cursor += 1;
66        // Reset selection when typing
67        self.category_list_index = 0;
68    }
69
70    /// Delete character before cursor
71    pub fn backspace(&mut self) {
72        if self.search_cursor > 0 {
73            self.search_cursor -= 1;
74            self.search_input.remove(self.search_cursor);
75            // Reset selection when typing
76            self.category_list_index = 0;
77        }
78    }
79
80    /// Clear search
81    pub fn clear_search(&mut self) {
82        self.search_input.clear();
83        self.search_cursor = 0;
84        self.category_list_index = 0;
85    }
86}
87
88/// Render the bulk categorize dialog
89pub fn render(frame: &mut Frame, app: &mut App) {
90    let area = centered_rect(55, 60, frame.area());
91
92    // Clear the background
93    frame.render_widget(Clear, area);
94
95    let count = app.selected_transactions.len();
96
97    let block = Block::default()
98        .title(format!(
99            " Categorize {} Transaction{} ",
100            count,
101            if count == 1 { "" } else { "s" }
102        ))
103        .title_style(
104            Style::default()
105                .fg(Color::Cyan)
106                .add_modifier(Modifier::BOLD),
107        )
108        .borders(Borders::ALL)
109        .border_style(Style::default().fg(Color::Cyan));
110
111    frame.render_widget(block, area);
112
113    // Inner area
114    let inner = Rect {
115        x: area.x + 2,
116        y: area.y + 1,
117        width: area.width.saturating_sub(4),
118        height: area.height.saturating_sub(2),
119    };
120
121    // Layout
122    let chunks = Layout::default()
123        .direction(Direction::Vertical)
124        .constraints([
125            Constraint::Length(1), // Search label
126            Constraint::Length(1), // Search input
127            Constraint::Length(1), // Spacer
128            Constraint::Min(6),    // Category list
129            Constraint::Length(1), // Spacer
130            Constraint::Length(1), // Error/success
131            Constraint::Length(1), // Hints
132        ])
133        .split(inner);
134
135    // Get categories
136    let category_service = CategoryService::new(app.storage);
137    let all_categories = category_service.list_categories().unwrap_or_default();
138
139    // Filter categories by search
140    let search = app.bulk_categorize_state.search_input.to_lowercase();
141    let filtered_categories: Vec<_> = all_categories
142        .iter()
143        .filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search))
144        .collect();
145
146    // Search input
147    render_search_field(
148        frame,
149        &app.bulk_categorize_state.search_input,
150        app.bulk_categorize_state.search_cursor,
151        chunks[0],
152        chunks[1],
153    );
154
155    // Category list
156    render_category_list(
157        frame,
158        &filtered_categories,
159        app.bulk_categorize_state.selected_category,
160        app.bulk_categorize_state.category_list_index,
161        chunks[3],
162    );
163
164    // Error/success message
165    if let Some(ref error) = app.bulk_categorize_state.error_message {
166        let error_line = Line::from(Span::styled(
167            error.as_str(),
168            Style::default().fg(Color::Red),
169        ));
170        frame.render_widget(Paragraph::new(error_line), chunks[5]);
171    } else if let Some(ref success) = app.bulk_categorize_state.success_message {
172        let success_line = Line::from(Span::styled(
173            success.as_str(),
174            Style::default().fg(Color::Green),
175        ));
176        frame.render_widget(Paragraph::new(success_line), chunks[5]);
177    }
178
179    // Hints
180    let hints = Line::from(vec![
181        Span::styled("[↑↓]", Style::default().fg(Color::Yellow)),
182        Span::raw(" Select  "),
183        Span::styled("[Enter]", Style::default().fg(Color::Green)),
184        Span::raw(" Apply  "),
185        Span::styled("[Esc]", Style::default().fg(Color::Red)),
186        Span::raw(" Cancel"),
187    ]);
188    frame.render_widget(Paragraph::new(hints), chunks[6]);
189}
190
191/// Render the search field
192fn render_search_field(
193    frame: &mut Frame,
194    search: &str,
195    cursor: usize,
196    label_area: Rect,
197    input_area: Rect,
198) {
199    // Label
200    let label = Line::from(Span::styled(
201        "Search categories:",
202        Style::default().fg(Color::Cyan),
203    ));
204    frame.render_widget(Paragraph::new(label), label_area);
205
206    // Input with cursor
207    let mut spans = vec![Span::raw("  ")];
208
209    let cursor_pos = cursor.min(search.len());
210    let (before, after) = search.split_at(cursor_pos);
211
212    spans.push(Span::styled(
213        before.to_string(),
214        Style::default().fg(Color::White),
215    ));
216
217    let cursor_char = after.chars().next().unwrap_or(' ');
218    spans.push(Span::styled(
219        cursor_char.to_string(),
220        Style::default().fg(Color::Black).bg(Color::Cyan),
221    ));
222
223    if after.len() > 1 {
224        spans.push(Span::styled(
225            after[1..].to_string(),
226            Style::default().fg(Color::White),
227        ));
228    }
229
230    if search.is_empty() {
231        spans.push(Span::styled(
232            " (type to filter)",
233            Style::default().fg(Color::Yellow),
234        ));
235    }
236
237    frame.render_widget(Paragraph::new(Line::from(spans)), input_area);
238}
239
240/// Render the category list
241fn render_category_list(
242    frame: &mut Frame,
243    categories: &[&crate::models::Category],
244    selected: Option<CategoryId>,
245    list_index: usize,
246    area: Rect,
247) {
248    if categories.is_empty() {
249        let text =
250            Paragraph::new("No matching categories").style(Style::default().fg(Color::Yellow));
251        frame.render_widget(text, area);
252        return;
253    }
254
255    let items: Vec<ListItem> = categories
256        .iter()
257        .map(|cat| {
258            let style = if Some(cat.id) == selected {
259                Style::default().fg(Color::Green)
260            } else {
261                Style::default().fg(Color::White)
262            };
263            ListItem::new(Line::from(Span::styled(format!("  {}", cat.name), style)))
264        })
265        .collect();
266
267    let list = List::new(items)
268        .highlight_style(
269            Style::default()
270                .bg(Color::DarkGray)
271                .add_modifier(Modifier::BOLD),
272        )
273        .highlight_symbol("▶ ");
274
275    let mut state = ListState::default();
276    state.select(Some(list_index.min(categories.len().saturating_sub(1))));
277
278    frame.render_stateful_widget(list, area, &mut state);
279}
280
281/// Handle key events for the bulk categorize dialog
282pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
283    use crossterm::event::KeyCode;
284
285    // Get filtered categories for index bounds
286    let category_service = CategoryService::new(app.storage);
287    let all_categories = category_service.list_categories().unwrap_or_default();
288    let search = app.bulk_categorize_state.search_input.to_lowercase();
289    let filtered: Vec<_> = all_categories
290        .iter()
291        .filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search))
292        .collect();
293    let cat_count = filtered.len();
294
295    match key.code {
296        KeyCode::Esc => {
297            app.bulk_categorize_state.reset();
298            app.close_dialog();
299            return true;
300        }
301
302        KeyCode::Enter => {
303            // Apply the selected category
304            if cat_count > 0 {
305                let idx = app
306                    .bulk_categorize_state
307                    .category_list_index
308                    .min(cat_count.saturating_sub(1));
309                if let Some(cat) = filtered.get(idx) {
310                    execute_bulk_categorize(app, cat.id);
311                }
312            } else {
313                app.bulk_categorize_state.set_error("No category selected");
314            }
315            return true;
316        }
317
318        KeyCode::Up | KeyCode::Char('k') => {
319            if app.bulk_categorize_state.category_list_index > 0 {
320                app.bulk_categorize_state.category_list_index -= 1;
321            }
322            return true;
323        }
324
325        KeyCode::Down | KeyCode::Char('j') => {
326            if app.bulk_categorize_state.category_list_index < cat_count.saturating_sub(1) {
327                app.bulk_categorize_state.category_list_index += 1;
328            }
329            return true;
330        }
331
332        KeyCode::Char(c) => {
333            app.bulk_categorize_state.clear_error();
334            app.bulk_categorize_state.insert_char(c);
335            return true;
336        }
337
338        KeyCode::Backspace => {
339            app.bulk_categorize_state.clear_error();
340            app.bulk_categorize_state.backspace();
341            return true;
342        }
343
344        KeyCode::Delete => {
345            app.bulk_categorize_state.clear_search();
346            return true;
347        }
348
349        _ => {}
350    }
351
352    false
353}
354
355/// Execute the bulk categorize operation
356fn execute_bulk_categorize(app: &mut App, category_id: CategoryId) {
357    let transaction_ids = app.selected_transactions.clone();
358
359    if transaction_ids.is_empty() {
360        app.bulk_categorize_state
361            .set_error("No transactions selected");
362        return;
363    }
364
365    let mut success_count = 0;
366    let mut error_count = 0;
367
368    for txn_id in &transaction_ids {
369        match app.storage.transactions.get(*txn_id) {
370            Ok(Some(mut txn)) => {
371                // Skip transfers (they shouldn't be categorized)
372                if txn.is_transfer() {
373                    continue;
374                }
375
376                // Update category
377                txn.category_id = Some(category_id);
378                txn.updated_at = chrono::Utc::now();
379
380                if app.storage.transactions.upsert(txn).is_ok() {
381                    success_count += 1;
382                } else {
383                    error_count += 1;
384                }
385            }
386            _ => {
387                error_count += 1;
388            }
389        }
390    }
391
392    // Save all changes
393    if let Err(e) = app.storage.transactions.save() {
394        app.bulk_categorize_state
395            .set_error(format!("Failed to save: {}", e));
396        return;
397    }
398
399    // Get category name for message
400    let category_service = CategoryService::new(app.storage);
401    let category_name = category_service
402        .get_category(category_id)
403        .ok()
404        .flatten()
405        .map(|c| c.name)
406        .unwrap_or_else(|| "Unknown".into());
407
408    // Clear selections
409    app.selected_transactions.clear();
410    app.multi_select_mode = false;
411
412    // Close dialog and show status
413    if error_count > 0 {
414        app.set_status(format!(
415            "Categorized {} transactions as '{}' ({} errors)",
416            success_count, category_name, error_count
417        ));
418    } else {
419        app.set_status(format!(
420            "Categorized {} transaction{} as '{}'",
421            success_count,
422            if success_count == 1 { "" } else { "s" },
423            category_name
424        ));
425    }
426
427    app.bulk_categorize_state.reset();
428    app.close_dialog();
429}