Compare commits
6
Commits
99c5aa613c
...
d029c5a347
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d029c5a347 | ||
|
|
b1869595ac | ||
|
|
0d4a6c4711 | ||
|
|
726a581a9e | ||
|
|
e99026e3d6 | ||
|
|
b25e045b99 |
+2
-1
@@ -4,8 +4,9 @@
|
|||||||
<head>
|
<head>
|
||||||
<link data-trunk rel="rust" data-bin="bin" />
|
<link data-trunk rel="rust" data-bin="bin" />
|
||||||
<link data-trunk rel="scss" href="src/styles/main.scss" />
|
<link data-trunk rel="scss" href="src/styles/main.scss" />
|
||||||
|
<link data-trunk rel="icon" href="src/assets/favicon.ico" type="image/x-icon" />
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Yew App</title>
|
<title>Ticket System CSG</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body></body>
|
<body></body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.3 KiB |
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
|
|||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
|
#[macro_export]
|
||||||
macro_rules! dequote {
|
macro_rules! dequote {
|
||||||
($str:expr) => {
|
($str:expr) => {
|
||||||
$str.trim_matches('"').to_string()
|
$str.trim_matches('"').to_string()
|
||||||
@@ -56,7 +57,7 @@ pub fn home_component() -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="form-container">
|
<div class="form-container home">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1>{ "Welcome" }</h1>
|
<h1>{ "Welcome" }</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ pub fn submit_ticket_component() -> Html {
|
|||||||
{
|
{
|
||||||
if !room_valid {
|
if !room_valid {
|
||||||
html! {
|
html! {
|
||||||
<p class="alert error">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
|
<p class="alert error">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K8, D1)" }</p>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
|
|||||||
+108
-11
@@ -6,6 +6,7 @@ use serde::Deserialize;
|
|||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
use crate::dequote;
|
||||||
use crate::pages::ticket::{ActiveUser, Ticket};
|
use crate::pages::ticket::{ActiveUser, Ticket};
|
||||||
|
|
||||||
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
|
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
|
||||||
@@ -20,6 +21,14 @@ use crate::pages::ticket::{ActiveUser, Ticket};
|
|||||||
struct TicketPartial {
|
struct TicketPartial {
|
||||||
date: DateTime<Utc>,
|
date: DateTime<Utc>,
|
||||||
room: i16,
|
room: i16,
|
||||||
|
user_id: i16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
||||||
|
struct UserPartial {
|
||||||
|
id: i16,
|
||||||
|
first_name: String,
|
||||||
|
last_name: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Properties for components that display room-wise ticket totals.
|
/// Properties for components that display room-wise ticket totals.
|
||||||
@@ -34,6 +43,12 @@ struct RoomTotalsProps {
|
|||||||
tickets: Vec<TicketPartial>,
|
tickets: Vec<TicketPartial>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
struct UserTotalProps {
|
||||||
|
users: Vec<UserPartial>,
|
||||||
|
tickets: Vec<TicketPartial>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Converts a `chrono::DateTime<Utc>` object's weekday to a 0-indexed integer.
|
/// 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)
|
/// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7)
|
||||||
@@ -287,9 +302,9 @@ pub fn ticket_count_component() -> Html {
|
|||||||
})
|
})
|
||||||
.count();
|
.count();
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<div class="open-tickets">
|
||||||
<h2>{ "Offene Tickets" }</h2>
|
<h2 class="left">{ "Offene Tickets" }</h2>
|
||||||
<h4 class="ticket_count">{ count }</h4>
|
<h4 class="ticket_count center">{ count }</h4>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -325,11 +340,13 @@ pub fn ticket_count_component() -> Html {
|
|||||||
#[component(SubmitStats)]
|
#[component(SubmitStats)]
|
||||||
pub fn submit_stats_component() -> Html {
|
pub fn submit_stats_component() -> Html {
|
||||||
let tickets = use_state(|| Vec::<TicketPartial>::new());
|
let tickets = use_state(|| Vec::<TicketPartial>::new());
|
||||||
|
let users = use_state(|| Vec::<UserPartial>::new());
|
||||||
let error = use_state(|| None::<String>);
|
let error = use_state(|| None::<String>);
|
||||||
let loading = use_state(|| false);
|
let loading = use_state(|| false);
|
||||||
|
|
||||||
{
|
{
|
||||||
let tickets = tickets.clone();
|
let tickets = tickets.clone();
|
||||||
|
let users = users.clone();
|
||||||
let error = error.clone();
|
let error = error.clone();
|
||||||
let loading = loading.clone();
|
let loading = loading.clone();
|
||||||
|
|
||||||
@@ -353,6 +370,24 @@ pub fn submit_stats_component() -> Html {
|
|||||||
}
|
}
|
||||||
Err(err) => error.set(Some(format!("Network error: {}", err))),
|
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);
|
loading.set(false);
|
||||||
});
|
});
|
||||||
|| ()
|
|| ()
|
||||||
@@ -414,8 +449,17 @@ pub fn submit_stats_component() -> Html {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="diagnostics-row">
|
||||||
|
<div class="diagnostics-column">
|
||||||
|
<h3>{ "Tickets pro Raum" }</h3>
|
||||||
<RoomTotalTickets tickets={(*tickets).clone()}/>
|
<RoomTotalTickets tickets={(*tickets).clone()}/>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="diagnostics-column">
|
||||||
|
<h3>{ "Tickets pro Benutzer" }</h3>
|
||||||
|
<UserTotal users={(*users).clone()} tickets={(*tickets).clone()}/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,19 +500,72 @@ fn room_total_component(props: &RoomTotalsProps) -> Html {
|
|||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="diagnostics-section">
|
<div class="diagnostics-section">
|
||||||
<h3>{ "Tickets pro Raum" }</h3>
|
<div class="chart">
|
||||||
<div class="room-chart">
|
|
||||||
{ for totals_vec.into_iter().map(|(room, count)| {
|
{ for totals_vec.into_iter().map(|(room, count)| {
|
||||||
let label = parse_room(room);
|
let label = parse_room(room);
|
||||||
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
|
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
|
||||||
html! {
|
html! {
|
||||||
<div class="room-bar-item">
|
<div class="bar-item">
|
||||||
<div class="room-header">
|
<div class="diagnostics-header">
|
||||||
<span class="room-label">{ label }</span>
|
<span class="diagnostics-label">{ label }</span>
|
||||||
<span class="room-count">{ count }</span>
|
<span class="diagnostics-count">{ count }</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="room-bar-container">
|
<div class="bar-container">
|
||||||
<div class="room-bar" style={format!("width: {}%;", bar_width_percent)}>
|
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}) }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0 0 $spacing-md 0;
|
margin: 0;
|
||||||
color: $color-primary;
|
color: $color-primary;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius;
|
||||||
background-color: $color-container;
|
background-color: $color-container;
|
||||||
|
margin: 16px 0px;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
background-color: $color-container-dark;
|
background-color: $color-container-dark;
|
||||||
@@ -52,6 +53,7 @@
|
|||||||
.weekday-bar {
|
.weekday-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: flex-end;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
@@ -100,7 +102,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-chart {
|
.chart {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -108,6 +110,7 @@
|
|||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius;
|
||||||
background-color: $color-container;
|
background-color: $color-container;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
background-color: $color-container-dark;
|
background-color: $color-container-dark;
|
||||||
@@ -115,17 +118,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-bar-item {
|
.bar-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
.room-header {
|
.diagnostics-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.room-label {
|
.diagnostics-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: $color-text;
|
color: $color-text;
|
||||||
@@ -136,14 +139,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-count {
|
.diagnostics-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $color-muted;
|
color: $color-muted;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-bar-container {
|
.bar-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background-color: #e5e7eb;
|
background-color: #e5e7eb;
|
||||||
@@ -154,10 +157,61 @@
|
|||||||
background-color: #555;
|
background-color: #555;
|
||||||
}
|
}
|
||||||
|
|
||||||
.room-bar {
|
.bar {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: $color-primary;
|
background-color: $color-primary;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagnostics-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagnostics-column {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 48%;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.open-tickets{
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-height: 60px;
|
||||||
|
|
||||||
|
.left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
max-width: 30%;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.center {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home {
|
||||||
|
.ticket_count {
|
||||||
|
font-size: xxx-large;
|
||||||
|
height: 115px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -75,4 +75,6 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
width: 250px;
|
width: 250px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: xx-large;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user