envelope_cli/tui/dialogs/
budget.rs

1//! Unified budget dialog
2//!
3//! A tabbed dialog combining:
4//! - Period budget editing (set amount for current period)
5//! - Target settings (recurring budget goals with cadence)
6
7use chrono::NaiveDate;
8use ratatui::{
9    layout::{Constraint, Direction, Layout, Rect},
10    style::{Color, Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, Clear, Paragraph},
13    Frame,
14};
15
16use crate::models::{BudgetTarget, CategoryId, Money, TargetCadence};
17use crate::services::BudgetService;
18use crate::tui::app::App;
19use crate::tui::layout::centered_rect_fixed;
20
21/// Which tab is currently active
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum BudgetTab {
24    #[default]
25    Period,
26    Target,
27}
28
29/// Which field is focused in the target tab
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum TargetField {
32    #[default]
33    Amount,
34    Cadence,
35    CustomDays,
36    TargetDate,
37}
38
39/// Cadence options for budget targets
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
41pub enum CadenceOption {
42    Weekly,
43    #[default]
44    Monthly,
45    Yearly,
46    Custom,
47    ByDate,
48}
49
50impl CadenceOption {
51    pub fn all() -> &'static [Self] {
52        &[
53            Self::Weekly,
54            Self::Monthly,
55            Self::Yearly,
56            Self::Custom,
57            Self::ByDate,
58        ]
59    }
60
61    pub fn label(&self) -> &'static str {
62        match self {
63            Self::Weekly => "Weekly",
64            Self::Monthly => "Monthly",
65            Self::Yearly => "Yearly",
66            Self::Custom => "Custom (every N days)",
67            Self::ByDate => "By Date",
68        }
69    }
70}
71
72/// State for the unified budget dialog
73#[derive(Debug, Clone, Default)]
74pub struct BudgetDialogState {
75    // Common fields
76    pub category_id: Option<CategoryId>,
77    pub category_name: String,
78    pub active_tab: BudgetTab,
79    pub error_message: Option<String>,
80
81    // Period tab fields
82    pub current_budgeted: Money,
83    pub suggested_amount: Option<Money>,
84    pub period_amount_input: String,
85    pub period_cursor: usize,
86
87    // Target tab fields
88    pub has_existing_target: bool,
89    pub target_amount_input: String,
90    pub target_amount_cursor: usize,
91    pub cadence: CadenceOption,
92    pub custom_days_input: String,
93    pub custom_days_cursor: usize,
94    pub target_date_input: String,
95    pub target_date_cursor: usize,
96    pub target_field: TargetField,
97}
98
99impl BudgetDialogState {
100    pub fn new() -> Self {
101        Self::default()
102    }
103
104    /// Initialize the dialog for a category
105    pub fn init_for_category(
106        &mut self,
107        category_id: CategoryId,
108        category_name: String,
109        current_budgeted: Money,
110        suggested_amount: Option<Money>,
111        existing_target: Option<&BudgetTarget>,
112    ) {
113        self.category_id = Some(category_id);
114        self.category_name = category_name;
115        self.active_tab = BudgetTab::Period;
116        self.error_message = None;
117
118        // Period tab initialization
119        self.current_budgeted = current_budgeted;
120        self.suggested_amount = suggested_amount;
121        let cents = current_budgeted.cents();
122        if cents == 0 {
123            self.period_amount_input = String::new();
124        } else {
125            self.period_amount_input = format!("{:.2}", cents as f64 / 100.0);
126        }
127        self.period_cursor = self.period_amount_input.len();
128
129        // Target tab initialization
130        if let Some(target) = existing_target {
131            self.has_existing_target = true;
132            let cents = target.amount.cents();
133            if cents == 0 {
134                self.target_amount_input = String::new();
135            } else {
136                self.target_amount_input = format!("{:.2}", cents as f64 / 100.0);
137            }
138            self.target_amount_cursor = self.target_amount_input.len();
139
140            match &target.cadence {
141                TargetCadence::Weekly => self.cadence = CadenceOption::Weekly,
142                TargetCadence::Monthly => self.cadence = CadenceOption::Monthly,
143                TargetCadence::Yearly => self.cadence = CadenceOption::Yearly,
144                TargetCadence::Custom { days } => {
145                    self.cadence = CadenceOption::Custom;
146                    self.custom_days_input = days.to_string();
147                    self.custom_days_cursor = self.custom_days_input.len();
148                }
149                TargetCadence::ByDate { target_date } => {
150                    self.cadence = CadenceOption::ByDate;
151                    self.target_date_input = target_date.format("%Y-%m-%d").to_string();
152                    self.target_date_cursor = self.target_date_input.len();
153                }
154            }
155        } else {
156            self.has_existing_target = false;
157            self.target_amount_input = String::new();
158            self.target_amount_cursor = 0;
159            self.cadence = CadenceOption::Monthly;
160            self.custom_days_input = "30".to_string();
161            self.custom_days_cursor = 2;
162            let default_date = chrono::Local::now().date_naive() + chrono::Duration::days(180);
163            self.target_date_input = default_date.format("%Y-%m-%d").to_string();
164            self.target_date_cursor = self.target_date_input.len();
165        }
166
167        self.target_field = TargetField::Amount;
168    }
169
170    /// Reset the state
171    pub fn reset(&mut self) {
172        *self = Self::default();
173    }
174
175    /// Switch to the other tab
176    pub fn toggle_tab(&mut self) {
177        self.active_tab = match self.active_tab {
178            BudgetTab::Period => BudgetTab::Target,
179            BudgetTab::Target => BudgetTab::Period,
180        };
181        self.error_message = None;
182    }
183
184    /// Fill in the suggested amount (period tab)
185    pub fn use_suggested(&mut self) {
186        if let Some(suggested) = self.suggested_amount {
187            let cents = suggested.cents();
188            if cents == 0 {
189                self.period_amount_input = String::new();
190            } else {
191                self.period_amount_input = format!("{:.2}", cents as f64 / 100.0);
192            }
193            self.period_cursor = self.period_amount_input.len();
194            self.error_message = None;
195        }
196    }
197
198    // Period tab input handling
199    pub fn period_insert_char(&mut self, c: char) {
200        if c.is_ascii_digit() || c == '.' {
201            self.period_amount_input.insert(self.period_cursor, c);
202            self.period_cursor += 1;
203            self.error_message = None;
204        }
205    }
206
207    pub fn period_backspace(&mut self) {
208        if self.period_cursor > 0 {
209            self.period_cursor -= 1;
210            self.period_amount_input.remove(self.period_cursor);
211            self.error_message = None;
212        }
213    }
214
215    pub fn period_move_left(&mut self) {
216        if self.period_cursor > 0 {
217            self.period_cursor -= 1;
218        }
219    }
220
221    pub fn period_move_right(&mut self) {
222        if self.period_cursor < self.period_amount_input.len() {
223            self.period_cursor += 1;
224        }
225    }
226
227    pub fn period_clear(&mut self) {
228        self.period_amount_input.clear();
229        self.period_cursor = 0;
230        self.error_message = None;
231    }
232
233    pub fn parse_period_amount(&self) -> Result<Money, String> {
234        if self.period_amount_input.trim().is_empty() {
235            return Ok(Money::zero());
236        }
237        Money::parse(&self.period_amount_input).map_err(|_| "Invalid amount format".to_string())
238    }
239
240    // Target tab field navigation
241    pub fn target_next_field(&mut self) {
242        self.target_field = match self.target_field {
243            TargetField::Amount => TargetField::Cadence,
244            TargetField::Cadence => match self.cadence {
245                CadenceOption::Custom => TargetField::CustomDays,
246                CadenceOption::ByDate => TargetField::TargetDate,
247                _ => TargetField::Amount,
248            },
249            TargetField::CustomDays => TargetField::Amount,
250            TargetField::TargetDate => TargetField::Amount,
251        };
252    }
253
254    pub fn target_prev_field(&mut self) {
255        self.target_field = match self.target_field {
256            TargetField::Amount => match self.cadence {
257                CadenceOption::Custom => TargetField::CustomDays,
258                CadenceOption::ByDate => TargetField::TargetDate,
259                _ => TargetField::Cadence,
260            },
261            TargetField::Cadence => TargetField::Amount,
262            TargetField::CustomDays => TargetField::Cadence,
263            TargetField::TargetDate => TargetField::Cadence,
264        };
265    }
266
267    pub fn next_cadence(&mut self) {
268        let options = CadenceOption::all();
269        let current_idx = options.iter().position(|c| *c == self.cadence).unwrap_or(0);
270        let next_idx = (current_idx + 1) % options.len();
271        self.cadence = options[next_idx];
272    }
273
274    pub fn prev_cadence(&mut self) {
275        let options = CadenceOption::all();
276        let current_idx = options.iter().position(|c| *c == self.cadence).unwrap_or(0);
277        let prev_idx = if current_idx == 0 {
278            options.len() - 1
279        } else {
280            current_idx - 1
281        };
282        self.cadence = options[prev_idx];
283    }
284
285    // Target tab input handling
286    pub fn target_insert_char(&mut self, c: char) {
287        match self.target_field {
288            TargetField::Amount => {
289                if c.is_ascii_digit() || c == '.' {
290                    self.target_amount_input
291                        .insert(self.target_amount_cursor, c);
292                    self.target_amount_cursor += 1;
293                    self.error_message = None;
294                }
295            }
296            TargetField::CustomDays => {
297                if c.is_ascii_digit() {
298                    self.custom_days_input.insert(self.custom_days_cursor, c);
299                    self.custom_days_cursor += 1;
300                    self.error_message = None;
301                }
302            }
303            TargetField::TargetDate => {
304                if c.is_ascii_digit() || c == '-' {
305                    self.target_date_input.insert(self.target_date_cursor, c);
306                    self.target_date_cursor += 1;
307                    self.error_message = None;
308                }
309            }
310            TargetField::Cadence => {}
311        }
312    }
313
314    pub fn target_backspace(&mut self) {
315        match self.target_field {
316            TargetField::Amount => {
317                if self.target_amount_cursor > 0 {
318                    self.target_amount_cursor -= 1;
319                    self.target_amount_input.remove(self.target_amount_cursor);
320                    self.error_message = None;
321                }
322            }
323            TargetField::CustomDays => {
324                if self.custom_days_cursor > 0 {
325                    self.custom_days_cursor -= 1;
326                    self.custom_days_input.remove(self.custom_days_cursor);
327                    self.error_message = None;
328                }
329            }
330            TargetField::TargetDate => {
331                if self.target_date_cursor > 0 {
332                    self.target_date_cursor -= 1;
333                    self.target_date_input.remove(self.target_date_cursor);
334                    self.error_message = None;
335                }
336            }
337            TargetField::Cadence => {}
338        }
339    }
340
341    pub fn target_move_left(&mut self) {
342        match self.target_field {
343            TargetField::Amount => {
344                if self.target_amount_cursor > 0 {
345                    self.target_amount_cursor -= 1;
346                }
347            }
348            TargetField::CustomDays => {
349                if self.custom_days_cursor > 0 {
350                    self.custom_days_cursor -= 1;
351                }
352            }
353            TargetField::TargetDate => {
354                if self.target_date_cursor > 0 {
355                    self.target_date_cursor -= 1;
356                }
357            }
358            TargetField::Cadence => self.prev_cadence(),
359        }
360    }
361
362    pub fn target_move_right(&mut self) {
363        match self.target_field {
364            TargetField::Amount => {
365                if self.target_amount_cursor < self.target_amount_input.len() {
366                    self.target_amount_cursor += 1;
367                }
368            }
369            TargetField::CustomDays => {
370                if self.custom_days_cursor < self.custom_days_input.len() {
371                    self.custom_days_cursor += 1;
372                }
373            }
374            TargetField::TargetDate => {
375                if self.target_date_cursor < self.target_date_input.len() {
376                    self.target_date_cursor += 1;
377                }
378            }
379            TargetField::Cadence => self.next_cadence(),
380        }
381    }
382
383    pub fn target_clear_field(&mut self) {
384        match self.target_field {
385            TargetField::Amount => {
386                self.target_amount_input.clear();
387                self.target_amount_cursor = 0;
388            }
389            TargetField::CustomDays => {
390                self.custom_days_input.clear();
391                self.custom_days_cursor = 0;
392            }
393            TargetField::TargetDate => {
394                self.target_date_input.clear();
395                self.target_date_cursor = 0;
396            }
397            TargetField::Cadence => {}
398        }
399        self.error_message = None;
400    }
401
402    pub fn parse_target_amount(&self) -> Result<Money, String> {
403        if self.target_amount_input.trim().is_empty() {
404            return Err("Amount is required".to_string());
405        }
406        Money::parse(&self.target_amount_input).map_err(|_| "Invalid amount format".to_string())
407    }
408
409    pub fn parse_custom_days(&self) -> Result<u32, String> {
410        self.custom_days_input
411            .parse::<u32>()
412            .map_err(|_| "Invalid number of days".to_string())
413            .and_then(|d| {
414                if d == 0 {
415                    Err("Days must be at least 1".to_string())
416                } else {
417                    Ok(d)
418                }
419            })
420    }
421
422    pub fn parse_target_date(&self) -> Result<NaiveDate, String> {
423        NaiveDate::parse_from_str(&self.target_date_input, "%Y-%m-%d")
424            .map_err(|_| "Invalid date format (use YYYY-MM-DD)".to_string())
425    }
426
427    pub fn build_cadence(&self) -> Result<TargetCadence, String> {
428        match self.cadence {
429            CadenceOption::Weekly => Ok(TargetCadence::Weekly),
430            CadenceOption::Monthly => Ok(TargetCadence::Monthly),
431            CadenceOption::Yearly => Ok(TargetCadence::Yearly),
432            CadenceOption::Custom => {
433                let days = self.parse_custom_days()?;
434                Ok(TargetCadence::Custom { days })
435            }
436            CadenceOption::ByDate => {
437                let target_date = self.parse_target_date()?;
438                Ok(TargetCadence::ByDate { target_date })
439            }
440        }
441    }
442
443    pub fn set_error(&mut self, msg: impl Into<String>) {
444        self.error_message = Some(msg.into());
445    }
446}
447
448/// Render the unified budget dialog
449pub fn render(frame: &mut Frame, app: &App) {
450    let state = &app.budget_dialog_state;
451
452    // Calculate height based on active tab and content
453    let height = match state.active_tab {
454        BudgetTab::Period => {
455            if state.suggested_amount.is_some() {
456                13
457            } else {
458                11
459            }
460        }
461        BudgetTab::Target => match state.cadence {
462            CadenceOption::Custom | CadenceOption::ByDate => 15,
463            _ => 13,
464        },
465    };
466
467    let area = centered_rect_fixed(55, height, frame.area());
468    frame.render_widget(Clear, area);
469
470    let block = Block::default()
471        .title(format!(" Budget: {} ", state.category_name))
472        .title_style(
473            Style::default()
474                .fg(Color::Cyan)
475                .add_modifier(Modifier::BOLD),
476        )
477        .borders(Borders::ALL)
478        .border_style(Style::default().fg(Color::Cyan));
479
480    let inner = block.inner(area);
481    frame.render_widget(block, area);
482
483    // Render tabs and content
484    let chunks = Layout::default()
485        .direction(Direction::Vertical)
486        .constraints([
487            Constraint::Length(1), // Tab bar
488            Constraint::Length(1), // Separator
489            Constraint::Min(0),    // Content
490        ])
491        .split(inner);
492
493    render_tab_bar(frame, chunks[0], state);
494
495    match state.active_tab {
496        BudgetTab::Period => render_period_tab(frame, chunks[2], app),
497        BudgetTab::Target => render_target_tab(frame, chunks[2], app),
498    }
499}
500
501fn render_tab_bar(frame: &mut Frame, area: Rect, state: &BudgetDialogState) {
502    let period_style = if state.active_tab == BudgetTab::Period {
503        Style::default()
504            .fg(Color::Cyan)
505            .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
506    } else {
507        Style::default().fg(Color::White)
508    };
509
510    let target_style = if state.active_tab == BudgetTab::Target {
511        Style::default()
512            .fg(Color::Cyan)
513            .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
514    } else {
515        Style::default().fg(Color::White)
516    };
517
518    let target_label = if state.has_existing_target {
519        "Target ✓"
520    } else {
521        "Target"
522    };
523
524    let tabs = Line::from(vec![
525        Span::raw("  "),
526        Span::styled("This Period", period_style),
527        Span::raw("    "),
528        Span::styled(target_label, target_style),
529        Span::raw("          "),
530        Span::styled("[Tab]", Style::default().fg(Color::Yellow)),
531        Span::styled(" switch", Style::default().fg(Color::White)),
532    ]);
533
534    frame.render_widget(Paragraph::new(tabs), area);
535}
536
537fn render_period_tab(frame: &mut Frame, area: Rect, app: &App) {
538    let state = &app.budget_dialog_state;
539    let has_suggested = state.suggested_amount.is_some();
540
541    let constraints = if has_suggested {
542        vec![
543            Constraint::Length(1), // Period
544            Constraint::Length(1), // Current
545            Constraint::Length(1), // Suggested
546            Constraint::Length(1), // Spacer
547            Constraint::Length(1), // New amount label
548            Constraint::Length(1), // Amount input
549            Constraint::Length(1), // Error
550            Constraint::Length(1), // Instructions
551            Constraint::Min(0),
552        ]
553    } else {
554        vec![
555            Constraint::Length(1), // Period
556            Constraint::Length(1), // Current
557            Constraint::Length(1), // Spacer
558            Constraint::Length(1), // New amount label
559            Constraint::Length(1), // Amount input
560            Constraint::Length(1), // Error
561            Constraint::Length(1), // Instructions
562            Constraint::Min(0),
563        ]
564    };
565
566    let chunks = Layout::default()
567        .direction(Direction::Vertical)
568        .constraints(constraints)
569        .split(area);
570
571    let mut row = 0;
572
573    // Period
574    let period_line = Line::from(vec![
575        Span::styled("Period:    ", Style::default().fg(Color::Yellow)),
576        Span::styled(
577            format!("{}", app.current_period),
578            Style::default().fg(Color::White),
579        ),
580    ]);
581    frame.render_widget(Paragraph::new(period_line), chunks[row]);
582    row += 1;
583
584    // Current amount
585    let current_line = Line::from(vec![
586        Span::styled("Current:   ", Style::default().fg(Color::Yellow)),
587        Span::styled(
588            format!("{}", state.current_budgeted),
589            Style::default().fg(Color::White),
590        ),
591    ]);
592    frame.render_widget(Paragraph::new(current_line), chunks[row]);
593    row += 1;
594
595    // Suggested amount
596    if let Some(suggested) = state.suggested_amount {
597        let suggested_line = Line::from(vec![
598            Span::styled("Suggested: ", Style::default().fg(Color::Green)),
599            Span::styled(
600                format!("{}", suggested),
601                Style::default()
602                    .fg(Color::Green)
603                    .add_modifier(Modifier::BOLD),
604            ),
605            Span::styled(" (from target)", Style::default().fg(Color::DarkGray)),
606        ]);
607        frame.render_widget(Paragraph::new(suggested_line), chunks[row]);
608        row += 1;
609    }
610
611    row += 1; // Spacer
612
613    // New amount label
614    let label = Line::from(Span::styled(
615        "New amount:",
616        Style::default().fg(Color::Cyan),
617    ));
618    frame.render_widget(Paragraph::new(label), chunks[row]);
619    row += 1;
620
621    // Amount input with cursor
622    let input_line =
623        render_input_with_cursor("$", &state.period_amount_input, state.period_cursor, true);
624    frame.render_widget(Paragraph::new(input_line), chunks[row]);
625    row += 1;
626
627    // Error message
628    if let Some(ref error) = state.error_message {
629        let error_line = Line::from(Span::styled(
630            error.as_str(),
631            Style::default().fg(Color::Red),
632        ));
633        frame.render_widget(Paragraph::new(error_line), chunks[row]);
634    }
635    row += 1;
636
637    // Instructions
638    let mut instructions = vec![
639        Span::styled("[Enter]", Style::default().fg(Color::Green)),
640        Span::raw(" Save  "),
641        Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
642        Span::raw(" Cancel"),
643    ];
644
645    if has_suggested {
646        instructions.push(Span::raw("  "));
647        instructions.push(Span::styled("[s]", Style::default().fg(Color::Green)));
648        instructions.push(Span::raw(" Use Suggested"));
649    }
650
651    frame.render_widget(Paragraph::new(Line::from(instructions)), chunks[row]);
652}
653
654fn render_target_tab(frame: &mut Frame, area: Rect, app: &App) {
655    let state = &app.budget_dialog_state;
656
657    let extra_field = matches!(state.cadence, CadenceOption::Custom | CadenceOption::ByDate);
658
659    let mut constraints = vec![
660        Constraint::Length(1), // Amount label+input
661        Constraint::Length(1), // Spacer
662        Constraint::Length(1), // Cadence
663    ];
664
665    if extra_field {
666        constraints.push(Constraint::Length(1)); // Extra field (days or date)
667    }
668
669    constraints.push(Constraint::Length(1)); // Spacer
670    constraints.push(Constraint::Length(1)); // Error
671    constraints.push(Constraint::Length(1)); // Instructions
672    constraints.push(Constraint::Min(0));
673
674    let chunks = Layout::default()
675        .direction(Direction::Vertical)
676        .constraints(constraints)
677        .split(area);
678
679    let mut row = 0;
680
681    // Amount field
682    render_labeled_input(
683        frame,
684        chunks[row],
685        "Amount",
686        "$",
687        &state.target_amount_input,
688        state.target_amount_cursor,
689        state.target_field == TargetField::Amount,
690    );
691    row += 2; // Skip spacer
692
693    // Cadence selector
694    render_selector_field(
695        frame,
696        chunks[row],
697        "Frequency",
698        state.cadence.label(),
699        state.target_field == TargetField::Cadence,
700    );
701    row += 1;
702
703    // Extra field for Custom or ByDate
704    if extra_field {
705        match state.cadence {
706            CadenceOption::Custom => {
707                render_labeled_input(
708                    frame,
709                    chunks[row],
710                    "Every N days",
711                    "",
712                    &state.custom_days_input,
713                    state.custom_days_cursor,
714                    state.target_field == TargetField::CustomDays,
715                );
716            }
717            CadenceOption::ByDate => {
718                render_labeled_input(
719                    frame,
720                    chunks[row],
721                    "Target date",
722                    "",
723                    &state.target_date_input,
724                    state.target_date_cursor,
725                    state.target_field == TargetField::TargetDate,
726                );
727            }
728            _ => {}
729        }
730        row += 1;
731    }
732
733    row += 1; // Spacer
734
735    // Error message
736    if let Some(ref error) = state.error_message {
737        let error_line = Line::from(Span::styled(
738            error.as_str(),
739            Style::default().fg(Color::Red),
740        ));
741        frame.render_widget(Paragraph::new(error_line), chunks[row]);
742    }
743    row += 1;
744
745    // Instructions
746    let instructions = Line::from(vec![
747        Span::styled("[Enter]", Style::default().fg(Color::Green)),
748        Span::raw(" Save  "),
749        Span::styled("[Esc]", Style::default().fg(Color::Yellow)),
750        Span::raw(" Cancel  "),
751        Span::styled("[Del]", Style::default().fg(Color::Magenta)),
752        Span::raw(" Remove  "),
753        Span::styled("[↑↓]", Style::default().fg(Color::Cyan)),
754        Span::raw(" Fields"),
755    ]);
756    frame.render_widget(Paragraph::new(instructions), chunks[row]);
757}
758
759fn render_input_with_cursor(
760    prefix: &str,
761    value: &str,
762    cursor: usize,
763    _focused: bool,
764) -> Line<'static> {
765    let mut spans = vec![];
766
767    if !prefix.is_empty() {
768        spans.push(Span::raw(prefix.to_string()));
769    }
770
771    let cursor_pos = cursor.min(value.len());
772    let (before, after) = value.split_at(cursor_pos);
773
774    spans.push(Span::styled(
775        before.to_string(),
776        Style::default().fg(Color::White),
777    ));
778
779    let cursor_char = after.chars().next().unwrap_or(' ');
780    spans.push(Span::styled(
781        cursor_char.to_string(),
782        Style::default().fg(Color::Black).bg(Color::Cyan),
783    ));
784
785    if after.len() > 1 {
786        spans.push(Span::styled(
787            after[1..].to_string(),
788            Style::default().fg(Color::White),
789        ));
790    }
791
792    Line::from(spans)
793}
794
795fn render_labeled_input(
796    frame: &mut Frame,
797    area: Rect,
798    label: &str,
799    prefix: &str,
800    value: &str,
801    cursor: usize,
802    focused: bool,
803) {
804    let label_style = if focused {
805        Style::default()
806            .fg(Color::Cyan)
807            .add_modifier(Modifier::BOLD)
808    } else {
809        Style::default().fg(Color::Yellow)
810    };
811
812    let mut spans = vec![Span::styled(format!("{}: ", label), label_style)];
813
814    if !prefix.is_empty() {
815        spans.push(Span::raw(prefix.to_string()));
816    }
817
818    if focused {
819        let cursor_pos = cursor.min(value.len());
820        let (before, after) = value.split_at(cursor_pos);
821
822        spans.push(Span::styled(
823            before.to_string(),
824            Style::default().fg(Color::White),
825        ));
826
827        let cursor_char = after.chars().next().unwrap_or(' ');
828        spans.push(Span::styled(
829            cursor_char.to_string(),
830            Style::default().fg(Color::Black).bg(Color::Cyan),
831        ));
832
833        if after.len() > 1 {
834            spans.push(Span::styled(
835                after[1..].to_string(),
836                Style::default().fg(Color::White),
837            ));
838        }
839    } else {
840        spans.push(Span::styled(
841            value.to_string(),
842            Style::default().fg(Color::White),
843        ));
844    }
845
846    frame.render_widget(Paragraph::new(Line::from(spans)), area);
847}
848
849fn render_selector_field(frame: &mut Frame, area: Rect, label: &str, value: &str, focused: bool) {
850    let label_style = if focused {
851        Style::default()
852            .fg(Color::Cyan)
853            .add_modifier(Modifier::BOLD)
854    } else {
855        Style::default().fg(Color::Yellow)
856    };
857
858    let value_style = if focused {
859        Style::default().fg(Color::White).bg(Color::DarkGray)
860    } else {
861        Style::default().fg(Color::White)
862    };
863
864    let hint = if focused { " ← j/k →" } else { "" };
865
866    let line = Line::from(vec![
867        Span::styled(format!("{}: ", label), label_style),
868        Span::styled(format!(" {} ", value), value_style),
869        Span::styled(hint.to_string(), Style::default().fg(Color::Yellow)),
870    ]);
871
872    frame.render_widget(Paragraph::new(line), area);
873}
874
875/// Handle key events for the budget dialog
876pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
877    use crossterm::event::KeyCode;
878
879    match key.code {
880        KeyCode::Esc => {
881            app.budget_dialog_state.reset();
882            app.close_dialog();
883            true
884        }
885
886        KeyCode::Tab => {
887            app.budget_dialog_state.toggle_tab();
888            true
889        }
890
891        KeyCode::Enter => {
892            match app.budget_dialog_state.active_tab {
893                BudgetTab::Period => {
894                    if let Err(e) = save_period_budget(app) {
895                        app.budget_dialog_state.set_error(e);
896                    }
897                }
898                BudgetTab::Target => {
899                    if let Err(e) = save_target(app) {
900                        app.budget_dialog_state.set_error(e);
901                    }
902                }
903            }
904            true
905        }
906
907        KeyCode::Delete => {
908            if app.budget_dialog_state.active_tab == BudgetTab::Target {
909                if let Err(e) = remove_target(app) {
910                    app.budget_dialog_state.set_error(e);
911                }
912            }
913            true
914        }
915
916        // Tab-specific handling
917        _ => match app.budget_dialog_state.active_tab {
918            BudgetTab::Period => handle_period_key(app, key),
919            BudgetTab::Target => handle_target_key(app, key),
920        },
921    }
922}
923
924fn handle_period_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
925    use crossterm::event::{KeyCode, KeyModifiers};
926
927    match key.code {
928        KeyCode::Char('s') => {
929            app.budget_dialog_state.use_suggested();
930            true
931        }
932
933        KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
934            app.budget_dialog_state.period_clear();
935            true
936        }
937
938        KeyCode::Char(c) => {
939            app.budget_dialog_state.period_insert_char(c);
940            true
941        }
942
943        KeyCode::Backspace => {
944            app.budget_dialog_state.period_backspace();
945            true
946        }
947
948        KeyCode::Left => {
949            app.budget_dialog_state.period_move_left();
950            true
951        }
952
953        KeyCode::Right => {
954            app.budget_dialog_state.period_move_right();
955            true
956        }
957
958        _ => false,
959    }
960}
961
962fn handle_target_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
963    use crossterm::event::{KeyCode, KeyModifiers};
964
965    match key.code {
966        KeyCode::Down => {
967            app.budget_dialog_state.target_next_field();
968            true
969        }
970
971        KeyCode::Up => {
972            app.budget_dialog_state.target_prev_field();
973            true
974        }
975
976        KeyCode::Char('j') if app.budget_dialog_state.target_field == TargetField::Cadence => {
977            app.budget_dialog_state.next_cadence();
978            true
979        }
980
981        KeyCode::Char('k') if app.budget_dialog_state.target_field == TargetField::Cadence => {
982            app.budget_dialog_state.prev_cadence();
983            true
984        }
985
986        KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
987            app.budget_dialog_state.target_clear_field();
988            true
989        }
990
991        KeyCode::Char(c) => {
992            app.budget_dialog_state.target_insert_char(c);
993            true
994        }
995
996        KeyCode::Backspace => {
997            app.budget_dialog_state.target_backspace();
998            true
999        }
1000
1001        KeyCode::Left => {
1002            app.budget_dialog_state.target_move_left();
1003            true
1004        }
1005
1006        KeyCode::Right => {
1007            app.budget_dialog_state.target_move_right();
1008            true
1009        }
1010
1011        _ => false,
1012    }
1013}
1014
1015fn save_period_budget(app: &mut App) -> Result<(), String> {
1016    let state = &app.budget_dialog_state;
1017
1018    let category_id = state.category_id.ok_or("No category selected")?;
1019    let amount = state.parse_period_amount()?;
1020
1021    let budget_service = BudgetService::new(app.storage);
1022    budget_service
1023        .assign_to_category(category_id, &app.current_period, amount)
1024        .map_err(|e| e.to_string())?;
1025
1026    let cat_name = state.category_name.clone();
1027    app.budget_dialog_state.reset();
1028    app.close_dialog();
1029    app.set_status(format!("Budget for '{}' set to {}", cat_name, amount));
1030
1031    Ok(())
1032}
1033
1034fn save_target(app: &mut App) -> Result<(), String> {
1035    let state = &app.budget_dialog_state;
1036
1037    let category_id = state.category_id.ok_or("No category selected")?;
1038    let amount = state.parse_target_amount()?;
1039    let cadence = state.build_cadence()?;
1040
1041    let budget_service = BudgetService::new(app.storage);
1042    budget_service
1043        .set_target(category_id, amount, cadence)
1044        .map_err(|e| e.to_string())?;
1045
1046    let cat_name = state.category_name.clone();
1047    app.budget_dialog_state.reset();
1048    app.close_dialog();
1049    app.set_status(format!("Budget target set for '{}'", cat_name));
1050
1051    Ok(())
1052}
1053
1054fn remove_target(app: &mut App) -> Result<(), String> {
1055    let state = &app.budget_dialog_state;
1056
1057    let category_id = state.category_id.ok_or("No category selected")?;
1058
1059    let budget_service = BudgetService::new(app.storage);
1060
1061    if budget_service
1062        .remove_target(category_id)
1063        .map_err(|e| e.to_string())?
1064    {
1065        let cat_name = state.category_name.clone();
1066        app.budget_dialog_state.reset();
1067        app.close_dialog();
1068        app.set_status(format!("Budget target removed for '{}'", cat_name));
1069    } else {
1070        return Err("No target to remove".to_string());
1071    }
1072
1073    Ok(())
1074}