use std::collections::HashMap; use chrono::{DateTime, Datelike, Utc}; use gloo_net::http::Request; use serde::Deserialize; use wasm_bindgen_futures::spawn_local; use yew::prelude::*; use crate::dequote; use crate::pages::ticket::{ActiveUser, Ticket}; /// A partial representation of a ticket, containing only the fields necessary for statistical analysis. /// /// This struct is used to efficiently retrieve and process ticket data for diagnostics /// without fetching the full ticket details. /// /// # Fields /// - `date`: The creation date and time of the ticket in UTC. /// - `room`: The room number associated with the ticket. #[derive(Debug, Deserialize, Clone, PartialEq)] struct TicketPartial { date: DateTime, room: i16, user_id: i16, } /// A partial representation of a user, containing only the fields necessary for statistical analysis. /// /// This struct is used to efficiently retrieve and process user data for diagnostics /// without fetching the full user details. /// /// # Fields /// - `id`: The unique identifier of the user. /// - `first_name`: The first name of the user. /// - `last_name`: The last name of the user. #[derive(Debug, Deserialize, Clone, PartialEq)] struct UserPartial { id: i16, first_name: String, last_name: String, } /// Properties for components that display room-wise ticket totals. /// /// This struct passes a list of partial ticket data to a component responsible /// for calculating and visualizing ticket distribution per room. /// /// # Fields /// - `tickets`: A vector of [`TicketPartial`] containing ticket data relevant for room totals. #[derive(Properties, PartialEq)] struct RoomTotalsProps { tickets: Vec, } /// Properties for components that display user-wise ticket totals. /// /// This struct passes a list of partial user data and partial ticket data to a component /// responsible for calculating and visualizing ticket distribution per user. /// /// # Fields /// - `users`: A vector of [`UserPartial`] containing user data relevant for user totals. /// - `tickets`: A vector of [`TicketPartial`] containing ticket data relevant for user totals. #[derive(Properties, PartialEq)] struct UserTotalProps { users: Vec, tickets: Vec, } /// Converts a `chrono::DateTime` object's weekday to a 0-indexed integer. /// /// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7) /// to a 0-indexed system (Monday = 0, Sunday = 6). /// /// # Arguments /// - `dt`: A reference to a `DateTime` object. /// /// # Returns /// A `usize` representing the weekday index (0 for Monday, ..., 6 for Sunday). fn weekday_index(dt: &DateTime) -> usize { // chrono::Weekday: Mon = 1 ... Sun = 7 match dt.weekday() { chrono::Weekday::Mon => 0, chrono::Weekday::Tue => 1, chrono::Weekday::Wed => 2, chrono::Weekday::Thu => 3, chrono::Weekday::Fri => 4, chrono::Weekday::Sat => 5, chrono::Weekday::Sun => 6, } } /// Counts the number of tickets submitted on each day of the week. /// /// This function takes a slice of `TicketPartial` and returns an array where each /// element corresponds to a day of the week (Monday=0, Sunday=6) and contains /// the total count of tickets submitted on that day. /// /// # Arguments /// - `tickets`: A slice of [`TicketPartial`] items to count. /// /// # Returns /// An array `[usize; 7]` with ticket counts for each weekday. fn count_by_weekday(tickets: &[TicketPartial]) -> [usize; 7] { let mut counts = [0usize; 7]; for t in tickets { counts[weekday_index(&t.date)] += 1; } counts } /// Calculates the occurrences of each weekday within the date range covered by the tickets. /// /// This function determines the minimum and maximum dates from the provided [`TicketPartial`] /// slice and then counts how many times each weekday occurs within that inclusive date range. /// This is useful for normalizing ticket counts against the number of available days for each weekday. /// /// # Arguments /// - `partials`: A slice of [`TicketPartial`] items defining the date range. /// /// # Returns /// An array `[usize; 7]` where each element represents the number of times a /// specific weekday (Monday=0, ..., Sunday=6) appears in the date range. fn day_counts(partials: &[TicketPartial]) -> [usize; 7] { if partials.is_empty() { return [0usize; 7]; } let mut min = partials[0].date.date_naive(); let mut max = min; for d in partials.iter().skip(1) { let dt = d.date.date_naive(); if dt < min { min = dt } if dt > max { max = dt } } let total_days = (max - min).num_days() + 1; if total_days <= 0 { return [0usize; 7]; } let mut occ = [0usize; 7]; let mut current = min; for _ in 0..total_days { let wd = current.weekday(); let idx = match wd { chrono::Weekday::Mon => 0, chrono::Weekday::Tue => 1, chrono::Weekday::Wed => 2, chrono::Weekday::Thu => 3, chrono::Weekday::Fri => 4, chrono::Weekday::Sat => 5, chrono::Weekday::Sun => 6, }; occ[idx] += 1; current += chrono::Duration::days(1); } occ } /// Converts a numerical room representation back into a human-readable string format. /// /// This function is the inverse of the room parsing logic in /// [`SubmitTicket`](`crate::pages::ticket::SubmitTicket`) component. /// It converts negative numbers back to "K" prefixed rooms, numbers >= 1000 back to "D" prefixed rooms, /// and other numbers to their string representation. /// /// # Arguments /// - `r`: An `i16` representing the internal numerical representation of a room. /// /// # Returns /// A `String` containing the formatted room number (e.g., "K1", "D1", "101"). fn parse_room(r: i16) -> String { if r < 0 { format!("K{}", r.abs()) } else if r > 1000 { format!("D{}", r - 1000) } else { r.to_string() } } /// The main diagnostics dashboard component. /// /// This component serves as a container for various statistical and diagnostic /// views related to the application's tickets. /// /// # Components Rendered /// - [`TicketCount`]: Displays the count of open tickets. /// - [`SubmitStats`]: Displays statistics related to ticket submissions (e.g., by weekday, by room). /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(Diagnostics)] pub fn diagnostics_component() -> Html { html! {
} } /// A component that displays the count of currently open (ToDo or InProgress) tickets. /// /// This component fetches all tickets and the current user's details. It then filters /// the tickets to count only those that are "ToDo" or "InProgress" and are either /// created by the current user (for non-admins) or all tickets (for admins). /// /// # State /// Uses `use_state` hooks to manage: /// - `tickets`: A vector of `Ticket` structs for all fetched tickets. /// - `error`: Any error message from API calls. /// - `loading`: A boolean indicating if data is being fetched. /// - `user`: The `ActiveUser` details for conditional filtering. /// /// # Functionality /// - Fetches all tickets from `/api/tickets`. /// - Fetches current user details from `/api/users/current`. /// - Filters tickets by status ("ToDo" or "InProgress") and user ID (if not admin). /// - Displays the count of matching tickets. /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(TicketCount)] pub fn ticket_count_component() -> Html { let tickets = use_state(Vec::::new); let error = use_state(|| None::); let loading = use_state(|| false); let user = use_state(|| ActiveUser { id: None, is_admin: false, }); { let tickets = tickets.clone(); let error = error.clone(); let loading = loading.clone(); use_effect_with((), move |_| { loading.set(true); spawn_local(async move { let url = "/api/tickets".to_string(); match Request::get(&url).send().await { Ok(response) if response.status() == 200 => { match response.json::>().await { Ok(t) => tickets.set(t), Err(e) => error.set(Some(format!("parse error: {}", e))), } } Ok(response) => { if let Ok(text) = response.text().await { error.set(Some(text)); } else { error.set(Some(format!("status {}", response.status()))); } } Err(err) => error.set(Some(format!("Network error: {}", err))), } loading.set(false); }); || () }); } { let user = user.clone(); use_effect_with((), move |_| { let user = user.clone(); spawn_local(async move { if let Ok(response) = Request::get("/api/users/current") .credentials(web_sys::RequestCredentials::Include) .send() .await && response.status() == 200 && let Ok(json) = response.json::().await { let id = json .get("data") .and_then(|d| d.get("id")) .and_then(|v| v.as_i64()) .and_then(|n| i16::try_from(n).ok()); let is_admin = json .get("data") .and_then(|d| d.get("is_admin")) .and_then(|v| v.as_bool()) .unwrap_or(false); user.set(ActiveUser { id, is_admin }); } }); || () }); } if *loading { html! {

