4 Commits
Author SHA1 Message Date
schn33fuchs af8efedf39 Styles better 2026-05-13 22:01:58 +02:00
schn33fuchs 5098ec8b99 Home networks 2026-05-13 22:01:48 +02:00
schn33fuchs dc7c87613a Funny counter 2026-05-13 22:01:39 +02:00
schn33fuchs 2c5458743c .gitignore 2026-05-13 16:59:10 +02:00
8 changed files with 208 additions and 29 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ frontend/node_modules/
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ .idea/
# Added by cargo # Added by cargo
+27 -3
View File
@@ -9,19 +9,43 @@ mod models;
/// Axum router configuration with all routes and middleware /// Axum router configuration with all routes and middleware
mod router; mod router;
use std::sync::Arc; use std::sync::{
Arc,
atomic::{AtomicI64, Ordering},
};
use axum::http::{ use axum::{
HeaderValue, Method, Json,
http::{
HeaderValue, Method, StatusCode,
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE}, header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
},
}; };
use dotenv::dotenv; use dotenv::dotenv;
use router::create_router; use router::create_router;
use serde::Serialize;
use sqlx::{PgPool, postgres::PgPoolOptions}; use sqlx::{PgPool, postgres::PgPoolOptions};
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use crate::env::Env; use crate::env::Env;
static EASY_FIX_COUNT: AtomicI64 = AtomicI64::new(0);
#[derive(Serialize)]
struct CounterResp {
value: i64,
}
async fn get_count() -> Json<CounterResp> {
let v = EASY_FIX_COUNT.load(Ordering::SeqCst);
Json(CounterResp { value: v })
}
async fn increment() -> Result<Json<CounterResp>, StatusCode> {
let new = EASY_FIX_COUNT.fetch_add(1, Ordering::SeqCst) + 1;
Ok(Json(CounterResp { value: new }))
}
/// Shared application state passed to all route handlers. /// Shared application state passed to all route handlers.
/// ///
/// Contains the database connection pool and environment configuration. /// Contains the database connection pool and environment configuration.
+5 -2
View File
@@ -8,13 +8,15 @@ use axum::{
use crate::{ use crate::{
AppState, AppState,
cookie::validation::{validate_admin, validate_token}, cookie::validation::{validate_admin, validate_token},
get_count,
handlers::{ handlers::{
auth::{ auth::{
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id, get_users, login, logout, check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id,
setup_initial_admin, update_user, get_users, login, logout, setup_initial_admin, update_user,
}, },
ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets}, ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets},
}, },
increment,
}; };
/// Creates the complete router with all API endpoints. /// Creates the complete router with all API endpoints.
@@ -73,6 +75,7 @@ pub fn create_router(state: Arc<AppState>) -> Router {
.route("/api/tickets/create", post(create_ticket)) .route("/api/tickets/create", post(create_ticket))
.route("/api/logout", get(logout)) .route("/api/logout", get(logout))
.route("/api/users/current", get(get_current_user)) .route("/api/users/current", get(get_current_user))
.route("/api/count", get(get_count).post(increment))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
state.clone(), state.clone(),
validate_token, validate_token,
+1 -1
View File
@@ -1,6 +1,6 @@
[serve] [serve]
# The address to serve on LAN. # The address to serve on LAN.
addresses = ["127.0.0.1"] #,"10.150.9.116"] addresses = ["127.0.0.1", "192.168.178.56"] #,"10.150.9.116"]
# The address to serve on WAN. # The address to serve on WAN.
# address = "0.0.0.0" # address = "0.0.0.0"
# The port to serve on. # The port to serve on.
+2 -1
View File
@@ -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>
+108 -11
View File
@@ -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;
@@ -100,7 +101,7 @@
} }
} }
.room-chart { .chart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
@@ -108,6 +109,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 +117,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 +138,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 +156,60 @@
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;
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;
} }