envelope_cli/tui/
handler.rs

1//! Event handler for the TUI
2//!
3//! Routes keyboard and mouse events to the appropriate handlers
4//! based on the current application state.
5
6use anyhow::Result;
7use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8
9use super::app::{ActiveDialog, ActiveView, App, FocusedPanel, InputMode};
10use super::commands::{CommandAction, COMMANDS};
11use super::event::Event;
12
13/// Handle an incoming event
14pub fn handle_event(app: &mut App, event: Event) -> Result<()> {
15    match event {
16        Event::Key(key) => handle_key_event(app, key),
17        Event::Mouse(_mouse) => {
18            // Mouse handling can be added later
19            Ok(())
20        }
21        Event::Tick => Ok(()),
22        Event::Resize(_, _) => Ok(()),
23    }
24}
25
26/// Handle a key event
27fn handle_key_event(app: &mut App, key: KeyEvent) -> Result<()> {
28    // Check if we're in a dialog first
29    if app.has_dialog() {
30        return handle_dialog_key(app, key);
31    }
32
33    // Check input mode
34    match app.input_mode {
35        InputMode::Normal => handle_normal_key(app, key),
36        InputMode::Editing => handle_editing_key(app, key),
37        InputMode::Command => handle_command_key(app, key),
38    }
39}
40
41/// Handle keys in normal mode
42fn handle_normal_key(app: &mut App, key: KeyEvent) -> Result<()> {
43    // Global keys (work everywhere)
44    match key.code {
45        // Quit
46        KeyCode::Char('q') | KeyCode::Char('Q') => {
47            app.quit();
48            return Ok(());
49        }
50
51        // Help
52        KeyCode::Char('?') => {
53            app.open_dialog(ActiveDialog::Help);
54            return Ok(());
55        }
56
57        // Command palette
58        KeyCode::Char(':') | KeyCode::Char('/') => {
59            app.open_dialog(ActiveDialog::CommandPalette);
60            return Ok(());
61        }
62
63        // Panel navigation
64        KeyCode::Tab => {
65            app.toggle_panel_focus();
66            return Ok(());
67        }
68        KeyCode::Char('h') | KeyCode::Left if key.modifiers.is_empty() => {
69            if app.focused_panel == FocusedPanel::Main {
70                app.focused_panel = FocusedPanel::Sidebar;
71                return Ok(());
72            }
73        }
74        KeyCode::Char('l') | KeyCode::Right if key.modifiers.is_empty() => {
75            if app.focused_panel == FocusedPanel::Sidebar {
76                app.focused_panel = FocusedPanel::Main;
77                app.ensure_selection_initialized();
78                return Ok(());
79            }
80        }
81
82        _ => {}
83    }
84
85    // View-specific keys
86    match app.focused_panel {
87        FocusedPanel::Sidebar => handle_sidebar_key(app, key),
88        FocusedPanel::Main => handle_main_panel_key(app, key),
89    }
90}
91
92/// Handle keys when sidebar is focused
93fn handle_sidebar_key(app: &mut App, key: KeyEvent) -> Result<()> {
94    // Get account count for bounds checking
95    let account_count = app
96        .storage
97        .accounts
98        .get_active()
99        .map(|a| a.len())
100        .unwrap_or(0);
101
102    match key.code {
103        // Navigation
104        KeyCode::Char('j') | KeyCode::Down => {
105            app.move_down(account_count);
106            // Update selected account
107            if let Ok(accounts) = app.storage.accounts.get_active() {
108                if let Some(account) = accounts.get(app.selected_account_index) {
109                    app.selected_account = Some(account.id);
110                }
111            }
112        }
113        KeyCode::Char('k') | KeyCode::Up => {
114            app.move_up();
115            // Update selected account
116            if let Ok(accounts) = app.storage.accounts.get_active() {
117                if let Some(account) = accounts.get(app.selected_account_index) {
118                    app.selected_account = Some(account.id);
119                }
120            }
121        }
122
123        // Select account and view register
124        KeyCode::Enter => {
125            if let Ok(accounts) = app.storage.accounts.get_active() {
126                if let Some(account) = accounts.get(app.selected_account_index) {
127                    app.selected_account = Some(account.id);
128                    app.switch_view(ActiveView::Register);
129                    app.focused_panel = FocusedPanel::Main;
130                }
131            }
132        }
133
134        // View switching from sidebar
135        KeyCode::Char('1') => app.switch_view(ActiveView::Accounts),
136        KeyCode::Char('2') => app.switch_view(ActiveView::Budget),
137        KeyCode::Char('3') => app.switch_view(ActiveView::Reports),
138
139        // Toggle archived accounts
140        KeyCode::Char('A') => {
141            app.show_archived = !app.show_archived;
142        }
143
144        // Add new account
145        KeyCode::Char('a') | KeyCode::Char('n') => {
146            app.open_dialog(ActiveDialog::AddAccount);
147        }
148
149        // Edit selected account
150        KeyCode::Char('e') => {
151            if let Ok(accounts) = app.storage.accounts.get_active() {
152                if let Some(account) = accounts.get(app.selected_account_index) {
153                    app.open_dialog(ActiveDialog::EditAccount(account.id));
154                }
155            }
156        }
157
158        _ => {}
159    }
160
161    Ok(())
162}
163
164/// Handle keys when main panel is focused
165fn handle_main_panel_key(app: &mut App, key: KeyEvent) -> Result<()> {
166    match app.active_view {
167        ActiveView::Accounts => handle_accounts_view_key(app, key),
168        ActiveView::Register => handle_register_view_key(app, key),
169        ActiveView::Budget => handle_budget_view_key(app, key),
170        ActiveView::Reports => handle_reports_view_key(app, key),
171        ActiveView::Reconcile => handle_reconcile_view_key(app, key),
172    }
173}
174
175/// Handle keys in the accounts view
176fn handle_accounts_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
177    let account_count = app
178        .storage
179        .accounts
180        .get_active()
181        .map(|a| a.len())
182        .unwrap_or(0);
183
184    match key.code {
185        KeyCode::Char('j') | KeyCode::Down => {
186            app.move_down(account_count);
187            if let Ok(accounts) = app.storage.accounts.get_active() {
188                if let Some(account) = accounts.get(app.selected_account_index) {
189                    app.selected_account = Some(account.id);
190                }
191            }
192        }
193        KeyCode::Char('k') | KeyCode::Up => {
194            app.move_up();
195            if let Ok(accounts) = app.storage.accounts.get_active() {
196                if let Some(account) = accounts.get(app.selected_account_index) {
197                    app.selected_account = Some(account.id);
198                }
199            }
200        }
201        KeyCode::Enter => {
202            // Switch to register view for selected account
203            app.switch_view(ActiveView::Register);
204        }
205        // Add new account
206        KeyCode::Char('a') | KeyCode::Char('n') => {
207            app.open_dialog(ActiveDialog::AddAccount);
208        }
209        // Edit selected account
210        KeyCode::Char('e') => {
211            if let Ok(accounts) = app.storage.accounts.get_active() {
212                if let Some(account) = accounts.get(app.selected_account_index) {
213                    app.open_dialog(ActiveDialog::EditAccount(account.id));
214                }
215            }
216        }
217        _ => {}
218    }
219
220    Ok(())
221}
222
223/// Get sorted transactions for an account (matches display order)
224fn get_sorted_transactions(app: &App) -> Vec<crate::models::Transaction> {
225    if let Some(account_id) = app.selected_account {
226        let mut txns = app
227            .storage
228            .transactions
229            .get_by_account(account_id)
230            .unwrap_or_default();
231        // Sort by date descending (matches render order)
232        txns.sort_by(|a, b| b.date.cmp(&a.date));
233        txns
234    } else {
235        Vec::new()
236    }
237}
238
239/// Handle keys in the register view
240fn handle_register_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
241    // Get sorted transactions (matches display order)
242    let txns = get_sorted_transactions(app);
243    let txn_count = txns.len();
244
245    match key.code {
246        // Navigation
247        KeyCode::Char('j') | KeyCode::Down => {
248            app.pending_g = false;
249            app.move_down(txn_count);
250            // Update selected transaction from sorted list
251            if let Some(txn) = txns.get(app.selected_transaction_index) {
252                app.selected_transaction = Some(txn.id);
253            }
254        }
255        KeyCode::Char('k') | KeyCode::Up => {
256            app.pending_g = false;
257            app.move_up();
258            // Update selected transaction from sorted list
259            if let Some(txn) = txns.get(app.selected_transaction_index) {
260                app.selected_transaction = Some(txn.id);
261            }
262        }
263
264        // Page navigation (Vim-style)
265        KeyCode::Char('G') => {
266            // Shift-G: Go to bottom
267            app.pending_g = false;
268            if txn_count > 0 {
269                app.selected_transaction_index = txn_count - 1;
270                if let Some(txn) = txns.get(app.selected_transaction_index) {
271                    app.selected_transaction = Some(txn.id);
272                }
273            }
274        }
275        KeyCode::Char('g') => {
276            // gg: Go to top (requires double-g press)
277            if app.pending_g {
278                // Second 'g' pressed - go to top
279                app.pending_g = false;
280                app.selected_transaction_index = 0;
281                if let Some(txn) = txns.first() {
282                    app.selected_transaction = Some(txn.id);
283                }
284            } else {
285                // First 'g' pressed - wait for second
286                app.pending_g = true;
287            }
288        }
289
290        // Add transaction
291        KeyCode::Char('a') | KeyCode::Char('n') => {
292            app.pending_g = false;
293            app.open_dialog(ActiveDialog::AddTransaction);
294        }
295
296        // Edit transaction
297        KeyCode::Char('e') => {
298            app.pending_g = false;
299            // DEBUG: Force initialize selection and try edit
300            if app.selected_transaction.is_none() {
301                let txns = get_sorted_transactions(app);
302                if let Some(txn) = txns.get(app.selected_transaction_index) {
303                    app.selected_transaction = Some(txn.id);
304                }
305            }
306            if let Some(txn_id) = app.selected_transaction {
307                app.open_dialog(ActiveDialog::EditTransaction(txn_id));
308            }
309        }
310        KeyCode::Enter => {
311            app.pending_g = false;
312            if app.selected_transaction.is_none() {
313                let txns = get_sorted_transactions(app);
314                if let Some(txn) = txns.get(app.selected_transaction_index) {
315                    app.selected_transaction = Some(txn.id);
316                }
317            }
318            if let Some(txn_id) = app.selected_transaction {
319                app.open_dialog(ActiveDialog::EditTransaction(txn_id));
320            }
321        }
322
323        // Clear transaction (toggle)
324        KeyCode::Char('c') => {
325            app.pending_g = false;
326            if let Some(txn_id) = app.selected_transaction {
327                // Toggle cleared status
328                if let Ok(Some(txn)) = app.storage.transactions.get(txn_id) {
329                    use crate::models::TransactionStatus;
330                    let new_status = match txn.status {
331                        TransactionStatus::Pending => TransactionStatus::Cleared,
332                        TransactionStatus::Cleared => TransactionStatus::Pending,
333                        TransactionStatus::Reconciled => TransactionStatus::Reconciled,
334                    };
335                    if new_status != txn.status {
336                        let mut txn = txn.clone();
337                        txn.set_status(new_status);
338                        let _ = app.storage.transactions.upsert(txn);
339                        let _ = app.storage.transactions.save();
340                        app.set_status(format!("Transaction marked as {}", new_status));
341                    }
342                }
343            }
344        }
345
346        // Delete transaction
347        KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
348            app.pending_g = false;
349            if app.selected_transaction.is_some() {
350                app.open_dialog(ActiveDialog::Confirm(
351                    "Delete this transaction?".to_string(),
352                ));
353            }
354        }
355
356        // Multi-select mode
357        KeyCode::Char('v') => {
358            app.pending_g = false;
359            app.toggle_multi_select();
360            if app.multi_select_mode {
361                app.set_status("Multi-select mode ON");
362            } else {
363                app.set_status("Multi-select mode OFF");
364            }
365        }
366
367        // Toggle selection in multi-select mode
368        KeyCode::Char(' ') if app.multi_select_mode => {
369            app.pending_g = false;
370            app.toggle_transaction_selection();
371        }
372
373        // Bulk categorize
374        KeyCode::Char('C') if app.multi_select_mode && !app.selected_transactions.is_empty() => {
375            app.pending_g = false;
376            app.open_dialog(ActiveDialog::BulkCategorize);
377        }
378
379        // Bulk delete
380        KeyCode::Char('D') if app.multi_select_mode && !app.selected_transactions.is_empty() => {
381            app.pending_g = false;
382            let count = app.selected_transactions.len();
383            app.open_dialog(ActiveDialog::Confirm(format!(
384                "Delete {} transaction{}?",
385                count,
386                if count == 1 { "" } else { "s" }
387            )));
388        }
389
390        _ => {
391            app.pending_g = false;
392        }
393    }
394
395    Ok(())
396}
397
398/// Get categories in visual order (grouped by group, same as render)
399fn get_categories_in_visual_order(app: &App) -> Vec<crate::models::Category> {
400    let groups = app.storage.categories.get_all_groups().unwrap_or_default();
401    let all_categories = app
402        .storage
403        .categories
404        .get_all_categories()
405        .unwrap_or_default();
406
407    let mut result = Vec::new();
408    for group in &groups {
409        let group_cats: Vec<_> = all_categories
410            .iter()
411            .filter(|c| c.group_id == group.id)
412            .cloned()
413            .collect();
414        result.extend(group_cats);
415    }
416    result
417}
418
419/// Handle keys in the budget view
420fn handle_budget_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
421    // Get categories in visual order (matches display)
422    let categories = get_categories_in_visual_order(app);
423    let category_count = categories.len();
424
425    match key.code {
426        // Navigation
427        KeyCode::Char('j') | KeyCode::Down => {
428            app.pending_g = false;
429            app.move_down(category_count);
430            if let Some(cat) = categories.get(app.selected_category_index) {
431                app.selected_category = Some(cat.id);
432            }
433        }
434        KeyCode::Char('k') | KeyCode::Up => {
435            app.pending_g = false;
436            app.move_up();
437            if let Some(cat) = categories.get(app.selected_category_index) {
438                app.selected_category = Some(cat.id);
439            }
440        }
441
442        // Page navigation (Vim-style)
443        KeyCode::Char('G') => {
444            // Shift-G: Go to bottom
445            app.pending_g = false;
446            if category_count > 0 {
447                app.selected_category_index = category_count - 1;
448                if let Some(cat) = categories.get(app.selected_category_index) {
449                    app.selected_category = Some(cat.id);
450                }
451            }
452        }
453        KeyCode::Char('g') => {
454            // gg: Go to top (requires double-g press)
455            if app.pending_g {
456                // Second 'g' pressed - go to top
457                app.pending_g = false;
458                app.selected_category_index = 0;
459                if let Some(cat) = categories.first() {
460                    app.selected_category = Some(cat.id);
461                }
462            } else {
463                // First 'g' pressed - wait for second
464                app.pending_g = true;
465            }
466        }
467
468        // Period navigation
469        KeyCode::Char('[') | KeyCode::Char('H') => {
470            app.pending_g = false;
471            app.prev_period();
472        }
473        KeyCode::Char(']') | KeyCode::Char('L') => {
474            app.pending_g = false;
475            app.next_period();
476        }
477
478        // Header display toggle (cycle through account types)
479        KeyCode::Char('<') | KeyCode::Char(',') => {
480            app.pending_g = false;
481            app.budget_header_display = app.budget_header_display.prev();
482        }
483        KeyCode::Char('>') | KeyCode::Char('.') => {
484            app.pending_g = false;
485            app.budget_header_display = app.budget_header_display.next();
486        }
487
488        // Move funds
489        KeyCode::Char('m') => {
490            app.pending_g = false;
491            app.open_dialog(ActiveDialog::MoveFunds);
492        }
493
494        // Add new category
495        KeyCode::Char('a') => {
496            app.pending_g = false;
497            app.open_dialog(ActiveDialog::AddCategory);
498        }
499
500        // Add new category group
501        KeyCode::Char('A') => {
502            app.pending_g = false;
503            app.open_dialog(ActiveDialog::AddGroup);
504        }
505
506        // Edit category group (Shift+E)
507        KeyCode::Char('E') => {
508            app.pending_g = false;
509            if let Some(cat) = categories.get(app.selected_category_index) {
510                app.open_dialog(ActiveDialog::EditGroup(cat.group_id));
511            }
512        }
513
514        // Delete category group (Shift+D)
515        KeyCode::Char('D') => {
516            app.pending_g = false;
517            if let Some(cat) = categories.get(app.selected_category_index) {
518                if let Ok(Some(group)) = app.storage.categories.get_group(cat.group_id) {
519                    let group_categories = app
520                        .storage
521                        .categories
522                        .get_categories_in_group(group.id)
523                        .unwrap_or_default();
524                    let warning = if group_categories.is_empty() {
525                        format!("Delete group '{}'?", group.name)
526                    } else {
527                        format!(
528                            "Delete group '{}' and its {} categories?",
529                            group.name,
530                            group_categories.len()
531                        )
532                    };
533                    app.open_dialog(ActiveDialog::Confirm(warning));
534                }
535            }
536        }
537
538        // Edit category
539        KeyCode::Char('e') => {
540            app.pending_g = false;
541            if let Some(cat) = categories.get(app.selected_category_index) {
542                app.selected_category = Some(cat.id);
543                app.open_dialog(ActiveDialog::EditCategory(cat.id));
544            }
545        }
546
547        // Delete category
548        KeyCode::Char('d') => {
549            app.pending_g = false;
550            if let Some(cat) = categories.get(app.selected_category_index) {
551                app.selected_category = Some(cat.id);
552                if let Ok(Some(category)) = app.storage.categories.get_category(cat.id) {
553                    app.open_dialog(ActiveDialog::Confirm(format!(
554                        "Delete category '{}'?",
555                        category.name
556                    )));
557                }
558            }
559        }
560
561        // Open unified budget dialog (period budget + target)
562        KeyCode::Enter | KeyCode::Char('b') | KeyCode::Char('t') => {
563            app.pending_g = false;
564            if let Some(cat) = categories.get(app.selected_category_index) {
565                app.selected_category = Some(cat.id);
566                app.open_dialog(ActiveDialog::Budget);
567            }
568        }
569
570        _ => {
571            app.pending_g = false;
572        }
573    }
574
575    Ok(())
576}
577
578/// Handle keys in the reports view
579fn handle_reports_view_key(_app: &mut App, _key: KeyEvent) -> Result<()> {
580    // Reports view keys will be added later
581    Ok(())
582}
583
584/// Handle keys in the reconcile view
585fn handle_reconcile_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
586    // Delegate to the reconcile view's key handler
587    super::views::reconcile::handle_key(app, key.code);
588    Ok(())
589}
590
591/// Handle keys in editing mode
592fn handle_editing_key(app: &mut App, key: KeyEvent) -> Result<()> {
593    match key.code {
594        KeyCode::Esc => {
595            app.input_mode = InputMode::Normal;
596        }
597        _ => {
598            // Pass to dialog if active
599        }
600    }
601    Ok(())
602}
603
604/// Handle keys in command mode (command palette)
605fn handle_command_key(app: &mut App, key: KeyEvent) -> Result<()> {
606    match key.code {
607        KeyCode::Esc => {
608            app.close_dialog();
609        }
610        KeyCode::Enter => {
611            // Get filtered commands (same logic as render)
612            let filtered_commands: Vec<&crate::tui::commands::Command> = COMMANDS
613                .iter()
614                .filter(|cmd| {
615                    if app.command_input.is_empty() {
616                        true
617                    } else {
618                        let query = app.command_input.to_lowercase();
619                        cmd.name.to_lowercase().contains(&query)
620                            || cmd.description.to_lowercase().contains(&query)
621                    }
622                })
623                .collect();
624
625            // Get the selected command
626            if !filtered_commands.is_empty() {
627                let selected_idx = app
628                    .selected_command_index
629                    .min(filtered_commands.len().saturating_sub(1));
630                let command = filtered_commands[selected_idx];
631                let action = command.action;
632
633                // Close dialog first
634                app.close_dialog();
635
636                // Execute the command action
637                execute_command_action(app, action)?;
638            } else {
639                app.close_dialog();
640            }
641        }
642        KeyCode::Char(c) => {
643            app.command_input.push(c);
644            // Reset selection when input changes
645            app.selected_command_index = 0;
646        }
647        KeyCode::Backspace => {
648            app.command_input.pop();
649            // Reset selection when input changes
650            app.selected_command_index = 0;
651        }
652        KeyCode::Up => {
653            if app.selected_command_index > 0 {
654                app.selected_command_index -= 1;
655            }
656        }
657        KeyCode::Down => {
658            // Get filtered count to bound selection
659            let filtered_count = COMMANDS
660                .iter()
661                .filter(|cmd| {
662                    if app.command_input.is_empty() {
663                        true
664                    } else {
665                        let query = app.command_input.to_lowercase();
666                        cmd.name.to_lowercase().contains(&query)
667                            || cmd.description.to_lowercase().contains(&query)
668                    }
669                })
670                .count();
671            if app.selected_command_index + 1 < filtered_count {
672                app.selected_command_index += 1;
673            }
674        }
675        _ => {}
676    }
677    Ok(())
678}
679
680/// Execute a command action from the command palette
681fn execute_command_action(app: &mut App, action: CommandAction) -> Result<()> {
682    match action {
683        // Navigation
684        CommandAction::ViewAccounts => {
685            app.switch_view(ActiveView::Accounts);
686        }
687        CommandAction::ViewBudget => {
688            app.switch_view(ActiveView::Budget);
689        }
690        CommandAction::ViewReports => {
691            app.switch_view(ActiveView::Reports);
692        }
693        CommandAction::ViewRegister => {
694            app.switch_view(ActiveView::Register);
695        }
696
697        // Account operations
698        CommandAction::AddAccount => {
699            app.open_dialog(ActiveDialog::AddAccount);
700        }
701        CommandAction::EditAccount => {
702            if let Ok(accounts) = app.storage.accounts.get_active() {
703                if let Some(account) = accounts.get(app.selected_account_index) {
704                    app.open_dialog(ActiveDialog::EditAccount(account.id));
705                }
706            }
707        }
708        CommandAction::ArchiveAccount => {
709            // Archive selected account with confirmation
710            if let Ok(accounts) = app.storage.accounts.get_active() {
711                if let Some(account) = accounts.get(app.selected_account_index) {
712                    app.open_dialog(ActiveDialog::Confirm(format!(
713                        "Archive account '{}'?",
714                        account.name
715                    )));
716                } else {
717                    app.set_status("No account selected".to_string());
718                }
719            }
720        }
721
722        // Transaction operations
723        CommandAction::AddTransaction => {
724            app.open_dialog(ActiveDialog::AddTransaction);
725        }
726        CommandAction::EditTransaction => {
727            if let Some(tx_id) = app.selected_transaction {
728                app.open_dialog(ActiveDialog::EditTransaction(tx_id));
729            } else {
730                app.set_status("No transaction selected".to_string());
731            }
732        }
733        CommandAction::DeleteTransaction => {
734            if app.selected_transaction.is_some() {
735                app.open_dialog(ActiveDialog::Confirm("Delete transaction?".to_string()));
736            } else {
737                app.set_status("No transaction selected".to_string());
738            }
739        }
740        CommandAction::ClearTransaction => {
741            // Toggle cleared status for selected transaction
742            if let Some(txn_id) = app.selected_transaction {
743                if let Ok(Some(txn)) = app.storage.transactions.get(txn_id) {
744                    use crate::models::TransactionStatus;
745                    let new_status = match txn.status {
746                        TransactionStatus::Pending => TransactionStatus::Cleared,
747                        TransactionStatus::Cleared => TransactionStatus::Pending,
748                        TransactionStatus::Reconciled => TransactionStatus::Reconciled,
749                    };
750                    if new_status != txn.status {
751                        let mut txn = txn.clone();
752                        txn.set_status(new_status);
753                        let _ = app.storage.transactions.upsert(txn);
754                        let _ = app.storage.transactions.save();
755                        app.set_status(format!("Transaction marked as {}", new_status));
756                    }
757                }
758            } else {
759                app.set_status("No transaction selected".to_string());
760            }
761        }
762
763        // Budget operations
764        CommandAction::MoveFunds => {
765            app.open_dialog(ActiveDialog::MoveFunds);
766        }
767        CommandAction::AssignBudget => {
768            // Open unified budget dialog for the selected category
769            if app.selected_category.is_some() {
770                app.open_dialog(ActiveDialog::Budget);
771            } else {
772                app.set_status("No category selected. Switch to Budget view first.".to_string());
773            }
774        }
775        CommandAction::NextPeriod => {
776            app.next_period();
777        }
778        CommandAction::PrevPeriod => {
779            app.prev_period();
780        }
781
782        // Category operations
783        CommandAction::AddCategory => {
784            // Initialize category form with available groups
785            let groups: Vec<_> = app
786                .storage
787                .categories
788                .get_all_groups()
789                .unwrap_or_default()
790                .into_iter()
791                .map(|g| (g.id, g.name.clone()))
792                .collect();
793            app.category_form.init_with_groups(groups);
794            app.open_dialog(ActiveDialog::AddCategory);
795        }
796        CommandAction::AddGroup => {
797            // Reset group form
798            app.group_form = super::dialogs::group::GroupFormState::new();
799            app.open_dialog(ActiveDialog::AddGroup);
800        }
801        CommandAction::EditCategory => {
802            // Open EditCategory dialog for the selected category
803            if let Some(category_id) = app.selected_category {
804                app.open_dialog(ActiveDialog::EditCategory(category_id));
805            } else {
806                app.set_status("No category selected. Switch to Budget view first.".to_string());
807            }
808        }
809        CommandAction::DeleteCategory => {
810            // Delete selected category with confirmation
811            if let Some(category_id) = app.selected_category {
812                if let Ok(Some(category)) = app.storage.categories.get_category(category_id) {
813                    app.open_dialog(ActiveDialog::Confirm(format!(
814                        "Delete category '{}'?",
815                        category.name
816                    )));
817                }
818            } else {
819                app.set_status("No category selected".to_string());
820            }
821        }
822        CommandAction::EditGroup => {
823            // Edit the group of the currently selected category
824            if let Some(category_id) = app.selected_category {
825                if let Ok(Some(category)) = app.storage.categories.get_category(category_id) {
826                    app.open_dialog(ActiveDialog::EditGroup(category.group_id));
827                }
828            } else {
829                app.set_status("No category selected. Switch to Budget view first.".to_string());
830            }
831        }
832        CommandAction::DeleteGroup => {
833            // Delete the group of the currently selected category with confirmation
834            if let Some(category_id) = app.selected_category {
835                if let Ok(Some(category)) = app.storage.categories.get_category(category_id) {
836                    if let Ok(Some(group)) = app.storage.categories.get_group(category.group_id) {
837                        // Check if group has categories
838                        let categories = app
839                            .storage
840                            .categories
841                            .get_categories_in_group(group.id)
842                            .unwrap_or_default();
843                        let warning = if categories.is_empty() {
844                            format!("Delete group '{}'?", group.name)
845                        } else {
846                            format!(
847                                "Delete group '{}' and its {} categories?",
848                                group.name,
849                                categories.len()
850                            )
851                        };
852                        app.open_dialog(ActiveDialog::Confirm(warning));
853                    }
854                }
855            } else {
856                app.set_status("No category selected".to_string());
857            }
858        }
859
860        // General
861        CommandAction::Help => {
862            app.open_dialog(ActiveDialog::Help);
863        }
864        CommandAction::Quit => {
865            app.quit();
866        }
867        CommandAction::Refresh => {
868            // Reload all data from disk
869            if let Err(e) = app.storage.accounts.load() {
870                app.set_status(format!("Failed to refresh accounts: {}", e));
871                return Ok(());
872            }
873            if let Err(e) = app.storage.transactions.load() {
874                app.set_status(format!("Failed to refresh transactions: {}", e));
875                return Ok(());
876            }
877            if let Err(e) = app.storage.categories.load() {
878                app.set_status(format!("Failed to refresh categories: {}", e));
879                return Ok(());
880            }
881            if let Err(e) = app.storage.budget.load() {
882                app.set_status(format!("Failed to refresh budget: {}", e));
883                return Ok(());
884            }
885            app.set_status("Data refreshed from disk".to_string());
886        }
887        CommandAction::ToggleArchived => {
888            app.show_archived = !app.show_archived;
889        }
890
891        // Target operations
892        CommandAction::AutoFillTargets => {
893            use crate::services::BudgetService;
894            let budget_service = BudgetService::new(app.storage);
895            match budget_service.auto_fill_all_targets(&app.current_period) {
896                Ok(allocations) => {
897                    if allocations.is_empty() {
898                        app.set_status("No targets to auto-fill".to_string());
899                    } else {
900                        let count = allocations.len();
901                        let plural = if count == 1 { "category" } else { "categories" };
902                        app.set_status(format!("{} {} updated from targets", count, plural));
903                    }
904                }
905                Err(e) => {
906                    app.set_status(format!("Auto-fill failed: {}", e));
907                }
908            }
909        }
910    }
911    Ok(())
912}
913
914/// Handle keys when a dialog is open
915fn handle_dialog_key(app: &mut App, key: KeyEvent) -> Result<()> {
916    match &app.active_dialog {
917        ActiveDialog::Help => {
918            // Close help on any key
919            app.close_dialog();
920        }
921        ActiveDialog::CommandPalette => {
922            handle_command_key(app, key)?;
923        }
924        ActiveDialog::Confirm(msg) => {
925            let msg = msg.clone();
926            match key.code {
927                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
928                    // Execute confirmed action based on the message
929                    app.close_dialog();
930                    execute_confirmed_action(app, &msg)?;
931                }
932                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
933                    app.close_dialog();
934                }
935                _ => {}
936            }
937        }
938        ActiveDialog::AddTransaction | ActiveDialog::EditTransaction(_) => {
939            // Delegate to transaction dialog key handler
940            super::dialogs::transaction::handle_key(app, key);
941        }
942        ActiveDialog::MoveFunds => {
943            super::dialogs::move_funds::handle_key(app, key);
944        }
945        ActiveDialog::BulkCategorize => {
946            super::dialogs::bulk_categorize::handle_key(app, key);
947        }
948        ActiveDialog::ReconcileStart => {
949            match key.code {
950                KeyCode::Esc => {
951                    app.close_dialog();
952                }
953                KeyCode::Enter => {
954                    // Start reconciliation
955                    app.close_dialog();
956                    app.switch_view(ActiveView::Reconcile);
957                }
958                _ => {
959                    super::dialogs::reconcile_start::handle_key(app, key.code);
960                }
961            }
962        }
963        ActiveDialog::UnlockConfirm(_) => {
964            match key.code {
965                KeyCode::Char('y') | KeyCode::Char('Y') => {
966                    // Unlock the transaction
967                    app.close_dialog();
968                }
969                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
970                    app.close_dialog();
971                }
972                _ => {}
973            }
974        }
975        ActiveDialog::Adjustment => {
976            match key.code {
977                KeyCode::Esc => {
978                    app.close_dialog();
979                }
980                KeyCode::Enter => {
981                    // Create adjustment
982                    app.close_dialog();
983                }
984                _ => {
985                    super::dialogs::adjustment::handle_key(app, key.code);
986                }
987            }
988        }
989        ActiveDialog::Budget => {
990            super::dialogs::budget::handle_key(app, key);
991        }
992        ActiveDialog::AddAccount | ActiveDialog::EditAccount(_) => {
993            super::dialogs::account::handle_key(app, key);
994        }
995        ActiveDialog::AddCategory | ActiveDialog::EditCategory(_) => {
996            super::dialogs::category::handle_key(app, key);
997        }
998        ActiveDialog::AddGroup | ActiveDialog::EditGroup(_) => {
999            super::dialogs::group::handle_key(app, key);
1000        }
1001        ActiveDialog::None => {}
1002    }
1003    Ok(())
1004}
1005
1006/// Execute an action after user confirmation
1007fn execute_confirmed_action(app: &mut App, message: &str) -> Result<()> {
1008    // Bulk delete transactions
1009    if message.contains("Delete")
1010        && message.contains("transaction")
1011        && !app.selected_transactions.is_empty()
1012    {
1013        let transaction_ids = app.selected_transactions.clone();
1014        let mut deleted_count = 0;
1015        let mut error_count = 0;
1016
1017        for txn_id in &transaction_ids {
1018            if app.storage.transactions.delete(*txn_id).is_err() {
1019                error_count += 1;
1020            } else {
1021                deleted_count += 1;
1022            }
1023        }
1024
1025        let _ = app.storage.transactions.save();
1026        app.selected_transactions.clear();
1027        app.multi_select_mode = false;
1028
1029        if error_count > 0 {
1030            app.set_status(format!(
1031                "Deleted {} transaction(s), {} failed",
1032                deleted_count, error_count
1033            ));
1034        } else {
1035            app.set_status(format!("Deleted {} transaction(s)", deleted_count));
1036        }
1037    }
1038    // Delete single transaction
1039    else if message.contains("Delete") && message.contains("transaction") {
1040        if let Some(txn_id) = app.selected_transaction {
1041            if let Err(e) = app.storage.transactions.delete(txn_id) {
1042                app.set_status(format!("Failed to delete: {}", e));
1043            } else {
1044                let _ = app.storage.transactions.save();
1045                app.selected_transaction = None;
1046                app.set_status("Transaction deleted".to_string());
1047            }
1048        }
1049    }
1050    // Archive account
1051    else if message.contains("Archive account") {
1052        if let Ok(accounts) = app.storage.accounts.get_active() {
1053            if let Some(account) = accounts.get(app.selected_account_index) {
1054                let mut account = account.clone();
1055                account.archive();
1056                if let Err(e) = app.storage.accounts.upsert(account.clone()) {
1057                    app.set_status(format!("Failed to archive: {}", e));
1058                } else {
1059                    let _ = app.storage.accounts.save();
1060                    app.set_status(format!("Account '{}' archived", account.name));
1061                    // Reset selection
1062                    app.selected_account_index = 0;
1063                    if let Ok(active) = app.storage.accounts.get_active() {
1064                        app.selected_account = active.first().map(|a| a.id);
1065                    }
1066                }
1067            }
1068        }
1069    }
1070    // Delete category
1071    else if message.contains("Delete category") {
1072        if let Some(category_id) = app.selected_category {
1073            use crate::services::CategoryService;
1074            let category_service = CategoryService::new(app.storage);
1075            match category_service.delete_category(category_id) {
1076                Ok(()) => {
1077                    app.set_status("Category deleted".to_string());
1078                    app.selected_category = None;
1079                    app.selected_category_index = 0;
1080                }
1081                Err(e) => {
1082                    app.set_status(format!("Failed to delete: {}", e));
1083                }
1084            }
1085        }
1086    }
1087    // Delete group
1088    else if message.contains("Delete group") {
1089        if let Some(category_id) = app.selected_category {
1090            use crate::services::CategoryService;
1091            if let Ok(Some(category)) = app.storage.categories.get_category(category_id) {
1092                let group_id = category.group_id;
1093                let category_service = CategoryService::new(app.storage);
1094                // force_delete_categories = true since user confirmed
1095                match category_service.delete_group(group_id, true) {
1096                    Ok(()) => {
1097                        app.set_status("Category group deleted".to_string());
1098                        app.selected_category = None;
1099                        app.selected_category_index = 0;
1100                    }
1101                    Err(e) => {
1102                        app.set_status(format!("Failed to delete: {}", e));
1103                    }
1104                }
1105            }
1106        }
1107    }
1108
1109    Ok(())
1110}