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    }
173
174    /// Move to previous account type
175    pub fn prev_account_type(&mut self) {
176        if self.account_type_index == 0 {
177            self.account_type_index = ACCOUNT_TYPES.len() - 1;
178        } else {
179            self.account_type_index -= 1;
180        }
181    }
182
183    /// Toggle on-budget status
184    pub fn toggle_on_budget(&mut self) {
185        self.on_budget = !self.on_budget;
186    }
187
188    /// Validate the form and return any error
189    pub fn validate(&self) -> Result<(), String> {
190        let name = self.name_input.value().trim();
191        if name.is_empty() {
192            return Err("Account name is required".to_string());
193        }
194        if name.len() > 100 {
195            return Err("Account name too long (max 100 chars)".to_string());
196        }
197
198        // Validate balance format
199        let balance_str = self.balance_input.value().trim();
200        if !balance_str.is_empty() && Money::parse(balance_str).is_err() {
201            return Err("Invalid balance format".to_string());
202        }
203
204        Ok(())
205    }
206
207    /// Build an account from the form state
208    pub fn build_account(&self) -> Result<Account, String> {
209        self.validate()?;
210
211        let name = self.name_input.value().trim().to_string();
212        let account_type = self.selected_account_type();
213
214        let balance_str = self.balance_input.value().trim();
215        let mut starting_balance = if balance_str.is_empty() {
216            Money::zero()
217        } else {
218            Money::parse(balance_str).map_err(|_| "Invalid balance")?
219        };
220
221        // For liability accounts (credit cards, lines of credit), balances represent
222        // debt owed and should be stored as negative values. Users naturally enter
223        // positive numbers when specifying debt, so we negate them.
224        if account_type.is_liability() && starting_balance.cents() > 0 {
225            starting_balance = Money::from_cents(-starting_balance.cents());
226        }
227
228        let mut account = Account::with_starting_balance(name, account_type, starting_balance);
229        account.on_budget = self.on_budget;
230
231        Ok(account)
232    }
233
234    /// Clear any error message
235    pub fn clear_error(&mut self) {
236        self.error_message = None;
237    }
238
239    /// Set an error message
240    pub fn set_error(&mut self, msg: impl Into<String>) {
241        self.error_message = Some(msg.into());
242    }
243}
244
245/// Render the account dialog
246pub fn render(frame: &mut Frame, app: &mut App) {
247    let area = centered_rect(60, 50, frame.area());
248
249    // Clear the background
250    frame.render_widget(Clear, area);
251
252    let title = if app.account_form.is_edit {
253        " Edit Account "
254    } else {
255        " Add Account "
256    };
257
258    let block = Block::default()
259        .title(title)
260        .title_style(
261            Style::default()
262                .fg(Color::Cyan)
263                .add_modifier(Modifier::BOLD),
264        )
265        .borders(Borders::ALL)
266        .border_style(Style::default().fg(Color::Cyan));
267
268    frame.render_widget(block, area);
269
270    // Inner area for content
271    let inner = Rect {
272        x: area.x + 2,
273        y: area.y + 1,
274        width: area.width.saturating_sub(4),
275        height: area.height.saturating_sub(2),
276    };
277
278    // Layout: fields + buttons
279    let chunks = Layout::default()
280        .direction(Direction::Vertical)
281        .constraints([
282            Constraint::Length(1), // Name
283            Constraint::Length(1), // Spacer
284            Constraint::Length(1), // Account Type label
285            Constraint::Length(5), // Account Type list
286            Constraint::Length(1), // Starting Balance
287            Constraint::Length(1), // On Budget
288            Constraint::Length(1), // Spacer
289            Constraint::Length(1), // Error
290            Constraint::Length(1), // Buttons
291            Constraint::Min(0),    // Remaining
292        ])
293        .split(inner);
294
295    // Extract values to avoid borrow conflicts
296    let name_value = app.account_form.name_input.value().to_string();
297    let name_focused = app.account_form.focused_field == AccountField::Name;
298    let name_cursor = app.account_form.name_input.cursor;
299    let name_placeholder = app.account_form.name_input.placeholder.clone();
300
301    let balance_value = app.account_form.balance_input.value().to_string();
302    let balance_focused = app.account_form.focused_field == AccountField::StartingBalance;
303    let balance_cursor = app.account_form.balance_input.cursor;
304    let balance_placeholder = app.account_form.balance_input.placeholder.clone();
305
306    let type_focused = app.account_form.focused_field == AccountField::AccountType;
307    let budget_focused = app.account_form.focused_field == AccountField::OnBudget;
308    let on_budget = app.account_form.on_budget;
309    let error_message = app.account_form.error_message.clone();
310
311    // Render name field
312    render_text_field(
313        frame,
314        chunks[0],
315        "Name",
316        &name_value,
317        name_focused,
318        name_cursor,
319        &name_placeholder,
320    );
321
322    // Render account type label
323    let type_label_style = if type_focused {
324        Style::default()
325            .fg(Color::Cyan)
326            .add_modifier(Modifier::BOLD)
327    } else {
328        Style::default().fg(Color::Yellow)
329    };
330    let type_label = Paragraph::new(Line::from(vec![
331        Span::styled("Type: ", type_label_style),
332        Span::styled("(↑/↓ to change)", Style::default().fg(Color::White)),
333    ]));
334    frame.render_widget(type_label, chunks[2]);
335
336    // Render account type list
337    render_account_type_list(frame, app, chunks[3]);
338
339    // Render starting balance field
340    render_text_field(
341        frame,
342        chunks[4],
343        "Balance",
344        &balance_value,
345        balance_focused,
346        balance_cursor,
347        &balance_placeholder,
348    );
349
350    // Render on budget toggle
351    let budget_label_style = if budget_focused {
352        Style::default()
353            .fg(Color::Cyan)
354            .add_modifier(Modifier::BOLD)
355    } else {
356        Style::default().fg(Color::Yellow)
357    };
358    let budget_value = if on_budget { "[x] Yes" } else { "[ ] No" };
359    let budget_hint = if budget_focused {
360        " (Space to toggle)"
361    } else {
362        ""
363    };
364    let budget_line = Line::from(vec![
365        Span::styled("On Budget: ", budget_label_style),
366        Span::styled(budget_value, Style::default().fg(Color::White)),
367        Span::styled(budget_hint, Style::default().fg(Color::White)),
368    ]);
369    frame.render_widget(Paragraph::new(budget_line), chunks[5]);
370
371    // Render error message if any
372    if let Some(ref error) = error_message {
373        let error_line = Line::from(Span::styled(
374            error.as_str(),
375            Style::default().fg(Color::Red),
376        ));
377        frame.render_widget(Paragraph::new(error_line), chunks[7]);
378    }
379
380    // Render buttons/hints
381    let hints = Line::from(vec![
382        Span::styled("[Tab]", Style::default().fg(Color::White)),
383        Span::raw(" Next  "),
384        Span::styled("[Enter]", Style::default().fg(Color::Green)),
385        Span::raw(" Save  "),
386        Span::styled("[Esc]", Style::default().fg(Color::Red)),
387        Span::raw(" Cancel"),
388    ]);
389    frame.render_widget(Paragraph::new(hints), chunks[8]);
390}
391
392/// Render a text field
393fn render_text_field(
394    frame: &mut Frame,
395    area: Rect,
396    label: &str,
397    value: &str,
398    focused: bool,
399    cursor: usize,
400    placeholder: &str,
401) {
402    let label_style = if focused {
403        Style::default()
404            .fg(Color::Cyan)
405            .add_modifier(Modifier::BOLD)
406    } else {
407        Style::default().fg(Color::Yellow)
408    };
409
410    let label_span = Span::styled(format!("{}: ", label), label_style);
411
412    let value_style = Style::default().fg(Color::White);
413
414    let display_value = if value.is_empty() && !focused {
415        placeholder.to_string()
416    } else {
417        value.to_string()
418    };
419
420    let mut spans = vec![label_span];
421
422    if focused {
423        let cursor_pos = cursor.min(display_value.len());
424        let (before, after) = display_value.split_at(cursor_pos);
425
426        spans.push(Span::styled(before.to_string(), value_style));
427
428        let cursor_char = after.chars().next().unwrap_or(' ');
429        spans.push(Span::styled(
430            cursor_char.to_string(),
431            Style::default().fg(Color::Black).bg(Color::Cyan),
432        ));
433
434        if after.len() > 1 {
435            spans.push(Span::styled(after[1..].to_string(), value_style));
436        }
437    } else {
438        spans.push(Span::styled(display_value, value_style));
439    }
440
441    frame.render_widget(Paragraph::new(Line::from(spans)), area);
442}
443
444/// Render the account type selection list
445fn render_account_type_list(frame: &mut Frame, app: &mut App, area: Rect) {
446    let form = &app.account_form;
447    let focused = form.focused_field == AccountField::AccountType;
448
449    let items: Vec<ListItem> = ACCOUNT_TYPES
450        .iter()
451        .map(|t| {
452            ListItem::new(Line::from(Span::styled(
453                format!("  {}", t),
454                Style::default().fg(Color::White),
455            )))
456        })
457        .collect();
458
459    let list = List::new(items)
460        .highlight_style(
461            Style::default()
462                .bg(Color::DarkGray)
463                .add_modifier(Modifier::BOLD),
464        )
465        .highlight_symbol("▶ ");
466
467    let mut state = ListState::default();
468    state.select(Some(form.account_type_index));
469
470    if focused {
471        frame.render_stateful_widget(list, area, &mut state);
472    } else {
473        // Show hint when not focused
474        let hint = Paragraph::new("  (Tab to this field to select)")
475            .style(Style::default().fg(Color::White));
476        frame.render_widget(hint, area);
477    }
478}
479
480/// Handle key input for the account dialog
481pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
482    use crossterm::event::{KeyCode, KeyModifiers};
483
484    let form = &mut app.account_form;
485
486    match key.code {
487        KeyCode::Esc => {
488            app.close_dialog();
489            return true;
490        }
491
492        KeyCode::Tab => {
493            if key.modifiers.contains(KeyModifiers::SHIFT) {
494                form.prev_field();
495            } else {
496                form.next_field();
497            }
498            return true;
499        }
500
501        KeyCode::BackTab => {
502            form.prev_field();
503            return true;
504        }
505
506        KeyCode::Enter => {
507            // Try to save
508            if let Err(e) = save_account(app) {
509                app.account_form.set_error(e);
510            }
511            return true;
512        }
513
514        KeyCode::Up => {
515            if form.focused_field == AccountField::AccountType {
516                form.prev_account_type();
517                return true;
518            }
519        }
520
521        KeyCode::Down => {
522            if form.focused_field == AccountField::AccountType {
523                form.next_account_type();
524                return true;
525            }
526        }
527
528        KeyCode::Char(' ') => {
529            if form.focused_field == AccountField::OnBudget {
530                form.toggle_on_budget();
531                return true;
532            }
533            // Otherwise fall through to character input
534        }
535
536        KeyCode::Backspace => {
537            form.clear_error();
538            if let Some(input) = form.focused_input() {
539                input.backspace();
540            }
541            return true;
542        }
543
544        KeyCode::Delete => {
545            form.clear_error();
546            if let Some(input) = form.focused_input() {
547                input.delete();
548            }
549            return true;
550        }
551
552        KeyCode::Left => {
553            if let Some(input) = form.focused_input() {
554                input.move_left();
555            }
556            return true;
557        }
558
559        KeyCode::Right => {
560            if let Some(input) = form.focused_input() {
561                input.move_right();
562            }
563            return true;
564        }
565
566        KeyCode::Home => {
567            if let Some(input) = form.focused_input() {
568                input.move_start();
569            }
570            return true;
571        }
572
573        KeyCode::End => {
574            if let Some(input) = form.focused_input() {
575                input.move_end();
576            }
577            return true;
578        }
579
580        KeyCode::Char(c) => {
581            form.clear_error();
582            if let Some(input) = form.focused_input() {
583                input.insert(c);
584            }
585            return true;
586        }
587
588        _ => {}
589    }
590
591    false
592}
593
594/// Save the account
595fn save_account(app: &mut App) -> Result<(), String> {
596    // Validate form
597    app.account_form.validate()?;
598
599    let is_edit = app.account_form.is_edit;
600    let editing_id = app.account_form.editing_account_id;
601
602    if is_edit {
603        // Update existing account
604        if let Some(account_id) = editing_id {
605            if let Ok(Some(mut existing)) = app.storage.accounts.get(account_id) {
606                existing.name = app.account_form.name_input.value().trim().to_string();
607                existing.account_type = app.account_form.selected_account_type();
608                existing.on_budget = app.account_form.on_budget;
609
610                // Update starting balance
611                let balance_str = app.account_form.balance_input.value().trim();
612                let mut new_balance = if balance_str.is_empty() {
613                    Money::zero()
614                } else {
615                    Money::parse(balance_str).map_err(|_| "Invalid balance")?
616                };
617
618                // For liability accounts, negate positive balances (debt is stored as negative)
619                if existing.account_type.is_liability() && new_balance.cents() > 0 {
620                    new_balance = Money::from_cents(-new_balance.cents());
621                }
622                existing.starting_balance = new_balance;
623
624                existing.updated_at = chrono::Utc::now();
625
626                let account_name = existing.name.clone();
627                app.storage
628                    .accounts
629                    .upsert(existing)
630                    .map_err(|e| e.to_string())?;
631
632                app.storage.accounts.save().map_err(|e| e.to_string())?;
633                app.close_dialog();
634                app.set_status(format!("Account '{}' updated", account_name));
635            }
636        }
637    } else {
638        // Build new account
639        let account = app.account_form.build_account()?;
640        let account_name = account.name.clone();
641
642        // Save to storage
643        app.storage
644            .accounts
645            .upsert(account)
646            .map_err(|e| e.to_string())?;
647
648        // Save to disk
649        app.storage.accounts.save().map_err(|e| e.to_string())?;
650
651        // Close dialog
652        app.close_dialog();
653        app.set_status(format!("Account '{}' created", account_name));
654    }
655
656    Ok(())
657}