1use std::collections::BTreeMap;
5use std::rc::Rc;
6
7use ravenrs_app::{
8 Action, ActionCategory, Criteria, EntityInfo, EnumOption, FieldInfo, FieldKind, Module, Object, Query,
9 Selection, Value, ViewController,
10};
11use ravenrs_app_ui::{Session, chart_view, summary_text};
12use ravenrs_layout::Align;
13use ravenrs_reactive::State;
14use ravenrs_style::{TextStyle, Weight};
15use ravenrs_widgets::{
16 AnyView, BuildCx, Button, ButtonSize, ButtonStyle, HStack, NodeId, ScrollView, Text, Tile, TileLayout,
17 TileSpot, VStack, View, ViewExt,
18};
19
20use crate::stored::{ReportWords, problem};
21
22pub const ANALYTICS_DASHBOARD: &str = "AnalyticsDashboard";
24pub const ANALYTICS_TILE: &str = "AnalyticsTile";
26const OPEN: &str = "OpenPanel";
27const TILE_COLUMNS: u16 = 4;
28const NUMBER_COLUMNS: u16 = 2;
29const TILE_ROWS: u16 = 2;
30const NUMBER_ROWS: u16 = 1;
31const CHART_HEIGHT: f32 = 240.0;
32
33pub fn analytics_entities() -> Vec<EntityInfo> {
36 let kinds = vec![
37 EnumOption::new("number", "Number"),
38 EnumOption::new("bar", "Bars"),
39 EnumOption::new("line", "Lines"),
40 EnumOption::new("area", "Areas"),
41 EnumOption::new("pie", "Pie"),
42 EnumOption::new("doughnut", "Doughnut"),
43 ];
44 vec![
45 EntityInfo::new(ANALYTICS_DASHBOARD)
46 .caption("Panels")
47 .navigation("Reports")
48 .icon("layout-dashboard")
49 .field(FieldInfo::new("id", FieldKind::Guid).key())
50 .field(FieldInfo::new("name", FieldKind::Text).default_property())
51 .field(
52 FieldInfo::new("tiles", FieldKind::Collection(ANALYTICS_TILE.to_owned()))
53 .back_reference("dashboard_id")
54 .aggregated(),
55 )
56 .rule(ravenrs_app::Rule::required("name")),
57 EntityInfo::new(ANALYTICS_TILE)
58 .caption("Tiles")
59 .field(FieldInfo::new("id", FieldKind::Guid).key())
60 .field(
61 FieldInfo::new("dashboard_id", FieldKind::Reference(ANALYTICS_DASHBOARD.to_owned()))
62 .caption("Panel"),
63 )
64 .field(FieldInfo::new("title", FieldKind::Text).default_property())
65 .field(FieldInfo::new("position", FieldKind::Integer).default_value(0.0))
66 .field(FieldInfo::new("target_entity", FieldKind::Text).caption("Entity"))
67 .field(FieldInfo::new("kind", FieldKind::Enum(kinds)))
68 .field(FieldInfo::new("argument", FieldKind::Text).caption("Along the axis"))
69 .field(FieldInfo::new("series", FieldKind::Text).caption("Values, as field:Sum"))
70 .field(FieldInfo::new("interval", FieldKind::Text).caption("Dates by Day, Month or Year"))
71 .field(FieldInfo::new("filter", FieldKind::Text))
72 .rule(ravenrs_app::Rule::required("title"))
73 .rule(ravenrs_app::Rule::required("target_entity")),
74 ]
75}
76
77fn text(record: &Object, name: &str) -> String {
78 match record.get(name) {
79 Value::Text(text) => text,
80 _ => String::new(),
81 }
82}
83
84fn tile_content(session: &Session, tile: &Object) -> AnyView {
86 let title = text(tile, "title");
87 let target = text(tile, "target_entity");
88 let Some(entity) = session.entity(&target) else {
89 return problem(&title, format!("{} {target}", session.words().list_editor_needs));
90 };
91 let mut query = Query::all();
92 let filter = text(tile, "filter");
93 if !filter.trim().is_empty() {
94 match Criteria::parse(&filter) {
95 Ok(criteria) => query = query.filter(criteria),
96 Err(error) => return problem(&title, error.to_string()),
97 }
98 }
99 let objects = match session.frame().space().borrow().query(&target, &query) {
100 Ok(objects) => objects,
101 Err(error) => return problem(&title, error.to_string()),
102 };
103 let (kind, series) = (text(tile, "kind"), text(tile, "series"));
104 if kind == "number" {
105 return match summary_text(session, &entity, &objects, &series) {
106 Ok(value) => Box::new(Text::new(value).style(TextStyle::Metric)),
107 Err(why) => problem(&title, why),
108 };
109 }
110 let mut settings = BTreeMap::new();
111 let chart_kind = match kind.as_str() {
112 "line" => "Line",
113 "area" => "Area",
114 "pie" => "Pie",
115 "doughnut" => "Doughnut",
116 _ => "Bar",
117 };
118 settings.insert("Kind".to_owned(), chart_kind.to_owned());
119 for (from, to) in [("argument", "Argument"), ("series", "Series"), ("interval", "Interval")] {
120 let value = text(tile, from);
121 if !value.trim().is_empty() {
122 settings.insert(to.to_owned(), value);
123 }
124 }
125 chart_view(session, &entity, Rc::new(objects), settings, CHART_HEIGHT)
126 .unwrap_or_else(|why| problem(&title, why))
127}
128
129#[derive(Clone)]
131struct PanelView {
132 session: Session,
133 panel: Value,
134 close: String,
135 title: String,
136 spots: State<Vec<TileSpot>>,
137 tiles: Rc<Vec<Object>>,
138}
139
140impl PanelView {
141 fn new(session: Session, panel: Value, title: String, close: String) -> Self {
142 let mut tiles: Vec<Object> = session
143 .frame()
144 .space()
145 .borrow()
146 .query(ANALYTICS_TILE, &Query::all())
147 .unwrap_or_default()
148 .into_iter()
149 .filter(|tile| tile.get("dashboard_id") == panel)
150 .collect();
151 tiles.sort_by_key(|tile| match tile.get("position") {
152 Value::Number(position) => position as i64,
153 _ => 0,
154 });
155 let spots = tiles
156 .iter()
157 .map(|tile| {
158 let (columns, rows) = if text(tile, "kind") == "number" {
159 (NUMBER_COLUMNS, NUMBER_ROWS)
160 } else {
161 (TILE_COLUMNS, TILE_ROWS)
162 };
163 TileSpot::new(ravenrs_app::key_text(&tile.get("id")).unwrap_or_default(), columns, rows)
164 })
165 .collect();
166 Self { session, panel, close, title, spots: State::new(spots), tiles: Rc::new(tiles) }
167 }
168}
169
170impl View for PanelView {
171 fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
172 self.session.revision();
174 let mut tiles = TileLayout::new(self.spots.binding());
175 for tile in self.tiles.iter() {
176 let key = ravenrs_app::key_text(&tile.get("id")).unwrap_or_default();
177 tiles = tiles.tile(Tile::new(key, text(tile, "title"), tile_content(&self.session, tile)));
178 }
179 let closing = self.session.clone();
180 let close = Button::new(self.close.clone())
181 .style(ButtonStyle::Secondary)
182 .size(ButtonSize::Small)
183 .on_click(move || closing.close_full());
184 let _ = &self.panel;
185 let top = HStack::new((
186 Text::new(self.title.clone()).style(TextStyle::Title).font_weight(Weight::SEMIBOLD).grow(1.0),
187 close,
188 ))
189 .align(Align::Center);
190 let content = VStack::new((top, tiles)).spacing(16.0).align(Align::Stretch).padding(16.0);
191 cx.child(&ScrollView::new((content,)).grow(1.0))
192 }
193}
194
195struct Panels {
196 words: ReportWords,
197}
198
199impl ViewController for Panels {
200 fn id(&self) -> &str {
201 "Panels"
202 }
203
204 fn actions(&self) -> Vec<Action> {
205 let close = self.words.close.clone();
206 vec![
207 Action::simple(OPEN, self.words.open_panel.clone())
208 .category(ActionCategory::View)
209 .target_entity(ANALYTICS_DASHBOARD)
210 .selection(Selection::RequireSingle)
211 .icon("layout-dashboard")
212 .on_execute(move |cx| {
213 let session = Session::of(cx).ok_or("this action needs the screens")?;
214 let record = cx.selected.first().ok_or("this action needs a panel")?;
215 let shown = PanelView::new(
216 session.clone(),
217 record.get("id"),
218 text(record, "name"),
219 close.clone(),
220 );
221 session.show_full(move || Box::new(shown.clone()) as AnyView);
222 Ok(())
223 }),
224 ]
225 }
226}
227
228#[derive(Debug, Default)]
230pub struct AnalyticsModule {
231 words: ReportWords,
232}
233
234impl AnalyticsModule {
235 pub fn new(words: ReportWords) -> Self {
237 Self { words }
238 }
239}
240
241impl Module for AnalyticsModule {
242 fn name(&self) -> &str {
243 "Analytics"
244 }
245
246 fn entities(&self) -> Vec<EntityInfo> {
247 analytics_entities()
248 }
249
250 fn view_controllers(&self) -> Vec<Box<dyn ViewController>> {
251 vec![Box::new(Panels { words: self.words.clone() })]
252 }
253}