Skip to main content

oxidize_pdf/dashboard/
pivot_table.rs

1//! PivotTable Component
2//!
3//! This module implements pivot tables for data aggregation and analysis,
4//! with support for grouping, aggregation functions, and formatting.
5
6use super::{
7    component::ComponentConfig, ComponentPosition, ComponentSpan, DashboardComponent,
8    DashboardTheme,
9};
10use crate::error::PdfError;
11use crate::page::Page;
12use std::collections::HashMap;
13
14/// PivotTable component for data aggregation
15#[derive(Debug, Clone)]
16pub struct PivotTable {
17    /// Component configuration
18    config: ComponentConfig,
19    /// Raw data for the pivot table
20    data: Vec<HashMap<String, String>>,
21    /// Pivot configuration
22    pivot_config: PivotConfig,
23    /// Computed pivot data
24    computed_data: Option<ComputedPivotData>,
25}
26
27impl PivotTable {
28    /// Create a new pivot table
29    pub fn new(data: Vec<HashMap<String, String>>) -> Self {
30        Self {
31            config: ComponentConfig::new(ComponentSpan::new(12)), // Full width by default
32            data,
33            pivot_config: PivotConfig::default(),
34            computed_data: None,
35        }
36    }
37
38    /// Set pivot configuration
39    pub fn with_config(mut self, config: PivotConfig) -> Self {
40        self.pivot_config = config;
41        self.computed_data = None; // Reset computed data
42        self
43    }
44
45    /// Add aggregation
46    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; // Reset computed data
55        self
56    }
57
58    /// Compute pivot data if not already computed
59    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    /// Compute pivot table data
67    fn compute_pivot_data(&self) -> Result<ComputedPivotData, PdfError> {
68        // Implementation placeholder - real implementation would be complex
69        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        // SAFETY: ensure_computed() guarantees computed_data is Some
92        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        // Render title if present
112        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        // Calculate column widths
122        let col_width = position.width / computed.headers.len() as f64;
123
124        // Render header row with background
125        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        // Render header border
136        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        // Render header text
148        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            // Draw column separator
158            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        // Render data rows
172        for (row_idx, row) in computed.rows.iter().enumerate() {
173            let is_totals = computed.totals_row == Some(row_idx);
174
175            // Alternate row background
176            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            // Totals row background
189            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            // Draw row border
202            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            // Render cells
210            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                // Draw column separator
226                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        // Draw final bottom border
241        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        // Draw left and right borders
249        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/// Pivot table configuration
287#[derive(Debug, Clone)]
288pub struct PivotConfig {
289    /// Table title
290    pub title: Option<String>,
291    /// Columns to group by (rows)
292    pub row_groups: Vec<String>,
293    /// Columns to group by (columns)
294    pub column_groups: Vec<String>,
295    /// Aggregation functions to apply
296    pub aggregations: Vec<AggregateFunction>,
297    /// Columns to aggregate
298    pub value_columns: Vec<String>,
299    /// Whether to show totals
300    pub show_totals: bool,
301    /// Whether to show subtotals
302    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/// Computed pivot table data
320#[derive(Debug, Clone)]
321pub struct ComputedPivotData {
322    /// Column headers
323    pub headers: Vec<String>,
324    /// Data rows
325    pub rows: Vec<Vec<String>>,
326    /// Index of totals row (if any)
327    pub totals_row: Option<usize>,
328}
329
330/// Aggregation functions for pivot tables
331#[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
358/// Builder for PivotTable
359pub 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    // ============ PivotTable Tests ============
392
393    #[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()); // Reset after config change
429    }
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        // Invalid function should be ignored, only default Count remains
468        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"]); // Add same again
480
481        // Count sum occurrences
482        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    // ============ PivotConfig Tests ============
492
493    #[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    // ============ AggregateFunction Tests ============
508
509    #[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    // ============ ComputedPivotData Tests ============
570
571    #[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    // ============ PivotTableBuilder Tests ============
600
601    #[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    // ============ DashboardComponent Trait Tests ============
617
618    #[test]
619    fn test_component_span() {
620        let pivot = PivotTable::new(sample_data());
621
622        // Default span should be 12 (full width)
623        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}