{ "Lade..." }

} } else if let Some(e) = &*error { html! {

{ format!("Fehler: {}", e) }

} } else { let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress"; let count = tickets .iter() .filter(|t| status_conditions(t) && (user.is_admin || (user.id == Some(t.user_id)))) .count(); html! {

{ "Offene Tickets" }

{ count }

} } } /// A component that displays various statistics about ticket submissions. /// /// This component fetches all tickets (in a partial format for efficiency) /// and calculates statistics such as tickets per weekday and tickets per room. /// It renders these statistics visually using simple bar charts. /// /// # State /// Uses `use_state` hooks to manage: /// - `tickets`: A vector of [`TicketPartial`] for statistical analysis. /// - `error`: Any error message from API calls. /// - `loading`: A boolean indicating if data is being fetched. /// /// # Functionality /// - Fetches all tickets (as [`TicketPartial`]) from `/api/tickets`. /// - Calculates: /// - `counts`: Number of tickets submitted on each weekday. /// - `occ`: Number of occurrences of each weekday in the ticket date range. /// - `avg`: Average number of tickets per day for each weekday, normalized by `occ`. /// - Renders a bar chart for average tickets per weekday. /// - Renders a [`RoomTotalTickets`] component to display tickets per room. /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(SubmitStats)] pub fn submit_stats_component() -> Html { let tickets = use_state(Vec::::new); let users = use_state(Vec::::new); let error = use_state(|| None::); let loading = use_state(|| false); { let tickets = tickets.clone(); let users = users.clone(); let error = error.clone(); let loading = loading.clone(); use_effect_with((), move |_| { loading.set(true); spawn_local(async move { let url = "/api/tickets".to_string(); match Request::get(&url).send().await { Ok(response) if response.status() == 200 => { match response.json::>().await { Ok(t) => tickets.set(t), Err(e) => error.set(Some(format!("parse error: {}", e))), } } Ok(response) => { if let Ok(text) = response.text().await { error.set(Some(text)); } else { error.set(Some(format!("status {}", response.status()))); } } Err(err) => error.set(Some(format!("Network error: {}", err))), } match Request::get("/api/users").send().await { Ok(response) if response.status() == 200 => { match response.json::>().await { Ok(u) => users.set(u), Err(e) => error.set(Some(format!("users parse error: {}", e))), } } Ok(response) => { if let Ok(text) = response.text().await { error.set(Some(text)); } else { error.set(Some(format!("users status {}", response.status()))); } } Err(err) => error.set(Some(format!("users network error: {}", err))), } loading.set(false); }); || () }); } let counts = count_by_weekday(&tickets); let occ = day_counts(&tickets); let mut avg = [0.0f64; 7]; for i in 0..7 { if occ[i] > 0 { avg[i] = counts[i] as f64 / occ[i] as f64; } else { avg[i] = 0.0; } } let weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; let (max_idx, _max_val) = counts .iter() .enumerate() .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) .map(|(i, _)| (i, ())) .unwrap_or((0, ())); html! {
if *loading {

{ "Lade..." }

} if let Some(e) = &*error {

{ e.clone() }

}

