envelope_cli/tui/dialogs/
bulk_categorize.rs1use ratatui::{
6 layout::{Constraint, Direction, Layout, Rect},
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
10 Frame,
11};
12
13use crate::models::CategoryId;
14use crate::services::CategoryService;
15use crate::tui::app::App;
16use crate::tui::layout::centered_rect;
17
18#[derive(Debug, Clone, Default)]
20pub struct BulkCategorizeState {
21 pub selected_category: Option<CategoryId>,
23 pub category_list_index: usize,
25 pub search_input: String,
27 pub search_cursor: usize,
29 pub error_message: Option<String>,
31 pub success_message: Option<String>,
33}
34
35impl BulkCategorizeState {
36 pub fn new() -> Self {
37 Self::default()
38 }
39
40 pub fn reset(&mut self) {
42 *self = Self::default();
43 }
44
45 pub fn clear_error(&mut self) {
47 self.error_message = None;
48 }
49
50 pub fn set_error(&mut self, msg: impl Into<String>) {
52 self.error_message = Some(msg.into());
53 self.success_message = None;
54 }
55
56 pub fn set_success(&mut self, msg: impl Into<String>) {
58 self.success_message = Some(msg.into());
59 self.error_message = None;
60 }
61
62 pub fn insert_char(&mut self, c: char) {
64 self.search_input.insert(self.search_cursor, c);
65 self.search_cursor += 1;
66 self.category_list_index = 0;
68 }
69
70 pub fn backspace(&mut self) {
72 if self.search_cursor > 0 {
73 self.search_cursor -= 1;
74 self.search_input.remove(self.search_cursor);
75 self.category_list_index = 0;
77 }
78 }
79
80 pub fn clear_search(&mut self) {
82 self.search_input.clear();
83 self.search_cursor = 0;
84 self.category_list_index = 0;
85 }
86}
87
88pub fn render(frame: &mut Frame, app: &mut App) {
90 let area = centered_rect(55, 60, frame.area());
91
92 frame.render_widget(Clear, area);
94
95 let count = app.selected_transactions.len();
96
97 let block = Block::default()
98 .title(format!(
99 " Categorize {} Transaction{} ",
100 count,
101 if count == 1 { "" } else { "s" }
102 ))
103 .title_style(
104 Style::default()
105 .fg(Color::Cyan)
106 .add_modifier(Modifier::BOLD),
107 )
108 .borders(Borders::ALL)
109 .border_style(Style::default().fg(Color::Cyan));
110
111 frame.render_widget(block, area);
112
113 let inner = Rect {
115 x: area.x + 2,
116 y: area.y + 1,
117 width: area.width.saturating_sub(4),
118 height: area.height.saturating_sub(2),
119 };
120
121 let chunks = Layout::default()
123 .direction(Direction::Vertical)
124 .constraints([
125 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Min(6), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
133 .split(inner);
134
135 let category_service = CategoryService::new(app.storage);
137 let all_categories = category_service.list_categories().unwrap_or_default();
138
139 let search = app.bulk_categorize_state.search_input.to_lowercase();
141 let filtered_categories: Vec<_> = all_categories
142 .iter()
143 .filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search))
144 .collect();
145
146 render_search_field(
148 frame,
149 &app.bulk_categorize_state.search_input,
150 app.bulk_categorize_state.search_cursor,
151 chunks[0],
152 chunks[1],
153 );
154
155 render_category_list(
157 frame,
158 &filtered_categories,
159 app.bulk_categorize_state.selected_category,
160 app.bulk_categorize_state.category_list_index,
161 chunks[3],
162 );
163
164 if let Some(ref error) = app.bulk_categorize_state.error_message {
166 let error_line = Line::from(Span::styled(
167 error.as_str(),
168 Style::default().fg(Color::Red),
169 ));
170 frame.render_widget(Paragraph::new(error_line), chunks[5]);
171 } else if let Some(ref success) = app.bulk_categorize_state.success_message {
172 let success_line = Line::from(Span::styled(
173 success.as_str(),
174 Style::default().fg(Color::Green),
175 ));
176 frame.render_widget(Paragraph::new(success_line), chunks[5]);
177 }
178
179 let hints = Line::from(vec![
181 Span::styled("[↑↓]", Style::default().fg(Color::Yellow)),
182 Span::raw(" Select "),
183 Span::styled("[Enter]", Style::default().fg(Color::Green)),
184 Span::raw(" Apply "),
185 Span::styled("[Esc]", Style::default().fg(Color::Red)),
186 Span::raw(" Cancel"),
187 ]);
188 frame.render_widget(Paragraph::new(hints), chunks[6]);
189}
190
191fn render_search_field(
193 frame: &mut Frame,
194 search: &str,
195 cursor: usize,
196 label_area: Rect,
197 input_area: Rect,
198) {
199 let label = Line::from(Span::styled(
201 "Search categories:",
202 Style::default().fg(Color::Cyan),
203 ));
204 frame.render_widget(Paragraph::new(label), label_area);
205
206 let mut spans = vec![Span::raw(" ")];
208
209 let cursor_pos = cursor.min(search.len());
210 let (before, after) = search.split_at(cursor_pos);
211
212 spans.push(Span::styled(
213 before.to_string(),
214 Style::default().fg(Color::White),
215 ));
216
217 let cursor_char = after.chars().next().unwrap_or(' ');
218 spans.push(Span::styled(
219 cursor_char.to_string(),
220 Style::default().fg(Color::Black).bg(Color::Cyan),
221 ));
222
223 if after.len() > 1 {
224 spans.push(Span::styled(
225 after[1..].to_string(),
226 Style::default().fg(Color::White),
227 ));
228 }
229
230 if search.is_empty() {
231 spans.push(Span::styled(
232 " (type to filter)",
233 Style::default().fg(Color::Yellow),
234 ));
235 }
236
237 frame.render_widget(Paragraph::new(Line::from(spans)), input_area);
238}
239
240fn render_category_list(
242 frame: &mut Frame,
243 categories: &[&crate::models::Category],
244 selected: Option<CategoryId>,
245 list_index: usize,
246 area: Rect,
247) {
248 if categories.is_empty() {
249 let text =
250 Paragraph::new("No matching categories").style(Style::default().fg(Color::Yellow));
251 frame.render_widget(text, area);
252 return;
253 }
254
255 let items: Vec<ListItem> = categories
256 .iter()
257 .map(|cat| {
258 let style = if Some(cat.id) == selected {
259 Style::default().fg(Color::Green)
260 } else {
261 Style::default().fg(Color::White)
262 };
263 ListItem::new(Line::from(Span::styled(format!(" {}", cat.name), style)))
264 })
265 .collect();
266
267 let list = List::new(items)
268 .highlight_style(
269 Style::default()
270 .bg(Color::DarkGray)
271 .add_modifier(Modifier::BOLD),
272 )
273 .highlight_symbol("▶ ");
274
275 let mut state = ListState::default();
276 state.select(Some(list_index.min(categories.len().saturating_sub(1))));
277
278 frame.render_stateful_widget(list, area, &mut state);
279}
280
281pub fn handle_key(app: &mut App, key: crossterm::event::KeyEvent) -> bool {
283 use crossterm::event::KeyCode;
284
285 let category_service = CategoryService::new(app.storage);
287 let all_categories = category_service.list_categories().unwrap_or_default();
288 let search = app.bulk_categorize_state.search_input.to_lowercase();
289 let filtered: Vec<_> = all_categories
290 .iter()
291 .filter(|c| search.is_empty() || c.name.to_lowercase().contains(&search))
292 .collect();
293 let cat_count = filtered.len();
294
295 match key.code {
296 KeyCode::Esc => {
297 app.bulk_categorize_state.reset();
298 app.close_dialog();
299 return true;
300 }
301
302 KeyCode::Enter => {
303 if cat_count > 0 {
305 let idx = app
306 .bulk_categorize_state
307 .category_list_index
308 .min(cat_count.saturating_sub(1));
309 if let Some(cat) = filtered.get(idx) {
310 execute_bulk_categorize(app, cat.id);
311 }
312 } else {
313 app.bulk_categorize_state.set_error("No category selected");
314 }
315 return true;
316 }
317
318 KeyCode::Up | KeyCode::Char('k') => {
319 if app.bulk_categorize_state.category_list_index > 0 {
320 app.bulk_categorize_state.category_list_index -= 1;
321 }
322 return true;
323 }
324
325 KeyCode::Down | KeyCode::Char('j') => {
326 if app.bulk_categorize_state.category_list_index < cat_count.saturating_sub(1) {
327 app.bulk_categorize_state.category_list_index += 1;
328 }
329 return true;
330 }
331
332 KeyCode::Char(c) => {
333 app.bulk_categorize_state.clear_error();
334 app.bulk_categorize_state.insert_char(c);
335 return true;
336 }
337
338 KeyCode::Backspace => {
339 app.bulk_categorize_state.clear_error();
340 app.bulk_categorize_state.backspace();
341 return true;
342 }
343
344 KeyCode::Delete => {
345 app.bulk_categorize_state.clear_search();
346 return true;
347 }
348
349 _ => {}
350 }
351
352 false
353}
354
355fn execute_bulk_categorize(app: &mut App, category_id: CategoryId) {
357 let transaction_ids = app.selected_transactions.clone();
358
359 if transaction_ids.is_empty() {
360 app.bulk_categorize_state
361 .set_error("No transactions selected");
362 return;
363 }
364
365 let mut success_count = 0;
366 let mut error_count = 0;
367
368 for txn_id in &transaction_ids {
369 match app.storage.transactions.get(*txn_id) {
370 Ok(Some(mut txn)) => {
371 if txn.is_transfer() {
373 continue;
374 }
375
376 txn.category_id = Some(category_id);
378 txn.updated_at = chrono::Utc::now();
379
380 if app.storage.transactions.upsert(txn).is_ok() {
381 success_count += 1;
382 } else {
383 error_count += 1;
384 }
385 }
386 _ => {
387 error_count += 1;
388 }
389 }
390 }
391
392 if let Err(e) = app.storage.transactions.save() {
394 app.bulk_categorize_state
395 .set_error(format!("Failed to save: {}", e));
396 return;
397 }
398
399 let category_service = CategoryService::new(app.storage);
401 let category_name = category_service
402 .get_category(category_id)
403 .ok()
404 .flatten()
405 .map(|c| c.name)
406 .unwrap_or_else(|| "Unknown".into());
407
408 app.selected_transactions.clear();
410 app.multi_select_mode = false;
411
412 if error_count > 0 {
414 app.set_status(format!(
415 "Categorized {} transactions as '{}' ({} errors)",
416 success_count, category_name, error_count
417 ));
418 } else {
419 app.set_status(format!(
420 "Categorized {} transaction{} as '{}'",
421 success_count,
422 if success_count == 1 { "" } else { "s" },
423 category_name
424 ));
425 }
426
427 app.bulk_categorize_state.reset();
428 app.close_dialog();
429}