1use serde_json::Value as Json;
6
7use crate::model::{
8 Band, Control, DataSource, Group, PageSettings, Report, Style, Table, TableRow, TextAlign,
9};
10
11#[derive(Clone, Debug, PartialEq)]
13pub struct FieldInfo {
14 pub path: String,
16 pub kind: FieldKind,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum FieldKind {
24 Text,
26 Number,
28 Date,
30 Boolean,
32 Collection,
34}
35
36pub fn rows_of<'a>(data: &'a Json, data_member: Option<&str>) -> Vec<&'a Json> {
38 let base = match data_member.filter(|m| !m.trim().is_empty()) {
39 Some(path) => {
40 let segs: Vec<String> = path.split('.').map(|s| s.trim().to_owned()).collect();
41 crate::engine::lookup(Some(data), &segs)
42 }
43 None => Some(data),
44 };
45 match base {
46 Some(Json::Array(items)) => items.iter().collect(),
47 Some(Json::Null) | None => Vec::new(),
48 Some(other) => vec![other],
49 }
50}
51
52pub fn fields(data: &Json, data_member: Option<&str>) -> Vec<FieldInfo> {
55 let mut out: Vec<FieldInfo> = Vec::new();
56 for row in rows_of(data, data_member).into_iter().take(20) {
57 collect(row, "", &mut out);
58 }
59 out
60}
61
62fn collect(value: &Json, prefix: &str, out: &mut Vec<FieldInfo>) {
63 let Json::Object(map) = value else { return };
64 for (key, v) in map {
65 let path = if prefix.is_empty() { key.clone() } else { format!("{prefix}.{key}") };
66 if let Json::Object(_) = v {
67 collect(v, &path, out);
68 continue;
69 }
70 let kind = match v {
71 Json::Number(_) => FieldKind::Number,
72 Json::Bool(_) => FieldKind::Boolean,
73 Json::Array(_) => FieldKind::Collection,
74 Json::String(s) if crate::DateTime::parse(s).is_some() => FieldKind::Date,
75 Json::Null => continue,
76 _ => FieldKind::Text,
77 };
78 if !out.iter().any(|f| f.path == path) {
79 out.push(FieldInfo { path, kind });
80 }
81 }
82}
83
84pub fn caption(path: &str) -> String {
86 let last = path.rsplit('.').next().unwrap_or(path);
87 let mut out = String::new();
88 let mut prev_lower = false;
89 for c in last.chars() {
90 if c == '_' {
91 out.push(' ');
92 prev_lower = false;
93 continue;
94 }
95 if c.is_uppercase() && prev_lower {
96 out.push(' ');
97 }
98 prev_lower = c.is_lowercase() || c.is_ascii_digit();
99 out.push(c);
100 }
101 out
102}
103
104pub fn default_format(kind: FieldKind) -> Option<&'static str> {
106 match kind {
107 FieldKind::Date => Some("d"),
108 _ => None,
109 }
110}
111
112#[derive(Clone, Debug)]
114pub struct WizardOptions {
115 pub title: String,
117 pub culture: String,
119 pub columns: Vec<FieldInfo>,
121 pub group_by: Option<String>,
123 pub totals: Vec<String>,
125 pub page: PageSettings,
127 pub accent: String,
129}
130
131impl Default for WizardOptions {
132 fn default() -> Self {
133 Self {
134 title: "Report".into(),
135 culture: "en-US".into(),
136 columns: Vec::new(),
137 group_by: None,
138 totals: Vec::new(),
139 page: PageSettings::a4(),
140 accent: "#3f57db".into(),
141 }
142 }
143}
144
145#[derive(Clone, Debug, Default, PartialEq, Eq)]
148pub struct ColumnWords {
149 pub caption: Option<String>,
151 pub format: Option<String>,
153}
154
155pub fn table_report(source: DataSource, options: &WizardOptions) -> Report {
158 table_report_with(source, options, &[])
159}
160
161pub fn table_report_with(source: DataSource, options: &WizardOptions, words: &[ColumnWords]) -> Report {
164 let page = if options.columns.len() > 6 { options.page.landscape() } else { options.page };
165 let width = page.content_width();
166 let weights: Vec<f32> =
168 options.columns.iter().map(|c| if matches!(c.kind, FieldKind::Text) { 2.0 } else { 1.0 }).collect();
169 let total_weight: f32 = weights.iter().sum::<f32>().max(1.0);
170 let widths: Vec<f32> = weights.iter().map(|w| (width * w / total_weight).floor()).collect();
171 let align = |kind: FieldKind| match kind {
172 FieldKind::Number => TextAlign::Right,
173 FieldKind::Boolean | FieldKind::Date => TextAlign::Center,
174 _ => TextAlign::Left,
175 };
176 let pt = options.culture.to_lowercase().starts_with("pt");
177 let rows_data = match &source.kind {
179 crate::model::DataSourceKind::Json { data } => rows_of(data, None),
180 _ => Vec::new(),
181 };
182 let whole: Vec<String> = options
183 .columns
184 .iter()
185 .filter(|c| {
186 c.kind == FieldKind::Number
187 && rows_data.iter().take(200).all(|r| {
188 let segs: Vec<String> = c.path.split('.').map(str::to_owned).collect();
189 crate::engine::lookup(Some(r), &segs)
190 .and_then(Json::as_f64)
191 .is_none_or(|n| n.fract() == 0.0)
192 })
193 })
194 .map(|c| c.path.clone())
195 .collect();
196 let told_format = |i: usize| words.get(i).and_then(|w| w.format.as_deref());
197 let number_format = |path: &str| if whole.iter().any(|w| w == path) { "N0" } else { "N2" };
198 let mut header = TableRow::new(22.0);
199 let mut detail = TableRow::new(18.0);
200 for (i, (c, w)) in options.columns.iter().zip(&widths).enumerate() {
201 let title = words.get(i).and_then(|w| w.caption.clone()).unwrap_or_else(|| caption(&c.path));
202 header = header.text(*w, &title.to_uppercase()).cell_style(Style::new().align(align(c.kind)));
203 let expr = match c.kind {
204 FieldKind::Boolean if pt => format!("Iif([{}], 'Sim', 'Não')", c.path),
205 FieldKind::Boolean => format!("Iif([{}], 'Yes', 'No')", c.path),
206 _ => format!("[{}]", c.path),
207 };
208 let format = told_format(i).or(match c.kind {
209 FieldKind::Number => Some(number_format(&c.path)),
210 kind => default_format(kind),
211 });
212 detail = detail.field(*w, &expr, format).cell_style(Style::new().align(align(c.kind)));
213 }
214 let header = header.style_name("th");
215 let detail = detail.style_name("td");
216 let totals_row = |label: &str, style: &str| {
217 let mut row = TableRow::new(20.0);
218 for (i, (c, w)) in options.columns.iter().zip(&widths).enumerate() {
219 row = if options.totals.contains(&c.path) {
220 let format = told_format(i).unwrap_or_else(|| number_format(&c.path));
221 row.field(*w, &format!("Sum([{}])", c.path), Some(format))
222 .cell_style(Style::new().align(TextAlign::Right))
223 } else if i == 0 {
224 row.text(*w, label)
225 } else {
226 row.text(*w, "")
227 };
228 }
229 row.style_name(style)
230 };
231 let mut report = Report::new(&options.title)
232 .culture(&options.culture)
233 .page(page)
234 .data_source(source)
235 .style(
236 "th",
237 Style::new().font_size(8.0).bold().color("#ffffff").background(&options.accent).padding(4.0),
238 )
239 .style("td", Style::new().font_size(9.0).color("#12151c").padding(4.0))
240 .style(
241 "total",
242 Style::new().font_size(9.0).bold().color("#12151c").padding(4.0).border("top", 1.0, "#12151c"),
243 )
244 .style("odd", Style::new().background("#ffffff"))
245 .style("even", Style::new().background("#f4f5f8"))
246 .report_header(
247 Band::new(48.0)
248 .control(
249 Control::label(&options.title)
250 .at(0.0, 0.0, width, 30.0)
251 .style(Style::new().font_size(20.0).bold().color("#12151c")),
252 )
253 .control(
254 Control::field(if pt {
255 "FormatString('{0:N0} registros', Count())"
256 } else {
257 "FormatString('{0:N0} records', Count())"
258 })
259 .at(0.0, 30.0, width, 16.0)
260 .style(Style::new().font_size(9.0).color("#5a6375")),
261 ),
262 )
263 .page_header(
264 Band::new(22.0).control(Control::table(Table::new().row(header)).at(0.0, 0.0, width, 22.0)),
265 )
266 .detail(
267 Band::new(18.0)
268 .control(Control::table(Table::new().row(detail)).at(0.0, 0.0, width, 18.0))
269 .zebra("odd", "even"),
270 )
271 .page_footer(
272 Band::new(20.0).control(
273 Control::field(if pt {
274 "FormatString('Página {0} de {1}', PageNumber(), PageCount())"
275 } else {
276 "FormatString('Page {0} of {1}', PageNumber(), PageCount())"
277 })
278 .at(0.0, 4.0, width, 14.0)
279 .style(Style::new().font_size(7.5).color("#5a6375").align(TextAlign::Right)),
280 ),
281 );
282 if let Some(field) = &options.group_by {
283 let mut group = Group::new(&format!("[{field}]")).header(
284 Band::new(26.0)
285 .control(
286 Control::field(format!("[{field}]"))
287 .at(0.0, 6.0, width, 18.0)
288 .style(Style::new().font_size(11.0).bold().color(&options.accent)),
289 )
290 .keep_with_next(),
291 );
292 if !options.totals.is_empty() {
293 group = group.footer(Band::new(22.0).control(
294 Control::table(Table::new().row(totals_row("Subtotal", "total"))).at(0.0, 0.0, width, 20.0),
295 ));
296 }
297 report = report.group(group);
298 }
299 if !options.totals.is_empty() {
300 report = report.report_footer(
301 Band::new(28.0).control(
302 Control::table(
303 Table::new().row(totals_row(if pt { "Total geral" } else { "Grand total" }, "total")),
304 )
305 .at(0.0, 6.0, width, 20.0),
306 ),
307 );
308 }
309 report
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::model::DataSourceKind;
316 use crate::{ReportEngine, samples};
317
318 #[test]
319 fn fields_flatten_objects_and_detect_kinds() {
320 let fields = fields(&samples::invoice_data(), None);
321 let get = |p: &str| fields.iter().find(|f| f.path == p).map(|f| f.kind);
322 assert_eq!(get("Customer.Name"), Some(FieldKind::Text));
323 assert_eq!(get("IssueDate"), Some(FieldKind::Date));
324 assert_eq!(get("Discount"), Some(FieldKind::Number));
325 assert_eq!(get("Items"), Some(FieldKind::Collection));
326 assert_eq!(caption("UnitPrice"), "Unit Price");
327 assert_eq!(caption("Customer.Name"), "Name");
328 }
329
330 #[test]
331 fn wizard_builds_a_grouped_report_with_totals() {
332 let data = samples::sales_data(120);
333 let all = fields(&data, None);
334 let pick = |p: &str| all.iter().find(|f| f.path == p).cloned().expect(p);
335 let options = WizardOptions {
336 title: "Vendas".into(),
337 culture: "pt-BR".into(),
338 columns: vec![pick("Customer"), pick("Product"), pick("Quantity"), pick("Paid")],
339 group_by: Some("Region".into()),
340 totals: vec!["Quantity".into()],
341 ..WizardOptions::default()
342 };
343 let report = table_report(
344 DataSource { name: "Vendas".into(), kind: DataSourceKind::Json { data: data.clone() } },
345 &options,
346 );
347 let doc = ReportEngine::new().render(&report, report.embedded_data().unwrap_or(&data));
348 assert!(doc.errors.is_empty(), "{:?}", doc.errors);
349 assert!(doc.page_contains(0, "CUSTOMER") && doc.page_contains(0, "Vendas"));
350 assert!(doc.page_contains(0, "120 registros"));
351 let last = doc.pages.len() - 1;
352 assert!(doc.page_contains(last, "Total geral"));
353 assert!(doc.page_contains(last, &format!("Página {} de {}", last + 1, doc.pages.len())));
354 }
355}