Skip to main content

ravenrs_app_report/
stored.rs

1//! Reports kept as records: people create them in the designer, on the data of an entity, and whoever
2//! can read one runs it over what they can read.
3
4use std::cell::RefCell;
5use std::collections::BTreeMap;
6use std::rc::Rc;
7
8use ravenrs_app::{
9    Action, ActionCategory, EntityInfo, FieldInfo, FieldKind, Message, MessageKind, Module, Object,
10    ObjectSpace, Operation, Query, Selection, TargetScope, Value, ViewController, ViewKind,
11};
12use ravenrs_app_i18n::{Language, Texts};
13use ravenrs_app_ui::Session;
14use ravenrs_reactive::State;
15use ravenrs_report::wizard::{self, ColumnWords, WizardOptions};
16use ravenrs_report::{DataSource, DataSourceKind, Document, RenderOptions, Report, ReportEngine};
17use ravenrs_report_designer::Designer;
18use ravenrs_report_export::ExportFormat;
19use ravenrs_report_viewer::{DocumentViewer, ParameterPanel, ParameterValues, ViewerState};
20use ravenrs_widgets::{
21    Alert, AnyView, BuildCx, Button, ButtonSize, ButtonStyle, Date, NodeId, Tone, VStack, View, ViewExt,
22};
23
24/// The entity of the reports people keep.
25pub const REPORT_DEFINITION: &str = "ReportDefinition";
26const SHOW_IN_REPORT: &str = "ShowInReport";
27const SHOW: &str = "ShowReport";
28const DESIGN: &str = "DesignReport";
29/// How much of an entity the designer has at hand for its preview.
30const SAMPLE_ROWS: usize = 200;
31const VIEWER_HEIGHT: f32 = 720.0;
32
33/// Their words in Brazilian Portuguese, as a difference for `translation`.
34pub const PT_BR: &str = ravenrs_app_i18n::model::REPORTS_PT_BR;
35
36/// What the screens of stored reports say.
37#[derive(Clone, Debug)]
38#[non_exhaustive]
39pub struct ReportWords {
40    /// Shows a report.
41    pub show: String,
42    /// Opens a report in the designer.
43    pub design: String,
44    /// Shows the records of a list in a report.
45    pub show_in_report: String,
46    /// Keeps the layout.
47    pub save: String,
48    /// Leaves.
49    pub close: String,
50    /// It was kept.
51    pub saved: String,
52    /// There is nothing to show this list in.
53    pub none_for_list: String,
54    /// The report names an entity there is not.
55    pub unknown_entity: String,
56    /// What is kept does not read as a report.
57    pub broken_layout: String,
58    /// Asks which report.
59    pub pick_report: String,
60    /// Opens a panel.
61    pub open_panel: String,
62}
63
64impl Default for ReportWords {
65    fn default() -> Self {
66        Self::from_texts(&Texts::new(Language::English))
67    }
68}
69
70impl ReportWords {
71    /// The ones that come with the framework, in this language.
72    pub fn of(language: Language) -> Self {
73        Self::from_texts(&Texts::new(language))
74    }
75
76    /// The texts of a catalog, in its language. What it does not have comes from English.
77    pub fn from_texts(texts: &Texts) -> Self {
78        Self {
79            show: texts.get("report.show"),
80            design: texts.get("report.design"),
81            show_in_report: texts.get("report.show_in_report"),
82            save: texts.get("report.save"),
83            close: texts.get("report.close"),
84            saved: texts.get("report.saved"),
85            none_for_list: texts.get("report.none_for_list"),
86            unknown_entity: texts.get("report.unknown_entity"),
87            broken_layout: texts.get("report.broken_layout"),
88            pick_report: texts.get("report.pick_report"),
89            open_panel: texts.get("report.open_panel"),
90        }
91    }
92
93    /// In Brazilian Portuguese.
94    pub fn pt_br() -> Self {
95        Self::from_texts(&Texts::new(Language::BrazilianPortuguese))
96    }
97}
98
99/// The entity of stored reports, which storage has to know. Who can see or change one goes by the
100/// permissions on it, as for any other record.
101pub fn report_entity() -> EntityInfo {
102    EntityInfo::new(REPORT_DEFINITION)
103        .caption("Reports")
104        .navigation("Reports")
105        .icon("file-text")
106        .field(FieldInfo::new("id", FieldKind::Guid).key())
107        .field(FieldInfo::new("name", FieldKind::Text).default_property())
108        .field(FieldInfo::new("target_entity", FieldKind::Text).caption("Entity"))
109        .field(FieldInfo::new("description", FieldKind::LongText))
110        .field(FieldInfo::new("layout", FieldKind::LongText).in_list(false).in_detail(false))
111        .rule(ravenrs_app::Rule::required("name"))
112        .rule(ravenrs_app::Rule::required("target_entity"))
113}
114
115/// A value as a report reads it. What a person cannot read comes empty, like everywhere else.
116fn json_of(
117    field: &FieldInfo,
118    value: &Value,
119    names: &BTreeMap<String, BTreeMap<String, String>>,
120) -> serde_json::Value {
121    use serde_json::{Value as Json, json};
122    match (&field.kind, value) {
123        (_, Value::Null) => Json::Null,
124        (FieldKind::Reference(target), value) => {
125            let key = ravenrs_app::key_text(value).unwrap_or_default();
126            let name = names.get(target).and_then(|all| all.get(&key)).cloned();
127            json!(name.unwrap_or(key))
128        }
129        (FieldKind::Money, Value::Number(cents)) => json!(cents / 100.0),
130        (FieldKind::Enum(options), Value::Text(code)) => {
131            json!(options.iter().find(|o| o.code == *code).map_or(code.as_str(), |o| o.caption.as_str()))
132        }
133        (FieldKind::Image | FieldKind::File, _) => Json::Null,
134        (FieldKind::Date, Value::Date(when)) => {
135            let date = Date::from_days(when.days);
136            json!(format!("{:04}-{:02}-{:02}", date.year(), date.month(), date.day()))
137        }
138        (_, Value::Date(when)) => {
139            let date = Date::from_days(when.days);
140            let (hour, minute) = (when.seconds / 3600, when.seconds % 3600 / 60);
141            json!(format!(
142                "{:04}-{:02}-{:02}T{hour:02}:{minute:02}:00",
143                date.year(),
144                date.month(),
145                date.day()
146            ))
147        }
148        (_, Value::Number(number)) => json!(number),
149        (_, Value::Bool(on)) => json!(on),
150        (_, Value::Text(text)) => json!(text),
151        _ => Json::Null,
152    }
153}
154
155fn plain_fields(entity: &EntityInfo) -> impl Iterator<Item = &FieldInfo> {
156    entity.fields.iter().filter(|field| {
157        !field.secret && !matches!(field.kind, FieldKind::Collection(_) | FieldKind::Image | FieldKind::File)
158    })
159}
160
161/// What names each record of the entities this one points to, as this person can read them.
162fn names_for(session: &Session, entity: &EntityInfo) -> BTreeMap<String, BTreeMap<String, String>> {
163    let mut all = BTreeMap::new();
164    for field in &entity.fields {
165        let FieldKind::Reference(target) = &field.kind else { continue };
166        let Some(other) = session.entity(target) else { continue };
167        let (Some(key), Some(name)) = (other.key_field(), other.default_property()) else { continue };
168        let found = session.frame().space().borrow().query(target, &Query::all()).unwrap_or_default();
169        let names = found
170            .iter()
171            .filter_map(|object| {
172                let key = ravenrs_app::key_text(&object.get(&key.name))?;
173                match object.get(&name.name) {
174                    Value::Text(text) => Some((key, text)),
175                    _ => None,
176                }
177            })
178            .collect();
179        all.insert(target.clone(), names);
180    }
181    all
182}
183
184/// Records as the rows a report reads.
185pub(crate) fn rows_of(session: &Session, entity: &EntityInfo, objects: &[Object]) -> serde_json::Value {
186    let names = names_for(session, entity);
187    serde_json::Value::Array(
188        objects
189            .iter()
190            .map(|object| {
191                let mut row = serde_json::Map::new();
192                for field in plain_fields(entity) {
193                    row.insert(field.name.clone(), json_of(field, &object.get(&field.name), &names));
194                }
195                serde_json::Value::Object(row)
196            })
197            .collect(),
198    )
199}
200
201/// A report with these rows as its data, and no way of its own to get any: what a layout says about
202/// addresses or queries does not run here, so all it can show is what the person can read.
203fn with_rows(mut report: Report, entity: &EntityInfo, rows: serde_json::Value) -> Report {
204    report.data_sources =
205        vec![DataSource { name: entity.name.clone(), kind: DataSourceKind::Json { data: rows } }];
206    report.data_source = Some(entity.name.clone());
207    report.data_member = None;
208    report
209}
210
211/// A first layout for an entity: a table of what its list shows, with money added up.
212fn first_layout(session: &Session, name: &str, entity: &EntityInfo, rows: serde_json::Value) -> Report {
213    let words = session.words();
214    let columns: Vec<_> = plain_fields(entity).filter(|field| field.in_list).collect();
215    let options = WizardOptions {
216        title: name.to_owned(),
217        culture: if words.decimal == ',' { "pt-BR".into() } else { "en-US".into() },
218        columns: columns
219            .iter()
220            .map(|field| wizard::FieldInfo {
221                path: field.name.clone(),
222                kind: match field.kind {
223                    FieldKind::Integer | FieldKind::Decimal(_) | FieldKind::Money => {
224                        wizard::FieldKind::Number
225                    }
226                    FieldKind::Boolean => wizard::FieldKind::Boolean,
227                    FieldKind::Date | FieldKind::DateTime => wizard::FieldKind::Date,
228                    _ => wizard::FieldKind::Text,
229                },
230            })
231            .collect(),
232        totals: columns
233            .iter()
234            .filter(|field| matches!(field.kind, FieldKind::Money))
235            .map(|field| field.name.clone())
236            .collect(),
237        ..WizardOptions::default()
238    };
239    let column_words: Vec<ColumnWords> = columns
240        .iter()
241        .map(|field| ColumnWords {
242            caption: Some(field.caption.clone()),
243            format: matches!(field.kind, FieldKind::Money).then(|| format!("{}#,##0.00", words.money_prefix)),
244        })
245        .collect();
246    let source = DataSource { name: entity.name.clone(), kind: DataSourceKind::Json { data: rows } };
247    wizard::table_report_with(source, &options, &column_words)
248}
249
250/// What is kept: the layout alone, and no one's data.
251fn layout_to_keep(mut report: Report) -> String {
252    for source in &mut report.data_sources {
253        source.kind = DataSourceKind::Json { data: serde_json::Value::Null };
254    }
255    report.to_json()
256}
257
258/// The stored report on this record, whatever shape it is in, or what is wrong with it.
259fn report_of(
260    session: &Session,
261    record: &Object,
262    words: &ReportWords,
263) -> Result<(Report, EntityInfo), String> {
264    let text = |name: &str| match record.get(name) {
265        Value::Text(text) => text,
266        _ => String::new(),
267    };
268    let target = text("target_entity");
269    let entity = session.entity(&target).ok_or_else(|| format!("{} {target}", words.unknown_entity))?;
270    let layout = text("layout");
271    if layout.trim().is_empty() {
272        return Ok((
273            first_layout(session, &text("name"), &entity, serde_json::Value::Array(Vec::new())),
274            entity,
275        ));
276    }
277    let report = Report::from_json(&layout).map_err(|error| format!("{} {error}", words.broken_layout))?;
278    Ok((report, entity))
279}
280
281fn every_record(session: &Session, entity: &str, limit: Option<usize>) -> Vec<Object> {
282    let query = match limit {
283        Some(limit) => Query::all().page(0, limit),
284        None => Query::all(),
285    };
286    session.frame().space().borrow().query(entity, &query).unwrap_or_default()
287}
288
289/// Shows a report over these records, in place of the view.
290fn show_report(
291    session: &Session,
292    report: Report,
293    entity: &EntityInfo,
294    objects: &[Object],
295    words: &ReportWords,
296) {
297    let rows = rows_of(session, entity, objects);
298    let shown = ReportView::new(session.clone(), with_rows(report, entity, rows), words.clone());
299    session.show_full(move || Box::new(shown.clone()) as AnyView);
300}
301
302/// Opens the designer on a stored report, with what the entity holds at hand.
303fn design_report(session: &Session, record: &Object, words: &ReportWords) -> Result<(), String> {
304    let (report, entity) = report_of(session, record, words)?;
305    let sample = every_record(session, &entity.name, Some(SAMPLE_ROWS));
306    let rows = rows_of(session, &entity, &sample);
307    let name = match record.get("name") {
308        Value::Text(name) => name,
309        _ => String::new(),
310    };
311    let report = if record.get("layout") == Value::Null {
312        first_layout(session, &name, &entity, rows)
313    } else {
314        with_rows(report, &entity, rows)
315    };
316    let (saving, closing, told) = (session.clone(), session.clone(), words.saved.clone());
317    let (key, entity_name) = (record.get("id"), REPORT_DEFINITION);
318    let designer = Rc::new(Designer::embedded(
319        report,
320        words.save.clone(),
321        words.close.clone(),
322        move |report| {
323            let space = saving.frame().space();
324            let mut space = space.borrow_mut();
325            let result = (|| {
326                let mut stored = space
327                    .find(entity_name, &key)?
328                    .ok_or_else(|| ravenrs_app::SpaceError::Conflict(told.clone()))?;
329                stored.set("layout", layout_to_keep(report));
330                space.save(stored)?;
331                space.commit()
332            })();
333            if let Err(error) = result {
334                space.rollback();
335                drop(space);
336                saving.show_message(Message::new(error.to_string(), MessageKind::Error));
337            } else {
338                drop(space);
339                saving.show_message(Message::new(told.clone(), MessageKind::Success));
340                saving.records_changed();
341            }
342        },
343        move || closing.close_full(),
344    ));
345    session.show_full(move || Box::new(SharedDesigner(designer.clone())) as AnyView);
346    Ok(())
347}
348
349struct SharedDesigner(Rc<Designer>);
350
351impl View for SharedDesigner {
352    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
353        self.0.build(cx)
354    }
355}
356
357/// The last render, by the parameters it was for.
358type Rendered = (String, Rc<Document>);
359
360/// A report on the screen: its parameters, its pages and getting it out as a file.
361#[derive(Clone)]
362struct ReportView {
363    session: Session,
364    report: Rc<Report>,
365    words: ReportWords,
366    applied: State<ParameterValues>,
367    state: Rc<ViewerState>,
368    engine: Rc<RefCell<ReportEngine>>,
369    rendered: Rc<RefCell<Option<Rendered>>>,
370}
371
372impl ReportView {
373    fn new(session: Session, report: Report, words: ReportWords) -> Self {
374        Self {
375            session,
376            report: Rc::new(report),
377            words,
378            applied: State::new(ParameterValues::new()),
379            state: Rc::new(ViewerState::new()),
380            engine: Rc::new(RefCell::new(ReportEngine::new())),
381            rendered: Rc::default(),
382        }
383    }
384
385    fn document(&self) -> Rc<Document> {
386        let parameters = self.applied.get();
387        let mut sorted: Vec<_> = parameters.iter().collect();
388        sorted.sort_by_key(|(name, _)| *name);
389        let key = format!("{sorted:?}");
390        if let Some((known, document)) = self.rendered.borrow().as_ref()
391            && *known == key
392        {
393            return document.clone();
394        }
395        let data = self.report.embedded_data().cloned().unwrap_or(serde_json::Value::Null);
396        let options = RenderOptions { parameters: parameters.clone(), ..RenderOptions::default() };
397        let document = Rc::new(self.engine.borrow_mut().render_with(&self.report, &data, &options));
398        *self.rendered.borrow_mut() = Some((key, document.clone()));
399        document
400    }
401}
402
403impl View for ReportView {
404    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
405        let document = self.document();
406        let picker = cx.file_picker();
407        let (session, closing) = (self.session.clone(), self.session.clone());
408        let trailing_document = document.clone();
409        let close = self.words.close.clone();
410        let viewer = DocumentViewer::new(document, &self.state).toolbar_trailing(move || {
411            let export = |label: &str, format: ExportFormat| -> AnyView {
412                let (session, picker, document) =
413                    (session.clone(), picker.clone(), trailing_document.clone());
414                Box::new(
415                    Button::new(label.to_owned())
416                        .style(ButtonStyle::Secondary)
417                        .size(ButtonSize::Small)
418                        .on_click(move || match ravenrs_report_export::export(&document, format, None) {
419                            Ok(file) => session.hand_file(file.name, file.bytes, &picker),
420                            Err(error) => {
421                                session.show_message(Message::new(error.to_string(), MessageKind::Error));
422                            }
423                        }),
424                )
425            };
426            let closing = closing.clone();
427            vec![
428                export("PDF", ExportFormat::Pdf),
429                export("XLSX", ExportFormat::Xlsx),
430                Box::new(
431                    Button::new(close.clone())
432                        .style(ButtonStyle::Ghost)
433                        .size(ButtonSize::Small)
434                        .on_click(move || closing.close_full()),
435                ) as AnyView,
436            ]
437        });
438        let panel = (!self.report.parameters.is_empty()).then(|| {
439            let today = Date::from_days(ravenrs_app::local_now().days);
440            ParameterPanel::new(self.report.parameters.clone(), self.applied.clone(), today)
441        });
442        let content = VStack::new((panel, viewer.height(VIEWER_HEIGHT)))
443            .spacing(8.0)
444            .align(ravenrs_layout::Align::Stretch)
445            .grow(1.0);
446        cx.child(&content)
447    }
448}
449
450struct StoredReports {
451    words: ReportWords,
452}
453
454impl ViewController for StoredReports {
455    fn id(&self) -> &str {
456        "StoredReports"
457    }
458
459    fn actions(&self) -> Vec<Action> {
460        let (show_words, design_words) = (self.words.clone(), self.words.clone());
461        vec![
462            Action::simple(SHOW, self.words.show.clone())
463                .category(ActionCategory::View)
464                .target_entity(REPORT_DEFINITION)
465                .selection(Selection::RequireSingle)
466                .icon("eye")
467                .on_execute(move |cx| {
468                    let session = Session::of(cx).ok_or("this action needs the screens")?;
469                    let record = cx.selected.first().ok_or("this action needs a report")?;
470                    let (report, entity) = report_of(&session, record, &show_words)?;
471                    let objects = every_record(&session, &entity.name, None);
472                    show_report(&session, report, &entity, &objects, &show_words);
473                    Ok(())
474                }),
475            Action::simple(DESIGN, self.words.design.clone())
476                .category(ActionCategory::Edit)
477                .target_entity(REPORT_DEFINITION)
478                .selection(Selection::RequireSingle)
479                .requires(Operation::Write)
480                .icon("pencil")
481                .on_execute(move |cx| {
482                    let session = Session::of(cx).ok_or("this action needs the screens")?;
483                    let record = cx.selected.first().ok_or("this action needs a report")?;
484                    design_report(&session, record, &design_words)
485                }),
486        ]
487    }
488}
489
490struct ShowInReport {
491    words: ReportWords,
492}
493
494impl ViewController for ShowInReport {
495    fn id(&self) -> &str {
496        "ShowInReport"
497    }
498
499    fn scope(&self) -> TargetScope {
500        TargetScope::any().view_kind(ViewKind::List)
501    }
502
503    fn activated(&mut self, frame: &ravenrs_app::Frame) {
504        // A report of reports, or of panels, says nothing.
505        let own = [REPORT_DEFINITION, crate::ANALYTICS_DASHBOARD, crate::ANALYTICS_TILE];
506        let shows =
507            frame.view().and_then(|view| view.entity).is_none_or(|name| !own.contains(&name.as_str()));
508        if let Some(action) = frame.action(SHOW_IN_REPORT) {
509            action.set_active("own records", shows);
510        }
511    }
512
513    fn actions(&self) -> Vec<Action> {
514        let words = self.words.clone();
515        vec![
516            Action::simple(SHOW_IN_REPORT, self.words.show_in_report.clone())
517                .category(ActionCategory::Export)
518                .icon("file-text")
519                .on_execute(move |cx| {
520                    let session = Session::of(cx).ok_or("this action needs the screens")?;
521                    let view = cx.view.as_ref().ok_or("this action needs a list")?;
522                    let entity = view
523                        .entity
524                        .as_deref()
525                        .and_then(|name| session.entity(name))
526                        .ok_or("this action needs a list")?;
527                    // The ones selected, or else what the list shows.
528                    let objects =
529                        if cx.selected.is_empty() { session.list_records(view) } else { cx.selected.clone() };
530                    let reports: Vec<Object> = every_record(&session, REPORT_DEFINITION, None)
531                        .into_iter()
532                        .filter(|report| report.get("target_entity") == Value::Text(entity.name.clone()))
533                        .collect();
534                    match reports.as_slice() {
535                        [] => session
536                            .show_message(Message::new(words.none_for_list.clone(), MessageKind::Warning)),
537                        [one] => {
538                            let (report, _) = report_of(&session, one, &words)?;
539                            show_report(&session, report, &entity, &objects, &words);
540                        }
541                        many => {
542                            let (session_for_list, words_for_list, many) =
543                                (session.clone(), words.clone(), many.to_vec());
544                            session.present(words.pick_report.clone(), move || {
545                                let buttons: Vec<AnyView> = many
546                                    .iter()
547                                    .map(|record| {
548                                        let name = match record.get("name") {
549                                            Value::Text(name) => name,
550                                            _ => String::new(),
551                                        };
552                                        let (session, words, record, entity, objects) = (
553                                            session_for_list.clone(),
554                                            words_for_list.clone(),
555                                            record.clone(),
556                                            entity.clone(),
557                                            objects.clone(),
558                                        );
559                                        Box::new(Button::new(name).on_click(move || {
560                                            session.close_presented();
561                                            match report_of(&session, &record, &words) {
562                                                Ok((report, _)) => {
563                                                    show_report(&session, report, &entity, &objects, &words);
564                                                }
565                                                Err(why) => {
566                                                    session
567                                                        .show_message(Message::new(why, MessageKind::Error));
568                                                }
569                                            }
570                                        })) as AnyView
571                                    })
572                                    .collect();
573                                Box::new(VStack::new(buttons).spacing(8.0)) as AnyView
574                            });
575                        }
576                    }
577                    Ok(())
578                }),
579        ]
580    }
581}
582
583/// Reports people create, keep and run: the records, showing and designing them, and showing a list
584/// in one. Storage has to know [`report_entity`].
585#[derive(Debug, Default)]
586pub struct StoredReportsModule {
587    words: ReportWords,
588    predefined: Vec<(String, String, String)>,
589}
590
591impl StoredReportsModule {
592    /// With these words.
593    pub fn new(words: ReportWords) -> Self {
594        Self { words, predefined: Vec::new() }
595    }
596
597    /// A report that comes with the application, on this entity, added once if it is not there.
598    pub fn predefined(mut self, name: impl Into<String>, entity: impl Into<String>, report: Report) -> Self {
599        self.predefined.push((name.into(), entity.into(), layout_to_keep(report)));
600        self
601    }
602}
603
604impl Module for StoredReportsModule {
605    fn name(&self) -> &str {
606        "StoredReports"
607    }
608
609    fn entities(&self) -> Vec<EntityInfo> {
610        vec![report_entity()]
611    }
612
613    fn view_controllers(&self) -> Vec<Box<dyn ViewController>> {
614        vec![
615            Box::new(StoredReports { words: self.words.clone() }),
616            Box::new(ShowInReport { words: self.words.clone() }),
617        ]
618    }
619
620    fn initial_records(&self, space: &mut dyn ObjectSpace) -> Result<(), String> {
621        let wrong = |error: ravenrs_app::SpaceError| error.to_string();
622        let existing = space.query(REPORT_DEFINITION, &Query::all()).map_err(wrong)?;
623        for (name, entity, layout) in &self.predefined {
624            if existing.iter().any(|record| record.get("name") == Value::Text(name.clone())) {
625                continue;
626            }
627            let mut record = space.new_object(REPORT_DEFINITION).map_err(wrong)?;
628            record.set("name", name.clone());
629            record.set("target_entity", entity.clone());
630            record.set("layout", layout.clone());
631            space.save(record).map_err(wrong)?;
632        }
633        Ok(())
634    }
635}
636
637/// Says something wrong with a report on the screen.
638pub(crate) fn problem(title: &str, why: String) -> AnyView {
639    Box::new(Alert::new(title.to_owned(), why).tone(Tone::Danger))
640}