1use super::{
7 component::ComponentConfig, ComponentPosition, ComponentSpan, DashboardComponent,
8 DashboardTheme,
9};
10use crate::error::PdfError;
11use crate::page::Page;
12use std::collections::HashMap;
13
14#[derive(Debug, Clone)]
16pub struct PivotTable {
17 config: ComponentConfig,
19 data: Vec<HashMap<String, String>>,
21 pivot_config: PivotConfig,
23 computed_data: Option<ComputedPivotData>,
25}
26
27impl PivotTable {
28 pub fn new(data: Vec<HashMap<String, String>>) -> Self {
30 Self {
31 config: ComponentConfig::new(ComponentSpan::new(12)), data,
33 pivot_config: PivotConfig::default(),
34 computed_data: None,
35 }
36 }
37
38 pub fn with_config(mut self, config: PivotConfig) -> Self {
40 self.pivot_config = config;
41 self.computed_data = None; self
43 }
44
45 pub fn aggregate_by(mut self, functions: &[&str]) -> Self {
47 for func_str in functions {
48 if let Ok(func) = func_str.parse::<AggregateFunction>() {
49 if !self.pivot_config.aggregations.contains(&func) {
50 self.pivot_config.aggregations.push(func);
51 }
52 }
53 }
54 self.computed_data = None; self
56 }
57
58 fn ensure_computed(&mut self) -> Result<(), PdfError> {
60 if self.computed_data.is_none() {
61 self.computed_data = Some(self.compute_pivot_data()?);
62 }
63 Ok(())
64 }
65
66 fn compute_pivot_data(&self) -> Result<ComputedPivotData, PdfError> {
68 Ok(ComputedPivotData {
70 headers: vec!["Group".to_string(), "Count".to_string()],
71 rows: vec![
72 vec!["Group A".to_string(), "10".to_string()],
73 vec!["Group B".to_string(), "15".to_string()],
74 vec!["Total".to_string(), "25".to_string()],
75 ],
76 totals_row: Some(2),
77 })
78 }
79}
80
81impl DashboardComponent for PivotTable {
82 fn render(
83 &self,
84 page: &mut Page,
85 position: ComponentPosition,
86 theme: &DashboardTheme,
87 ) -> Result<(), PdfError> {
88 let mut table = self.clone();
89 table.ensure_computed()?;
90
91 let computed = table.computed_data.as_ref().ok_or_else(|| {
93 PdfError::InvalidOperation("Failed to compute pivot data".to_string())
94 })?;
95
96 if computed.headers.is_empty() {
97 return Ok(());
98 }
99
100 let title_height = if table.pivot_config.title.is_some() {
101 30.0
102 } else {
103 0.0
104 };
105 let row_height = 22.0;
106 let header_height = 25.0;
107 let padding = 5.0;
108
109 let mut current_y = position.y + position.height - title_height;
110
111 if let Some(ref title) = table.pivot_config.title {
113 page.text()
114 .set_font(crate::Font::HelveticaBold, theme.typography.heading_size)
115 .set_fill_color(theme.colors.text_primary)
116 .at(position.x, current_y - 15.0)
117 .write(title)?;
118 current_y -= title_height;
119 }
120
121 let col_width = position.width / computed.headers.len() as f64;
123
124 page.graphics()
126 .set_fill_color(crate::graphics::Color::gray(0.9))
127 .rect(
128 position.x,
129 current_y - header_height,
130 position.width,
131 header_height,
132 )
133 .fill();
134
135 page.graphics()
137 .set_stroke_color(crate::graphics::Color::gray(0.6))
138 .set_line_width(1.0)
139 .rect(
140 position.x,
141 current_y - header_height,
142 position.width,
143 header_height,
144 )
145 .stroke();
146
147 for (i, header) in computed.headers.iter().enumerate() {
149 let x = position.x + i as f64 * col_width + padding;
150
151 page.text()
152 .set_font(crate::Font::HelveticaBold, 10.0)
153 .set_fill_color(theme.colors.text_primary)
154 .at(x, current_y - header_height + 7.0)
155 .write(header)?;
156
157 if i < computed.headers.len() - 1 {
159 let sep_x = position.x + (i + 1) as f64 * col_width;
160 page.graphics()
161 .set_stroke_color(crate::graphics::Color::gray(0.6))
162 .set_line_width(0.5)
163 .move_to(sep_x, current_y - header_height)
164 .line_to(sep_x, current_y)
165 .stroke();
166 }
167 }
168
169 current_y -= header_height;
170
171 for (row_idx, row) in computed.rows.iter().enumerate() {
173 let is_totals = computed.totals_row == Some(row_idx);
174
175 if !is_totals && row_idx % 2 == 1 {
177 page.graphics()
178 .set_fill_color(crate::graphics::Color::gray(0.97))
179 .rect(
180 position.x,
181 current_y - row_height,
182 position.width,
183 row_height,
184 )
185 .fill();
186 }
187
188 if is_totals {
190 page.graphics()
191 .set_fill_color(crate::graphics::Color::gray(0.85))
192 .rect(
193 position.x,
194 current_y - row_height,
195 position.width,
196 row_height,
197 )
198 .fill();
199 }
200
201 page.graphics()
203 .set_stroke_color(crate::graphics::Color::gray(0.8))
204 .set_line_width(0.5)
205 .move_to(position.x, current_y - row_height)
206 .line_to(position.x + position.width, current_y - row_height)
207 .stroke();
208
209 for (col_idx, cell) in row.iter().enumerate() {
211 let x = position.x + col_idx as f64 * col_width + padding;
212
213 let font = if is_totals {
214 crate::Font::HelveticaBold
215 } else {
216 crate::Font::Helvetica
217 };
218
219 page.text()
220 .set_font(font, 9.0)
221 .set_fill_color(theme.colors.text_primary)
222 .at(x, current_y - row_height + 6.0)
223 .write(cell)?;
224
225 if col_idx < row.len() - 1 {
227 let sep_x = position.x + (col_idx + 1) as f64 * col_width;
228 page.graphics()
229 .set_stroke_color(crate::graphics::Color::gray(0.8))
230 .set_line_width(0.5)
231 .move_to(sep_x, current_y - row_height)
232 .line_to(sep_x, current_y)
233 .stroke();
234 }
235 }
236
237 current_y -= row_height;
238 }
239
240 page.graphics()
242 .set_stroke_color(crate::graphics::Color::gray(0.6))
243 .set_line_width(1.0)
244 .move_to(position.x, current_y)
245 .line_to(position.x + position.width, current_y)
246 .stroke();
247
248 page.graphics()
250 .set_stroke_color(crate::graphics::Color::gray(0.6))
251 .set_line_width(1.0)
252 .move_to(position.x, position.y + position.height - title_height)
253 .line_to(position.x, current_y)
254 .stroke();
255
256 page.graphics()
257 .set_stroke_color(crate::graphics::Color::gray(0.6))
258 .set_line_width(1.0)
259 .move_to(
260 position.x + position.width,
261 position.y + position.height - title_height,
262 )
263 .line_to(position.x + position.width, current_y)
264 .stroke();
265
266 Ok(())
267 }
268
269 fn get_span(&self) -> ComponentSpan {
270 self.config.span
271 }
272 fn set_span(&mut self, span: ComponentSpan) {
273 self.config.span = span;
274 }
275 fn preferred_height(&self, _available_width: f64) -> f64 {
276 200.0
277 }
278 fn component_type(&self) -> &'static str {
279 "PivotTable"
280 }
281 fn complexity_score(&self) -> u8 {
282 85
283 }
284}
285
286#[derive(Debug, Clone)]
288pub struct PivotConfig {
289 pub title: Option<String>,
291 pub row_groups: Vec<String>,
293 pub column_groups: Vec<String>,
295 pub aggregations: Vec<AggregateFunction>,
297 pub value_columns: Vec<String>,
299 pub show_totals: bool,
301 pub show_subtotals: bool,
303}
304
305impl Default for PivotConfig {
306 fn default() -> Self {
307 Self {
308 title: None,
309 row_groups: vec![],
310 column_groups: vec![],
311 aggregations: vec![AggregateFunction::Count],
312 value_columns: vec![],
313 show_totals: true,
314 show_subtotals: false,
315 }
316 }
317}
318
319#[derive(Debug, Clone)]
321pub struct ComputedPivotData {
322 pub headers: Vec<String>,
324 pub rows: Vec<Vec<String>>,
326 pub totals_row: Option<usize>,
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
332pub enum AggregateFunction {
333 Count,
334 Sum,
335 Average,
336 Min,
337 Max,
338}
339
340impl std::str::FromStr for AggregateFunction {
341 type Err = PdfError;
342
343 fn from_str(s: &str) -> Result<Self, Self::Err> {
344 match s.to_lowercase().as_str() {
345 "count" => Ok(AggregateFunction::Count),
346 "sum" => Ok(AggregateFunction::Sum),
347 "avg" | "average" => Ok(AggregateFunction::Average),
348 "min" => Ok(AggregateFunction::Min),
349 "max" => Ok(AggregateFunction::Max),
350 _ => Err(PdfError::InvalidOperation(format!(
351 "Unknown aggregate function: {}",
352 s
353 ))),
354 }
355 }
356}
357
358pub struct PivotTableBuilder;
360
361impl PivotTableBuilder {
362 pub fn new() -> Self {
363 Self
364 }
365 pub fn build(self) -> PivotTable {
366 PivotTable::new(vec![])
367 }
368}
369
370#[cfg(test)]
371mod tests {
372 use super::*;
373
374 fn sample_data() -> Vec<HashMap<String, String>> {
375 vec![
376 {
377 let mut m = HashMap::new();
378 m.insert("category".to_string(), "A".to_string());
379 m.insert("value".to_string(), "10".to_string());
380 m
381 },
382 {
383 let mut m = HashMap::new();
384 m.insert("category".to_string(), "B".to_string());
385 m.insert("value".to_string(), "20".to_string());
386 m
387 },
388 ]
389 }
390
391 #[test]
394 fn test_pivot_table_new() {
395 let data = sample_data();
396 let pivot = PivotTable::new(data.clone());
397
398 assert_eq!(pivot.data.len(), 2);
399 assert!(pivot.computed_data.is_none());
400 }
401
402 #[test]
403 fn test_pivot_table_new_empty() {
404 let pivot = PivotTable::new(vec![]);
405
406 assert!(pivot.data.is_empty());
407 assert!(pivot.computed_data.is_none());
408 }
409
410 #[test]
411 fn test_pivot_table_with_config() {
412 let pivot = PivotTable::new(sample_data());
413
414 let config = PivotConfig {
415 title: Some("Sales Report".to_string()),
416 row_groups: vec!["category".to_string()],
417 column_groups: vec![],
418 aggregations: vec![AggregateFunction::Sum],
419 value_columns: vec!["value".to_string()],
420 show_totals: true,
421 show_subtotals: true,
422 };
423
424 let pivot = pivot.with_config(config.clone());
425
426 assert_eq!(pivot.pivot_config.title, Some("Sales Report".to_string()));
427 assert!(pivot.pivot_config.show_subtotals);
428 assert!(pivot.computed_data.is_none()); }
430
431 #[test]
432 fn test_pivot_table_aggregate_by_single() {
433 let pivot = PivotTable::new(sample_data()).aggregate_by(&["sum"]);
434
435 assert!(pivot
436 .pivot_config
437 .aggregations
438 .contains(&AggregateFunction::Sum));
439 }
440
441 #[test]
442 fn test_pivot_table_aggregate_by_multiple() {
443 let pivot = PivotTable::new(sample_data()).aggregate_by(&["sum", "avg", "min", "max"]);
444
445 assert!(pivot
446 .pivot_config
447 .aggregations
448 .contains(&AggregateFunction::Sum));
449 assert!(pivot
450 .pivot_config
451 .aggregations
452 .contains(&AggregateFunction::Average));
453 assert!(pivot
454 .pivot_config
455 .aggregations
456 .contains(&AggregateFunction::Min));
457 assert!(pivot
458 .pivot_config
459 .aggregations
460 .contains(&AggregateFunction::Max));
461 }
462
463 #[test]
464 fn test_pivot_table_aggregate_by_invalid_ignored() {
465 let pivot = PivotTable::new(sample_data()).aggregate_by(&["invalid_func"]);
466
467 assert_eq!(pivot.pivot_config.aggregations.len(), 1);
469 assert!(pivot
470 .pivot_config
471 .aggregations
472 .contains(&AggregateFunction::Count));
473 }
474
475 #[test]
476 fn test_pivot_table_aggregate_by_no_duplicates() {
477 let pivot = PivotTable::new(sample_data())
478 .aggregate_by(&["sum"])
479 .aggregate_by(&["sum"]); let sum_count = pivot
483 .pivot_config
484 .aggregations
485 .iter()
486 .filter(|a| **a == AggregateFunction::Sum)
487 .count();
488 assert_eq!(sum_count, 1);
489 }
490
491 #[test]
494 fn test_pivot_config_default() {
495 let config = PivotConfig::default();
496
497 assert!(config.title.is_none());
498 assert!(config.row_groups.is_empty());
499 assert!(config.column_groups.is_empty());
500 assert_eq!(config.aggregations.len(), 1);
501 assert!(config.aggregations.contains(&AggregateFunction::Count));
502 assert!(config.value_columns.is_empty());
503 assert!(config.show_totals);
504 assert!(!config.show_subtotals);
505 }
506
507 #[test]
510 fn test_aggregate_function_parse_count() {
511 let func: AggregateFunction = "count".parse().unwrap();
512 assert_eq!(func, AggregateFunction::Count);
513
514 let func: AggregateFunction = "COUNT".parse().unwrap();
515 assert_eq!(func, AggregateFunction::Count);
516 }
517
518 #[test]
519 fn test_aggregate_function_parse_sum() {
520 let func: AggregateFunction = "sum".parse().unwrap();
521 assert_eq!(func, AggregateFunction::Sum);
522
523 let func: AggregateFunction = "SUM".parse().unwrap();
524 assert_eq!(func, AggregateFunction::Sum);
525 }
526
527 #[test]
528 fn test_aggregate_function_parse_average() {
529 let func: AggregateFunction = "average".parse().unwrap();
530 assert_eq!(func, AggregateFunction::Average);
531
532 let func: AggregateFunction = "avg".parse().unwrap();
533 assert_eq!(func, AggregateFunction::Average);
534
535 let func: AggregateFunction = "AVG".parse().unwrap();
536 assert_eq!(func, AggregateFunction::Average);
537 }
538
539 #[test]
540 fn test_aggregate_function_parse_min() {
541 let func: AggregateFunction = "min".parse().unwrap();
542 assert_eq!(func, AggregateFunction::Min);
543
544 let func: AggregateFunction = "MIN".parse().unwrap();
545 assert_eq!(func, AggregateFunction::Min);
546 }
547
548 #[test]
549 fn test_aggregate_function_parse_max() {
550 let func: AggregateFunction = "max".parse().unwrap();
551 assert_eq!(func, AggregateFunction::Max);
552
553 let func: AggregateFunction = "MAX".parse().unwrap();
554 assert_eq!(func, AggregateFunction::Max);
555 }
556
557 #[test]
558 fn test_aggregate_function_parse_invalid() {
559 let result: Result<AggregateFunction, _> = "invalid".parse();
560 assert!(result.is_err());
561
562 let result: Result<AggregateFunction, _> = "median".parse();
563 assert!(result.is_err());
564
565 let result: Result<AggregateFunction, _> = "".parse();
566 assert!(result.is_err());
567 }
568
569 #[test]
572 fn test_computed_pivot_data_structure() {
573 let data = ComputedPivotData {
574 headers: vec!["Category".to_string(), "Sum".to_string()],
575 rows: vec![
576 vec!["A".to_string(), "100".to_string()],
577 vec!["B".to_string(), "200".to_string()],
578 vec!["Total".to_string(), "300".to_string()],
579 ],
580 totals_row: Some(2),
581 };
582
583 assert_eq!(data.headers.len(), 2);
584 assert_eq!(data.rows.len(), 3);
585 assert_eq!(data.totals_row, Some(2));
586 }
587
588 #[test]
589 fn test_computed_pivot_data_no_totals() {
590 let data = ComputedPivotData {
591 headers: vec!["Name".to_string()],
592 rows: vec![vec!["Item".to_string()]],
593 totals_row: None,
594 };
595
596 assert!(data.totals_row.is_none());
597 }
598
599 #[test]
602 fn test_pivot_table_builder_new() {
603 let builder = PivotTableBuilder::new();
604 let pivot = builder.build();
605
606 assert!(pivot.data.is_empty());
607 }
608
609 #[test]
610 fn test_pivot_table_builder_chain() {
611 let pivot = PivotTableBuilder::new().build();
612
613 assert_eq!(pivot.component_type(), "PivotTable");
614 }
615
616 #[test]
619 fn test_component_span() {
620 let pivot = PivotTable::new(sample_data());
621
622 assert_eq!(pivot.get_span().columns, 12);
624 }
625
626 #[test]
627 fn test_component_set_span() {
628 let mut pivot = PivotTable::new(sample_data());
629
630 pivot.set_span(ComponentSpan::new(6));
631 assert_eq!(pivot.get_span().columns, 6);
632 }
633
634 #[test]
635 fn test_component_type() {
636 let pivot = PivotTable::new(sample_data());
637 assert_eq!(pivot.component_type(), "PivotTable");
638 }
639
640 #[test]
641 fn test_complexity_score() {
642 let pivot = PivotTable::new(sample_data());
643 assert_eq!(pivot.complexity_score(), 85);
644 }
645
646 #[test]
647 fn test_preferred_height() {
648 let pivot = PivotTable::new(sample_data());
649 assert_eq!(pivot.preferred_height(500.0), 200.0);
650 }
651}