Compare commits
4
Commits
4bdf3dc04a
...
1017f495f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1017f495f8 | ||
|
|
38603dc84b | ||
|
|
504569e305 | ||
|
|
37c00b0190 |
@@ -23,6 +23,8 @@ enum Route {
|
|||||||
AllUsers,
|
AllUsers,
|
||||||
#[at("/users/:id")]
|
#[at("/users/:id")]
|
||||||
UserByID { id: i16 },
|
UserByID { id: i16 },
|
||||||
|
#[at("/diagnostics")]
|
||||||
|
Diagnostics,
|
||||||
#[at("/denied")]
|
#[at("/denied")]
|
||||||
PermissionDenied,
|
PermissionDenied,
|
||||||
#[not_found]
|
#[not_found]
|
||||||
@@ -70,6 +72,11 @@ fn switch(route: Route) -> Html {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
},
|
},
|
||||||
Route::PermissionDenied => html! { <basic_pages::PermissionDenied/> },
|
Route::PermissionDenied => html! { <basic_pages::PermissionDenied/> },
|
||||||
|
Route::Diagnostics => html! {
|
||||||
|
<ProtectedRoute admin_page={true}>
|
||||||
|
<utilities::Diagnostics/>
|
||||||
|
</ProtectedRoute>
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,5 @@ pub mod basic_pages;
|
|||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
pub mod ticket;
|
pub mod ticket;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod utilities;
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use chrono_tz::Europe::Berlin;
|
use chrono_tz::Europe::Berlin;
|
||||||
use gloo_net::http::Request;
|
use gloo_net::http::Request;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -7,6 +9,15 @@ use web_sys::HtmlSelectElement;
|
|||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
const VALID_ROOMS: &[i16] = &[
|
||||||
|
1, 2, 3, 4, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 19, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30,
|
||||||
|
32, 34, 36, 37, 39, 41, 49, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113,
|
||||||
|
118, 120, 121, 122, 123, 124, 126, 127, 128, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142,
|
||||||
|
143, 144, 145, 146, 147, 148, 150, 152, 153, 154, 155, 156, 157, 158, 159, 160, 201, 202, 203,
|
||||||
|
204, 205, 206, 207, 208, 209, 210, 211, 212, 214, 1001, 1002, 1003, -1, -2, -3, -4, -5, -6, -7,
|
||||||
|
-8,
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
|
||||||
pub struct TicketCreateScheme {
|
pub struct TicketCreateScheme {
|
||||||
pub category: String,
|
pub category: String,
|
||||||
@@ -41,8 +52,8 @@ pub struct TicketProps {
|
|||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct ActiveUser {
|
pub struct ActiveUser {
|
||||||
id: Option<i16>,
|
pub id: Option<i16>,
|
||||||
is_admin: bool,
|
pub is_admin: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
@@ -56,22 +67,34 @@ pub fn submit_ticket_component() -> Html {
|
|||||||
let category = use_state(|| "".to_string());
|
let category = use_state(|| "".to_string());
|
||||||
let betreff = use_state(|| "".to_string());
|
let betreff = use_state(|| "".to_string());
|
||||||
let description = use_state(|| "".to_string());
|
let description = use_state(|| "".to_string());
|
||||||
let room = use_state(|| 0i16);
|
let room = use_state(|| None::<i16>);
|
||||||
|
let room_input = use_state(|| "".to_string());
|
||||||
let status = use_state(|| None::<String>);
|
let status = use_state(|| None::<String>);
|
||||||
|
|
||||||
|
let valid_rooms: HashSet<i16> = VALID_ROOMS.iter().copied().collect();
|
||||||
|
|
||||||
let onsubmit = {
|
let onsubmit = {
|
||||||
let category = category.clone();
|
let category = category.clone();
|
||||||
let betreff = betreff.clone();
|
let betreff = betreff.clone();
|
||||||
let description = description.clone();
|
let description = description.clone();
|
||||||
let room = room.clone();
|
let room = room.clone();
|
||||||
let status = status.clone();
|
let status = status.clone();
|
||||||
|
let valid_rooms = valid_rooms.clone();
|
||||||
|
|
||||||
Callback::from(move |e: SubmitEvent| {
|
Callback::from(move |e: SubmitEvent| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
if room.is_none() {
|
||||||
|
status.set(Some("Invalid room".into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
let category = (*category).clone();
|
let category = (*category).clone();
|
||||||
let betreff = (*betreff).clone();
|
let betreff = (*betreff).clone();
|
||||||
let description = (*description).clone();
|
let description = (*description).clone();
|
||||||
let room = *room;
|
let room = room.unwrap();
|
||||||
|
if !valid_rooms.contains(&room) {
|
||||||
|
status.set(Some("Room not allowed".into()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
let status = status.clone();
|
let status = status.clone();
|
||||||
|
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
@@ -123,12 +146,50 @@ pub fn submit_ticket_component() -> Html {
|
|||||||
|
|
||||||
let room_change = {
|
let room_change = {
|
||||||
let room = room.clone();
|
let room = room.clone();
|
||||||
|
let room_input = room_input.clone();
|
||||||
Callback::from(move |e: InputEvent| {
|
Callback::from(move |e: InputEvent| {
|
||||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||||
room.set(input.value().parse().unwrap_or(0));
|
let raw = input.value();
|
||||||
|
room_input.set(raw.clone());
|
||||||
|
|
||||||
|
let parsed_value: Option<i16> = if raw.trim().is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let raw_trim = raw.trim();
|
||||||
|
if raw_trim.len() > 1 && (raw_trim.starts_with('K') || raw_trim.starts_with('k')) {
|
||||||
|
match raw_trim[1..].parse::<i16>() {
|
||||||
|
Ok(n) => Some(-n),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
} else if raw_trim.len() > 1
|
||||||
|
&& (raw_trim.starts_with('D') || raw_trim.starts_with('d'))
|
||||||
|
{
|
||||||
|
match raw_trim[1..].parse::<i16>() {
|
||||||
|
Ok(n) => Some(1000 + n),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match raw_trim.parse::<i16>() {
|
||||||
|
Ok(n) => Some(n),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(v) = parsed_value {
|
||||||
|
if !valid_rooms.contains(&v) {
|
||||||
|
room.set(None);
|
||||||
|
} else {
|
||||||
|
room.set(Some(v));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
room.set(None);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let room_valid = (*room).is_some();
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<form {onsubmit}>
|
<form {onsubmit}>
|
||||||
<label>{ "Betreff:" }
|
<label>{ "Betreff:" }
|
||||||
@@ -151,8 +212,17 @@ pub fn submit_ticket_component() -> Html {
|
|||||||
</label>
|
</label>
|
||||||
<br/>
|
<br/>
|
||||||
<label>{ "Raum:" }
|
<label>{ "Raum:" }
|
||||||
<input type="number" value={room.to_string()} oninput={room_change}/>
|
<input type="text" value={(*room_input).clone()} oninput={room_change}/>
|
||||||
</label>
|
</label>
|
||||||
|
{
|
||||||
|
if !room_valid {
|
||||||
|
html! {
|
||||||
|
<p style="color: red;">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}
|
||||||
|
}
|
||||||
<br/>
|
<br/>
|
||||||
<button type="submit">{ "Send" }</button>
|
<button type="submit">{ "Send" }</button>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
use std::{collections::HashMap, fmt::format};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Datelike, Utc};
|
||||||
|
use gloo_net::http::Request;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use wasm_bindgen_futures::spawn_local;
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::pages::ticket::{ActiveUser, Ticket};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
||||||
|
struct TicketPartial {
|
||||||
|
date: DateTime<Utc>,
|
||||||
|
room: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct RoomTotalsProps {
|
||||||
|
tickets: Vec<TicketPartial>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_by_weekday(tickets: &[TicketPartial]) -> [usize; 7] {
|
||||||
|
let mut counts = [0usize; 7];
|
||||||
|
for t in tickets {
|
||||||
|
counts[weekday_index(&t.date)] += 1;
|
||||||
|
}
|
||||||
|
counts
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = current.succ();
|
||||||
|
}
|
||||||
|
|
||||||
|
occ
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component(Diagnostics)]
|
||||||
|
pub fn diagnostics_component() -> Html {
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<TicketCount/>
|
||||||
|
<SubmitStats/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 = format!("/api/tickets");
|
||||||
|
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
|
||||||
|
{
|
||||||
|
if response.status() == 200 {
|
||||||
|
if 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>{ "Loading" }</p>}
|
||||||
|
} else if let Some(e) = &*error {
|
||||||
|
html! { <p>{ format!("Error: {}", 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.map_or(false, |uid| t.user_id == uid))
|
||||||
|
})
|
||||||
|
.count();
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<h2>{ "Offene Tickets" }</h2>
|
||||||
|
<h4>{ count }</h4>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component(SubmitStats)]
|
||||||
|
pub fn submit_stats_component() -> Html {
|
||||||
|
let tickets = use_state(|| Vec::<TicketPartial>::new());
|
||||||
|
let error = use_state(|| None::<String>);
|
||||||
|
let loading = use_state(|| 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<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))),
|
||||||
|
}
|
||||||
|
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 = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
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>
|
||||||
|
if *loading {
|
||||||
|
<p>{ "Loading..." }</p>
|
||||||
|
}
|
||||||
|
if let Some(e) = &*error {
|
||||||
|
<p style="color: red;">{ e.clone() }</p>
|
||||||
|
}
|
||||||
|
<h3>{ "Tickets per weekday" }</h3>
|
||||||
|
<ul>
|
||||||
|
{ for (0..7).map(|i| {
|
||||||
|
let is_max = i == max_idx;
|
||||||
|
html! {
|
||||||
|
<li style={ if is_max { "font-weight: bold; color: green;" } else { "" } }>
|
||||||
|
{ format!("{}: {:3}", weekdays[i], avg[i]) }
|
||||||
|
{ if is_max { html!{ <span>{ " ← most" }</span> } } else { html!{} } }
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
<RoomTotalTickets tickets={(*tickets).clone()}/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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));
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<h3>{ "Tickets pro Raum" }</h3>
|
||||||
|
<ul>
|
||||||
|
{ for totals_vec.into_iter().map(|(room, count)| {
|
||||||
|
let label = parse_room(room);
|
||||||
|
html! { <li>{ format!("{}: {}", label, count) }</li> }
|
||||||
|
}) }
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user