29 Commits
Author SHA1 Message Date
schn33fuchs 5101c0ff4d Sam did styling 2026-06-08 12:52:54 +02:00
schn33fuchs 2a53c7372d Archive style 2026-06-07 20:43:12 +02:00
schn33fuchs 75f3ef392e Mobile fix
The CSG logo shrinks when on mobile screens
2026-06-07 12:11:49 +02:00
schn33fuchs 159202a7af Sidebar improvement
It now works on mobile
2026-06-07 12:11:32 +02:00
schn33fuchs 8fd35f3e82 Documentation 2026-06-07 11:56:16 +02:00
schn33fuchs 177e959bbd Center 2026-06-06 18:53:44 +02:00
schn33fuchs 3d68064b33 Resize 2026-06-06 18:50:56 +02:00
schn33fuchs 52ca6ec96f Home button
now has an svg
2026-06-06 18:48:14 +02:00
schn33fuchs 7f3913dcfb Post merge fix 2026-06-06 18:44:40 +02:00
schn33fuchs 31e98c0936 Merge branch 'ticket_background' 2026-06-06 18:36:31 +02:00
schn33fuchs b404ff5bc9 Path
Fixed layout issues and joke message
2026-06-06 18:31:05 +02:00
schn33fuchs c3b7321005 Styling
The ticket backgrounds have color depending on background and some
improvements
2026-06-06 18:18:22 +02:00
TheDiluTek 2a15f77f4f so ein bisschen buttons und links und non admin user tests 2026-06-06 18:13:40 +02:00
TheDiluTek dfb031949b view tickets 2026-06-06 17:25:12 +02:00
TheDiluTek b030e133a2 Merge branch 'main' of https://git.raffauf-clan.de/schn33fuchs/ticketsystem 2026-06-06 17:01:58 +02:00
schn33fuchs 810d554cb9 Fixed bug
You could set the password to be an empty string
2026-06-05 21:35:54 +02:00
TheDiluTek 2e6bd2a781 bisschen sidebar schön. problem gefunden: es reicht wenn man benutzernamen eingibt. man logt sich auch ohne passwort ein 2026-06-04 21:48:47 +02:00
schn33fuchs 390630886e Merge pull request 'Icon' (#42) from Icon into main
Reviewed-on: #42
2026-06-03 16:57:13 +02:00
schn33fuchs 0ee20e2833 Icon in the corner 2026-06-03 16:10:37 +02:00
schn33fuchs b7f8df624c Favion
Its there now
2026-06-03 15:53:57 +02:00
schn33fuchs 8a9c9efbcc Icons 2026-06-03 15:52:10 +02:00
schn33fuchs c31375573f .gitignore
update
2026-06-03 15:48:52 +02:00
TheDiluTek b2daed8e99 bug fixes 2026-06-03 15:13:09 +02:00
TheDiluTek 076bd6c545 mehr von login seite gemacht, sah voll gut aus aber hab irgendwas kaputt gemacht und finds nicht 2026-05-30 23:45:39 +02:00
TheDiluTek a42897de9f anmeldung überarbeitet, login braucht noch mehr gap als die anderen inputs haben (command?) 2026-05-30 14:03:25 +02:00
TheDiluTek 3154e3de44 login schön gemacht 2026-05-30 13:29:41 +02:00
schn33fuchs bf0197adae .gitignore 2026-05-13 16:58:44 +02:00
schn33fuchs c6a4a24fb6 Sam did something 2026-05-13 13:31:56 +02:00
schn33fuchs 52387f7333 Fun times 2026-05-13 12:18:55 +02:00
40 changed files with 866 additions and 1706 deletions
+1 -1
View File
@@ -21,9 +21,9 @@ frontend/node_modules/
# 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/
.antigravitycli/
# Added by cargo # Added by cargo
/target /target
.antigravitycli/
+8 -3
View File
@@ -45,14 +45,19 @@ location /api/ {
``` ```
## Usage of AI ## Usage of AI
Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation: Github Copilot CLI was used with the model Claude Haiku 4.5 to generate most of the documentation
Google Antigravity generated the Sequence Diagrams
### Prompt ### Prompt
Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc. Generate comments for cargo doc describing the indivilual components and create links to relevant structs, functions etc.
Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output ### Output
The comments with `///` or `//!` The comments with `///` or `//!`
I've gone over most of it and modified it to my needs and opinions I've gone over it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+16 -2
View File
@@ -248,6 +248,10 @@ classDiagram
class SidebarShellProps { class SidebarShellProps {
+children: Children +children: Children
} }
class SidebarComponentProps {
+is_open: bool
+on_close: Callback~()~
}
class AdminCheckWrapperProps { class AdminCheckWrapperProps {
+children: Children +children: Children
} }
@@ -255,6 +259,7 @@ classDiagram
RoomTotalsProps --> TicketPartial RoomTotalsProps --> TicketPartial
UserTotalProps --> UserPartial UserTotalProps --> UserPartial
UserTotalProps --> TicketPartial UserTotalProps --> TicketPartial
SidebarShellProps ..> SidebarComponentProps
``` ```
@@ -351,6 +356,15 @@ sequenceDiagram
DB-->>BE: Success DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"} BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Update ticket status in UI FE-->>Admin: Update ticket status in UI
Note over Admin, DB: Ticket Archiving Flow (Admin Only Route)
Admin->>FE: Open Archived Tickets page
FE->>BE: GET /api/tickets/archive (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & admin role
BE->>DB: SELECT * FROM tickets WHERE status = 'Archived'
DB-->>BE: Return archived tickets
BE-->>FE: HTTP 200 OK [Archived Tickets]
FE-->>Admin: Render historical archive board
``` ```
## Usage of AI ## Usage of AI
@@ -366,6 +380,6 @@ Generate a sequence diagramm in @[README.md] behind the class diagramms
### Output ### Output
The comments with `///` or `//!` The comments with `///` or `//!`
I've gone over most of it and modified it to my needs and opinions I've gone over it and modified it to my needs and opinions
The sequence Diagrams above The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+2 -2
View File
@@ -43,7 +43,7 @@ pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String {
expires: expires as usize, expires: expires as usize,
}; };
let token = encode(header, &claims, key); let token = encode(header, &claims, key);
token.expect("token return failed") return token.expect("token return failed");
} }
/// Decodes and validates a JSON Web Token (JWT). /// Decodes and validates a JSON Web Token (JWT).
@@ -77,5 +77,5 @@ pub fn decode_token(token: String, key: &DecodingKey) -> Result<Claims, (StatusC
(StatusCode::UNAUTHORIZED, Json(error)) (StatusCode::UNAUTHORIZED, Json(error))
})? })?
.claims; .claims;
Ok(claims) return Ok(claims);
} }
+16 -4
View File
@@ -48,7 +48,13 @@ pub async fn validate_token(
.headers() .headers()
.get(header::AUTHORIZATION) .get(header::AUTHORIZATION)
.and_then(|header| header.to_str().ok()) .and_then(|header| header.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer ").map(|s| s.to_owned())) .and_then(|value| {
if value.starts_with("Bearer ") {
Some(value[7..].to_owned())
} else {
None
}
})
}); });
let token = token.ok_or_else(|| { let token = token.ok_or_else(|| {
@@ -71,7 +77,7 @@ pub async fn validate_token(
(status, Json(error)) (status, Json(error))
})?; })?;
let uuid = claims.sub.parse::<i16>().map_err(|_| { let uuid = (&claims.sub).parse::<i16>().map_err(|_| {
let error = json!({ let error = json!({
"status": "error", "status": "error",
"message": "Invalid user id" "message": "Invalid user id"
@@ -137,7 +143,13 @@ pub async fn validate_admin(
.headers() .headers()
.get(header::AUTHORIZATION) .get(header::AUTHORIZATION)
.and_then(|header| header.to_str().ok()) .and_then(|header| header.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer ").map(|s| s.to_owned())) .and_then(|value| {
if value.starts_with("Bearer ") {
Some(value[7..].to_owned())
} else {
None
}
})
}); });
let token = token.ok_or_else(|| { let token = token.ok_or_else(|| {
@@ -160,7 +172,7 @@ pub async fn validate_admin(
(status, Json(error)) (status, Json(error))
})?; })?;
let uuid = claims.sub.parse::<i16>().map_err(|_| { let uuid = (&claims.sub).parse::<i16>().map_err(|_| {
let error = json!({ let error = json!({
"status": "error", "status": "error",
"message": "Invalid user id" "message": "Invalid user id"
-4
View File
@@ -21,8 +21,6 @@ pub struct Env {
pub token_secret: String, pub token_secret: String,
/// Frontend origin URL for CORS policy /// Frontend origin URL for CORS policy
pub origin: String, pub origin: String,
/// Backend port number
pub backend_port: String,
} }
impl Env { impl Env {
@@ -47,12 +45,10 @@ impl Env {
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set"); let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
let token_secret = std::env::var("TOKEN_SECRET").expect("TOKEN_SECRET must be set"); let token_secret = std::env::var("TOKEN_SECRET").expect("TOKEN_SECRET must be set");
let origin = std::env::var("ORIGIN").expect("ORIGIN must be set"); let origin = std::env::var("ORIGIN").expect("ORIGIN must be set");
let backend_port = std::env::var("BACKEND_PORT").expect("BACKEND_PORT must be set");
Env { Env {
db_url, db_url,
token_secret, token_secret,
origin, origin,
backend_port,
} }
} }
} }
+47 -36
View File
@@ -60,7 +60,7 @@ pub async fn create_user(
) )
})?; })?;
if exist_check.is_some() { if let Some(_) = exist_check {
return Err(( return Err((
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(json!({"status": "error", "message": "user already exists"})), Json(json!({"status": "error", "message": "user already exists"})),
@@ -90,10 +90,10 @@ pub async fn create_user(
})?; })?;
if user.rows_affected() < 1 { if user.rows_affected() < 1 {
Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": "Error creating user"})), Json(json!({"status": "error", "message": "Error creating user"})),
)) ));
} else { } else {
Ok(Json(json!({"status": "success", "result": "User created"}))) Ok(Json(json!({"status": "success", "result": "User created"})))
} }
@@ -151,7 +151,7 @@ pub async fn login(
let pwd_hash = PasswordHash::new(&user.pwd); let pwd_hash = PasswordHash::new(&user.pwd);
let valid_pwd = Argon2::default() let valid_pwd = Argon2::default()
.verify_password(request.pwd.as_bytes(), &pwd_hash.unwrap()) .verify_password(&request.pwd.as_bytes(), &pwd_hash.unwrap())
.is_ok(); .is_ok();
if !valid_pwd { if !valid_pwd {
@@ -268,7 +268,7 @@ pub async fn get_current_user(
/// - `404 Not Found` if user doesn't exist /// - `404 Not Found` if user doesn't exist
/// - `500 Internal Server Error` if database error occurs /// - `500 Internal Server Error` if database error occurs
pub async fn delete_user( pub async fn delete_user(
Path(id): Path<i32>, Path(id): Path<i16>,
State(data): State<Arc<AppState>>, State(data): State<Arc<AppState>>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#) let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#)
@@ -340,7 +340,7 @@ pub async fn get_users(
let response = users let response = users
.iter() .iter()
.map(filter_user) .map(|user| filter_user(&user))
.collect::<Vec<FilteredUser>>(); .collect::<Vec<FilteredUser>>();
let json_respnse = json!(response); let json_respnse = json!(response);
Ok(Json(json_respnse)) Ok(Json(json_respnse))
@@ -372,22 +372,22 @@ pub async fn get_user_by_id(
match query { match query {
Ok(user) => { Ok(user) => {
let response = serde_json::json!(filter_user(&user)); let response = serde_json::json!(filter_user(&user));
Ok(Json(response)) return Ok(Json(response));
} }
Err(sqlx::Error::RowNotFound) => { Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({ let error_response = serde_json::json!({
"status": "fail", "status": "fail",
"message": format!("User with ID {} not found", id) "message": format!("User with ID {} not found", id)
}); });
Err((StatusCode::NOT_FOUND, Json(error_response))) return Err((StatusCode::NOT_FOUND, Json(error_response)));
} }
Err(e) => { Err(e) => {
Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})), Json(json!({"status": "error", "message": format!("{:?}", e)})),
)) ));
} }
} };
} }
/// Updates an existing user's information. /// Updates an existing user's information.
@@ -411,32 +411,43 @@ pub async fn get_user_by_id(
/// - This endpoint requires admin privileges (enforced by middleware via /// - This endpoint requires admin privileges (enforced by middleware via
/// [`validate_admin`](crate::cookie::validation::validate_admin)). /// [`validate_admin`](crate::cookie::validation::validate_admin)).
pub async fn update_user( pub async fn update_user(
Path(id): Path<i32>, Path(id): Path<i16>,
State(data): State<Arc<AppState>>, State(data): State<Arc<AppState>>,
Json(body): Json<UserUpdateScheme>, Json(body): Json<UserUpdateScheme>,
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> { ) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
let argon = Argon2::default(); let update_result = if !body.new_pwd.is_empty() {
let salt = SaltString::generate(&mut OsRng); let argon = Argon2::default();
let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) { let salt = SaltString::generate(&mut OsRng);
Ok(h) => h.to_string(), let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) {
Err(e) => panic!("Error hashing {:}", e), Ok(h) => h.to_string(),
}; Err(e) => panic!("Error hashing {:}", e),
};
let update_result = sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#) sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#)
.bind(body.first_name.to_owned()) .bind(body.first_name.to_owned())
.bind(body.last_name.to_owned()) .bind(body.last_name.to_owned())
.bind(body.username.to_owned()) .bind(body.username.to_owned())
.bind(&hashed_pwd) .bind(hashed_pwd)
.bind(body.make_admin.to_owned()) .bind(body.make_admin.to_owned())
.bind(id) .bind(id)
.execute(&data.db) .execute(&data.db)
.await .await
.map_err(|e| { } else {
( sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, is_admin = $4 WHERE id = $5"#)
StatusCode::INTERNAL_SERVER_ERROR, .bind(body.first_name.to_owned())
Json(json!({"status": "error", "message": format!("{:?}", e)})), .bind(body.last_name.to_owned())
) .bind(body.username.to_owned())
})?; .bind(body.make_admin.to_owned())
.bind(id)
.execute(&data.db)
.await
}
.map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})),
)
})?;
if update_result.rows_affected() == 0 { if update_result.rows_affected() == 0 {
let error_response = serde_json::json!({ let error_response = serde_json::json!({
@@ -572,10 +583,10 @@ pub async fn setup_initial_admin(
})?; })?;
if user.rows_affected() < 1 { if user.rows_affected() < 1 {
Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": "Error creating admin user"})), Json(json!({"status": "error", "message": "Error creating admin user"})),
)) ));
} else { } else {
Ok(Json( Ok(Json(
json!({"status": "success", "result": "Admin user created"}), json!({"status": "success", "result": "Admin user created"}),
@@ -616,6 +627,6 @@ pub fn filter_user(user: &User) -> FilteredUser {
first_name: user.first_name.clone(), first_name: user.first_name.clone(),
last_name: user.last_name.clone(), last_name: user.last_name.clone(),
username: user.username.clone(), username: user.username.clone(),
is_admin: user.is_admin, is_admin: user.is_admin.clone(),
} }
} }
+5 -5
View File
@@ -230,22 +230,22 @@ pub async fn get_ticket_by_id(
user_last_name: row.get("last_name"), user_last_name: row.get("last_name"),
}; };
let response = serde_json::json!(ticket_response); let response = serde_json::json!(ticket_response);
Ok(Json(response)) return Ok(Json(response));
} }
Err(sqlx::Error::RowNotFound) => { Err(sqlx::Error::RowNotFound) => {
let error_response = serde_json::json!({ let error_response = serde_json::json!({
"status": "fail", "status": "fail",
"message": format!("Ticket with ID {} not found", id) "message": format!("Ticket with ID {} not found", id)
}); });
Err((StatusCode::NOT_FOUND, Json(error_response))) return Err((StatusCode::NOT_FOUND, Json(error_response)));
} }
Err(e) => { Err(e) => {
Err(( return Err((
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"status": "error", "message": format!("{:?}", e)})), Json(json!({"status": "error", "message": format!("{:?}", e)})),
)) ));
} }
} };
} }
/// Updates a ticket's status. /// Updates a ticket's status.
+2 -3
View File
@@ -64,7 +64,7 @@ async fn main() {
let database_url = &env.db_url; let database_url = &env.db_url;
// Establish connection pool to PostgreSQL // Establish connection pool to PostgreSQL
let pool = match PgPoolOptions::new().connect(database_url).await { let pool = match PgPoolOptions::new().connect(&database_url).await {
Ok(pool) => { Ok(pool) => {
println!("Database connection successful"); println!("Database connection successful");
pool pool
@@ -90,7 +90,6 @@ async fn main() {
.layer(cors); .layer(cors);
// Start listening for incoming connections // Start listening for incoming connections
let uri = format!("0.0.0.0:{}", env.backend_port); let listener = tokio::net::TcpListener::bind("0.0.0.0:8001").await.unwrap();
let listener = tokio::net::TcpListener::bind(&uri).await.unwrap();
let _ = axum::serve(listener, app).await; let _ = axum::serve(listener, app).await;
} }
+1 -1
View File
@@ -8,7 +8,7 @@ services:
environment: environment:
- POSTGRES_PASSWORD=tickets - POSTGRES_PASSWORD=tickets
volumes: volumes:
- pg_data:/var/lib/postgresql/pg_data - pg_data:/var/lib/postregsql/pg_data
volumes: volumes:
pg_data: pg_data:
+1 -2
View File
@@ -16,8 +16,7 @@ serde = { workspace = true }
wasm-bindgen-futures = "0.4.70" wasm-bindgen-futures = "0.4.70"
web-sys = { version = "0.3.95", features = [ web-sys = { version = "0.3.95", features = [
"Window","Document","Request","Response","Headers", "HtmlSelectElement", "RequestCredentials", "Window","Document","Request","Response","Headers", "HtmlSelectElement", "RequestCredentials",
"SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent", "SubmitEvent","InputEvent","HtmlInputElement","Event", "HtmlFormElement", "MouseEvent"
"Element", "MediaQueryList", "DomTokenList"
] } ] }
gloo-net = "0.7.0" gloo-net = "0.7.0"
gloo-storage = "0.4.0" gloo-storage = "0.4.0"
+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"] #,"10.150.9.7"]
# 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
@@ -5,8 +5,9 @@
<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" /> <link data-trunk rel="icon" href="src/assets/favicon.ico" type="image/x-icon" />
<link data-trunk rel="copy-dir" href="src/assets" />
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Ticket System CSG</title> <title>Yew App</title>
</head> </head>
<body></body> <body></body>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+2 -2
View File
@@ -109,7 +109,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
AuthState { AuthState {
is_authenticated: None, is_authenticated: None,
.. ..
} => html! { <div>{ "Lade..." } </div> }, } => html! { <div>{ "Loading..." } </div> },
AuthState { AuthState {
is_authenticated: Some(false), is_authenticated: Some(false),
.. ..
@@ -126,7 +126,7 @@ pub fn protected_route(props: &ProtectedRouteProps) -> Html {
Some(false) => { Some(false) => {
html! { <Redirect<crate::Route> to={crate::Route::PermissionDenied}/> } html! { <Redirect<crate::Route> to={crate::Route::PermissionDenied}/> }
} }
None => html! { <div>{ "Berechtigungen werden geprüft..." }</div> }, None => html! { <div>{ "Checking permissions..." }</div> },
} }
} else { } else {
props.children.clone().into() props.children.clone().into()
-65
View File
@@ -1,65 +0,0 @@
use gloo_storage::{LocalStorage, Storage};
use web_sys::window;
use yew::prelude::*;
#[function_component(DarkModeToggle)]
pub fn dark_mode_toggle() -> Html {
// 1. Initialize state from LocalStorage or system preference
let is_dark = use_state(|| {
LocalStorage::get::<bool>("dark-mode").unwrap_or_else(|_| {
// Fallback to system preference if not set
window()
.and_then(|w| w.match_media("(prefers-color-scheme: dark)").ok().flatten())
.map(|m| m.matches())
.unwrap_or(false)
})
});
// 2. Synchronize the class on the body when the state changes
{
let is_dark = is_dark.clone();
use_effect_with(is_dark, |is_dark| {
if let Some(win) = window() {
if let Some(doc) = win.document() {
if let Some(body) = doc.body() {
if **is_dark {
let _ = body.class_list().add_1("theme-dark");
let _ = body.class_list().remove_1("theme-light");
let _ = LocalStorage::set("dark-mode", true);
} else {
let _ = body.class_list().add_1("theme-light");
let _ = body.class_list().remove_1("theme-dark");
let _ = LocalStorage::set("dark-mode", false);
}
}
}
}
|| ()
});
}
// 3. Toggle action
let onclick = {
let is_dark = is_dark.clone();
Callback::from(move |_| {
is_dark.set(!*is_dark);
})
};
html! {
<button class="dark-mode-toggle" {onclick}>
if *is_dark {
// Sun Icon for switching to light mode
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="4"/>
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M6.34 17.66l-1.41 1.41M19.07 4.93l-1.41 1.41"/>
</svg>
} else {
// Moon Icon for switching to dark mode
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
</svg>
}
</button>
}
}
+50 -6
View File
@@ -1,6 +1,5 @@
mod auth; mod auth;
mod pages; mod pages;
mod dark_mode;
use crate::auth::ProtectedRoute; use crate::auth::ProtectedRoute;
use crate::pages::*; use crate::pages::*;
use gloo_net::http::Request; use gloo_net::http::Request;
@@ -70,8 +69,15 @@ pub struct SidebarShellProps {
/// This component is designed to wrap page-specific content, ensuring that the sidebar /// This component is designed to wrap page-specific content, ensuring that the sidebar
/// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation. /// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation.
/// ///
/// # Mobile Support
/// On mobile displays, the sidebar is hidden by default and can be toggled:
/// - **Menu Toggle Button**: Renders a floating menu button to slide the sidebar open.
/// - **Dark Mode Toggle**: Floating button to change theme.
/// - **Overlay Backdrop**: Dims the screen when the sidebar is open. Clicking it closes the sidebar.
/// - **Auto-Close on Navigation**: Automatically closes the sidebar when navigating to a new route.
///
/// # Components /// # Components
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component. /// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component, accepting mobile open state.
/// - Main content area: Renders the `children` passed to this component. /// - Main content area: Renders the `children` passed to this component.
/// ///
/// # Example /// # Example
@@ -84,9 +90,46 @@ pub struct SidebarShellProps {
/// ``` /// ```
#[component(SidebarShell)] #[component(SidebarShell)]
fn sidebar_shell(props: &SidebarShellProps) -> Html { fn sidebar_shell(props: &SidebarShellProps) -> Html {
let route = use_route::<Route>();
let mobile_sidebar_open = use_state(|| false);
// Close mobile sidebar automatically on any route transition
{
let mobile_sidebar_open = mobile_sidebar_open.clone();
use_effect_with(route, move |_| {
mobile_sidebar_open.set(false);
|| ()
});
}
let on_open = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: MouseEvent| mobile_sidebar_open.set(true))
};
let on_close = {
let mobile_sidebar_open = mobile_sidebar_open.clone();
Callback::from(move |_: ()| mobile_sidebar_open.set(false))
};
let on_close_click = {
let on_close = on_close.clone();
Callback::from(move |_: MouseEvent| on_close.emit(()))
};
html! { html! {
<div class="layout"> <div class="layout">
<sidebar::Sidebar/> <button class="mobile-menu-toggle" onclick={on_open}>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="12" y2="12"/>
<line x1="4" x2="20" y1="6" y2="6"/>
<line x1="4" x2="20" y1="18" y2="18"/>
</svg>
</button>
<div class={if *mobile_sidebar_open { "sidebar-overlay open" } else { "sidebar-overlay" }} onclick={on_close_click}></div>
<sidebar::Sidebar is_open={*mobile_sidebar_open} on_close={on_close} />
<main class="content"> <main class="content">
{ for props.children.iter() } { for props.children.iter() }
</main> </main>
@@ -94,6 +137,7 @@ fn sidebar_shell(props: &SidebarShellProps) -> Html {
} }
} }
/// Props for the AdminCheckWrapper component. /// Props for the AdminCheckWrapper component.
#[derive(Properties, PartialEq)] #[derive(Properties, PartialEq)]
pub struct AdminCheckWrapperProps { pub struct AdminCheckWrapperProps {
@@ -148,10 +192,10 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
} }
match *admin_exists { match *admin_exists {
None => html! { <div>{ "Lade..." }</div> }, None => html! { <div>{ "Loading..." }</div> },
Some(false) => { Some(false) => {
navigator.push(&Route::Setup); navigator.push(&Route::Setup);
html! { <div>{ "Leite weiter zur Einrichtung..." }</div> } html! { <div>{ "Redirecting to setup..." }</div> }
} }
Some(true) => props.children.clone().into(), Some(true) => props.children.clone().into(),
} }
@@ -272,7 +316,7 @@ fn switch(route: Route) -> Html {
pub fn app() -> Html { pub fn app() -> Html {
html! { html! {
<BrowserRouter> <BrowserRouter>
<dark_mode::DarkModeToggle /> <basic_pages::Icon/>
<Switch<Route> render={switch} /> <Switch<Route> render={switch} />
</BrowserRouter> </BrowserRouter>
} }
+20 -24
View File
@@ -3,7 +3,6 @@ use wasm_bindgen_futures::spawn_local;
use yew::prelude::*; use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
#[macro_export]
/// Removes surrounding double quotes from a string. /// Removes surrounding double quotes from a string.
/// ///
/// This macro takes an expression that evaluates to a string and returns a new `String` /// This macro takes an expression that evaluates to a string and returns a new `String`
@@ -72,7 +71,7 @@ pub fn home_component() -> Html {
); );
name.set(name_value); name.set(name_value);
} }
_ => name.set("Unbekannt".to_string()), _ => name.set("Unknown".to_string()),
} }
}); });
|| () || ()
@@ -80,15 +79,11 @@ pub fn home_component() -> Html {
} }
html! { html! {
<div class="form-container home"> <div class="rundbg">
<div class="page-header">
<h1>{ "Willkommen" }</h1>
</div>
<crate::utilities::TicketCount/> <crate::utilities::TicketCount/>
<div>
<p>{ "Sie sind angemeldet als: " }</p> <p>{ "You are logged in as: " }</p>
<p class="text-muted">{ &*name }</p> <p>{ &*name }</p>
</div>
</div> </div>
} }
} }
@@ -106,14 +101,11 @@ pub fn home_component() -> Html {
/// ``` /// ```
#[component(NotFound)] #[component(NotFound)]
pub fn not_found_component() -> Html { pub fn not_found_component() -> Html {
let message = "404 Nicht gefunden"; let message = "404 Not found";
html! { html! {
<div class="form-container"> <div>
<div class="empty-state"> <h1>{&message}</h1>
<h1>{&message}</h1> <Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
<p>{ "Die von Ihnen gesuchte Seite existiert nicht." }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div> </div>
} }
} }
@@ -133,13 +125,17 @@ pub fn not_found_component() -> Html {
#[component(PermissionDenied)] #[component(PermissionDenied)]
pub fn denied_component() -> Html { pub fn denied_component() -> Html {
html! { html! {
<div class="form-container"> <div>
<div class="empty-state"> <h1>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</h1>
<h1>{ "Zugriff verweigert" }</h1> <h3>{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</h3>
<p>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</p> <Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
<p class="text-muted">{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div> </div>
} }
} }
#[component(Icon)]
pub fn icon_component() -> Html {
html! {
<img src="assets/csg.png" class="csg-icon" />
}
}
+20 -20
View File
@@ -67,7 +67,7 @@ pub fn initial_admin_setup() -> Html {
let username = use_state(|| "".to_string()); let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string()); let pwd = use_state(|| "".to_string());
let pwd_confirm = use_state(|| "".to_string()); let pwd_confirm = use_state(|| "".to_string());
let error = use_state(String::new); let error = use_state(|| String::new());
let success = use_state(|| false); let success = use_state(|| false);
let loading = use_state(|| false); let loading = use_state(|| false);
let admin_check_done = use_state(|| false); let admin_check_done = use_state(|| false);
@@ -103,7 +103,7 @@ pub fn initial_admin_setup() -> Html {
} }
if !*admin_check_done { if !*admin_check_done {
return html! { <div>{ "Wird überprüft..." }</div> }; return html! { <div>{ "Checking..." }</div> };
} }
let onsubmit = { let onsubmit = {
@@ -121,17 +121,17 @@ pub fn initial_admin_setup() -> Html {
e.prevent_default(); e.prevent_default();
if (*pwd).is_empty() || (*pwd_confirm).is_empty() { if (*pwd).is_empty() || (*pwd_confirm).is_empty() {
error.set("Passwortfelder dürfen nicht leer sein".to_string()); error.set("Password fields cannot be empty".to_string());
return; return;
} }
if *pwd != *pwd_confirm { if *pwd != *pwd_confirm {
error.set("Passwörter stimmen nicht überein".to_string()); error.set("Passwords do not match".to_string());
return; return;
} }
if (*username).is_empty() { if (*username).is_empty() {
error.set("Benutzername darf nicht leer sein".to_string()); error.set("Username cannot be empty".to_string());
return; return;
} }
@@ -175,7 +175,7 @@ pub fn initial_admin_setup() -> Html {
let text = r.text().await.unwrap_or_else(|_| "unknown".into()); let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text)); error.set(format!("HTTP {}: {}", r.status(), text));
} }
Err(err) => error.set(format!("Netzwerkfehler: {}", err)), Err(err) => error.set(format!("Network error: {}", err)),
} }
}); });
}) })
@@ -184,16 +184,16 @@ pub fn initial_admin_setup() -> Html {
html! { html! {
<div class="setup-container"> <div class="setup-container">
<div class="setup-box"> <div class="setup-box">
<h1>{ "Erstmalige Admin-Einrichtung" }</h1> <h1>{ "Initial Admin Setup" }</h1>
<p>{ "Erstellen Sie Ihr erstes Administrator-Konto" }</p> <p>{ "Create your first administrator account" }</p>
<form {onsubmit} class="setup-form"> <form {onsubmit} class="setup-form">
<div class="form-group"> <div class="form-group">
<label for="first_name">{ "Vorname:" } <label for="first_name">{ "First Name:" }
<input <input
id="first_name" id="first_name"
type="text" type="text"
placeholder="Vorname" placeholder="First name"
value={(*first_name).clone()} value={(*first_name).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -204,11 +204,11 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="last_name">{ "Nachname:" } <label for="last_name">{ "Last Name:" }
<input <input
id="last_name" id="last_name"
type="text" type="text"
placeholder="Nachname" placeholder="Last name"
value={(*last_name).clone()} value={(*last_name).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -219,11 +219,11 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="username">{ "Benutzername:" } <label for="username">{ "Username:" }
<input <input
id="username" id="username"
type="text" type="text"
placeholder="Benutzername" placeholder="Username"
value={(*username).clone()} value={(*username).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -234,11 +234,11 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="password">{ "Passwort:" } <label for="password">{ "Password:" }
<input <input
id="password" id="password"
type="password" type="password"
placeholder="Passwort" placeholder="Password"
value={(*pwd).clone()} value={(*pwd).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -249,11 +249,11 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="pwd_confirm">{ "Passwort bestätigen:" } <label for="pwd_confirm">{ "Confirm Password:" }
<input <input
id="pwd_confirm" id="pwd_confirm"
type="password" type="password"
placeholder="Passwort bestätigen" placeholder="Confirm password"
value={(*pwd_confirm).clone()} value={(*pwd_confirm).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let input: web_sys::HtmlInputElement = e.target_unchecked_into();
@@ -264,7 +264,7 @@ pub fn initial_admin_setup() -> Html {
</div> </div>
<button type="submit" disabled={*loading} class="submit-btn"> <button type="submit" disabled={*loading} class="submit-btn">
{ if *loading { "Wird erstellt..." } else { "Admin-Konto erstellen" } } { if *loading { "Creating..." } else { "Create Admin Account" } }
</button> </button>
if !error.is_empty() { if !error.is_empty() {
@@ -272,7 +272,7 @@ pub fn initial_admin_setup() -> Html {
} }
if *success { if *success {
<p class="success-message" style="color:green">{ "Admin-Konto erfolgreich erstellt! Weiterleitung zum Login..." }</p> <p class="success-message" style="color:green">{ "Admin account created successfully! Redirecting to login..." }</p>
} }
</form> </form>
</div> </div>
+94 -53
View File
@@ -234,10 +234,10 @@ pub fn ticket_menu() -> Html {
html! { html! {
<ul class="submenu" role="menu"> <ul class="submenu" role="menu">
<li role="none"> <li role="none">
<Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Ticket erstellen" }</span></Link<crate::Route>> <Link<crate::Route> to={crate::Route::Ticket}><span role="menuitem">{ "Submit Ticket" }</span></Link<crate::Route>>
</li> </li>
<li role="none"> <li role="none">
<Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "Tickets anzeigen" }</span></Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllTickets}><span role="menuitem">{ "View Tickets" }</span></Link<crate::Route>>
</li> </li>
</ul> </ul>
} }
@@ -289,7 +289,7 @@ pub fn users_menu() -> Html {
onclick={on_toggle} onclick={on_toggle}
aria-expanded={open.to_string()} aria-expanded={open.to_string()}
> >
{ "Benutzer" } { "Users" }
{ if open { "" } else { "" } } { if open { "" } else { "" } }
</button> </button>
@@ -298,10 +298,10 @@ pub fn users_menu() -> Html {
html! { html! {
<ul class="submenu" role="menu"> <ul class="submenu" role="menu">
<li role="none"> <li role="none">
<Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Benutzer erstellen" }</span></Link<crate::Route>> <Link<crate::Route> to={crate::Route::Register}><span role="menuitem">{ "Create User" }</span></Link<crate::Route>>
</li> </li>
<li role="none"> <li role="none">
<Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "Benutzer anzeigen" }</span></Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllUsers}><span role="menuitem">{ "View Users" }</span></Link<crate::Route>>
</li> </li>
</ul> </ul>
} }
@@ -319,6 +319,11 @@ pub fn users_menu() -> Html {
/// and administrative status. It fetches the current user's details via `/api/users/current` /// and administrative status. It fetches the current user's details via `/api/users/current`
/// to determine what menu items to display. /// to determine what menu items to display.
/// ///
/// # Mobile Support
/// On small screens:
/// - Slides into view from the left when `props.is_open` is `true`.
/// - Renders a close button (`✕`) in the header that emits `props.on_close`.
///
/// # Structure /// # Structure
/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state. /// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state.
/// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items. /// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
@@ -337,8 +342,22 @@ pub fn users_menu() -> Html {
/// # Logout Functionality /// # Logout Functionality
/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session, /// The "Logout" button sends a GET request to `/api/logout`, clears the user's session,
/// and then redirects the user to the login page (`crate::Route::Login`). /// and then redirects the user to the login page (`crate::Route::Login`).
/// Properties for the [`Sidebar`] component.
///
/// These properties enable managing and controlling the responsive mobile sidebar
/// layout and its visibility settings.
#[derive(Properties, PartialEq)]
pub struct SidebarComponentProps {
/// A boolean flag indicating whether the mobile sidebar is currently slid open (`true`) or hidden (`false`).
#[prop_or_default]
pub is_open: bool,
/// A callback emitted when the user requests to close/hide the mobile sidebar.
#[prop_or_default]
pub on_close: Callback<()>,
}
#[component(Sidebar)] #[component(Sidebar)]
pub fn sidebar() -> Html { pub fn sidebar(props: &SidebarComponentProps) -> Html {
let is_admin = use_state(|| None::<bool>); let is_admin = use_state(|| None::<bool>);
let navigator = use_navigator().expect("Sidebar must be used within a Router"); let navigator = use_navigator().expect("Sidebar must be used within a Router");
@@ -382,56 +401,78 @@ pub fn sidebar() -> Html {
None => html! { <div class="sidebar-loading">{ "Lade..." }</div> }, None => html! { <div class="sidebar-loading">{ "Lade..." }</div> },
// Non-admin: render a condensed user sidebar (no diagnostics, limited links) // Non-admin: render a condensed user sidebar (no diagnostics, limited links)
Some(false) => html! { Some(false) => {
<SidebarStateProvider> let on_close = props.on_close.clone();
<nav class="sidebar user"> html! {
<ul> <SidebarStateProvider>
<Link<crate::Route> to={crate::Route::Home} classes="home"> <nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <ul>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <li class="sidebar-header">
<polyline points="9 22 9 12 15 12 15 22"/> <Link<crate::Route> to={crate::Route::Home} classes="home">
</svg> <svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</Link<crate::Route>> <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<TicketMenu/> <polyline points="9 22 9 12 15 12 15 22"/>
<li class="logout-item"> </svg>
<button </Link<crate::Route>>
class="logout-button" <button class="sidebar-close" onclick={move |_| on_close.emit(())}>
onclick={on_logout.clone()} <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
> <line x1="18" y1="6" x2="6" y2="18"/>
{ "Abmelden" } <line x1="6" y1="6" x2="18" y2="18"/>
</button> </svg>
</li> </button>
</ul> </li>
</nav> <TicketMenu/>
</SidebarStateProvider> <li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
}, },
// Admin: full sidebar wrapped in provider so submenu state persists // Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => html! { Some(true) => {
<SidebarStateProvider> let on_close = props.on_close.clone();
<nav class="sidebar admin"> html! {
<ul> <SidebarStateProvider>
<Link<crate::Route> to={crate::Route::Home} classes="home"> <nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
<svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <ul>
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <li class="sidebar-header">
<polyline points="9 22 9 12 15 12 15 22"/> <Link<crate::Route> to={crate::Route::Home} classes="home">
</svg> <svg xmlns="http://www.w3.org/2000/svg" class="home-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
</Link<crate::Route>> <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<TicketMenu/> <polyline points="9 22 9 12 15 12 15 22"/>
<UsersMenu/> </svg>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>> </Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>> <button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<li class="logout-item"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<button <line x1="18" y1="6" x2="6" y2="18"/>
class="logout-button" <line x1="6" y1="6" x2="18" y2="18"/>
onclick={on_logout.clone()} </svg>
> </button>
{ "Abmelden" } </li>
</button> <TicketMenu/>
</li> <UsersMenu/>
</ul> <Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
</nav> <Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
</SidebarStateProvider> <li class="logout-item">
<button
class="logout-button"
onclick={on_logout.clone()}
>
{ "Abmelden" }
</button>
</li>
</ul>
</nav>
</SidebarStateProvider>
}
}, },
} }
} }
+155 -146
View File
@@ -180,7 +180,7 @@ pub fn submit_ticket_component() -> Html {
Callback::from(move |e: SubmitEvent| { Callback::from(move |e: SubmitEvent| {
e.prevent_default(); e.prevent_default();
if room.is_none() { if room.is_none() {
status.set(Some("Ungültiger Raum".into())); status.set(Some("Invalid room".into()));
return; return;
} }
let category = (*category).clone(); let category = (*category).clone();
@@ -188,7 +188,7 @@ pub fn submit_ticket_component() -> Html {
let description = (*description).clone(); let description = (*description).clone();
let room = room.unwrap(); let room = room.unwrap();
if !valid_rooms.contains(&room) { if !valid_rooms.contains(&room) {
status.set(Some("Raum nicht erlaubt".into())); status.set(Some("Room not allowed".into()));
return; return;
} }
let status = status.clone(); let status = status.clone();
@@ -208,11 +208,9 @@ pub fn submit_ticket_component() -> Html {
.expect("Failed to build request"); .expect("Failed to build request");
match request.send().await { match request.send().await {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
status.set(Some("Erfolgreich".into())) Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
} Err(err) => status.set(Some(format!("Network error: {}", err))),
Ok(response) => status.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -267,7 +265,10 @@ pub fn submit_ticket_component() -> Html {
Err(_) => None, Err(_) => None,
} }
} else { } else {
raw_trim.parse::<i16>().ok() match raw_trim.parse::<i16>() {
Ok(n) => Some(n),
Err(_) => None,
}
} }
}; };
@@ -286,53 +287,52 @@ pub fn submit_ticket_component() -> Html {
let room_valid = (*room).is_some(); let room_valid = (*room).is_some();
html! { html! {
<div class="form-container"> <form class="rundbg" {onsubmit}>
<div class="page-header"> <label>{ "Betreff:" }
<h1>{ "Ticket erstellen" }</h1> <input type="text" value={(*betreff).clone()} oninput={betreff_change}/>
</div> </label>
<form {onsubmit}> <br/>
<label>{ "Betreff:" } <label>{ "Beschreibung:" }
<input type="text" value={(*betreff).clone()} oninput={betreff_change}/> <input type="text" value={(*description).clone()} oninput={description_change}/>
</label> </label>
<label>{ "Beschreibung:" } <br/>
<textarea value={(*description).clone()} oninput={description_change}/> <label>{ "Kategorie:" }
</label> <select value={(*category).clone()} onchange={category_change}>
<label>{ "Kategorie:" } <option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
<select value={(*category).clone()} onchange={category_change}> <option value="Internet">{ "Internet" }</option>
<option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option> <option value="iPad Koffer">{ "iPad Koffer" }</option>
<option value="Internet">{ "Internet" }</option> <option value="Apple TV">{ "Apple TV" }</option>
<option value="iPad Koffer">{ "iPad Koffer" }</option> <option value="Docu Cam">{ "Dokumenten Kamera" }</option>
<option value="Apple TV">{ "Apple TV" }</option> <option value="Sonstiges">{ "Sonstiges" }</option>
<option value="Docu Cam">{ "Dokumenten Kamera" }</option> </select>
<option value="Sonstiges">{ "Sonstiges" }</option> </label>
</select> <br/>
</label> <label>{ "Raum:" }
<label>{ "Raum:" } <input type="text" value={(*room_input).clone()} oninput={room_change}/>
<input type="text" value={(*room_input).clone()} oninput={room_change}/> </label>
</label> {
{ if !room_valid {
if !room_valid { html! {
html! { <p class="warning" >{ "Ungültiger / 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 {
html! {}
} }
} else {
html! {}
} }
<button type="submit">{ "Absenden" }</button> }
<br/>
<button type="submit">{ "Send" }</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Alle Tickets anzeigen" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllTickets}>{ "Tickets ansehen" }</Link<crate::Route>>
{ {
if let Some(s) = &*status { if let Some(s) = &*status {
html!{ <p class="alert success">{ s }</p> } html!{ <p>{ s }</p> }
} else { } else {
html!{} html!{}
}
} }
}
</form> </form>
</div>
} }
} }
@@ -396,7 +396,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
if status == 200 { if status == 200 {
match response.json::<Ticket>().await { match response.json::<Ticket>().await {
Ok(t) => ticket.set(Some(t)), Ok(t) => ticket.set(Some(t)),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))), Err(err) => error.set(Some(format!("Parse error: {}", err))),
} }
} else { } else {
match response.json::<ApiError>().await { match response.json::<ApiError>().await {
@@ -405,13 +405,13 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
if let Ok(text) = response.text().await { if let Ok(text) = response.text().await {
error.set(Some(text)); error.set(Some(text));
} else { } else {
error.set(Some(format!("Server-Fehler: {}", status))); error.set(Some(format!("Server error: {}", status)));
} }
} }
} }
} }
} }
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => error.set(Some(format!("Network error: {}", err))),
} }
loading.set(false); loading.set(false);
}); });
@@ -420,6 +420,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
} }
let onsubmit = { let onsubmit = {
let status = status.clone(); let status = status.clone();
let id = id.clone();
let error = error.clone(); let error = error.clone();
Callback::from(move |e: SubmitEvent| { Callback::from(move |e: SubmitEvent| {
@@ -435,6 +436,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
.unwrap_or_else(|| (*status).clone()); .unwrap_or_else(|| (*status).clone());
status.set(new_status.clone()); status.set(new_status.clone());
let id = id.clone();
let error = error.clone(); let error = error.clone();
spawn_local(async move { spawn_local(async move {
@@ -446,11 +448,9 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
.expect("Failed to construct request"); .expect("Failed to construct request");
match request.send().await { match request.send().await {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => error.set(Some("Success".into())),
error.set(Some("Erfolgreich".into())) Ok(response) => error.set(Some(format!("Error: {}", response.status()))),
} Err(err) => error.set(Some(format!("Network error: {}", err))),
Ok(response) => error.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -463,6 +463,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
let deleting = deleting.clone(); let deleting = deleting.clone();
let delete_error = delete_error.clone(); let delete_error = delete_error.clone();
let ticket_state = ticket.clone(); let ticket_state = ticket.clone();
let id = id;
Callback::from(move |e: MouseEvent| { Callback::from(move |e: MouseEvent| {
e.prevent_default(); e.prevent_default();
@@ -491,10 +492,10 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
ticket_state.set(None); // clears the shown item ticket_state.set(None); // clears the shown item
} }
Ok(resp) => { Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into()); let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt))); delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
} }
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
} }
deleting.set(false); deleting.set(false);
}); });
@@ -502,9 +503,9 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
}; };
if *loading { if *loading {
html! {<p>{ "Lade..." }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else if let Some(t) = &*ticket { } else if let Some(t) = &*ticket {
html! { html! {
<div> <div>
@@ -537,18 +538,18 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
<button type="submit">{ "Aktualisieren" }</button> <button type="submit">{ "Aktualisieren" }</button>
</form> </form>
<button onclick={ondelete} disabled={*deleting} class="delete"> <button onclick={ondelete} disabled={*deleting}>
{if *deleting {"Löschen..."} else {"Löschen"}} {if *deleting {"Löschen..."} else {"Löschen"}}
</button> </button>
<Link<crate::Route> to={crate::Route::AllTickets} classes="return-to">{ "Zurück zur Ticketübersicht" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllTickets}>{ "Zurück zur Ticketübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error { if let Some(err) = &*delete_error {
<p class="alert error">{ err.clone() }</p> <p style="color:red">{ err.clone() }</p>
} }
</div> </div>
} }
} else { } else {
html! { <p>{ "Kein Ticket gefunden." }</p> } html! { <p>{ "No ticket found." }</p> }
} }
} }
@@ -587,7 +588,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
/// ``` /// ```
#[component(AllTickets)] #[component(AllTickets)]
pub fn all_tickets_component() -> Html { pub fn all_tickets_component() -> Html {
let tickets = use_state(Vec::<Ticket>::new); let tickets = use_state(|| Vec::<Ticket>::new());
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let loading = use_state(|| false); let loading = use_state(|| false);
let user = use_state(|| ActiveUser { let user = use_state(|| ActiveUser {
@@ -603,22 +604,22 @@ pub fn all_tickets_component() -> Html {
use_effect_with((), move |_| { use_effect_with((), move |_| {
loading.set(true); loading.set(true);
spawn_local(async move { spawn_local(async move {
let url = "/api/tickets".to_string(); let url = format!("/api/tickets");
match Request::get(&url).send().await { match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await { match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t), Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parser-Fehler: {}", e))), Err(e) => error.set(Some(format!("parse error: {}", e))),
} }
} }
Ok(response) => { Ok(response) => {
if let Ok(text) = response.text().await { if let Ok(text) = response.text().await {
error.set(Some(text)); error.set(Some(text));
} else { } else {
error.set(Some(format!("Status {}", response.status()))); error.set(Some(format!("status {}", response.status())));
} }
} }
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => error.set(Some(format!("Network error: {}", err))),
} }
loading.set(false); loading.set(false);
}); });
@@ -635,20 +636,22 @@ pub fn all_tickets_component() -> Html {
.credentials(web_sys::RequestCredentials::Include) .credentials(web_sys::RequestCredentials::Include)
.send() .send()
.await .await
&& response.status() == 200
&& let Ok(json) = response.json::<serde_json::Value>().await
{ {
let id = json if response.status() == 200 {
.get("data") if let Ok(json) = response.json::<serde_json::Value>().await {
.and_then(|d| d.get("id")) let id = json
.and_then(|v| v.as_i64()) .get("data")
.and_then(|n| i16::try_from(n).ok()); .and_then(|d| d.get("id"))
let is_admin = json .and_then(|v| v.as_i64())
.get("data") .and_then(|n| i16::try_from(n).ok());
.and_then(|d| d.get("is_admin")) let is_admin = json
.and_then(|v| v.as_bool()) .get("data")
.unwrap_or(false); .and_then(|d| d.get("is_admin"))
user.set(ActiveUser { id, is_admin }); .and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
} }
}); });
|| () || ()
@@ -656,36 +659,39 @@ pub fn all_tickets_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Lade..." }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else { } else {
html! { html! {
<div> <ul class= "postits">
<div class="page-header"> { for tickets.iter().filter(|t| t.status != "Archived" && (if user.is_admin { true } else if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| {
<h1>{ "Alle Tickets" }</h1> let status_class = match t.status.as_str() {
</div> "ToDo" => "To-Do",
<ul class="ticket-list"> "InProgress" => "InProgress",
{ for tickets.iter().filter(|t| t.status != "Archived" && (if user.is_admin { true } else if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| { "Completed" => "Completed",
let status_class = match t.status.as_str() { "Archived" => "Archived",
"ToDo" => "To-Do", _ => "To-Do"
"InProgress" => "InProgress", };
"Completed" => "Completed", html! {
"Archived" => "Archived", <div class={classes!{status_class, "listbox"}}>
_ => "To-Do" <li key={t.id.to_string()}>
}; <Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{} - #{}", t.betreff, t.id) }</h3></Link<crate::Route>>
html! { <p>{ &t.description }</p>
<li key={t.id.to_string()} class={status_class}> <p>{ match t.status.as_str() {
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>> "ToDo" => "Zu tun",
<p>{ &t.description }</p> "InProgress" => "In Bearbeitung",
</li> "Completed" => "Erledigt",
} "Archived" => "Archiviert",
})} _ => "Ungültiger Status"
</ul> }}</p>
<div class="ticket-list-actions"> </li>
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div> </div>
</div> }})}
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</ul>
} }
} }
} }
@@ -725,7 +731,7 @@ pub fn all_tickets_component() -> Html {
/// ``` /// ```
#[component(ArchivedTickets)] #[component(ArchivedTickets)]
pub fn archived_tickets_component() -> Html { pub fn archived_tickets_component() -> Html {
let tickets = use_state(Vec::<Ticket>::new); let tickets = use_state(|| Vec::<Ticket>::new());
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let loading = use_state(|| false); let loading = use_state(|| false);
let user = use_state(|| ActiveUser { let user = use_state(|| ActiveUser {
@@ -741,22 +747,22 @@ pub fn archived_tickets_component() -> Html {
use_effect_with((), move |_| { use_effect_with((), move |_| {
loading.set(true); loading.set(true);
spawn_local(async move { spawn_local(async move {
let url = "/api/tickets".to_string(); let url = format!("/api/tickets");
match Request::get(&url).send().await { match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await { match response.json::<Vec<Ticket>>().await {
Ok(t) => tickets.set(t), Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parser-Fehler: {}", e))), Err(e) => error.set(Some(format!("parse error: {}", e))),
} }
} }
Ok(response) => { Ok(response) => {
if let Ok(text) = response.text().await { if let Ok(text) = response.text().await {
error.set(Some(text)); error.set(Some(text));
} else { } else {
error.set(Some(format!("Status {}", response.status()))); error.set(Some(format!("status {}", response.status())));
} }
} }
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => error.set(Some(format!("Network error: {}", err))),
} }
loading.set(false); loading.set(false);
}); });
@@ -773,20 +779,22 @@ pub fn archived_tickets_component() -> Html {
.credentials(web_sys::RequestCredentials::Include) .credentials(web_sys::RequestCredentials::Include)
.send() .send()
.await .await
&& response.status() == 200
&& let Ok(json) = response.json::<serde_json::Value>().await
{ {
let id = json if response.status() == 200 {
.get("data") if let Ok(json) = response.json::<serde_json::Value>().await {
.and_then(|d| d.get("id")) let id = json
.and_then(|v| v.as_i64()) .get("data")
.and_then(|n| i16::try_from(n).ok()); .and_then(|d| d.get("id"))
let is_admin = json .and_then(|v| v.as_i64())
.get("data") .and_then(|n| i16::try_from(n).ok());
.and_then(|d| d.get("is_admin")) let is_admin = json
.and_then(|v| v.as_bool()) .get("data")
.unwrap_or(false); .and_then(|d| d.get("is_admin"))
user.set(ActiveUser { id, is_admin }); .and_then(|v| v.as_bool())
.unwrap_or(false);
user.set(ActiveUser { id, is_admin });
}
}
} }
}); });
|| () || ()
@@ -794,28 +802,29 @@ pub fn archived_tickets_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Lade..." }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else { } else {
html! { html! {
<div> <ul class="postits">
<div class="page-header"> { for tickets.iter().filter(|t| t.status == "Archived" && (user.is_admin || if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| html! {
<h1>{ "Archivierte Tickets" }</h1> <div class="listbox">
</div> <li key={t.id.to_string()}>
<ul class="ticket-list"> <Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{} - #{}", t.betreff, t.id) }</h3></Link<crate::Route>>
{ for tickets.iter().filter(|t| t.status == "Archived" && (user.is_admin || if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| html! { <p>{ &t.description }</p>
<div> <p>{ match t.status.as_str() {
"ToDo" => "Zu tun",
"InProgress" => "In Bearbeitung",
"Completed" => "Erledigt",
"Archived" => "Archiviert",
_ => "Ungültiger Status"
}}</p>
</li>
<li key={t.id.to_string()}> </div>
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>> })}
<p>{ &t.description }</p> </ul>
</li>
</div>
})}
</ul>
</div>
} }
} }
} }
+85 -113
View File
@@ -181,11 +181,9 @@ pub fn register_component() -> Html {
.expect("Error building request"); .expect("Error building request");
match request.send().await { match request.send().await {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
status.set(Some("Erfolgreich".into())) Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
} Err(err) => status.set(Some(format!("Network error: {}", err))),
Ok(response) => status.set(Some(format!("Fehler: {}", response.status()))),
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -232,29 +230,24 @@ pub fn register_component() -> Html {
}; };
html! { html! {
<div class="form-container"> <form {onsubmit} class="rundbg">
<div class="page-header"> <label>{ "Vorname:" }
<h1>{ "Benutzer registrieren" }</h1> <input type="text" value={(*first_name).clone()} oninput={fn_change}/>
</div> </label>
<form {onsubmit}> <label>{ "Nachname:" }
<label>{ "Vorname:" } <input type="text" value={(*last_name).clone()} oninput={ln_change}/>
<input type="text" value={(*first_name).clone()} oninput={fn_change}/> </label>
</label> <label>{ "Benutzername:" }
<label>{ "Nachname:" } <input type="text" value={(*username).clone()} oninput={un_change}/>
<input type="text" value={(*last_name).clone()} oninput={ln_change}/> </label>
</label> <label>{ "Admin:" }
<label>{ "Benutzername:" } <input type="checkbox" checked={*is_admin} onchange={admin_change}/>
<input type="text" value={(*username).clone()} oninput={un_change}/> </label>
</label> <label>{ "Password:" }
<label>{ "Admin:" } <input type="password" value={(*pwd).clone()} oninput={pwd_change}/>
<input type="checkbox" checked={*is_admin} onchange={admin_change}/> </label>
</label> <button type="submit">{ "Bestätigen" }</button>
<label>{ "Passwort:" } </form>
<input type="password" value={(*pwd).clone()} oninput={pwd_change}/>
</label>
<button type="submit">{ "Bestätigen" }</button>
</form>
</div>
} }
} }
@@ -289,7 +282,7 @@ pub fn login_component() -> Html {
let username = use_state(|| "".to_string()); let username = use_state(|| "".to_string());
let pwd = use_state(|| "".to_string()); let pwd = use_state(|| "".to_string());
let loading = use_state(|| false); let loading = use_state(|| false);
let error = use_state(String::new); let error = use_state(|| String::new());
let success = use_state(|| false); let success = use_state(|| false);
let navigator = use_navigator().unwrap(); let navigator = use_navigator().unwrap();
@@ -333,44 +326,40 @@ pub fn login_component() -> Html {
navigator.push(&crate::Route::Home); navigator.push(&crate::Route::Home);
} }
Ok(r) => { Ok(r) => {
let text = r.text().await.unwrap_or_else(|_| "unbekannt".into()); let text = r.text().await.unwrap_or_else(|_| "unknown".into());
error.set(format!("HTTP {}: {}", r.status(), text)); error.set(format!("HTTP {}: {}", r.status(), text));
} }
Err(err) => error.set(format!("Netzwerkfehler: {}", err)), Err(err) => error.set(format!("Network error: {}", err)),
} }
}); });
}) })
}; };
html! { html! {
<main class="content"> <div>
<div class="form-container"> <h1 class="headline">{ "Anmeldung" }</h1>
<div class="page-header"> <form {onsubmit} class="rundbg login">
<h1>{ "Anmelden" }</h1> <input
</div> placeholder="username"
<form {onsubmit}> value={(*username).clone()}
<input oninput={Callback::from(move |e: InputEvent| {
placeholder="Benutzername" let input: web_sys::HtmlInputElement = e.target_unchecked_into();
value={(*username).clone()} username.set(input.value());
oninput={Callback::from(move |e: InputEvent| { })}
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); />
username.set(input.value()); <input
})} type="password"
/> placeholder="password"
<input value={(*pwd).clone()}
type="password" oninput={Callback::from(move |e: InputEvent| {
placeholder="Passwort" let input: web_sys::HtmlInputElement = e.target_unchecked_into();
value={(*pwd).clone()} pwd.set(input.value());
oninput={Callback::from(move |e: InputEvent| { })}
let input: web_sys::HtmlInputElement = e.target_unchecked_into(); />
pwd.set(input.value()); <button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
})} if !error.is_empty() { <p style="color:red">{(*error).clone()}</p> }
/> </form>
<button type="submit" disabled={*loading}>{ if *loading { "Wird angemeldet..." } else { "Anmelden" } }</button> </div>
if !error.is_empty() { <p class="alert error">{(*error).clone()}</p> }
</form>
</div>
</main>
} }
} }
@@ -403,7 +392,7 @@ pub fn login_component() -> Html {
/// ``` /// ```
#[component(AllUsers)] #[component(AllUsers)]
pub fn all_users_component() -> Html { pub fn all_users_component() -> Html {
let users = use_state(Vec::<FilteredUser>::new); let users = use_state(|| Vec::<FilteredUser>::new());
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let loading = use_state(|| false); let loading = use_state(|| false);
@@ -415,22 +404,22 @@ pub fn all_users_component() -> Html {
use_effect_with((), move |_| { use_effect_with((), move |_| {
loading.set(true); loading.set(true);
spawn_local(async move { spawn_local(async move {
let url = "/api/users".to_string(); let url = format!("/api/users");
match Request::get(&url).send().await { match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => {
match response.json::<Vec<FilteredUser>>().await { match response.json::<Vec<FilteredUser>>().await {
Ok(u) => users.set(u), Ok(u) => users.set(u),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))), Err(err) => error.set(Some(format!("Parse error: {}", err))),
} }
} }
Ok(response) => { Ok(response) => {
if let Ok(text) = response.text().await { if let Ok(text) = response.text().await {
error.set(Some(text)); error.set(Some(text));
} else { } else {
error.set(Some(format!("Status {}", response.status()))); error.set(Some(format!("status {}", response.status())));
} }
} }
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => error.set(Some(format!("Network error: {}", err))),
} }
loading.set(false); loading.set(false);
}); });
@@ -439,37 +428,18 @@ pub fn all_users_component() -> Html {
} }
if *loading { if *loading {
html! { html! {<p>{ "Loading" }</p>}
<div class="form-container">
<div class="page-header">
<h1>{ "Alle Benutzer" }</h1>
</div>
<p>{ "Lade..." }</p>
</div>
}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { html! { <p>{ format!("Error: {}", e) }</p> }
<div class="form-container">
<div class="page-header">
<h1>{ "Alle Benutzer" }</h1>
</div>
<p class="alert error">{ format!("Fehler: {}", e) }</p>
</div>
}
} else { } else {
html! { html! {
<div class="form-container"> <ul class="postits">
<div class="page-header"> { for users.iter().map(|t| html! {
<h1>{ "Alle Benutzer" }</h1> <li key={t.id.to_string()} class="listbox">
</div> <Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}- #{}", t.first_name, t.last_name, t.id) }</h3></Link<crate::Route>>
<ul class="user-list"> </li>
{ for users.iter().map(|t| html! { })}
<li key={t.id.to_string()}> </ul>
<Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}", t.first_name, t.last_name) }</h3></Link<crate::Route>>
</li>
})}
</ul>
</div>
} }
} }
} }
@@ -537,7 +507,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if status == 200 { if status == 200 {
match response.json::<FilteredUser>().await { match response.json::<FilteredUser>().await {
Ok(u) => user.set(Some(u)), Ok(u) => user.set(Some(u)),
Err(err) => error.set(Some(format!("Parser-Fehler: {}", err))), Err(err) => error.set(Some(format!("Parse error: {}", err))),
} }
} else { } else {
match response.json::<ApiError>().await { match response.json::<ApiError>().await {
@@ -546,13 +516,13 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if let Ok(text) = response.text().await { if let Ok(text) = response.text().await {
error.set(Some(text)); error.set(Some(text));
} else { } else {
error.set(Some(format!("Server-Fehler: {}", status))); error.set(Some(format!("Server error: {}", status)));
} }
} }
} }
} }
} }
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => error.set(Some(format!("Network error: {}", err))),
} }
loading.set(false); loading.set(false);
}); });
@@ -564,7 +534,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let last_name = use_state(|| "".to_string()); let last_name = use_state(|| "".to_string());
let username = use_state(|| "".to_string()); let username = use_state(|| "".to_string());
let make_admin = use_state(|| false); let make_admin = use_state(|| false);
let new_pwd = use_state(String::new); let new_pwd = use_state(|| String::new());
let saving = use_state(|| false); let saving = use_state(|| false);
let save_error = use_state(|| None::<String>); let save_error = use_state(|| None::<String>);
let save_success = use_state(|| false); let save_success = use_state(|| false);
@@ -604,6 +574,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let last_name = (*last_name).clone(); let last_name = (*last_name).clone();
let username = (*username).clone(); let username = (*username).clone();
let make_admin = *make_admin; let make_admin = *make_admin;
let new_pwd_state = new_pwd.clone();
let new_pwd = (*new_pwd).clone(); let new_pwd = (*new_pwd).clone();
saving.set(true); saving.set(true);
@@ -614,10 +585,11 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let save_error = save_error.clone(); let save_error = save_error.clone();
let save_success = save_success.clone(); let save_success = save_success.clone();
let user_state = user_state.clone(); let user_state = user_state.clone();
let id = id;
spawn_local(async move { spawn_local(async move {
let payload = UserUpdateScheme { let payload = UserUpdateScheme {
id, id: id,
first_name, first_name,
last_name, last_name,
username, username,
@@ -637,13 +609,14 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
if let Ok(updated) = resp.json::<FilteredUser>().await { if let Ok(updated) = resp.json::<FilteredUser>().await {
user_state.set(Some(updated)); user_state.set(Some(updated));
} }
new_pwd_state.set(String::new());
save_success.set(true); save_success.set(true);
} }
Ok(resp) => { Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into()); let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
save_error.set(Some(format!("HTTP {}: {}", resp.status(), txt))); save_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
} }
Err(err) => save_error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => save_error.set(Some(format!("Network error: {}", err))),
} }
saving.set(false); saving.set(false);
}); });
@@ -657,16 +630,15 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let deleting = deleting.clone(); let deleting = deleting.clone();
let delete_error = delete_error.clone(); let delete_error = delete_error.clone();
let user_state = user.clone(); // or ticket let user_state = user.clone(); // or ticket
let id = id;
Callback::from(move |e: MouseEvent| { Callback::from(move |e: MouseEvent| {
e.prevent_default(); e.prevent_default();
// confirm // confirm
if !web_sys::window() if !web_sys::window()
.and_then(|w| { .and_then(|w| {
w.confirm_with_message( w.confirm_with_message("Are you sure you want to delete this item?")
"Sind Sie sicher, dass Sie dieses Element löschen möchten?", .ok()
)
.ok()
}) })
.unwrap_or(false) .unwrap_or(false)
{ {
@@ -690,10 +662,10 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
user_state.set(None); // clears the shown item user_state.set(None); // clears the shown item
} }
Ok(resp) => { Ok(resp) => {
let txt = resp.text().await.unwrap_or_else(|_| "unbekannt".into()); let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into());
delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt))); delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt)));
} }
Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))), Err(err) => delete_error.set(Some(format!("Network error: {}", err))),
} }
deleting.set(false); deleting.set(false);
}); });
@@ -701,9 +673,9 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
}; };
if *loading { if *loading {
html! {<p>{ "Lade..." }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p class="alert error">{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else if let Some(u) = &*user { } else if let Some(u) = &*user {
html! { html! {
<div> <div>
@@ -714,7 +686,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p> <p><strong>{ "Ist Admin: " }</strong>{ u.is_admin }</p>
</div> </div>
<h1>{ format!("Benutzer #{}", u.id) }</h1> <h1>{ format!("User #{}", u.id) }</h1>
<form onsubmit={onsubmit}> <form onsubmit={onsubmit}>
<div> <div>
<label>{ "Vorname" } <label>{ "Vorname" }
@@ -758,7 +730,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
</label> </label>
</div> </div>
<div> <div>
<label>{ "Neues Passwort (leer = unverändert)" } <label>{ "Neues Passwort (leer = unchanged)" }
<input name="new_pwd" type="password" <input name="new_pwd" type="password"
value={(*new_pwd).clone()} value={(*new_pwd).clone()}
oninput={Callback::from(move |e: InputEvent| { oninput={Callback::from(move |e: InputEvent| {
@@ -771,24 +743,24 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
<button type="submit" disabled={*saving}>{ if *saving { "Speichern..." } else { "Speichern" } }</button> <button type="submit" disabled={*saving}>{ if *saving { "Speichern..." } else { "Speichern" } }</button>
if *save_success { if *save_success {
<p style="color:green">{ "Erfolgreich aktualisiert" }</p> <p style="color:green">{ "Updated successfully" }</p>
} }
if let Some(err) = &*save_error { if let Some(err) = &*save_error {
<p style="color:red">{ err.clone() }</p> <p style="color:red">{ err.clone() }</p>
} }
</form> </form>
<button onclick={ondelete} disabled={*deleting} class="delete"> <button onclick={ondelete} disabled={*deleting}>
{if *deleting {"Löschen..."} else {"Löschen"}} {if *deleting {"Löschen..."} else {"Löschen"}}
</button> </button>
<Link<crate::Route> to={crate::Route::AllUsers} classes="return-to">{ "Zurück zur Benutzerübersicht" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllUsers}>{ "Zurück zur Benutzerübersicht" }</Link<crate::Route>>
if let Some(err) = &*delete_error { if let Some(err) = &*delete_error {
<p style="color:red">{ err.clone() }</p> <p style="color:red">{ err.clone() }</p>
} }
</div> </div>
} }
} else { } else {
html! { <p>{ "Kein Benutzer gefunden." }</p> } html! { <p>{ "No ticket found." }</p> }
} }
} }
+27 -162
View File
@@ -6,7 +6,6 @@ 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.
@@ -21,23 +20,6 @@ use crate::pages::ticket::{ActiveUser, Ticket};
struct TicketPartial { struct TicketPartial {
date: DateTime<Utc>, date: DateTime<Utc>,
room: i16, 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. /// Properties for components that display room-wise ticket totals.
@@ -52,20 +34,6 @@ struct RoomTotalsProps {
tickets: Vec<TicketPartial>, 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. /// 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)
@@ -156,7 +124,7 @@ fn day_counts(partials: &[TicketPartial]) -> [usize; 7] {
chrono::Weekday::Sun => 6, chrono::Weekday::Sun => 6,
}; };
occ[idx] += 1; occ[idx] += 1;
current += chrono::Duration::days(1); current = current + chrono::Duration::days(1);
} }
occ occ
@@ -236,7 +204,7 @@ pub fn diagnostics_component() -> Html {
/// ``` /// ```
#[component(TicketCount)] #[component(TicketCount)]
pub fn ticket_count_component() -> Html { pub fn ticket_count_component() -> Html {
let tickets = use_state(Vec::<Ticket>::new); let tickets = use_state(|| Vec::<Ticket>::new());
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let loading = use_state(|| false); let loading = use_state(|| false);
let user = use_state(|| ActiveUser { let user = use_state(|| ActiveUser {
@@ -252,7 +220,7 @@ pub fn ticket_count_component() -> Html {
use_effect_with((), move |_| { use_effect_with((), move |_| {
loading.set(true); loading.set(true);
spawn_local(async move { spawn_local(async move {
let url = "/api/tickets".to_string(); let url = format!("/api/tickets");
match Request::get(&url).send().await { match Request::get(&url).send().await {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => {
match response.json::<Vec<Ticket>>().await { match response.json::<Vec<Ticket>>().await {
@@ -284,8 +252,9 @@ pub fn ticket_count_component() -> Html {
.credentials(web_sys::RequestCredentials::Include) .credentials(web_sys::RequestCredentials::Include)
.send() .send()
.await .await
&& response.status() == 200 {
&& let Ok(json) = response.json::<serde_json::Value>().await { if response.status() == 200 {
if let Ok(json) = response.json::<serde_json::Value>().await {
let id = json let id = json
.get("data") .get("data")
.and_then(|d| d.get("id")) .and_then(|d| d.get("id"))
@@ -298,28 +267,30 @@ pub fn ticket_count_component() -> Html {
.unwrap_or(false); .unwrap_or(false);
user.set(ActiveUser { id, is_admin }); user.set(ActiveUser { id, is_admin });
} }
}
}
}); });
|| () || ()
}); });
} }
if *loading { if *loading {
html! {<p>{ "Lade..." }</p>} html! {<p>{ "Loading" }</p>}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> } html! { <p>{ format!("Error: {}", e) }</p> }
} else { } else {
let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress"; let status_conditions = |t: &Ticket| t.status == "ToDo" || t.status == "InProgress";
let count = tickets let count = tickets
.iter() .iter()
.filter(|t| { .filter(|t| {
status_conditions(t) status_conditions(t)
&& (user.is_admin || (user.id == Some(t.user_id))) && (user.is_admin || user.id.map_or(false, |uid| t.user_id == uid))
}) })
.count(); .count();
html! { html! {
<div class="open-tickets"> <div>
<h2 class="left">{ "Offene Tickets" }</h2> <h2>{ "Offene Tickets" }</h2>
<h4 class="ticket_count center">{ count }</h4> <h4>{ count }</h4>
</div> </div>
} }
} }
@@ -354,14 +325,12 @@ 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();
@@ -385,24 +354,6 @@ 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);
}); });
|| () || ()
@@ -421,7 +372,7 @@ pub fn submit_stats_component() -> Html {
} }
} }
let weekdays = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]; let weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
let (max_idx, _max_val) = counts let (max_idx, _max_val) = counts
.iter() .iter()
.enumerate() .enumerate()
@@ -432,12 +383,12 @@ pub fn submit_stats_component() -> Html {
html! { html! {
<div class="diagnostics-section"> <div class="diagnostics-section">
if *loading { if *loading {
<p>{ "Lade..." }</p> <p>{ "Loading..." }</p>
} }
if let Some(e) = &*error { if let Some(e) = &*error {
<p style="color: red;">{ e.clone() }</p> <p style="color: red;">{ e.clone() }</p>
} }
<h3>{ "Tickets pro Wochentag" }</h3> <h3>{ "Tickets per weekday" }</h3>
<div class="weekday-chart"> <div class="weekday-chart">
<div class="weekday-bars"> <div class="weekday-bars">
{ for (0..7).map(|i| { { for (0..7).map(|i| {
@@ -464,16 +415,7 @@ pub fn submit_stats_component() -> Html {
})} })}
</div> </div>
</div> </div>
<div class="diagnostics-row"> <RoomTotalTickets tickets={(*tickets).clone()}/>
<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> </div>
} }
} }
@@ -515,96 +457,19 @@ fn room_total_component(props: &RoomTotalsProps) -> Html {
html! { html! {
<div class="diagnostics-section"> <div class="diagnostics-section">
<div class="chart"> <h3>{ "Tickets pro Raum" }</h3>
<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="bar-item"> <div class="room-bar-item">
<div class="diagnostics-header"> <div class="room-header">
<span class="diagnostics-label">{ label }</span> <span class="room-label">{ label }</span>
<span class="diagnostics-count">{ count }</span> <span class="room-count">{ count }</span>
</div> </div>
<div class="bar-container"> <div class="room-bar-container">
<div class="bar" style={format!("width: {}%;", bar_width_percent)}> <div class="room-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> </div>
+3 -7
View File
@@ -1,12 +1,8 @@
@use "variables" as *; @use "variables" as *;
@mixin card { @mixin card {
background: $color-container; backgroud: #fff;
border-radius: $border-radius; border-radius: &border-radius;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0,0,0,0.06);
padding: $spacing-md; padding: $spacing-md;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
} }
+1
View File
@@ -3,3 +3,4 @@
.u-hidden { display: none !important; } .u-hidden { display: none !important; }
.u-gap-sm { gap: $spacing-sm; } .u-gap-sm { gap: $spacing-sm; }
.text-muted { color: $color-muted; } .text-muted { color: $color-muted; }
+16 -31
View File
@@ -1,34 +1,19 @@
// Color Palette (Reference Style) :root{
$color-bg: var(--color-bg); $color-bg: #ffffff;
$color-bg-dark: var(--color-bg-dark); $color-sidebar: #570000;
$color-container: var(--color-container); $color-accent: #2563eb;
$color-container-dark: var(--color-container-dark); $color-muted: #6b7280;
$color-sidebar: #0f172a; $spacing-sm: 8px;
$color-primary: #2b79c2; $spacing-md: 16px;
$color-primary-hover: #1d5fa0; $border-radius: 6px;
$color-accent: #2b79c2; $rundbgbackgroundcolor: #a0a0a0
$color-muted: #6b7280; }
$color-text: var(--color-text);
$color-text-dark: var(--color-text-dark);
// Status Colors @media (prefers-color-scheme: dark) {
$color-status-todo: #ffcccc; :root {
$color-status-todo-text: #a00; $color-bg: #000000;
$color-status-inprogress: #fff3cd; $color-sidebar: #f3ff73;
$color-status-inprogress-text: #856404;
$color-status-done: #d4edda;
$color-status-done-text: #155724;
$color-status-archived: #e0e0e0;
$color-status-archived-text: #666666;
// Action Colors
$color-logout: #ff4d4d;
$color-logout-hover: #e60000;
// Spacing
$spacing-sm: 8px;
$spacing-md: 16px;
$spacing-lg: 2rem;
$border-radius: 0.5rem;
$border-radius-lg: 10px;
}
}
@@ -1,82 +0,0 @@
@use "../variables" as *;
.ticket-list {
list-style: none;
padding: 0;
margin: 0;
margin-bottom: $spacing-lg;
li {
border-left: 4px solid;
padding: 1rem;
margin-bottom: 1rem;
border-radius: $border-radius-lg;
background: $color-container;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
h3 {
margin-top: 0;
margin-bottom: $spacing-md;
color: $color-primary;
a {
color: $color-primary;
text-decoration: none;
transition: color 0.2s ease-in-out;
&:hover {
color: $color-primary-hover;
text-decoration: underline;
}
}
}
p {
margin: 0.3rem 0;
}
&.To-Do {
border-left-color: $color-status-todo-text;
}
&.InProgress {
border-left-color: $color-status-inprogress-text;
}
&.Completed {
border-left-color: $color-status-done-text;
}
&.Archived {
border-left-color: $color-status-archived-text;
}
}
}
.ticket-list-actions {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
margin-top: $spacing-lg;
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
@@ -1,13 +0,0 @@
@use "../variables" as *;
.user-list {
list-style: none;
padding-left: 0;
margin: 0;
li {
h3 {
margin-top: 0;
}
}
}
@@ -1,75 +0,0 @@
:root {
--color-bg: #f0f2f5;
--color-bg-dark: #121212;
--color-container: #ffffff;
--color-container-dark: #333333;
--color-text: #111827;
--color-text-dark: #e2e2e2;
}
// Automatically respect standard media query if no override is set
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #121212;
--color-container: #333333;
--color-text: #e2e2e2;
}
}
// Force Light Mode overrides
body.theme-light {
--color-bg: #f0f2f5;
--color-bg-dark: #f0f2f5;
--color-container: #ffffff;
--color-container-dark: #ffffff;
--color-text: #111827;
--color-text-dark: #111827;
}
// Force Dark Mode overrides
body.theme-dark {
--color-bg: #121212;
--color-bg-dark: #121212;
--color-container: #333333;
--color-container-dark: #333333;
--color-text: #e2e2e2;
--color-text-dark: #e2e2e2;
}
// Fixed positioning ensures NO other component's layout is ever altered
.dark-mode-toggle {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
width: 55px;
height: 55px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
cursor: pointer;
transition: all 0.2s ease-in-out;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
svg {
width: 36px;
height: 36px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.15);
background-color: var(--color-bg);
}
&:active {
transform: translateY(0);
}
}
@@ -8,18 +8,14 @@
} }
.diagnostics-section { .diagnostics-section {
background-color: $color-container; background-color: $color-bg;
border-radius: $border-radius; border-radius: $border-radius;
padding: $spacing-md; padding: $spacing-md;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
}
h3 { h3 {
margin: 0; margin: 0 0 $spacing-md 0;
color: $color-primary; color: #1f2937;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
} }
@@ -31,13 +27,7 @@
gap: 12px; gap: 12px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: $border-radius; border-radius: $border-radius;
background-color: $color-container; background-color: #f9fafb;
margin: 16px 0px;
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
border-color: #555;
}
} }
.weekday-bars { .weekday-bars {
@@ -53,7 +43,6 @@
.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;
@@ -61,7 +50,7 @@
.bar { .bar {
width: 100%; width: 100%;
background-color: $color-primary; background-color: $color-accent;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
@@ -87,12 +76,8 @@
.day { .day {
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
color: $color-text; color: #1f2937;
margin-bottom: 2px; margin-bottom: 2px;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
} }
.value { .value {
@@ -102,116 +87,51 @@
} }
} }
.chart { .room-chart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: $spacing-md; padding: $spacing-md;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: $border-radius; border-radius: $border-radius;
background-color: $color-container; background-color: #f9fafb;
width: 100%;
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
border-color: #555;
}
} }
.bar-item { .room-bar-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
.diagnostics-header { .room-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
.diagnostics-label { .room-label {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
color: $color-text; color: #1f2937;
min-width: 50px; min-width: 50px;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
} }
.diagnostics-count { .room-count {
font-size: 12px; font-size: 12px;
color: $color-muted; color: $color-muted;
font-weight: 500; font-weight: 500;
} }
} }
.bar-container { .room-bar-container {
width: 100%; width: 100%;
height: 24px; height: 24px;
background-color: #e5e7eb; background-color: #e5e7eb;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
@media (prefers-color-scheme: dark) { .room-bar {
background-color: #555;
}
.bar {
height: 100%; height: 100%;
background-color: $color-primary; background-color: #570000;
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;
}
}
@@ -1,97 +0,0 @@
@use "../variables" as *;
.form-container {
display: flex;
flex-direction: column;
gap: $spacing-lg;
margin-bottom: $spacing-lg;
form {
display: flex;
flex-direction: column;
gap: $spacing-md;
label {
display: flex;
flex-direction: column;
gap: $spacing-sm;
font-weight: 500;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
input, textarea, select {
padding: $spacing-md;
border: 1px solid #ccc;
border-radius: $border-radius;
font-family: Arial, sans-serif;
font-size: 1rem;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: $color-text-dark;
border-color: #555;
}
&:focus {
outline: none;
border-color: $color-primary;
box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
}
}
textarea {
min-height: 120px;
resize: vertical;
}
button {
align-self: flex-start;
padding: $spacing-md $spacing-lg;
background-color: $color-primary;
color: white;
border: none;
border-radius: $border-radius;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
&:active {
transform: scale(0.98);
}
}
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
text-align: center;
transition: background-color 0.2s ease-in-out;
align-self: flex-start;
&:hover {
background-color: $color-primary-hover;
}
}
}
ul {
list-style: none;
padding-left: 0;
li h3 {
margin-top: 0;
}
}
}
@@ -0,0 +1,7 @@
.headline {
font-size: 40px;
font-weight: bold;
text-align: center;
color: #570000;
margin-top: 150px;
}
+27
View File
@@ -0,0 +1,27 @@
.csg-icon {
position: fixed;
top: 1rem;
right: 1rem;
z-index: 9999;
width: auto;
height: auto;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
@media (max-width: 768px) {
max-width: 60px;
img {
width: 100%;
height: auto;
}
}
img {
width: auto;
height: auto;
}
}
@@ -1,39 +0,0 @@
@use "../variables" as *;
.login-btn {
display: inline-block;
padding: 12px 400px;
background: $color-primary;
color: white;
border-radius: $border-radius-lg;
text-decoration: none;
font-weight: bold;
font-size: 1rem;
cursor: pointer;
border: none;
transition: all 0.2s ease-in-out;
margin-bottom: 15px;
&:hover {
background: $color-primary-hover;
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
}
.logout-btn {
display: inline-block;
padding: 10px 20px;
background: $color-logout;
color: white;
border-radius: $border-radius-lg;
text-decoration: none;
font-weight: bold;
transition: 0.2s ease-in-out;
margin-bottom: 20px;
&:hover {
background: $color-logout-hover;
transform: scale(1.05);
}
}
-294
View File
@@ -1,294 +0,0 @@
@use "../variables" as *;
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-lg;
padding-bottom: $spacing-md;
border-bottom: 2px solid #e5e7eb;
@media (prefers-color-scheme: dark) {
border-bottom-color: #555;
}
h1 {
margin: 0;
color: $color-primary;
font-size: 1.75rem;
}
}
.page-actions {
display: flex;
gap: $spacing-md;
flex-wrap: wrap;
a, button {
display: inline-block;
padding: $spacing-md;
background: $color-primary;
color: white;
text-decoration: none;
border: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
input[type="checkbox"] {
width: fit-content;
}
.grid-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: $spacing-lg;
margin-top: $spacing-lg;
.grid-item {
background: $color-container;
border: 1px solid #e5e7eb;
border-radius: $border-radius;
padding: $spacing-md;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.2s ease, transform 0.2s ease;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
border-color: #555;
}
&:hover {
box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
h3 {
color: $color-primary;
margin-top: 0;
margin-bottom: $spacing-md;
}
p {
margin: $spacing-sm 0;
color: $color-muted;
&:last-child {
margin-bottom: 0;
}
}
.item-actions {
display: flex;
gap: $spacing-md;
margin-top: $spacing-md;
flex-wrap: wrap;
a, button {
flex: 1;
min-width: 100px;
padding: $spacing-md;
border: none;
border-radius: $border-radius;
font-weight: bold;
cursor: pointer;
text-decoration: none;
text-align: center;
transition: background-color 0.2s ease-in-out;
}
a.view, button.view {
background: $color-primary;
color: white;
&:hover {
background-color: $color-primary-hover;
}
}
a.edit, button.edit {
background: #f59e0b;
color: white;
&:hover {
background-color: #d97706;
}
}
a.delete, button.delete {
background: $color-logout;
color: white;
&:hover {
background-color: $color-logout-hover;
}
}
}
}
}
.table-container {
overflow-x: auto;
margin-top: $spacing-lg;
border-radius: $border-radius;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
}
table {
width: 100%;
border-collapse: collapse;
background: $color-container;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
thead {
background: $color-primary;
color: white;
th {
padding: $spacing-md;
text-align: left;
font-weight: 600;
border-bottom: 2px solid darken($color-primary, 10%);
}
}
tbody {
tr {
border-bottom: 1px solid #e5e7eb;
transition: background-color 0.2s ease;
@media (prefers-color-scheme: dark) {
border-bottom-color: #555;
}
&:hover {
background-color: #f9fafb;
@media (prefers-color-scheme: dark) {
background-color: #444;
}
}
td {
padding: $spacing-md;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
}
}
}
}
.alert {
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-lg;
border-left: 4px solid;
&.success {
background: $color-status-done;
color: $color-status-done-text;
border-left-color: $color-status-done-text;
}
&.error {
background: $color-status-todo;
color: $color-status-todo-text;
border-left-color: $color-status-todo-text;
}
&.warning {
background: $color-status-inprogress;
color: $color-status-inprogress-text;
border-left-color: $color-status-inprogress-text;
}
&.info {
background: #dbeafe;
color: #1e40af;
border-left-color: #1e40af;
@media (prefers-color-scheme: dark) {
background: #1e3a8a;
color: #93c5fd;
border-left-color: #93c5fd;
}
}
}
.loading-spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid #e5e7eb;
border-top-color: $color-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
@keyframes spin {
to { transform: rotate(360deg); }
}
}
.empty-state {
text-align: center;
padding: $spacing-lg;
color: $color-muted;
p {
font-size: 1.1rem;
margin-bottom: $spacing-md;
}
a {
display: inline-block;
padding: $spacing-md $spacing-lg;
background: $color-primary;
color: white;
text-decoration: none;
border-radius: $border-radius;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: $color-primary-hover;
}
}
}
.return-to {
display: block;
padding: 16px 2rem;
background: #2b79c2;
background-color: rgb(43, 121, 194);
color: white;
text-decoration: none;
text-align: center;
border-radius: 0.5rem;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: #1d5fa0;
}
}
.delete {
display: block;
width: 100%;
text-align: center;
}
-120
View File
@@ -1,120 +0,0 @@
@use "../variables" as *;
.setup-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: $color-bg;
padding: $spacing-lg;
@media (prefers-color-scheme: dark) {
background: $color-bg-dark;
}
}
.setup-box {
width: 100%;
max-width: 400px;
background: $color-container;
padding: $spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
h1 {
color: $color-primary;
text-align: center;
margin-top: 0;
margin-bottom: $spacing-md;
}
p {
text-align: center;
color: $color-muted;
margin-bottom: $spacing-lg;
}
}
.setup-form {
display: flex;
flex-direction: column;
gap: $spacing-md;
.form-group {
display: flex;
flex-direction: column;
gap: $spacing-sm;
label {
font-weight: 500;
color: $color-text;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
}
}
input {
padding: $spacing-md;
border: 1px solid #ccc;
border-radius: $border-radius;
font-family: Arial, sans-serif;
font-size: 1rem;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: $color-text-dark;
border-color: #555;
}
&:focus {
outline: none;
border-color: $color-primary;
box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
}
}
button {
padding: $spacing-md;
background-color: $color-primary;
color: white;
border: none;
border-radius: $border-radius;
font-size: 1rem;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s ease-in-out;
margin-top: $spacing-md;
&:hover {
background-color: $color-primary-hover;
}
&:active {
transform: scale(0.98);
}
}
}
.success-message {
background: $color-status-done;
color: $color-status-done-text;
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-md;
border-left: 4px solid $color-status-done-text;
}
.error-message {
background: $color-status-todo;
color: $color-status-todo-text;
padding: $spacing-md;
border-radius: $border-radius;
margin-bottom: $spacing-md;
border-left: 4px solid $color-status-todo-text;
}
+109 -2
View File
@@ -49,9 +49,50 @@
text-align: left; text-align: left;
} }
.home { .sidebar-header {
display: flex; display: flex;
justify-content: center; align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: $spacing-md;
.home {
flex-grow: 1;
display: flex;
justify-content: center;
@media (max-width: 768px) {
padding-left: 36px;
}
}
.sidebar-close {
display: none;
background: transparent;
border: none;
color: #fff;
cursor: pointer;
padding: 8px;
border-radius: 50%;
align-items: center;
justify-content: center;
transition: background 0.2s ease-in-out;
margin-bottom: 0;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
svg {
width: 20px;
height: 20px;
stroke: #fff;
}
@media (max-width: 768px) {
display: flex;
}
}
} }
.home-svg { .home-svg {
@@ -64,3 +105,69 @@
&.user { width: 220px; background: darken($color-sidebar, 6%); } &.user { width: 220px; background: darken($color-sidebar, 6%); }
} }
// Floating mobile menu toggle button
.mobile-menu-toggle {
display: none;
@media (max-width: 768px) {
display: flex;
position: fixed;
top: 1rem;
left: 1rem;
z-index: 998;
width: 48px;
height: 48px;
border-radius: 50%;
border: 1px solid rgba(128, 128, 128, 0.2);
background-color: var(--color-container);
color: var(--color-text);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
cursor: pointer;
align-items: center;
justify-content: center;
padding: 0;
transition: all 0.2s ease-in-out;
svg {
width: 24px;
height: 24px;
stroke: var(--color-text);
transition: stroke 0.2s ease-in-out;
}
&:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
background-color: var(--color-bg);
}
&:active {
transform: translateY(0);
}
}
}
// Overlay backdrop that dims the screen and allows dismissals on mobile
.sidebar-overlay {
display: none;
@media (max-width: 768px) {
display: block;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 999;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
&.open {
opacity: 1;
pointer-events: auto;
}
}
}
+32 -53
View File
@@ -1,80 +1,59 @@
@use "../mixins" as *; @use "../mixins" as *;
@use "../variables" as *; @use "../variables" as *;
.ticket {
border: 1px solid #ccc;
padding: 1rem;
margin-bottom: 1rem;
border-radius: $border-radius-lg;
background: $color-container;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
h3 {
margin-top: 0;
color: $color-primary;
}
p {
margin: 0.3rem 0;
}
}
.ticket-card { .ticket-card {
@include card(); @include card();
border-left: 4px solid $color-primary; border-left: 4px solid $color-accent;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: $spacing-sm; gap: $spacing-sm;
.meta { color: $color-muted; font-size: 0.9rem; } .meta { color: $color-muted; font-size: 0.9rem; }
.title { font-weight: 600; } .title { font-weight: 600; }
} }
.status { .listbox {
display: inline-block; max-width: auto;
padding: 4px 10px; margin:0 0;
border-radius: 8px; background-color: #90ff82;
font-size: 0.8rem; background-color: $rundbgbackgroundcolor;
font-weight: bold; padding-bottom: 96px;
margin-top: 8px; color: white;
padding-left: 32px;
padding-right: 32px;
padding-top: 96px;
border-radius: 20px;
border-color: #000000;
border-style: double;
box-shadow: 10px 10px 10px rgba(43, 43, 43, 0.8);
font-size: 26px;
list-style-type: none;
&.To-Do { &.To-Do {
background: $color-status-todo; background-color: #b1003b;
color: $color-status-todo-text;
} }
&.InProgress { &.InProgress {
background: $color-status-inprogress; background-color: #fff385;
color: $color-status-inprogress-text;
}
&.Done {
background: $color-status-done;
color: $color-status-done-text;
} }
&.Completed { &.Completed {
background: $color-status-done; background-color: #90ff82;
color: $color-status-done-text;
} }
&.Archived { &.Archived {
background: $color-status-archived; background-color: #af69ee;
color: $color-status-archived-text;
} }
} }
.ticket_count { .postits {
text-align: center; display: grid;
align-content: center; grid-template-columns: auto auto auto;
color: #005f00; gap: 8px;
border: 2px solid #848484; margin-top: 96px;
border-radius: 10px; }
height: 50px; .warning {
width: 250px; color: #570000;
margin: 0;
font-size: xx-large;
} }
+89 -130
View File
@@ -3,126 +3,39 @@
@use "utilities"; @use "utilities";
@use "components/sidebar"; @use "components/sidebar";
@use "components/tickets"; @use "components/tickets";
@use "components/alltickets";
@use "components/allusers";
@use "components/forms";
@use "components/login";
@use "components/diagnostics"; @use "components/diagnostics";
@use "components/pages"; @use "components/frontpage";
@use "components/setup"; @use "components/home";
@use "components/dark_mode"; @use "components/icon";
* { :root {
box-sizing: border-box; --color-bg: #ffffff;
--color-container: #ffffff;
--color-text: #111827;
}
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #121212;
--color-container: #333333;
--color-text: #e2e2e2;
}
} }
body { body {
background: variables.$color-bg; background: variables.$color-bg;
font-family: Arial, sans-serif; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
color: variables.$color-text; color: #111827;
margin: 0; margin: 0;
padding: 0;
@media (prefers-color-scheme: dark) {
background: variables.$color-bg-dark;
color: variables.$color-text-dark;
}
}
input, textarea, select {
width: 100%;
padding: variables.$spacing-md;
font-size: 1rem;
border-radius: variables.$border-radius;
border: 1px solid #ccc;
box-sizing: border-box;
margin-bottom: 15px;
font-family: Arial, sans-serif;
@media (prefers-color-scheme: dark) {
background-color: #6b6b6b;
color: variables.$color-text-dark;
border-color: #555;
}
}
button {
padding: variables.$spacing-md;
font-size: 1rem;
border-radius: variables.$border-radius;
background-color: variables.$color-primary;
color: white;
border: none;
cursor: pointer;
margin-bottom: 15px;
transition: background-color 0.2s ease-in-out;
font-family: Arial, sans-serif;
font-weight: bold;
&:hover {
background-color: variables.$color-primary-hover;
}
}
form {
display: flex;
flex-direction: column;
gap: variables.$spacing-md;
label {
display: flex;
flex-direction: column;
gap: variables.$spacing-sm;
font-weight: 500;
}
a {
display: inline-block;
padding: variables.$spacing-md;
background: variables.$color-primary;
color: white;
text-decoration: none;
border-radius: variables.$border-radius;
text-align: center;
transition: background-color 0.2s ease-in-out;
&:hover {
background-color: variables.$color-primary-hover;
}
}
}
.container {
max-width: 900px;
margin: auto;
background: variables.$color-container;
padding: variables.$spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: variables.$color-container-dark;
}
}
.header-with-image {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.header-with-image img {
height: 60px;
} }
.admin { display: flex; } .admin { display: flex; }
.layout { .layout {
display: flex; display: flex;
min-height: 100vh; height: 100vh;
margin: 0; overflow: hidden;
padding: 0; box-sizing: border-box;
} }
.sidebar { .sidebar {
@@ -133,44 +46,90 @@ form {
top: 0; top: 0;
height: 100vh; height: 100vh;
overflow-y: auto; overflow-y: auto;
box-sizing: border-box;
} }
.content { .content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
margin-left: 260px; margin-left: 260px;
padding: variables.$spacing-lg; height: 100%;
overflow-y: auto;
> :first-child { padding: variables.$spacing-md;
max-width: 900px; box-sizing: border-box;
margin: 0 auto;
background: variables.$color-container;
padding: variables.$spacing-lg;
border-radius: 1rem;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background: variables.$color-container-dark;
}
}
h1, h2, h3 {
color: variables.$color-primary;
margin-top: 0;
}
} }
@media (max-width: 768px) { @media (max-width: 768px) {
.sidebar { .sidebar {
position: fixed; position: fixed;
top: 0;
bottom: 0;
left: -260px; left: -260px;
transition: left .3s ease-in-out; transition: left .3s ease-in-out;
z-index: 1000; z-index: 1000;
height: 100%;
box-sizing: border-box;
} }
.sidebar.open { .sidebar.open { left: 0; }
left: 0; .content { margin-left: 0; padding: variables.$spacing-md; box-sizing: border-box; }
}
.rundbg {
display: flex;
justify-content: center;
flex-direction: column;
max-width: 40%;
margin: 0 auto;
background-color: variables.$rundbgbackgroundcolor;
padding-bottom: 96px;
padding-left: 32px;
padding-right: 32px;
padding-top: 96px;
border-radius: 20px;
gap: 8px;
border-color: #000000;
border-style: double;
box-shadow: 20px 20px 10px rgb(43, 43, 43);
font-size: 26px;
label {
display: block;
width: 100%;
box-sizing: border-box;
} }
.content {
margin-left: 0; input, select, button, a {
width: 100%;
box-sizing: border-box;
padding: 16px;
font-size: 1.8rem;
background-color: #f0f0f0;
box-shadow: #6d6d6d;
box-shadow: 6px 6px 8px;
}
input[type="checkbox"] {
width: auto;
}
button {
margin: 16px 0;
}
a {
color: #000000;
text-align: center;
border-style: dashed;
}
&.login {
margin-top: 96px;
} }
} }
.setup-form input {
width: 100%;
box-sizing: border-box;
}