envelope_cli/tui/dialogs/
transaction.rs

1//! Transaction entry/edit dialog
2//!
3//! Modal dialog for adding or editing transactions with form fields,
4//! tab navigation, validation, and save/cancel functionality.
5
6use 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/// Which field is currently focused in the transaction form
22#[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    /// Get the next field (for Tab navigation)
35    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    /// Get the previous field (for Shift+Tab navigation)
47    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/// State for the transaction form dialog
60#[derive(Debug, Clone)]
61pub struct TransactionFormState {
62    /// Currently focused field
63    pub focused_field: TransactionField,
64
65    /// Date input
66    pub date_input: TextInput,
67
68    /// Payee input
69    pub payee_input: TextInput,
70
71    /// Category search input
72    pub category_input: TextInput,
73
74    /// Currently selected category ID
75    pub selected_category: Option<CategoryId>,
76
77    /// Category selection index (for dropdown)
78    pub category_list_index: usize,
79
80    /// Show category dropdown
81    pub show_category_dropdown: bool,
82
83    /// Outflow input (money going out - expenses)
84    pub outflow_input: TextInput,
85
86    /// Inflow input (money coming in - income)
87    pub inflow_input: TextInput,
88
89    /// Memo input
90    pub memo_input: TextInput,
91
92    /// Whether this is an edit (vs new transaction)
93    pub is_edit: bool,
94
95    /// Error message to display
96    pub error_message: Option<String>,
97}
98
99impl Default for TransactionFormState {
100    fn default() -> Self {
101        Self::new()
102    }
103}
104
105impl TransactionFormState {
106    /// Create a new form state with default values
107    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    /// Create form state pre-populated from an existing transaction
133    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        // Populate outflow/inflow based on amount sign
142        let cents = txn.amount.cents();
143        if cents < 0 {
144            // Negative = outflow (expense)
145            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            // Positive = inflow (income)
151            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        // Set category
160        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    /// Move to the next field
171    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    /// Move to the previous field
178    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    /// Update which input has focus
185    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        // Show dropdown when category is focused
194        if self.focused_field == TransactionField::Category {
195            self.show_category_dropdown = true;
196        }
197    }
198
199    /// Set focus to a specific field
200    pub fn set_focus(&mut self, field: TransactionField) {
201        self.focused_field = field;
202        self.update_focus();
203    }
204
205    /// Get the currently focused input
206    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    /// Validate the form and return any error
218    pub fn validate(&self) -> Result<(), String> {
219        // Validate date
220        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        // Validate outflow/inflow - at least one must have a value
225        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    /// Build a transaction from the form state
251    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        // Calculate amount from outflow/inflow
261        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            // Outflow = negative amount (expense)
266            let parsed = Money::parse(outflow_str).map_err(|_| "Invalid outflow")?;
267            -parsed
268        } else {
269            // Inflow = positive amount (income)
270            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    /// Clear any error message
288    pub fn clear_error(&mut self) {
289        self.error_message = None;
290    }
291
292    /// Set an error message
293    pub fn set_error(&mut self, msg: impl Into<String>) {
294        self.error_message = Some(msg.into());
295    }
296}
297
298/// Render the transaction dialog
299pub fn render(frame: &mut Frame, app: &mut App) {
300    let area = centered_rect(70, 70, frame.area());
301
302    // Clear the background
303    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    // Inner area for content
324    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    // Layout: fields + category dropdown + buttons
332    let chunks = Layout::default()
333        .direction(Direction::Vertical)
334        .constraints([
335            Constraint::Length(1), // Date
336            Constraint::Length(1), // Payee
337            Constraint::Length(1), // Category input
338            Constraint::Length(6), // Category dropdown
339            Constraint::Length(1), // Outflow
340            Constraint::Length(1), // Inflow
341            Constraint::Length(1), // Memo
342            Constraint::Length(1), // Spacer
343            Constraint::Length(1), // Error
344            Constraint::Length(1), // Buttons
345            Constraint::Min(0),    // Remaining
346        ])
347        .split(inner);
348
349    // Extract values we need from form (to avoid borrow conflicts)
350    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 date field
378    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 payee field
389    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 (needs app for category lookup)
400    render_category_field(frame, app, chunks[2], chunks[3]);
401
402    // Render outflow field
403    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 inflow field
414    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 memo field
425    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    // Render error message if any
436    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    // Render buttons/hints
445    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
458/// Render a single form field with extracted values
459fn 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    // Label
469    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    // Value with cursor if focused
480    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        // Show value with cursor
496        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        // Cursor character
502        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        // Rest after cursor
509        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
519/// Render the category field with dropdown
520fn 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    // Render the input line
525    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    // Show selected category name or search input
534    let display_value = if let Some(cat_id) = form.selected_category {
535        // Try to get category name
536        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        // Show input with cursor
561        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    // Render dropdown if focused and no category selected
588    if focused {
589        render_category_dropdown(frame, app, dropdown_area);
590    }
591}
592
593/// Render the category dropdown list
594fn 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    // Filter categories based on search input
599    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
645/// Handle key input for the transaction dialog
646/// Returns true if the key was handled, false otherwise
647pub 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 in category dropdown and category is focused, select the category
674            if form.focused_field == TransactionField::Category && form.selected_category.is_none()
675            {
676                select_category_from_dropdown(app);
677                return true;
678            }
679
680            // Otherwise, try to save
681            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 category is selected and we're in category field, clear it
709            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            // Normal backspace on focused input
717            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 category is selected and we're typing, clear it first
751            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            // Reset category list index when typing in category field
760            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
773/// Select the currently highlighted category from the dropdown
774fn 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(); // Move to next field after selection
793    }
794}
795
796/// Save the transaction
797fn save_transaction(app: &mut App) -> Result<(), String> {
798    // Validate form
799    app.transaction_form.validate()?;
800
801    // Get account ID
802    let account_id = app.selected_account.ok_or("No account selected")?;
803
804    // Build transaction
805    let txn = app.transaction_form.build_transaction(account_id)?;
806
807    // Check if edit or new
808    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            // Update existing transaction
813            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        // Create new transaction
829        app.storage
830            .transactions
831            .upsert(txn)
832            .map_err(|e| e.to_string())?;
833    }
834
835    // Save to disk
836    app.storage.transactions.save().map_err(|e| e.to_string())?;
837
838    // Close dialog
839    app.close_dialog();
840    app.set_status(if is_edit {
841        "Transaction updated"
842    } else {
843        "Transaction created"
844    });
845
846    Ok(())
847}