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 }
173
174 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 pub fn toggle_on_budget(&mut self) {
185 self.on_budget = !self.on_budget;
186 }
187
188 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 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 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 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 pub fn clear_error(&mut self) {
236 self.error_message = None;
237 }
238
239 pub fn set_error(&mut self, msg: impl Into<String>) {
241 self.error_message = Some(msg.into());
242 }
243}
244
245pub fn render(frame: &mut Frame, app: &mut App) {
247 let area = centered_rect(60, 50, frame.area());
248
249 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 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 let chunks = Layout::default()
280 .direction(Direction::Vertical)
281 .constraints([
282 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), ])
293 .split(inner);
294
295 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_text_field(
313 frame,
314 chunks[0],
315 "Name",
316 &name_value,
317 name_focused,
318 name_cursor,
319 &name_placeholder,
320 );
321
322 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(frame, app, chunks[3]);
338
339 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 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 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 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
392fn 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
444fn 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 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
480pub 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 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 }
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
594fn save_account(app: &mut App) -> Result<(), String> {
596 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 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 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 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 let account = app.account_form.build_account()?;
640 let account_name = account.name.clone();
641
642 app.storage
644 .accounts
645 .upsert(account)
646 .map_err(|e| e.to_string())?;
647
648 app.storage.accounts.save().map_err(|e| e.to_string())?;
650
651 app.close_dialog();
653 app.set_status(format!("Account '{}' created", account_name));
654 }
655
656 Ok(())
657}