615 lines
22 KiB
Rust
615 lines
22 KiB
Rust
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<Utc>,
|
|
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<TicketPartial>,
|
|
}
|
|
|
|
/// 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<UserPartial>,
|
|
tickets: Vec<TicketPartial>,
|
|
}
|
|
|
|
/// Converts a `chrono::DateTime<Utc>` 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<Utc>` object.
|
|
///
|
|
/// # Returns
|
|
/// A `usize` representing the weekday index (0 for Monday, ..., 6 for Sunday).
|
|
fn weekday_index(dt: &DateTime<Utc>) -> 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! {
|
|
/// <Diagnostics />
|
|
/// }
|
|
/// ```
|
|
#[component(Diagnostics)]
|
|
pub fn diagnostics_component() -> Html {
|
|
html! {
|
|
<div>
|
|
<TicketCount/>
|
|
<SubmitStats/>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// 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! {
|
|
/// <TicketCount />
|
|
/// }
|
|
/// ```
|
|
#[component(TicketCount)]
|
|
pub fn ticket_count_component() -> Html {
|
|
let tickets = use_state(Vec::<Ticket>::new);
|
|
let error = use_state(|| None::<String>);
|
|
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::<Vec<Ticket>>().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::<serde_json::Value>().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! {<p>{ "Lade..." }</p>}
|
|
} else if let Some(e) = &*error {
|
|
html! { <p>{ format!("Fehler: {}", e) }</p> }
|
|
} 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! {
|
|
<div class="open-tickets">
|
|
<h2 class="left">{ "Offene Tickets" }</h2>
|
|
<h4 class="ticket_count center">{ count }</h4>
|
|
</div>
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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! {
|
|
/// <SubmitStats />
|
|
/// }
|
|
/// ```
|
|
#[component(SubmitStats)]
|
|
pub fn submit_stats_component() -> Html {
|
|
let tickets = use_state(Vec::<TicketPartial>::new);
|
|
let users = use_state(Vec::<UserPartial>::new);
|
|
let error = use_state(|| None::<String>);
|
|
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::<Vec<TicketPartial>>().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::<Vec<UserPartial>>().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! {
|
|
<div class="diagnostics-section">
|
|
if *loading {
|
|
<p>{ "Lade..." }</p>
|
|
}
|
|
if let Some(e) = &*error {
|
|
<p class="alert error">{ e.clone() }</p>
|
|
}
|
|
<h3>{ "Tickets pro Wochentag" }</h3>
|
|
<div class="weekday-chart">
|
|
<div class="weekday-bars">
|
|
{ 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! {
|
|
<div class="weekday-bar">
|
|
<div class={format!("bar{}", if is_max { " max" } else { "" })} style={format!("height: {}px;", bar_height_px)}>
|
|
</div>
|
|
</div>
|
|
}
|
|
})}
|
|
</div>
|
|
<div class="weekday-labels">
|
|
{ for (0..7).map(|i| {
|
|
html! {
|
|
<div class="label-item">
|
|
<div class="day">{ weekdays[i] }</div>
|
|
<div class="value">{ format!("{:.1}", avg[i]) }</div>
|
|
</div>
|
|
}
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div class="diagnostics-row">
|
|
<div class="diagnostics-column">
|
|
<h3>{ "Tickets pro Raum" }</h3>
|
|
<RoomTotalTickets tickets={(*tickets).clone()}/>
|
|
</div>
|
|
<div class="diagnostics-column">
|
|
<h3>{ "Tickets pro Benutzer" }</h3>
|
|
<UserTotal users={(*users).clone()} tickets={(*tickets).clone()}/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// 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<TicketPartial>` 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! {
|
|
/// <RoomTotalTickets tickets={my_ticket_partials} />
|
|
/// }
|
|
/// ```
|
|
#[component(RoomTotalTickets)]
|
|
fn room_total_component(props: &RoomTotalsProps) -> Html {
|
|
let mut totals: HashMap<i16, usize> = 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! {
|
|
<div class="diagnostics-section">
|
|
<div class="chart">
|
|
{ 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! {
|
|
<div class="bar-item">
|
|
<div class="diagnostics-header">
|
|
<span class="diagnostics-label">{ label }</span>
|
|
<span class="diagnostics-count">{ count }</span>
|
|
</div>
|
|
<div class="bar-container">
|
|
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}) }
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|
|
|
|
/// 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<UserPartial>` containing partial user data.
|
|
/// - `tickets`: A `Vec<TicketPartial>` 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! {
|
|
/// <UserTotal users={my_user_partials} tickets={my_ticket_partials} />
|
|
/// }
|
|
/// ```
|
|
#[component(UserTotal)]
|
|
fn user_total_component(props: &UserTotalProps) -> Html {
|
|
let name_map: HashMap<i16, (String, String)> = props
|
|
.users
|
|
.iter()
|
|
.map(|u| (u.id, (u.first_name.clone(), u.last_name.clone())))
|
|
.collect();
|
|
|
|
let mut counts: HashMap<i16, usize> = 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! {
|
|
<div class="diagnostics-section">
|
|
<div class="chart">
|
|
{ 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! {
|
|
<div class="bar-item">
|
|
<div class="diagnostics-header">
|
|
<span class="diagnostics-label">{ label }</span>
|
|
<span class="diagnostics-count">{ count }</span>
|
|
</div>
|
|
<div class="bar-container">
|
|
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
}) }
|
|
</div>
|
|
</div>
|
|
}
|
|
}
|