1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
23pub enum BudgetTab {
24 #[default]
25 Period,
26 Target,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
31pub enum TargetField {
32 #[default]
33 Amount,
34 Cadence,
35 CustomDays,
36 TargetDate,
37}
38
39#[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#[derive(Debug, Clone, Default)]
74pub struct BudgetDialogState {
75 pub category_id: Option<CategoryId>,
77 pub category_name: String,
78 pub active_tab: BudgetTab,
79 pub error_message: Option<String>,
80
81 pub current_budgeted: Money,
83 pub suggested_amount: Option<Money>,
84 pub period_amount_input: String,
85 pub period_cursor: usize,
86
87 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 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 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 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 pub fn reset(&mut self) {
172 *self = Self::default();
173 }
174
175 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 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 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 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 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
448pub fn render(frame: &mut Frame, app: &App) {
450 let state = &app.budget_dialog_state;
451
452 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 let chunks = Layout::default()
485 .direction(Direction::Vertical)
486 .constraints([
487 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
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), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(0),
552 ]
553 } else {
554 vec![
555 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), 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 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 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 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; 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 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 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 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), Constraint::Length(1), Constraint::Length(1), ];
664
665 if extra_field {
666 constraints.push(Constraint::Length(1)); }
668
669 constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); constraints.push(Constraint::Length(1)); 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 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; 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 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; 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 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
875pub 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 _ => 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}