1use serde_json::{Value as Json, json};
5
6use crate::chart::ChartKind;
7use crate::model::{
8 Band, Chart, ChartSeries, Control, ControlKind, DataSource, DataSourceKind, DetailReport, Group, Label,
9 LinkLabel, PageSettings, ParameterKind, Report, Style, Table, TableRow, TextAlign, VerticalAlign,
10};
11
12const ACCENT: &str = "#3f57db";
14const INK: &str = "#12151c";
15const MUTED: &str = "#5a6375";
16const LINE: &str = "#dadde5";
17const STRIPE: &str = "#f4f5f8";
18
19struct Rng(u64);
21
22impl Rng {
23 fn next(&mut self) -> u64 {
24 self.0 ^= self.0 << 13;
25 self.0 ^= self.0 >> 7;
26 self.0 ^= self.0 << 17;
27 self.0
28 }
29
30 fn pick<'a>(&mut self, items: &[&'a str]) -> &'a str {
31 items[(self.next() % items.len() as u64) as usize]
32 }
33
34 fn range(&mut self, lo: u64, hi: u64) -> u64 {
35 lo + self.next() % (hi - lo + 1)
36 }
37}
38
39const FIRST: [&str; 16] = [
40 "Ana", "Bruno", "Carla", "Diego", "Elisa", "Felipe", "Gabriela", "Henrique", "Isabela", "João",
41 "Larissa", "Marcos", "Natália", "Otávio", "Paula", "Rafael",
42];
43const LAST: [&str; 12] = [
44 "Silva", "Souza", "Oliveira", "Lima", "Pereira", "Costa", "Almeida", "Ribeiro", "Carvalho", "Gomes",
45 "Martins", "Rocha",
46];
47const CITIES: [(&str, &str); 10] = [
48 ("São Paulo", "Sudeste"),
49 ("Rio de Janeiro", "Sudeste"),
50 ("Belo Horizonte", "Sudeste"),
51 ("Curitiba", "Sul"),
52 ("Porto Alegre", "Sul"),
53 ("Florianópolis", "Sul"),
54 ("Recife", "Nordeste"),
55 ("Salvador", "Nordeste"),
56 ("Fortaleza", "Nordeste"),
57 ("Manaus", "Norte"),
58];
59const PRODUCTS: [(&str, f64); 10] = [
60 ("Notebook Pro 14", 7_499.0),
61 ("Monitor 27\" 4K", 2_299.0),
62 ("Teclado mecânico", 489.9),
63 ("Mouse sem fio", 159.9),
64 ("Headset", 349.0),
65 ("Webcam Full HD", 279.0),
66 ("Dock USB-C", 899.0),
67 ("SSD 1 TB", 529.0),
68 ("Cadeira ergonômica", 1_890.0),
69 ("Mesa elevatória", 2_790.0),
70];
71
72pub fn sales_data(count: usize) -> Json {
74 let mut rng = Rng(0x5eed_1234_abcd_9876);
75 let rows: Vec<Json> = (0..count)
76 .map(|i| {
77 let (city, region) = CITIES[(rng.next() % CITIES.len() as u64) as usize];
78 let (product, price) = PRODUCTS[(rng.next() % PRODUCTS.len() as u64) as usize];
79 let month = rng.range(1, 12);
80 let day = rng.range(1, 28);
81 json!({
82 "Id": 10_000 + i,
83 "Date": format!("2026-{month:02}-{day:02}"),
84 "Region": region,
85 "City": city,
86 "Customer": format!("{} {}", rng.pick(&FIRST), rng.pick(&LAST)),
87 "Product": product,
88 "Quantity": rng.range(1, 12),
89 "UnitPrice": price,
90 "Paid": !rng.next().is_multiple_of(5),
91 })
92 })
93 .collect();
94 Json::Array(rows)
95}
96
97pub fn invoice_data() -> Json {
99 json!({
100 "Number": "NF-2026-00417",
101 "IssueDate": "2026-11-01",
102 "DueDate": "2026-11-16",
103 "Seller": { "Name": "Raven Tecnologia Ltda.", "Document": "12.345.678/0001-90", "City": "Recife - PE", "Email": "financeiro@raven.dev" },
104 "Customer": { "Name": "Kestrel Saúde S.A.", "Document": "98.765.432/0001-10", "Address": "Av. Boa Viagem, 1200 - Recife - PE", "Email": "compras@kestrel.com.br" },
105 "Discount": 350.0,
106 "Notes": "Pagamento via PIX ou boleto. Após o vencimento, multa de 2% e juros de 1% ao mês.",
107 "Items": [
108 { "Sku": "NB-PRO14", "Description": "Notebook Pro 14, 32 GB, 1 TB", "Quantity": 4, "UnitPrice": 7499.0 },
109 { "Sku": "MN-27K", "Description": "Monitor 27\" 4K com ajuste de altura", "Quantity": 4, "UnitPrice": 2299.0 },
110 { "Sku": "DK-USBC", "Description": "Dock USB-C com 2 saídas de vídeo e carregamento de 100 W", "Quantity": 4, "UnitPrice": 899.0 },
111 { "Sku": "KB-MEC", "Description": "Teclado mecânico ABNT2", "Quantity": 4, "UnitPrice": 489.9 },
112 { "Sku": "SV-INST", "Description": "Instalação e configuração no local", "Quantity": 1, "UnitPrice": 1200.0 }
113 ]
114 })
115}
116
117fn title() -> Style {
118 Style::new().font_size(20.0).bold().color(INK)
119}
120
121fn caption() -> Style {
122 Style::new().font_size(7.5).bold().color(MUTED)
123}
124
125fn header_cell() -> Style {
126 Style::new().font_size(8.0).bold().color("#ffffff").background(ACCENT).padding(4.0)
127}
128
129fn cell() -> Style {
130 Style::new().font_size(9.0).color(INK).padding(4.0)
131}
132
133fn money(style: Style) -> Style {
134 style.align(TextAlign::Right)
135}
136
137fn page_footer(culture_pt: bool) -> Band {
138 let page = if culture_pt { "Página {0} de {1}" } else { "Page {0} of {1}" };
139 Band::new(22.0)
140 .control(Control::line().at(0.0, 0.0, 510.0, 2.0).style(Style::new().color(LINE)))
141 .control(
142 Control::field("FormatString('{0:dd/MM/yyyy HH:mm}', Now())")
143 .at(0.0, 4.0, 200.0, 16.0)
144 .style(Style::new().font_size(7.5).color(MUTED)),
145 )
146 .control(
147 Control::field(format!("FormatString('{page}', PageNumber(), PageCount())"))
148 .at(310.0, 4.0, 200.0, 16.0)
149 .style(Style::new().font_size(7.5).color(MUTED).align(TextAlign::Right)),
150 )
151}
152
153pub fn sales_by_region(rows: usize) -> Report {
156 let w = PageSettings::a4().content_width();
157 let cols = [70.0, 150.0, 140.0, 40.0, 50.0, w - 450.0];
158 let header = TableRow::new(20.0)
159 .text(cols[0], "DATA")
160 .text(cols[1], "CLIENTE")
161 .text(cols[2], "PRODUTO")
162 .text(cols[3], "QTD")
163 .cell_style(Style::new().align(TextAlign::Right))
164 .text(cols[4], "PAGO")
165 .cell_style(Style::new().align(TextAlign::Center))
166 .text(cols[5], "TOTAL")
167 .cell_style(Style::new().align(TextAlign::Right))
168 .style_name("th");
169 let detail = TableRow::new(16.0)
170 .field(cols[0], "[Date]", Some("dd/MM/yyyy"))
171 .field(cols[1], "[Customer]", None)
172 .field(cols[2], "[Product]", None)
173 .field(cols[3], "[Quantity]", None)
174 .cell_style(Style::new().align(TextAlign::Right))
175 .field(cols[4], "Iif([Paid], 'Sim', 'Não')", None)
176 .cell_style(Style::new().align(TextAlign::Center))
177 .field(cols[5], "[Total]", Some("C2"))
178 .cell_style(Style::new().align(TextAlign::Right))
179 .style_name("td");
180 Report::new("Vendas por região")
181 .culture("pt-BR")
182 .data_source(DataSource {
183 name: "Vendas".into(),
184 kind: DataSourceKind::Json { data: sales_data(rows) },
185 })
186 .calculated("Total", "[Quantity] * [UnitPrice]")
187 .parameter("MinTotal", ParameterKind::Number, json!(0))
188 .parameter_label("Valor mínimo")
189 .filter("[Total] >= ?MinTotal")
190 .sort_by("[Date]", false)
191 .style("th", header_cell())
192 .style("td", cell())
193 .style("odd", Style::new().background("#ffffff"))
194 .style("even", Style::new().background(STRIPE))
195 .report_header(
196 Band::new(64.0)
197 .control(Control::label("Vendas por região").at(0.0, 0.0, 360.0, 30.0).style(title()))
198 .control(
199 Control::field("FormatString('{0:N0} pedidos · {1:C2}', Count(), Sum([Total]))")
200 .at(0.0, 30.0, 360.0, 18.0)
201 .style(Style::new().font_size(10.0).color(MUTED)),
202 )
203 .control(
204 Control::label("RAVEN TECNOLOGIA")
205 .at(w - 160.0, 4.0, 160.0, 14.0)
206 .style(caption().align(TextAlign::Right).color(ACCENT)),
207 ),
208 )
209 .page_footer(page_footer(true))
210 .group(
211 Group::new("[Region]")
212 .header(
213 Band::new(30.0)
214 .control(
215 Control::rect(3.0)
216 .at(0.0, 10.0, 4.0, 16.0)
217 .style(Style::new().background(ACCENT)),
218 )
219 .control(Control::bookmark("[Region]", 0).at(0.0, 8.0, 80.0, 12.0))
220 .control(
221 Control::field("'Região ' + [Region]")
222 .at(10.0, 8.0, 300.0, 20.0)
223 .style(Style::new().font_size(13.0).bold().color(INK)),
224 )
225 .keep_with_next(),
226 )
227 .footer(
228 Band::new(26.0)
229 .control(
230 Control::field("'Total ' + [Region]")
231 .at(0.0, 4.0, 300.0, 18.0)
232 .style(Style::new().bold().color(INK)),
233 )
234 .control(
235 Control::field("Sum([Total])")
236 .format("C2")
237 .at(w - 160.0, 4.0, 160.0, 18.0)
238 .style(
239 Style::new()
240 .bold()
241 .color(INK)
242 .align(TextAlign::Right)
243 .border("top", 1.0, INK),
244 ),
245 ),
246 )
247 .repeat_header(),
248 )
249 .group(
250 Group::new("[City]")
251 .header(
252 Band::new(44.0)
253 .control(
254 Control::field("[City]")
255 .at(0.0, 2.0, 300.0, 18.0)
256 .style(Style::new().bold().color(MUTED)),
257 )
258 .control(Control::table(Table::new().row(header)).at(0.0, 22.0, w, 20.0))
259 .keep_with_next(),
260 )
261 .footer(
262 Band::new(24.0).control(
263 Control::field(
264 "FormatString('{0} pedidos em {1}: {2:C2}', Count(), [City], Sum([Total]))",
265 )
266 .at(0.0, 2.0, w, 18.0)
267 .style(Style::new().font_size(8.5).color(MUTED).align(TextAlign::Right)),
268 ),
269 )
270 .repeat_header(),
271 )
272 .detail(
273 Band::new(16.0)
274 .control(
275 Control::table(Table::new().row(detail))
276 .at(0.0, 0.0, w, 16.0)
277 .rule("[Total] >= 20000", Style::new().color("#c93434").bold()),
278 )
279 .zebra("odd", "even"),
280 )
281 .report_footer(
282 Band::new(40.0).control(
283 Control::field("FormatString('Total geral: {0:C2}', Sum([Total]))")
284 .at(w - 260.0, 12.0, 260.0, 22.0)
285 .style(
286 Style::new()
287 .font_size(12.0)
288 .bold()
289 .color("#ffffff")
290 .background(INK)
291 .padding(5.0)
292 .align(TextAlign::Right),
293 ),
294 ),
295 )
296}
297
298pub fn invoice() -> Report {
301 let w = PageSettings::a4().content_width();
302 let cols = [70.0, w - 300.0, 50.0, 90.0, 90.0];
303 let party = |label: &str, field: &str, x: f32| {
304 Control::table(
305 Table::new()
306 .row(TableRow::new(14.0).text(190.0, label).style_name("caption"))
307 .row(
308 TableRow::new(18.0)
309 .field(190.0, &format!("[{field}.Name]"), None)
310 .cell_style(Style::new().bold().font_size(11.0).color(INK).padding(2.0)),
311 )
312 .row(
313 TableRow::new(14.0)
314 .field(190.0, &format!("'CNPJ ' + [{field}.Document]"), None)
315 .style_name("small"),
316 )
317 .row(
318 TableRow::new(14.0)
319 .field(190.0, &format!("IsNull([{field}.Address], [{field}.City])"), None)
320 .style_name("small"),
321 )
322 .row(TableRow::new(14.0).field(190.0, &format!("[{field}.Email]"), None).style_name("small")),
323 )
324 .at(x, 70.0, 190.0, 74.0)
325 };
326 let total_line = |label: &str, expr: &str, y: f32, strong: bool| {
327 let style = if strong {
328 Style::new().font_size(12.0).bold().color("#ffffff").background(ACCENT).padding(5.0)
329 } else {
330 Style::new().color(INK).padding(3.0)
331 };
332 [
333 Control::label(label)
334 .at(w - 260.0, y, 130.0, if strong { 26.0 } else { 18.0 })
335 .style(style.clone()),
336 Control::field(expr)
337 .format("C2")
338 .at(w - 130.0, y, 130.0, if strong { 26.0 } else { 18.0 })
339 .style(money(style)),
340 ]
341 };
342 let [s1, s2] = total_line("Subtotal", "[Items][].Sum([Quantity] * [UnitPrice])", 8.0, false);
343 let [d1, d2] = total_line("Desconto", "-[Discount]", 26.0, false);
344 let [t1, t2] =
345 total_line("Total a pagar", "[Items][].Sum([Quantity] * [UnitPrice]) - [Discount]", 48.0, true);
346 Report::new("Fatura")
347 .culture("pt-BR")
348 .data_source(DataSource {
349 name: "Fatura".into(),
350 kind: DataSourceKind::Json { data: invoice_data() },
351 })
352 .style("caption", caption())
353 .style("small", Style::new().font_size(8.5).color(MUTED).padding(2.0))
354 .style("th", header_cell())
355 .style("td", cell().border("bottom", 0.5, LINE))
356 .report_header(
357 Band::new(160.0)
358 .control(Control::rect(6.0).at(0.0, 0.0, 34.0, 34.0).style(Style::new().background(ACCENT)))
359 .control(
360 Control::label("R").at(0.0, 0.0, 34.0, 34.0).style(
361 Style::new()
362 .font_size(18.0)
363 .bold()
364 .color("#ffffff")
365 .align(TextAlign::Center)
366 .valign(VerticalAlign::Middle),
367 ),
368 )
369 .control(
370 Control::field("[Seller.Name]")
371 .at(44.0, 0.0, 260.0, 18.0)
372 .style(Style::new().bold().font_size(11.0).color(INK)),
373 )
374 .control(
375 Control {
376 kind: ControlKind::Link(LinkLabel {
377 label: Label {
378 expression: Some("[Seller.Email]".to_owned()),
379 ..Label::default()
380 },
381 url_expression: Some("'mailto:' + [Seller.Email]".to_owned()),
382 ..LinkLabel::default()
383 }),
384 ..Control::default()
385 }
386 .at(44.0, 18.0, 260.0, 14.0)
387 .style(Style::new().font_size(8.5).color(MUTED)),
388 )
389 .control(
390 Control::label("FATURA")
391 .at(w - 200.0, 0.0, 200.0, 26.0)
392 .style(title().align(TextAlign::Right)),
393 )
394 .control(
395 Control::field("[Number]")
396 .at(w - 200.0, 26.0, 200.0, 14.0)
397 .style(Style::new().color(MUTED).align(TextAlign::Right)),
398 )
399 .control(Control::line().at(0.0, 52.0, w, 2.0).style(Style::new().color(LINE)))
400 .control(party("EMITENTE", "Seller", 0.0))
401 .control(party("CLIENTE", "Customer", 200.0))
402 .control(
403 Control::table(
404 Table::new()
405 .row(TableRow::new(14.0).text(110.0, "EMISSÃO").style_name("caption"))
406 .row(
407 TableRow::new(18.0)
408 .field(110.0, "[IssueDate]", Some("dd/MM/yyyy"))
409 .style_name("td"),
410 )
411 .row(TableRow::new(14.0).text(110.0, "VENCIMENTO").style_name("caption"))
412 .row(
413 TableRow::new(18.0)
414 .field(110.0, "[DueDate]", Some("dd/MM/yyyy"))
415 .cell_style(cell().bold().color(ACCENT)),
416 ),
417 )
418 .at(w - 110.0, 70.0, 110.0, 64.0),
419 ),
420 )
421 .detail_report(
422 DetailReport::new("Items")
423 .header(
424 Band::new(22.0).control(
425 Control::table(
426 Table::new().row(
427 TableRow::new(22.0)
428 .text(cols[0], "CÓDIGO")
429 .text(cols[1], "DESCRIÇÃO")
430 .text(cols[2], "QTD")
431 .cell_style(Style::new().align(TextAlign::Right))
432 .text(cols[3], "PREÇO UNIT.")
433 .cell_style(Style::new().align(TextAlign::Right))
434 .text(cols[4], "TOTAL")
435 .cell_style(Style::new().align(TextAlign::Right))
436 .style_name("th"),
437 ),
438 )
439 .at(0.0, 0.0, w, 22.0),
440 ),
441 )
442 .detail(
443 Band::new(20.0).control(
444 Control::table(
445 Table::new().row(
446 TableRow::new(20.0)
447 .field(cols[0], "[Sku]", None)
448 .field(cols[1], "[Description]", None)
449 .field(cols[2], "[Quantity]", None)
450 .cell_style(Style::new().align(TextAlign::Right))
451 .field(cols[3], "[UnitPrice]", Some("C2"))
452 .cell_style(Style::new().align(TextAlign::Right))
453 .field(cols[4], "[Quantity] * [UnitPrice]", Some("C2"))
454 .cell_style(Style::new().align(TextAlign::Right))
455 .style_name("td"),
456 ),
457 )
458 .at(0.0, 0.0, w, 20.0),
459 ),
460 ),
461 )
462 .report_footer(
463 Band::new(150.0)
464 .control(s1)
465 .control(s2)
466 .control(d1)
467 .control(d2)
468 .control(t1)
469 .control(t2)
470 .control(Control::label("OBSERVAÇÕES").at(0.0, 90.0, 300.0, 14.0).style(caption()))
471 .control(
472 Control::field("[Notes]")
473 .at(0.0, 104.0, w, 30.0)
474 .style(Style::new().font_size(8.5).color(MUTED).valign(VerticalAlign::Top)),
475 ),
476 )
477 .page_footer(page_footer(true))
478}
479
480pub fn sales_dashboard() -> Report {
482 let w = PageSettings::a4().content_width();
483 let half = (w - 16.0) / 2.0;
484 let total = "[Quantity] * [UnitPrice]";
485 let monthly = Chart {
486 kind: ChartKind::Area,
487 title: "Vendas por mês".to_owned(),
488 argument: Some("GetMonth([Date])".to_owned()),
489 series: vec![ChartSeries::new("Total", total)],
490 sorted: true,
491 format: Some("N0".to_owned()),
492 ..Chart::default()
493 };
494 let chart = |chart: Chart, x: f32, y: f32, width: f32, height: f32| {
495 Control { kind: ControlKind::Chart(chart), ..Control::default() }
496 .at(x, y, width, height)
497 .style(Style::new().font_size(9.0).color(INK))
498 };
499 Report::new("Painel de vendas")
500 .culture("pt-BR")
501 .data_source(DataSource {
502 name: "Vendas".into(),
503 kind: DataSourceKind::Json { data: sales_data(300) },
504 })
505 .report_header(
506 Band::new(620.0)
507 .control(
508 Control::label("Painel de vendas")
509 .at(0.0, 0.0, w, 30.0)
510 .style(Style::new().font_size(20.0).bold().color(INK)),
511 )
512 .control(
513 Control::field(
514 "FormatString('{0} pedidos · {1:C2}', Count(), Sum([Quantity] * [UnitPrice]))",
515 )
516 .at(0.0, 30.0, w, 16.0)
517 .style(Style::new().font_size(10.0).color(MUTED)),
518 )
519 .control(chart(monthly, 0.0, 60.0, w, 200.0))
520 .control(chart(
521 Chart {
522 kind: ChartKind::Bar,
523 title: "Por região".to_owned(),
524 argument: Some("[Region]".to_owned()),
525 series: vec![ChartSeries::new("Total", total)],
526 sorted: true,
527 ..Chart::default()
528 },
529 0.0,
530 276.0,
531 half,
532 230.0,
533 ))
534 .control(chart(
535 Chart {
536 kind: ChartKind::Doughnut,
537 title: "Por produto".to_owned(),
538 argument: Some("[Product]".to_owned()),
539 series: vec![ChartSeries::new("Total", total)],
540 ..Chart::default()
541 },
542 half + 16.0,
543 276.0,
544 half,
545 230.0,
546 ))
547 .control(Control::bookmark("'Painel'", 0).at(0.0, 0.0, 80.0, 12.0)),
548 )
549}
550
551pub fn customer_list() -> Report {
553 let w = PageSettings::a4().content_width();
554 Report::new("Clientes")
555 .culture("pt-BR")
556 .data_source(DataSource {
557 name: "Vendas".into(),
558 kind: DataSourceKind::Json { data: sales_data(60) },
559 })
560 .sort_by("[Customer]", false)
561 .report_header(
562 Band::new(40.0).control(Control::label("Clientes").at(0.0, 0.0, 300.0, 30.0).style(title())),
563 )
564 .group(
565 Group::new("Substring([Customer], 0, 1)").header(
566 Band::new(26.0)
567 .control(
568 Control::field("Substring([Customer], 0, 1)")
569 .at(0.0, 4.0, 30.0, 20.0)
570 .style(Style::new().font_size(14.0).bold().color(ACCENT)),
571 )
572 .keep_with_next(),
573 ),
574 )
575 .detail(
576 Band::new(18.0)
577 .control(Control::field("[Customer]").at(20.0, 0.0, 200.0, 18.0).style(cell()))
578 .control(
579 Control::field("[City] + ' · ' + [Region]")
580 .at(220.0, 0.0, 200.0, 18.0)
581 .style(cell().color(MUTED)),
582 )
583 .control(
584 Control::check_box("[Paid]", "Em dia")
585 .at(w - 80.0, 0.0, 80.0, 18.0)
586 .style(Style::new().font_size(8.5).color(MUTED)),
587 ),
588 )
589 .page_footer(page_footer(true))
590}