1use chrono::{Local, NaiveDate};
7use ratatui::{
8 layout::{Constraint, Direction, Layout, Rect},
9 style::{Color, Modifier, Style},
10 text::{Line, Span},
11 widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
12 Frame,
13};
14
15use crate::models::{CategoryId, Money, Transaction, TransactionStatus};
16use crate::services::CategoryService;
17use crate::tui::app::{ActiveDialog, App};
18use crate::tui::layout::centered_rect;
19use crate::tui::widgets::input::TextInput;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum TransactionField {
24 #[default]
25 Date,
26 Payee,
27 Category,
28 Outflow,
29 Inflow,
30 Memo,
31}
32
33impl TransactionField {
34 pub fn next(self) -> Self {
36 match self {
37 Self::Date => Self::Payee,
38 Self::Payee => Self::Category,
39 Self::Category => Self::Outflow,
40 Self::Outflow => Self::Inflow,
41 Self::Inflow => Self::Memo,
42 Self::Memo => Self::Date,
43 }
44 }
45
46 pub fn prev(self) -> Self {
48 match self {
49 Self::Date => Self::Memo,
50 Self::Payee => Self::Date,
51 Self::Category => Self::Payee,
52 Self::Outflow => Self::Category,
53 Self::Inflow => Self::Outflow,
54 Self::Memo => Self::Inflow,
55 }
56 }
57}
58
59#[derive(Debug, Clone)]
61pub struct TransactionFormState {
62 pub focused_field: TransactionField,
64
65 pub date_input: TextInput,
67
68 pub payee_input: TextInput,
70
71 pub category_input: TextInput,
73
74 pub selected_category: Option<CategoryId>,
76
77 pub category_list_index: usize,
79
80 pub show_category_dropdown: bool,
82
83 pub outflow_input: TextInput,
85
86 pub inflow_input: TextInput,
88
89 pub memo_input: TextInput,
91
92 pub is_edit: bool,
94
95 pub error_message: Option<String>,
97}
98
99impl Default for TransactionFormState {
100 fn default() -> Self {
101 Self::new()
102 }
103}
104
105impl TransactionFormState {
106 pub fn new() -> Self {
108 let today = Local::now().date_naive();
109 Self {
110 focused_field: TransactionField::Date,
111 date_input: TextInput::new()
112 .label("Date")
113 .placeholder("YYYY-MM-DD")
114 .content(today.format("%Y-%m-%d").to_string()),
115 payee_input: TextInput::new()
116 .label("Payee")
117 .placeholder("Enter payee name"),
118 category_input: TextInput::new()
119 .label("Category")
120 .placeholder("Type to search..."),
121 selected_category: None,
122 category_list_index: 0,
123 show_category_dropdown: false,
124 outflow_input: TextInput::new().label("Outflow").placeholder("(expense)"),
125 inflow_input: TextInput::new().label("Inflow").placeholder("(income)"),
126 memo_input: TextInput::new().label("Memo").placeholder("Optional note"),
127 is_edit: false,
128 error_message: None,
129 }
130 }
131
132 pub fn from_transaction(txn: &Transaction, categories: &[(CategoryId, String)]) -> Self {
134 let mut state = Self::new();
135 state.is_edit = true;
136 state.date_input = TextInput::new()
137 .label("Date")
138 .content(txn.date.format("%Y-%m-%d").to_string());
139 state.payee_input = TextInput::new().label("Payee").content(&txn.payee_name);
140
141 let cents = txn.amount.cents();
143 if cents < 0 {
144 state.outflow_input = TextInput::new()
146 .label("Outflow")
147 .content(format!("{:.2}", (-cents) as f64 / 100.0));
148 state.inflow_input = TextInput::new().label("Inflow").placeholder("0.00");
149 } else if cents > 0 {
150 state.outflow_input = TextInput::new().label("Outflow").placeholder("0.00");
152 state.inflow_input = TextInput::new()
153 .label("Inflow")
154 .content(format!("{:.2}", cents as f64 / 100.0));
155 }
156
157 state.memo_input = TextInput::new().label("Memo").content(&txn.memo);
158
159 if let Some(cat_id) = txn.category_id {
161 state.selected_category = Some(cat_id);
162 if let Some((_, name)) = categories.iter().find(|(id, _)| *id == cat_id) {
163 state.category_input = TextInput::new().label("Category").content(name);
164 }
165 }
166
167 state
168 }
169
170 pub fn next_field(&mut self) {
172 self.show_category_dropdown = false;
173 self.focused_field = self.focused_field.next();
174 self.update_focus();
175 }
176
177 pub fn prev_field(&mut self) {
179 self.show_category_dropdown = false;
180 self.focused_field = self.focused_field.prev();
181 self.update_focus();
182 }
183
184 fn update_focus(&mut self) {
186 self.date_input.focused = self.focused_field == TransactionField::Date;
187 self.payee_input.focused = self.focused_field == TransactionField::Payee;
188 self.category_input.focused = self.focused_field == TransactionField::Category;
189 self.outflow_input.focused = self.focused_field == TransactionField::Outflow;
190 self.inflow_input.focused = self.focused_field == TransactionField::Inflow;
191 self.memo_input.focused = self.focused_field == TransactionField::Memo;
192
193 if self.focused_field == TransactionField::Category {
195 self.show_category_dropdown = true;
196 }
197 }
198
199 pub fn set_focus(&mut self, field: TransactionField) {
201 self.focused_field = field;
202 self.update_focus();
203 }
204
205 pub fn focused_input(&mut self) -> &mut TextInput {
207 match self.focused_field {
208 TransactionField::Date => &mut self.date_input,
209 TransactionField::Payee => &mut self.payee_input,
210 TransactionField::Category => &mut self.category_input,
211 TransactionField::Outflow => &mut self.outflow_input,
212 TransactionField::Inflow => &mut self.inflow_input,
213 TransactionField::Memo => &mut self.memo_input,
214 }
215 }
216
217 pub fn validate(&self) -> Result<(), String> {
219 if NaiveDate::parse_from_str(self.date_input.value(), "%Y-%m-%d").is_err() {
221 return Err("Invalid date format. Use YYYY-MM-DD".to_string());
222 }
223
224 let outflow_str = self.outflow_input.value().trim();
226 let inflow_str = self.inflow_input.value().trim();
227
228 let has_outflow = !outflow_str.is_empty();
229 let has_inflow = !inflow_str.is_empty();
230
231 if !has_outflow && !has_inflow {
232 return Err("Enter an outflow or inflow amount".to_string());
233 }
234
235 if has_outflow && has_inflow {
236 return Err("Enter either outflow OR inflow, not both".to_string());
237 }
238
239 if has_outflow && Money::parse(outflow_str).is_err() {
240 return Err("Invalid outflow format".to_string());
241 }
242
243 if has_inflow && Money::parse(inflow_str).is_err() {
244 return Err("Invalid inflow format".to_string());
245 }
246
247 Ok(())
248 }
249
250 pub fn build_transaction(
252 &self,
253 account_id: crate::models::AccountId,
254 ) -> Result<Transaction, String> {
255 self.validate()?;
256
257 let date = NaiveDate::parse_from_str(self.date_input.value(), "%Y-%m-%d")
258 .map_err(|_| "Invalid date")?;
259
260 let outflow_str = self.outflow_input.value().trim();
262 let inflow_str = self.inflow_input.value().trim();
263
264 let amount = if !outflow_str.is_empty() {
265 let parsed = Money::parse(outflow_str).map_err(|_| "Invalid outflow")?;
267 -parsed
268 } else {
269 Money::parse(inflow_str).map_err(|_| "Invalid inflow")?
271 };
272
273 let mut txn = Transaction::with_details(
274 account_id,
275 date,
276 amount,
277 self.payee_input.value(),
278 self.selected_category,
279 self.memo_input.value(),
280 );
281
282 txn.status = TransactionStatus::Pending;
283
284 Ok(txn)
285 }
286
287 pub fn clear_error(&mut self) {
289 self.error_message = None;
290 }
291
292 pub fn set_error(&mut self, msg: impl Into<String>) {
294 self.error_message = Some(msg.into());
295 }
296}
297
298pub fn render(frame: &mut Frame, app: &mut App) {
300 let area = centered_rect(70, 70, frame.area());
301
302 frame.render_widget(Clear, area);
304
305 let title = match &app.active_dialog {
306 ActiveDialog::AddTransaction => " Add Transaction ",
307 ActiveDialog::EditTransaction(_) => " Edit Transaction ",
308 _ => " Transaction ",
309 };
310
311 let block = Block::default()
312 .title(title)
313 .title_style(
314 Style::default()
315 .fg(Color::Cyan)
316 .add_modifier(Modifier::BOLD),
317 )
318 .borders(Borders::ALL)
319 .border_style(Style::default().fg(Color::Cyan));
320
321 frame.render_widget(block, area);
322
323 let inner = Rect {
325 x: area.x + 2,
326 y: area.y + 1,
327 width: area.width.saturating_sub(4),
328 height: area.height.saturating_sub(2),
329 };
330
331 let chunks = Layout::default()
333 .direction(Direction::Vertical)
334 .constraints([
335 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(6), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
347 .split(inner);
348
349 let date_value = app.transaction_form.date_input.value().to_string();
351 let date_focused = app.transaction_form.focused_field == TransactionField::Date;
352 let date_cursor = app.transaction_form.date_input.cursor;
353 let date_placeholder = app.transaction_form.date_input.placeholder.clone();
354
355 let payee_value = app.transaction_form.payee_input.value().to_string();
356 let payee_focused = app.transaction_form.focused_field == TransactionField::Payee;
357 let payee_cursor = app.transaction_form.payee_input.cursor;
358 let payee_placeholder = app.transaction_form.payee_input.placeholder.clone();
359
360 let outflow_value = app.transaction_form.outflow_input.value().to_string();
361 let outflow_focused = app.transaction_form.focused_field == TransactionField::Outflow;
362 let outflow_cursor = app.transaction_form.outflow_input.cursor;
363 let outflow_placeholder = app.transaction_form.outflow_input.placeholder.clone();
364
365 let inflow_value = app.transaction_form.inflow_input.value().to_string();
366 let inflow_focused = app.transaction_form.focused_field == TransactionField::Inflow;
367 let inflow_cursor = app.transaction_form.inflow_input.cursor;
368 let inflow_placeholder = app.transaction_form.inflow_input.placeholder.clone();
369
370 let memo_value = app.transaction_form.memo_input.value().to_string();
371 let memo_focused = app.transaction_form.focused_field == TransactionField::Memo;
372 let memo_cursor = app.transaction_form.memo_input.cursor;
373 let memo_placeholder = app.transaction_form.memo_input.placeholder.clone();
374
375 let error_message = app.transaction_form.error_message.clone();
376
377 render_field_simple(
379 frame,
380 chunks[0],
381 "Date",
382 &date_value,
383 date_focused,
384 date_cursor,
385 &date_placeholder,
386 );
387
388 render_field_simple(
390 frame,
391 chunks[1],
392 "Payee",
393 &payee_value,
394 payee_focused,
395 payee_cursor,
396 &payee_placeholder,
397 );
398
399 render_category_field(frame, app, chunks[2], chunks[3]);
401
402 render_field_simple(
404 frame,
405 chunks[4],
406 "Outflow",
407 &outflow_value,
408 outflow_focused,
409 outflow_cursor,
410 &outflow_placeholder,
411 );
412
413 render_field_simple(
415 frame,
416 chunks[5],
417 "Inflow",
418 &inflow_value,
419 inflow_focused,
420 inflow_cursor,
421 &inflow_placeholder,
422 );
423
424 render_field_simple(
426 frame,
427 chunks[6],
428 "Memo",
429 &memo_value,
430 memo_focused,
431 memo_cursor,
432 &memo_placeholder,
433 );
434
435 if let Some(ref error) = error_message {
437 let error_line = Line::from(Span::styled(
438 error.as_str(),
439 Style::default().fg(Color::Red),
440 ));
441 frame.render_widget(Paragraph::new(error_line), chunks[8]);
442 }
443
444 let hints = Line::from(vec![
446 Span::styled("[Tab]", Style::default().fg(Color::Yellow)),
447 Span::raw(" Next "),
448 Span::styled("[Shift+Tab]", Style::default().fg(Color::Yellow)),
449 Span::raw(" Prev "),
450 Span::styled("[Enter]", Style::default().fg(Color::Green)),
451 Span::raw(" Save "),
452 Span::styled("[Esc]", Style::default().fg(Color::Red)),
453 Span::raw(" Cancel"),
454 ]);
455 frame.render_widget(Paragraph::new(hints), chunks[9]);
456}
457
458fn render_field_simple(
460 frame: &mut Frame,
461 area: Rect,
462 label: &str,
463 value: &str,
464 focused: bool,
465 cursor: usize,
466 placeholder: &str,
467) {
468 let label_style = if focused {
470 Style::default()
471 .fg(Color::Cyan)
472 .add_modifier(Modifier::BOLD)
473 } else {
474 Style::default().fg(Color::Cyan)
475 };
476
477 let label_span = Span::styled(format!("{:>10}: ", label), label_style);
478
479 let value_style = if focused {
481 Style::default().fg(Color::White)
482 } else {
483 Style::default().fg(Color::Yellow)
484 };
485
486 let display_value = if value.is_empty() && !focused {
487 placeholder.to_string()
488 } else {
489 value.to_string()
490 };
491
492 let mut spans = vec![label_span];
493
494 if focused {
495 let cursor_pos = cursor.min(display_value.len());
497 let (before, after) = display_value.split_at(cursor_pos);
498
499 spans.push(Span::styled(before.to_string(), value_style));
500
501 let cursor_char = after.chars().next().unwrap_or(' ');
503 spans.push(Span::styled(
504 cursor_char.to_string(),
505 Style::default().fg(Color::Black).bg(Color::Cyan),
506 ));
507
508 if after.len() > 1 {
510 spans.push(Span::styled(after[1..].to_string(), value_style));
511 }
512 } else {
513 spans.push(Span::styled(display_value, value_style));
514 }
515
516 frame.render_widget(Paragraph::new(Line::from(spans)), area);
517}
518
519fn render_category_field(frame: &mut Frame, app: &mut App, input_area: Rect, dropdown_area: Rect) {
521 let form = &app.transaction_form;
522 let focused = form.focused_field == TransactionField::Category;
523
524 let label_style = if focused {
526 Style::default()
527 .fg(Color::Cyan)
528 .add_modifier(Modifier::BOLD)
529 } else {
530 Style::default().fg(Color::Cyan)
531 };
532
533 let display_value = if let Some(cat_id) = form.selected_category {
535 if let Ok(categories) = app.storage.categories.get_all_categories() {
537 categories
538 .iter()
539 .find(|c| c.id == cat_id)
540 .map(|c| c.name.clone())
541 .unwrap_or_else(|| form.category_input.value().to_string())
542 } else {
543 form.category_input.value().to_string()
544 }
545 } else if form.category_input.value().is_empty() && !focused {
546 form.category_input.placeholder.clone()
547 } else {
548 form.category_input.value().to_string()
549 };
550
551 let value_style = if focused {
552 Style::default().fg(Color::White)
553 } else {
554 Style::default().fg(Color::Yellow)
555 };
556
557 let mut spans = vec![Span::styled(format!("{:>10}: ", "Category"), label_style)];
558
559 if focused && form.selected_category.is_none() {
560 let cursor_pos = form.category_input.cursor.min(display_value.len());
562 let (before, after) = display_value.split_at(cursor_pos);
563
564 spans.push(Span::styled(before.to_string(), value_style));
565
566 let cursor_char = after.chars().next().unwrap_or(' ');
567 spans.push(Span::styled(
568 cursor_char.to_string(),
569 Style::default().fg(Color::Black).bg(Color::Cyan),
570 ));
571
572 if after.len() > 1 {
573 spans.push(Span::styled(after[1..].to_string(), value_style));
574 }
575 } else {
576 spans.push(Span::styled(display_value, value_style));
577 if focused && form.selected_category.is_some() {
578 spans.push(Span::styled(
579 " (Backspace to clear)",
580 Style::default().fg(Color::Yellow),
581 ));
582 }
583 }
584
585 frame.render_widget(Paragraph::new(Line::from(spans)), input_area);
586
587 if focused {
589 render_category_dropdown(frame, app, dropdown_area);
590 }
591}
592
593fn render_category_dropdown(frame: &mut Frame, app: &mut App, area: Rect) {
595 let category_service = CategoryService::new(app.storage);
596 let categories = category_service.list_categories().unwrap_or_default();
597
598 let search = app.transaction_form.category_input.value().to_lowercase();
600 let filtered: Vec<_> = categories
601 .iter()
602 .filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search))
603 .take(5)
604 .collect();
605
606 if filtered.is_empty() {
607 let hint = if search.is_empty() {
608 "No categories available"
609 } else {
610 "No matching categories"
611 };
612 let text = Paragraph::new(hint).style(Style::default().fg(Color::Yellow));
613 frame.render_widget(text, area);
614 return;
615 }
616
617 let items: Vec<ListItem> = filtered
618 .iter()
619 .map(|cat| {
620 ListItem::new(Line::from(Span::styled(
621 format!(" {}", cat.name),
622 Style::default().fg(Color::White),
623 )))
624 })
625 .collect();
626
627 let list = List::new(items)
628 .highlight_style(
629 Style::default()
630 .bg(Color::DarkGray)
631 .add_modifier(Modifier::BOLD),
632 )
633 .highlight_symbol("▶ ");
634
635 let mut state = ListState::default();
636 let idx = app
637 .transaction_form
638 .category_list_index
639 .min(filtered.len().saturating_sub(1));
640 state.select(Some(idx));
641
642 frame.render_stateful_widget(list, area, &mut state);
643}
644
645pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
648 use crossterm::event::{KeyCode, KeyModifiers};
649
650 let form = &mut app.transaction_form;
651
652 match key.code {
653 KeyCode::Esc => {
654 app.close_dialog();
655 return true;
656 }
657
658 KeyCode::Tab => {
659 if key.modifiers.contains(KeyModifiers::SHIFT) {
660 form.prev_field();
661 } else {
662 form.next_field();
663 }
664 return true;
665 }
666
667 KeyCode::BackTab => {
668 form.prev_field();
669 return true;
670 }
671
672 KeyCode::Enter => {
673 if form.focused_field == TransactionField::Category && form.selected_category.is_none()
675 {
676 select_category_from_dropdown(app);
677 return true;
678 }
679
680 if let Err(e) = save_transaction(app) {
682 app.transaction_form.set_error(e);
683 }
684 return true;
685 }
686
687 KeyCode::Up => {
688 if form.focused_field == TransactionField::Category && form.selected_category.is_none()
689 {
690 if form.category_list_index > 0 {
691 form.category_list_index -= 1;
692 }
693 return true;
694 }
695 }
696
697 KeyCode::Down => {
698 if form.focused_field == TransactionField::Category && form.selected_category.is_none()
699 {
700 form.category_list_index += 1;
701 return true;
702 }
703 }
704
705 KeyCode::Backspace => {
706 form.clear_error();
707
708 if form.focused_field == TransactionField::Category && form.selected_category.is_some()
710 {
711 form.selected_category = None;
712 form.category_input.clear();
713 return true;
714 }
715
716 form.focused_input().backspace();
718 return true;
719 }
720
721 KeyCode::Delete => {
722 form.clear_error();
723 form.focused_input().delete();
724 return true;
725 }
726
727 KeyCode::Left => {
728 form.focused_input().move_left();
729 return true;
730 }
731
732 KeyCode::Right => {
733 form.focused_input().move_right();
734 return true;
735 }
736
737 KeyCode::Home => {
738 form.focused_input().move_start();
739 return true;
740 }
741
742 KeyCode::End => {
743 form.focused_input().move_end();
744 return true;
745 }
746
747 KeyCode::Char(c) => {
748 form.clear_error();
749
750 if form.focused_field == TransactionField::Category && form.selected_category.is_some()
752 {
753 form.selected_category = None;
754 form.category_input.clear();
755 }
756
757 form.focused_input().insert(c);
758
759 if form.focused_field == TransactionField::Category {
761 form.category_list_index = 0;
762 }
763
764 return true;
765 }
766
767 _ => {}
768 }
769
770 false
771}
772
773fn select_category_from_dropdown(app: &mut App) {
775 let category_service = CategoryService::new(app.storage);
776 let categories = category_service.list_categories().unwrap_or_default();
777
778 let search = app.transaction_form.category_input.value().to_lowercase();
779 let filtered: Vec<_> = categories
780 .iter()
781 .filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search))
782 .take(5)
783 .collect();
784
785 let idx = app
786 .transaction_form
787 .category_list_index
788 .min(filtered.len().saturating_sub(1));
789 if let Some(cat) = filtered.get(idx) {
790 app.transaction_form.selected_category = Some(cat.id);
791 app.transaction_form.category_input = TextInput::new().label("Category").content(&cat.name);
792 app.transaction_form.next_field(); }
794}
795
796fn save_transaction(app: &mut App) -> Result<(), String> {
798 app.transaction_form.validate()?;
800
801 let account_id = app.selected_account.ok_or("No account selected")?;
803
804 let txn = app.transaction_form.build_transaction(account_id)?;
806
807 let is_edit = matches!(app.active_dialog, ActiveDialog::EditTransaction(_));
809
810 if is_edit {
811 if let ActiveDialog::EditTransaction(txn_id) = app.active_dialog {
812 if let Ok(Some(mut existing)) = app.storage.transactions.get(txn_id) {
814 existing.date = txn.date;
815 existing.amount = txn.amount;
816 existing.payee_name = txn.payee_name;
817 existing.category_id = txn.category_id;
818 existing.memo = txn.memo;
819 existing.updated_at = chrono::Utc::now();
820
821 app.storage
822 .transactions
823 .upsert(existing)
824 .map_err(|e| e.to_string())?;
825 }
826 }
827 } else {
828 app.storage
830 .transactions
831 .upsert(txn)
832 .map_err(|e| e.to_string())?;
833 }
834
835 app.storage.transactions.save().map_err(|e| e.to_string())?;
837
838 app.close_dialog();
840 app.set_status(if is_edit {
841 "Transaction updated"
842 } else {
843 "Transaction created"
844 });
845
846 Ok(())
847}