{ "Tickets pro Wochentag" }

{ for (0..7).map(|i| { let is_max = i == max_idx; let max_value = counts.iter().cloned().max().unwrap_or(1).max(1); let bar_height_percent = if max_value > 0 { (avg[i] / max_value as f64) * 100.0 } else { 0.0 }; let bar_height_px = (bar_height_percent * 200.0 / 100.0) as i32; html! {
} })}
{ for (0..7).map(|i| { html! {
{ weekdays[i] }
{ format!("{:.1}", avg[i]) }
} })}

{ "Tickets pro Raum" }

{ "Tickets pro Benutzer" }

} } /// A component that displays the total number of tickets per room. /// /// This component takes a list of [`TicketPartial`] items and calculates the /// total number of tickets for each room. It then displays these totals /// in a sorted list with a bar chart visualization. /// /// # Props /// - `tickets`: A `Vec` containing the partial ticket data for analysis. /// /// # Functionality /// - **Calculates Totals**: Aggregates ticket counts for each unique room. /// - **Sorts Results**: Displays rooms sorted by ticket count in descending order. /// - **Visualizes Data**: Renders a bar chart where the width of each bar is /// proportional to the ticket count for that room, relative to the room with the maximum tickets. /// - **Room Formatting**: Uses the [`parse_room`] function to display room numbers /// in a human-readable format. /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(RoomTotalTickets)] fn room_total_component(props: &RoomTotalsProps) -> Html { let mut totals: HashMap = HashMap::new(); for t in &props.tickets { *totals.entry(t.room).or_insert(0) += 1; } let mut totals_vec: Vec<(i16, usize)> = totals.into_iter().collect(); totals_vec.sort_by(|a, b| b.1.cmp(&a.1)); let max_count = totals_vec.iter().map(|(_, c)| *c).max().unwrap_or(1).max(1); html! {
{ for totals_vec.into_iter().map(|(room, count)| { let label = parse_room(room); let bar_width_percent = (count as f64 / max_count as f64) * 100.0; html! {
{ label } { count }
} }) }
} } /// A component that displays the total number of tickets per user. /// /// This component takes lists of [`UserPartial`] and [`TicketPartial`] items to calculate /// and display the total number of tickets submitted by each user. /// The results are presented in a sorted list with a bar chart visualization. /// /// # Props /// - `users`: A `Vec` containing partial user data. /// - `tickets`: A `Vec` containing partial ticket data for analysis. /// /// # Functionality /// - **Maps User Names**: Creates a map from user IDs to their first and last names for display. /// - **Calculates Totals**: Aggregates ticket counts for each user. /// - **Sorts Results**: Displays users sorted by their ticket count in descending order. /// - **Visualizes Data**: Renders a bar chart where the width of each bar is proportional /// to the ticket count for that user, relative to the user with the maximum tickets. /// - **Name Formatting**: Uses the [`dequote!`] macro to clean up user names before display. /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(UserTotal)] fn user_total_component(props: &UserTotalProps) -> Html { let name_map: HashMap = props .users .iter() .map(|u| (u.id, (u.first_name.clone(), u.last_name.clone()))) .collect(); let mut counts: HashMap = HashMap::new(); for t in &props.tickets { *counts.entry(t.user_id).or_insert(0) += 1; } let mut totals_vec: Vec<(i16, String, String, usize)> = name_map .into_iter() .map(|(id, (fname, lname))| { let c = counts.get(&id).cloned().unwrap_or(0); (id, fname, lname, c) }) .collect(); totals_vec.sort_by(|a, b| b.3.cmp(&a.3)); let max_count = totals_vec .iter() .map(|(_, _, _, c)| *c) .max() .unwrap_or(1) .max(1); html! {
{ for totals_vec.into_iter().map(|(_id, fname, lname, count)| { let label = format!("{} {}", dequote!(fname), dequote!(lname)); let bar_width_percent = (count as f64 / max_count as f64) * 100.0; html! {
{ label } { count }
} }) }
} }