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        // Open income dialog
571        KeyCode::Char('i') => {
572            app.pending_g = false;
573            app.open_dialog(ActiveDialog::Income);
574        }
575
576        _ => {
577            app.pending_g = false;
578        }
579    }
580
581    Ok(())
582}
583
584/// Handle keys in the reports view
585fn handle_reports_view_key(_app: &mut App, _key: KeyEvent) -> Result<()> {
586    // Reports view keys will be added later
587    Ok(())
588}
589
590/// Handle keys in the reconcile view
591fn handle_reconcile_view_key(app: &mut App, key: KeyEvent) -> Result<()> {
592    // Delegate to the reconcile view's key handler
593    super::views::reconcile::handle_key(app, key.code);
594    Ok(())
595}
596
597/// Handle keys in editing mode
598fn handle_editing_key(app: &mut App, key: KeyEvent) -> Result<()> {
599    match key.code {
600        KeyCode::Esc => {
601            app.input_mode = InputMode::Normal;
602        }
603        _ => {
604            // Pass to dialog if active
605        }
606    }
607    Ok(())
608}
609
610/// Handle keys in command mode (command palette)
611fn handle_command_key(app: &mut App, key: KeyEvent) -> Result<()> {
612    match key.code {
613        KeyCode::Esc => {
614            app.close_dialog();
615        }
616        KeyCode::Enter => {
617            // Get filtered commands (same logic as render)
618            let filtered_commands: Vec<&crate::tui::commands::Command> = COMMANDS
619                .iter()
620                .filter(|cmd| {
621                    if app.command_input.is_empty() {
622                        true
623                    } else {
624                        let query = app.command_input.to_lowercase();
625                        cmd.name.to_lowercase().contains(&query)
626                            || cmd.description.to_lowercase().contains(&query)
627                    }
628                })
629                .collect();
630
631            // Get the selected command
632            if !filtered_commands.is_empty() {
633                let selected_idx = app
634                    .selected_command_index
635                    .min(filtered_commands.len().saturating_sub(1));
636                let command = filtered_commands[selected_idx];
637                let action = command.action;
638
639                // Close dialog first
640                app.close_dialog();
641
642                // Execute the command action
643                execute_command_action(app, action)?;
644            } else {
645                app.close_dialog();
646            }
647        }
648        KeyCode::Char(c) => {
649            app.command_input.push(c);
650            // Reset selection when input changes
651            app.selected_command_index = 0;
652        }
653        KeyCode::Backspace => {
654            app.command_input.pop();
655            // Reset selection when input changes
656            app.selected_command_index = 0;
657        }
658        KeyCode::Up => {
659            if app.selected_command_index > 0 {
660                app.selected_command_index -= 1;
661            }
662        }
663        KeyCode::Down => {
664            // Get filtered count to bound selection
665            let filtered_count = COMMANDS
666                .iter()
667                .filter(|cmd| {
668                    if app.command_input.is_empty() {
669                        true
670                    } else {
671                        let query = app.command_input.to_lowercase();
672                        cmd.name.to_lowercase().contains(&query)
673                            || cmd.description.to_lowercase().contains(&query)
674                    }
675                })
676                .count();
677            if app.selected_command_index + 1 < filtered_count {
678                app.selected_command_index += 1;
679            }
680        }
681        _ => {}
682    }
683    Ok(())
684}
685
686/// Execute a command action from the command palette
687fn execute_command_action(app: &mut App, action: CommandAction) -> Result<()> {
688    match action {
689        // Navigation
690        CommandAction::ViewAccounts => {
691            app.switch_view(ActiveView::Accounts);
692        }
693        CommandAction::ViewBudget => {
694            app.switch_view(ActiveView::Budget);
695        }
696        CommandAction::ViewReports => {
697            app.switch_view(ActiveView::Reports);
698        }
699        CommandAction::ViewRegister => {
700            app.switch_view(ActiveView::Register);
701        }
702
703        // Account operations
704        CommandAction::AddAccount => {
705            app.open_dialog(ActiveDialog::AddAccount);
706        }
707        CommandAction::EditAccount => {
708            if let Ok(accounts) = app.storage.accounts.get_active() {
709                if let Some(account) = accounts.get(app.selected_account_index) {
710                    app.open_dialog(ActiveDialog::EditAccount(account.id));
711                }
712            }
713        }
714        CommandAction::ArchiveAccount => {
715            // Archive selected account with confirmation
716            if let Ok(accounts) = app.storage.accounts.get_active() {
717                if let Some(account) = accounts.get(app.selected_account_index) {
718                    app.open_dialog(ActiveDialog::Confirm(format!(
719                        "Archive account '{}'?",
720                        account.name
721                    )));
722                } else {
723                    app.set_status("No account selected".to_string());
724                }
725            }
726        }
727
728        // Transaction operations
729        CommandAction::AddTransaction => {
730            app.open_dialog(ActiveDialog::AddTransaction);
731        }
732        CommandAction::EditTransaction => {
733            if let Some(tx_id) = app.selected_transaction {
734                app.open_dialog(ActiveDialog::EditTransaction(tx_id));
735            } else {
736                app.set_status("No transaction selected".to_string());
737            }
738        }
739        CommandAction::DeleteTransaction => {
740            if app.selected_transaction.is_some() {
741                app.open_dialog(ActiveDialog::Confirm("Delete transaction?".to_string()));
742            } else {
743                app.set_status("No transaction selected".to_string());
744            }
745        }
746        CommandAction::ClearTransaction => {
747            // Toggle cleared status for selected transaction
748            if let Some(txn_id) = app.selected_transaction {
749                if let Ok(Some(txn)) = app.storage.transactions.get(txn_id) {
750                    use crate::models::TransactionStatus;
751                    let new_status = match txn.status {
752                        TransactionStatus::Pending => TransactionStatus::Cleared,
753                        TransactionStatus::Cleared => TransactionStatus::Pending,
754                        TransactionStatus::Reconciled => TransactionStatus::Reconciled,
755                    };
756                    if new_status != txn.status {
757                        let mut txn = txn.clone();
758                        txn.set_status(new_status);
759                        let _ = app.storage.transactions.upsert(txn);
760                        let _ = app.storage.transactions.save();
761                        app.set_status(format!("Transaction marked as {}", new_status));
762                    }
763                }
764            } else {
765                app.set_status("No transaction selected".to_string());
766            }
767        }
768
769        // Budget operations
770        CommandAction::MoveFunds => {
771            app.open_dialog(ActiveDialog::MoveFunds);
772        }
773        CommandAction::AssignBudget => {
774            // Open unified budget dialog for the selected category
775            if app.selected_category.is_some() {
776                app.open_dialog(ActiveDialog::Budget);
777            } else {
778                app.set_status("No category selected. Switch to Budget view first.".to_string());
779            }
780        }
781        CommandAction::NextPeriod => {
782            app.next_period();
783        }
784        CommandAction::PrevPeriod => {
785            app.prev_period();
786        }
787
788        // Income operations
789        CommandAction::SetIncome => {
790            app.open_dialog(ActiveDialog::Income);
791        }
792
793        // Category operations
794        CommandAction::AddCategory => {
795            // Initialize category form with available groups
796            let groups: Vec<_> = app
797                .storage
798                .categories
799                .get_all_groups()
800                .unwrap_or_default()
801                .into_iter()
802                .map(|g| (g.id, g.name.clone()))
803                .collect();
804            app.category_form.init_with_groups(groups);
805            app.open_dialog(ActiveDialog::AddCategory);
806        }
807        CommandAction::AddGroup => {
808            // Reset group form
809            app.group_form = super::dialogs::group::GroupFormState::new();
810            app.open_dialog(ActiveDialog::AddGroup);
811        }
812        CommandAction::EditCategory => {
813            // Open EditCategory dialog for the selected category
814            if let Some(category_id) = app.selected_category {
815                app.open_dialog(ActiveDialog::EditCategory(category_id));
816            } else {
817                app.set_status("No category selected. Switch to Budget view first.".to_string());
818            }
819        }
820        CommandAction::DeleteCategory => {
821            // Delete selected category with confirmation
822            if let Some(category_id) = app.selected_category {
823                if let Ok(Some(category)) = app.storage.categories.get_category(category_id) {
824                    app.open_dialog(ActiveDialog::Confirm(format!(
825                        "Delete category '{}'?",
826                        category.name
827                    )));
828                }
829            } else {
830                app.set_status("No category selected".to_string());
831            }
832        }
833        CommandAction::EditGroup => {
834            // Edit the group of the currently selected category
835            if let Some(category_id) = app.selected_category {
836                if let Ok(Some(category)) = app.storage.categories.get_category(category_id) {
837                    app.open_dialog(ActiveDialog::EditGroup(category.group_id));
838                }
839            } else {
840                app.set_status("No category selected. Switch to Budget view first.".to_string());
841            }
842        }
843        CommandAction::DeleteGroup => {
844            // Delete the group of the currently selected category with confirmation
845            if let Some(category_id) = app.selected_category {
846                if let Ok(Some(category)) = app.storage.categories.get_category(category_id) {
847                    if let Ok(Some(group)) = app.storage.categories.get_group(category.group_id) {
848                        // Check if group has categories
849                        let categories = app
850                            .storage
851                            .categories
852                            .get_categories_in_group(group.id)
853                            .unwrap_or_default();
854                        let warning = if categories.is_empty() {
855                            format!("Delete group '{}'?", group.name)
856                        } else {
857                            format!(
858                                "Delete group '{}' and its {} categories?",
859                                group.name,
860                                categories.len()
861                            )
862                        };
863                        app.open_dialog(ActiveDialog::Confirm(warning));
864                    }
865                }
866            } else {
867                app.set_status("No category selected".to_string());
868            }
869        }
870
871        // General
872        CommandAction::Help => {
873            app.open_dialog(ActiveDialog::Help);
874        }
875        CommandAction::Quit => {
876            app.quit();
877        }
878        CommandAction::Refresh => {
879            // Reload all data from disk
880            if let Err(e) = app.storage.accounts.load() {
881                app.set_status(format!("Failed to refresh accounts: {}", e));
882                return Ok(());
883            }
884            if let Err(e) = app.storage.transactions.load() {
885                app.set_status(format!("Failed to refresh transactions: {}", e));
886                return Ok(());
887            }
888            if let Err(e) = app.storage.categories.load() {
889                app.set_status(format!("Failed to refresh categories: {}", e));
890                return Ok(());
891            }
892            if let Err(e) = app.storage.budget.load() {
893                app.set_status(format!("Failed to refresh budget: {}", e));
894                return Ok(());
895            }
896            app.set_status("Data refreshed from disk".to_string());
897        }
898        CommandAction::ToggleArchived => {
899            app.show_archived = !app.show_archived;
900        }
901
902        // Target operations
903        CommandAction::AutoFillTargets => {
904            use crate::services::BudgetService;
905            let budget_service = BudgetService::new(app.storage);
906            match budget_service.auto_fill_all_targets(&app.current_period) {
907                Ok(allocations) => {
908                    if allocations.is_empty() {
909                        app.set_status("No targets to auto-fill".to_string());
910                    } else {
911                        let count = allocations.len();
912                        let plural = if count == 1 { "category" } else { "categories" };
913                        app.set_status(format!("{} {} updated from targets", count, plural));
914                    }
915                }
916                Err(e) => {
917                    app.set_status(format!("Auto-fill failed: {}", e));
918                }
919            }
920        }
921    }
922    Ok(())
923}
924
925/// Handle keys when a dialog is open
926fn handle_dialog_key(app: &mut App, key: KeyEvent) -> Result<()> {
927    match &app.active_dialog {
928        ActiveDialog::Help => {
929            // Close help on any key
930            app.close_dialog();
931        }
932        ActiveDialog::CommandPalette => {
933            handle_command_key(app, key)?;
934        }
935        ActiveDialog::Confirm(msg) => {
936            let msg = msg.clone();
937            match key.code {
938                KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
939                    // Execute confirmed action based on the message
940                    app.close_dialog();
941                    execute_confirmed_action(app, &msg)?;
942                }
943                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
944                    app.close_dialog();
945                }
946                _ => {}
947            }
948        }
949        ActiveDialog::AddTransaction | ActiveDialog::EditTransaction(_) => {
950            // Delegate to transaction dialog key handler
951            super::dialogs::transaction::handle_key(app, key);
952        }
953        ActiveDialog::MoveFunds => {
954            super::dialogs::move_funds::handle_key(app, key);
955        }
956        ActiveDialog::BulkCategorize => {
957            super::dialogs::bulk_categorize::handle_key(app, key);
958        }
959        ActiveDialog::ReconcileStart => {
960            match key.code {
961                KeyCode::Esc => {
962                    app.close_dialog();
963                }
964                KeyCode::Enter => {
965                    // Start reconciliation
966                    app.close_dialog();
967                    app.switch_view(ActiveView::Reconcile);
968                }
969                _ => {
970                    super::dialogs::reconcile_start::handle_key(app, key.code);
971                }
972            }
973        }
974        ActiveDialog::UnlockConfirm(_) => {
975            match key.code {
976                KeyCode::Char('y') | KeyCode::Char('Y') => {
977                    // Unlock the transaction
978                    app.close_dialog();
979                }
980                KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
981                    app.close_dialog();
982                }
983                _ => {}
984            }
985        }
986        ActiveDialog::Adjustment => {
987            match key.code {
988                KeyCode::Esc => {
989                    app.close_dialog();
990                }
991                KeyCode::Enter => {
992                    // Create adjustment
993                    app.close_dialog();
994                }
995                _ => {
996                    super::dialogs::adjustment::handle_key(app, key.code);
997                }
998            }
999        }
1000        ActiveDialog::Budget => {
1001            super::dialogs::budget::handle_key(app, key);
1002        }
1003        ActiveDialog::Income => {
1004            super::dialogs::income::handle_key(app, key);
1005        }
1006        ActiveDialog::AddAccount | ActiveDialog::EditAccount(_) => {
1007            super::dialogs::account::handle_key(app, key);
1008        }
1009        ActiveDialog::AddCategory | ActiveDialog::EditCategory(_) => {
1010            super::dialogs::category::handle_key(app, key);
1011        }
1012        ActiveDialog::AddGroup | ActiveDialog::EditGroup(_) => {
1013            super::dialogs::group::handle_key(app, key);
1014        }
1015        ActiveDialog::None => {}
1016    }
1017    Ok(())
1018}
1019
1020/// Execute an action after user confirmation
1021fn execute_confirmed_action(app: &mut App, message: &str) -> Result<()> {
1022    // Bulk delete transactions
1023    if message.contains("Delete")
1024        && message.contains("transaction")
1025        && !app.selected_transactions.is_empty()
1026    {
1027        let transaction_ids = app.selected_transactions.clone();
1028        let mut deleted_count = 0;
1029        let mut error_count = 0;
1030
1031        for txn_id in &transaction_ids {
1032            if app.storage.transactions.delete(*txn_id).is_err() {
1033                error_count += 1;
1034            } else {
1035                deleted_count += 1;
1036            }
1037        }
1038
1039        let _ = app.storage.transactions.save();
1040        app.selected_transactions.clear();
1041        app.multi_select_mode = false;
1042
1043        if error_count > 0 {
1044            app.set_status(format!(
1045                "Deleted {} transaction(s), {} failed",
1046                deleted_count, error_count
1047            ));
1048        } else {
1049            app.set_status(format!("Deleted {} transaction(s)", deleted_count));
1050        }
1051    }
1052    // Delete single transaction
1053    else if message.contains("Delete") && message.contains("transaction") {
1054        if let Some(txn_id) = app.selected_transaction {
1055            if let Err(e) = app.storage.transactions.delete(txn_id) {
1056                app.set_status(format!("Failed to delete: {}", e));
1057            } else {
1058                let _ = app.storage.transactions.save();
1059                app.selected_transaction = None;
1060                app.set_status("Transaction deleted".to_string());
1061            }
1062        }
1063    }
1064    // Archive account
1065    else if message.contains("Archive account") {
1066        if let Ok(accounts) = app.storage.accounts.get_active() {
1067            if let Some(account) = accounts.get(app.selected_account_index) {
1068                let mut account = account.clone();
1069                account.archive();
1070                if let Err(e) = app.storage.accounts.upsert(account.clone()) {
1071                    app.set_status(format!("Failed to archive: {}", e));
1072                } else {
1073                    let _ = app.storage.accounts.save();
1074                    app.set_status(format!("Account '{}' archived", account.name));
1075                    // Reset selection
1076                    app.selected_account_index = 0;
1077                    if let Ok(active) = app.storage.accounts.get_active() {
1078                        app.selected_account = active.first().map(|a| a.id);
1079                    }
1080                }
1081            }
1082        }
1083    }
1084    // Delete category
1085    else if message.contains("Delete category") {
1086        if let Some(category_id) = app.selected_category {
1087            use crate::services::CategoryService;
1088            let category_service = CategoryService::new(app.storage);
1089            match category_service.delete_category(category_id) {
1090                Ok(()) => {
1091                    app.set_status("Category deleted".to_string());
1092                    app.selected_category = None;
1093                    app.selected_category_index = 0;
1094                }
1095                Err(e) => {
1096                    app.set_status(format!("Failed to delete: {}", e));
1097                }
1098            }
1099        }
1100    }
1101    // Delete group
1102    else if message.contains("Delete group") {
1103        if let Some(category_id) = app.selected_category {
1104            use crate::services::CategoryService;
1105            if let Ok(Some(category)) = app.storage.categories.get_category(category_id) {
1106                let group_id = category.group_id;
1107                let category_service = CategoryService::new(app.storage);
1108                // force_delete_categories = true since user confirmed
1109                match category_service.delete_group(group_id, true) {
1110                    Ok(()) => {
1111                        app.set_status("Category group deleted".to_string());
1112                        app.selected_category = None;
1113                        app.selected_category_index = 0;
1114                    }
1115                    Err(e) => {
1116                        app.set_status(format!("Failed to delete: {}", e));
1117                    }
1118                }
1119            }
1120        }
1121    }
1122
1123    Ok(())
1124}