1use 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
19const 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#[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 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 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#[derive(Debug, Clone)]
64pub struct AccountFormState {
65 pub focused_field: AccountField,
67
68 pub name_input: TextInput,
70
71 pub account_type_index: usize,
73
74 pub balance_input: TextInput,
76
77 pub on_budget: bool,
79
80 pub is_edit: bool,
82
83 pub editing_account_id: Option<crate::models::AccountId>,
85
86 pub error_message: Option<String>,
88}
89
90impl Default for AccountFormState {
91 fn default() -> Self {
92 Self::new()
93 }
94}
95
96impl AccountFormState {
97 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, 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 pub fn from_account(account: &Account) -> Self {
116 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 pub fn next_field(&mut self) {
139 self.focused_field = self.focused_field.next();
140 }
141
142 pub fn prev_field(&mut self) {
144 self.focused_field = self.focused_field.prev();
145 }
146
147 pub fn set_focus(&mut self, field: AccountField) {
149 self.focused_field = field;
150 }
151
152 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 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 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 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 fn default_on_budget_for_type(&self) -> bool {
187 match self.selected_account_type() {
188 AccountType::Investment => false,
190 _ => true,
192 }
193 }
194
195 pub fn toggle_on_budget(&mut self) {
197 self.on_budget = !self.on_budget;
198 }
199
200 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 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 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 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 pub fn clear_error(&mut self) {
248 self.error_message = None;
249 }
250
251 pub fn set_error(&mut self, msg: impl Into<String>) {
253 self.error_message = Some(msg.into());
254 }
255}
256
257pub fn render(frame: &mut Frame, app: &mut App) {
259 let area = centered_rect(60, 50, frame.area());
260
261 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 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 let chunks = Layout::default()
292 .direction(Direction::Vertical)
293 .constraints([
294 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
305 .split(inner);
306
307 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_text_field(
325 frame,
326 chunks[0],
327 "Name",
328 &name_value,
329 name_focused,
330 name_cursor,
331 &name_placeholder,
332 );
333
334 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(frame, app, chunks[3]);
350
351 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 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 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 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
404fn 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
456fn 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 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
492pub 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 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 }
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
606fn save_account(app: &mut App) -> Result<(), String> {
608 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 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 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 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 let account = app.account_form.build_account()?;
652 let account_name = account.name.clone();
653
654 app.storage
656 .accounts
657 .upsert(account)
658 .map_err(|e| e.to_string())?;
659
660 app.storage.accounts.save().map_err(|e| e.to_string())?;
662
663 app.close_dialog();
665 app.set_status(format!("Account '{}' created", account_name));
666 }
667
668 Ok(())
669}