envelope_cli/tui/dialogs/
account.rs

1//! Account entry dialog
2//!
3//! Modal dialog for adding new accounts with form fields,
4//! tab navigation, validation, and save/cancel functionality.
5
6use ratatui::{
7    layout::{Constraint, Direction, Layout, Rect},
8    style::{Color, Modifier, Style},
9    text::{Line, Span},
10    widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
11    Frame,
12};
13
14use crate::models::{Account, AccountType, Money};
15use crate::tui::app::App;
16use crate::tui::layout::centered_rect;
17use crate::tui::widgets::input::TextInput;
18
19/// Account types available for selection
20const ACCOUNT_TYPES: &[AccountType] = &[
21    AccountType::Checking,
22    AccountType::Savings,
23    AccountType::Credit,
24    AccountType::Cash,
25    AccountType::Investment,
26    AccountType::LineOfCredit,
27    AccountType::Other,
28];
29
30/// Which field is currently focused in the account form
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
32pub enum AccountField {
33    #[default]
34    Name,
35    AccountType,
36    StartingBalance,
37    OnBudget,
38}
39
40impl AccountField {
41    /// Get the next field (for Tab navigation)
42    pub fn next(self) -> Self {
43        match self {
44            Self::Name => Self::AccountType,
45            Self::AccountType => Self::StartingBalance,
46            Self::StartingBalance => Self::OnBudget,
47            Self::OnBudget => Self::Name,
48        }
49    }
50
51    /// Get the previous field (for Shift+Tab navigation)
52    pub fn prev(self) -> Self {
53        match self {
54            Self::Name => Self::OnBudget,
55            Self::AccountType => Self::Name,
56            Self::StartingBalance => Self::AccountType,
57            Self::OnBudget => Self::StartingBalance,
58        }
59    }
60}
61
62/// State for the account form dialog
63#[derive(Debug, Clone)]
64pub struct AccountFormState {
65    /// Currently focused field
66    pub focused_field: AccountField,
67
68    /// Name input
69    pub name_input: TextInput,
70
71    /// Selected account type index
72    pub account_type_index: usize,
73
74    /// Starting balance input
75    pub balance_input: TextInput,
76
77    /// Whether account is on budget
78    pub on_budget: bool,
79
80    /// Whether this is an edit (vs new account)
81    pub is_edit: bool,
82
83    /// Account ID being edited (if editing)
84    pub editing_account_id: Option<crate::models::AccountId>,
85
86    /// Error message to display
87    pub error_message: Option<String>,
88}
89
90impl Default for AccountFormState {
91    fn default() -> Self {
92        Self::new()
93    }
94}
95
96impl AccountFormState {
97    /// Create a new form state with default values
98    pub fn new() -> Self {
99        Self {
100            focused_field: AccountField::Name,
101            name_input: TextInput::new().label("Name").placeholder("Account name"),
102            account_type_index: 0, // Checking
103            balance_input: TextInput::new()
104                .label("Balance")
105                .placeholder("0.00")
106                .content("0.00"),
107            on_budget: true,
108            is_edit: false,
109            editing_account_id: None,
110            error_message: None,
111        }
112    }
113
114    /// Create form state pre-populated from an existing account
115    pub fn from_account(account: &Account) -> Self {
116        // Find the account type index
117        let account_type_index = ACCOUNT_TYPES
118            .iter()
119            .position(|t| *t == account.account_type)
120            .unwrap_or(0);
121
122        Self {
123            focused_field: AccountField::Name,
124            name_input: TextInput::new().label("Name").content(&account.name),
125            account_type_index,
126            balance_input: TextInput::new().label("Balance").content(format!(
127                "{:.2}",
128                account.starting_balance.cents() as f64 / 100.0
129            )),
130            on_budget: account.on_budget,
131            is_edit: true,
132            editing_account_id: Some(account.id),
133            error_message: None,
134        }
135    }
136
137    /// Move to the next field
138    pub fn next_field(&mut self) {
139        self.focused_field = self.focused_field.next();
140    }
141
142    /// Move to the previous field
143    pub fn prev_field(&mut self) {
144        self.focused_field = self.focused_field.prev();
145    }
146
147    /// Set focus to a specific field
148    pub fn set_focus(&mut self, field: AccountField) {
149        self.focused_field = field;
150    }
151
152    /// Get the currently focused text input (if applicable)
153    pub fn focused_input(&mut self) -> Option<&mut TextInput> {
154        match self.focused_field {
155            AccountField::Name => Some(&mut self.name_input),
156            AccountField::StartingBalance => Some(&mut self.balance_input),
157            _ => None,
158        }
159    }
160
161    /// Get selected account type
162    pub fn selected_account_type(&self) -> AccountType {
163        ACCOUNT_TYPES
164            .get(self.account_type_index)
165            .copied()
166            .unwrap_or(AccountType::Checking)
167    }
168
169    /// Move to next account type
170    pub fn next_account_type(&mut self) {
171        self.account_type_index = (self.account_type_index + 1) % ACCOUNT_TYPES.len();
172        self.on_budget = self.default_on_budget_for_type();
173    }
174
175    /// Move to previous account type
176    pub fn prev_account_type(&mut self) {
177        if self.account_type_index == 0 {
178            self.account_type_index = ACCOUNT_TYPES.len() - 1;
179        } else {
180            self.account_type_index -= 1;
181        }
182        self.on_budget = self.default_on_budget_for_type();
183    }
184
185    /// Get the default on_budget value for the current account type
186    fn default_on_budget_for_type(&self) -> bool {
187        match self.selected_account_type() {
188            // Investment accounts default to off-budget
189            AccountType::Investment => false,
190            // All other account types default to on-budget
191            _ => true,
192        }
193    }
194
195    /// Toggle on-budget status
196    pub fn toggle_on_budget(&mut self) {
197        self.on_budget = !self.on_budget;
198    }
199
200    /// Validate the form and return any error
201    pub fn validate(&self) -> Result<(), String> {
202        let name = self.name_input.value().trim();
203        if name.is_empty() {
204            return Err("Account name is required".to_string());
205        }
206        if name.len() > 100 {
207            return Err("Account name too long (max 100 chars)".to_string());
208        }
209
210        // Validate balance format
211        let balance_str = self.balance_input.value().trim();
212        if !balance_str.is_empty() && Money::parse(balance_str).is_err() {
213            return Err("Invalid balance format".to_string());
214        }
215
216        Ok(())
217    }
218
219    /// Build an account from the form state
220    pub fn build_account(&self) -> Result<Account, String> {
221        self.validate()?;
222
223        let name = self.name_input.value().trim().to_string();
224        let account_type = self.selected_account_type();
225
226        let balance_str = self.balance_input.value().trim();
227        let mut starting_balance = if balance_str.is_empty() {
228            Money::zero()
229        } else {
230            Money::parse(balance_str).map_err(|_| "Invalid balance")?
231        };
232
233        // For liability accounts (credit cards, lines of credit), balances represent
234        // debt owed and should be stored as negative values. Users naturally enter
235        // positive numbers when specifying debt, so we negate them.
236        if account_type.is_liability() && starting_balance.cents() > 0 {
237            starting_balance = Money::from_cents(-starting_balance.cents());
238        }
239
240        let mut account = Account::with_starting_balance(name, account_type, starting_balance);
241        account.on_budget = self.on_budget;
242
243        Ok(account)
244    }
245
246    /// Clear any error message
247    pub fn clear_error(&mut self) {
248        self.error_message = None;
249    }
250
251    /// Set an error message
252    pub fn set_error(&mut self, msg: impl Into<String>) {
253        self.error_message = Some(msg.into());
254    }
255}
256
257/// Render the account dialog
258pub fn render(frame: &mut Frame, app: &mut App) {
259    let area = centered_rect(60, 50, frame.area());
260
261    // Clear the background
262    frame.render_widget(Clear, area);
263
264    let title = if app.account_form.is_edit {
265        " Edit Account "
266    } else {
267        " Add Account "
268    };
269
270    let block = Block::default()
271        .title(title)
272        .title_style(
273            Style::default()
274                .fg(Color::Cyan)
275                .add_modifier(Modifier::BOLD),
276        )
277        .borders(Borders::ALL)
278        .border_style(Style::default().fg(Color::Cyan));
279
280    frame.render_widget(block, area);
281
282    // Inner area for content
283    let inner = Rect {
284        x: area.x + 2,
285        y: area.y + 1,
286        width: area.width.saturating_sub(4),
287        height: area.height.saturating_sub(2),
288    };
289
290    // Layout: fields + buttons
291    let chunks = Layout::default()
292        .direction(Direction::Vertical)
293        .constraints([
294            Constraint::Length(1), // Name
295            Constraint::Length(1), // Spacer
296            Constraint::Length(1), // Account Type label
297            Constraint::Length(5), // Account Type list
298            Constraint::Length(1), // Starting Balance
299            Constraint::Length(1), // On Budget
300            Constraint::Length(1), // Spacer
301            Constraint::Length(1), // Error
302            Constraint::Length(1), // Buttons
303            Constraint::Min(0),    // Remaining
304        ])
305        .split(inner);
306
307    // Extract values to avoid borrow conflicts
308    let name_value = app.account_form.name_input.value().to_string();
309    let name_focused = app.account_form.focused_field == AccountField::Name;
310    let name_cursor = app.account_form.name_input.cursor;
311    let name_placeholder = app.account_form.name_input.placeholder.clone();
312
313    let balance_value = app.account_form.balance_input.value().to_string();
314    let balance_focused = app.account_form.focused_field == AccountField::StartingBalance;
315    let balance_cursor = app.account_form.balance_input.cursor;
316    let balance_placeholder = app.account_form.balance_input.placeholder.clone();
317
318    let type_focused = app.account_form.focused_field == AccountField::AccountType;
319    let budget_focused = app.account_form.focused_field == AccountField::OnBudget;
320    let on_budget = app.account_form.on_budget;
321    let error_message = app.account_form.error_message.clone();
322
323    // Render name field
324    render_text_field(
325        frame,
326        chunks[0],
327        "Name",
328        &name_value,
329        name_focused,
330        name_cursor,
331        &name_placeholder,
332    );
333
334    // Render account type label
335    let type_label_style = if type_focused {
336        Style::default()
337            .fg(Color::Cyan)
338            .add_modifier(Modifier::BOLD)
339    } else {
340        Style::default().fg(Color::Yellow)
341    };
342    let type_label = Paragraph::new(Line::from(vec![
343        Span::styled("Type: ", type_label_style),
344        Span::styled("(↑/↓ to change)", Style::default().fg(Color::White)),
345    ]));
346    frame.render_widget(type_label, chunks[2]);
347
348    // Render account type list
349    render_account_type_list(frame, app, chunks[3]);
350
351    // Render starting balance field
352    render_text_field(
353        frame,
354        chunks[4],
355        "Balance",
356        &balance_value,
357        balance_focused,
358        balance_cursor,
359        &balance_placeholder,
360    );
361
362    // Render on budget toggle
363    let budget_label_style = if budget_focused {
364        Style::default()
365            .fg(Color::Cyan)
366            .add_modifier(Modifier::BOLD)
367    } else {
368        Style::default().fg(Color::Yellow)
369    };
370    let budget_value = if on_budget { "[x] Yes" } else { "[ ] No" };
371    let budget_hint = if budget_focused {
372        " (Space to toggle)"
373    } else {
374        ""
375    };
376    let budget_line = Line::from(vec![
377        Span::styled("On Budget: ", budget_label_style),
378        Span::styled(budget_value, Style::default().fg(Color::White)),
379        Span::styled(budget_hint, Style::default().fg(Color::White)),
380    ]);
381    frame.render_widget(Paragraph::new(budget_line), chunks[5]);
382
383    // Render error message if any
384    if let Some(ref error) = error_message {
385        let error_line = Line::from(Span::styled(
386            error.as_str(),
387            Style::default().fg(Color::Red),
388        ));
389        frame.render_widget(Paragraph::new(error_line), chunks[7]);
390    }
391
392    // Render buttons/hints
393    let hints = Line::from(vec![
394        Span::styled("[Tab]", Style::default().fg(Color::White)),
395        Span::raw(" Next  "),
396        Span::styled("[Enter]", Style::default().fg(Color::Green)),
397        Span::raw(" Save  "),
398        Span::styled("[Esc]", Style::default().fg(Color::Red)),
399        Span::raw(" Cancel"),
400    ]);
401    frame.render_widget(Paragraph::new(hints), chunks[8]);
402}
403
404/// Render a text field
405fn render_text_field(
406    frame: &mut Frame,
407    area: Rect,
408    label: &str,
409    value: &str,
410    focused: bool,
411    cursor: usize,
412    placeholder: &str,
413) {
414    let label_style = if focused {
415        Style::default()
416            .fg(Color::Cyan)
417            .add_modifier(Modifier::BOLD)
418    } else {
419        Style::default().fg(Color::Yellow)
420    };
421
422    let label_span = Span::styled(format!("{}: ", label), label_style);
423
424    let value_style = Style::default().fg(Color::White);
425
426    let display_value = if value.is_empty() && !focused {
427        placeholder.to_string()
428    } else {
429        value.to_string()
430    };
431
432    let mut spans = vec![label_span];
433
434    if focused {
435        let cursor_pos = cursor.min(display_value.len());
436        let (before, after) = display_value.split_at(cursor_pos);
437
438        spans.push(Span::styled(before.to_string(), value_style));
439
440        let cursor_char = after.chars().next().unwrap_or(' ');
441        spans.push(Span::styled(
442            cursor_char.to_string(),
443            Style::default().fg(Color::Black).bg(Color::Cyan),
444        ));
445
446        if after.len() > 1 {
447            spans.push(Span::styled(after[1..].to_string(), value_style));
448        }
449    } else {
450        spans.push(Span::styled(display_value, value_style));
451    }
452
453    frame.render_widget(Paragraph::new(Line::from(spans)), area);
454}
455
456/// Render the account type selection list
457fn render_account_type_list(frame: &mut Frame, app: &mut App, area: Rect) {
458    let form = &app.account_form;
459    let focused = form.focused_field == AccountField::AccountType;
460
461    let items: Vec<ListItem> = ACCOUNT_TYPES
462        .iter()
463        .map(|t| {
464            ListItem::new(Line::from(Span::styled(
465                format!("  {}", t),
466                Style::default().fg(Color::White),
467            )))
468        })
469        .collect();
470
471    let list = List::new(items)
472        .highlight_style(
473            Style::default()
474                .bg(Color::DarkGray)
475                .add_modifier(Modifier::BOLD),
476        )
477        .highlight_symbol("▶ ");
478
479    let mut state = ListState::default();
480    state.select(Some(form.account_type_index));
481
482    if focused {
483        frame.render_stateful_widget(list, area, &mut state);
484    } else {
485        // Show hint when not focused
486        let hint = Paragraph::new("  (Tab to this field to select)")
487            .style(Style::default().fg(Color::White));
488        frame.render_widget(hint, area);
489    }
490}
491
492/// Handle key input for the account dialog
493pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
494    use crossterm::event::{KeyCode, KeyModifiers};
495
496    let form = &mut app.account_form;
497
498    match key.code {
499        KeyCode::Esc => {
500            app.close_dialog();
501            return true;
502        }
503
504        KeyCode::Tab => {
505            if key.modifiers.contains(KeyModifiers::SHIFT) {
506                form.prev_field();
507            } else {
508                form.next_field();
509            }
510            return true;
511        }
512
513        KeyCode::BackTab => {
514            form.prev_field();
515            return true;
516        }
517
518        KeyCode::Enter => {
519            // Try to save
520            if let Err(e) = save_account(app) {
521                app.account_form.set_error(e);
522            }
523            return true;
524        }
525
526        KeyCode::Up => {
527            if form.focused_field == AccountField::AccountType {
528                form.prev_account_type();
529                return true;
530            }
531        }
532
533        KeyCode::Down => {
534            if form.focused_field == AccountField::AccountType {
535                form.next_account_type();
536                return true;
537            }
538        }
539
540        KeyCode::Char(' ') => {
541            if form.focused_field == AccountField::OnBudget {
542                form.toggle_on_budget();
543                return true;
544            }
545            // Otherwise fall through to character input
546        }
547
548        KeyCode::Backspace => {
549            form.clear_error();
550            if let Some(input) = form.focused_input() {
551                input.backspace();
552            }
553            return true;
554        }
555
556        KeyCode::Delete => {
557            form.clear_error();
558            if let Some(input) = form.focused_input() {
559                input.delete();
560            }
561            return true;
562        }
563
564        KeyCode::Left => {
565            if let Some(input) = form.focused_input() {
566                input.move_left();
567            }
568            return true;
569        }
570
571        KeyCode::Right => {
572            if let Some(input) = form.focused_input() {
573                input.move_right();
574            }
575            return true;
576        }
577
578        KeyCode::Home => {
579            if let Some(input) = form.focused_input() {
580                input.move_start();
581            }
582            return true;
583        }
584
585        KeyCode::End => {
586            if let Some(input) = form.focused_input() {
587                input.move_end();
588            }
589            return true;
590        }
591
592        KeyCode::Char(c) => {
593            form.clear_error();
594            if let Some(input) = form.focused_input() {
595                input.insert(c);
596            }
597            return true;
598        }
599
600        _ => {}
601    }
602
603    false
604}
605
606/// Save the account
607fn save_account(app: &mut App) -> Result<(), String> {
608    // Validate form
609    app.account_form.validate()?;
610
611    let is_edit = app.account_form.is_edit;
612    let editing_id = app.account_form.editing_account_id;
613
614    if is_edit {
615        // Update existing account
616        if let Some(account_id) = editing_id {
617            if let Ok(Some(mut existing)) = app.storage.accounts.get(account_id) {
618                existing.name = app.account_form.name_input.value().trim().to_string();
619                existing.account_type = app.account_form.selected_account_type();
620                existing.on_budget = app.account_form.on_budget;
621
622                // Update starting balance
623                let balance_str = app.account_form.balance_input.value().trim();
624                let mut new_balance = if balance_str.is_empty() {
625                    Money::zero()
626                } else {
627                    Money::parse(balance_str).map_err(|_| "Invalid balance")?
628                };
629
630                // For liability accounts, negate positive balances (debt is stored as negative)
631                if existing.account_type.is_liability() && new_balance.cents() > 0 {
632                    new_balance = Money::from_cents(-new_balance.cents());
633                }
634                existing.starting_balance = new_balance;
635
636                existing.updated_at = chrono::Utc::now();
637
638                let account_name = existing.name.clone();
639                app.storage
640                    .accounts
641                    .upsert(existing)
642                    .map_err(|e| e.to_string())?;
643
644                app.storage.accounts.save().map_err(|e| e.to_string())?;
645                app.close_dialog();
646                app.set_status(format!("Account '{}' updated", account_name));
647            }
648        }
649    } else {
650        // Build new account
651        let account = app.account_form.build_account()?;
652        let account_name = account.name.clone();
653
654        // Save to storage
655        app.storage
656            .accounts
657            .upsert(account)
658            .map_err(|e| e.to_string())?;
659
660        // Save to disk
661        app.storage.accounts.save().map_err(|e| e.to_string())?;
662
663        // Close dialog
664        app.close_dialog();
665        app.set_status(format!("Account '{}' created", account_name));
666    }
667
668    Ok(())
669}