16 Commits
Author SHA1 Message Date
schn33fuchs 721e43c380 Refined docs and stuff
Docs link to each other and are generally better
2026-05-20 12:50:00 +02:00
schn33fuchs 0db9b76cad A new env variable
BACKEND_PORT for the port of the backend
2026-05-20 12:49:33 +02:00
schn33fuchs 7f4237a6b7 README update 2026-05-20 12:49:11 +02:00
schn33fuchs d029c5a347 Some improvements 2026-05-16 19:57:17 +02:00
schn33fuchs b1869595ac Fix
Weekday bars now at the bottom of everything
2026-05-16 19:57:04 +02:00
schn33fuchs 0d4a6c4711 favicon 2026-05-16 19:56:47 +02:00
schn33fuchs 726a581a9e Component Fix
easy fix carried over removed now
2026-05-16 19:56:25 +02:00
schn33fuchs e99026e3d6 Styles
Its more beautifull now
2026-05-16 19:54:13 +02:00
schn33fuchs b25e045b99 Styles better 2026-05-16 19:51:08 +02:00
schn33fuchs 99c5aa613c .gitignore 2026-05-13 16:59:32 +02:00
schn33fuchs cf24d6156c Small Fix
Removed unneccesary things from archive
2026-05-12 15:47:35 +02:00
schn33fuchs de2199e1c3 After merge fix
Forgor a &&
2026-05-11 21:23:08 +02:00
schn33fuchs 5199300856 Merge branch 'main' into styles-nino 2026-05-11 21:06:26 +02:00
schn33fuchs 289f83df3b Old logout look
It's better
2026-05-11 20:48:04 +02:00
schn33fuchs 928ca1de11 Improvements
More styling
2026-05-11 19:53:30 +02:00
schn33fuchs 10de47b911 Styling
Copied Ninos styles with copilot
2026-05-11 13:15:03 +02:00
37 changed files with 1500 additions and 1596 deletions
-1
View File
@@ -26,4 +26,3 @@ frontend/node_modules/
# Added by cargo # Added by cargo
/target /target
.antigravitycli/
Generated
+6 -333
View File
@@ -23,12 +23,6 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "anymap2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c"
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -214,12 +208,6 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "boolinator"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfa8873f51c92e232f9bac4065cddef41b714152812bfc5f7672ba16d6ef8cd9"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.2" version = "3.20.2"
@@ -665,11 +653,10 @@ dependencies = [
"gloo-storage 0.4.0", "gloo-storage 0.4.0",
"serde", "serde",
"serde_json", "serde_json",
"theme",
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"yew 0.23.0", "yew",
"yew-router", "yew-router",
] ]
@@ -808,44 +795,6 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gloo"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28999cda5ef6916ffd33fb4a7b87e1de633c47c0dc6d97905fee1cdaa142b94d"
dependencies = [
"gloo-console 0.2.3",
"gloo-dialogs 0.1.1",
"gloo-events 0.1.2",
"gloo-file 0.2.3",
"gloo-history 0.1.5",
"gloo-net 0.3.1",
"gloo-render 0.1.1",
"gloo-storage 0.2.2",
"gloo-timers 0.2.6",
"gloo-utils 0.1.7",
"gloo-worker 0.2.1",
]
[[package]]
name = "gloo"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd35526c28cc55c1db77aed6296de58677dbab863b118483a27845631d870249"
dependencies = [
"gloo-console 0.3.0",
"gloo-dialogs 0.2.0",
"gloo-events 0.2.0",
"gloo-file 0.3.0",
"gloo-history 0.2.2",
"gloo-net 0.4.0",
"gloo-render 0.2.0",
"gloo-storage 0.3.0",
"gloo-timers 0.3.0",
"gloo-utils 0.2.0",
"gloo-worker 0.4.0",
]
[[package]] [[package]]
name = "gloo" name = "gloo"
version = "0.11.0" version = "0.11.0"
@@ -884,19 +833,6 @@ dependencies = [
"gloo-worker 0.6.0", "gloo-worker 0.6.0",
] ]
[[package]]
name = "gloo-console"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b7ce3c05debe147233596904981848862b068862e9ec3e34be446077190d3f"
dependencies = [
"gloo-utils 0.1.7",
"js-sys",
"serde",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-console" name = "gloo-console"
version = "0.3.0" version = "0.3.0"
@@ -923,16 +859,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-dialogs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67062364ac72d27f08445a46cab428188e2e224ec9e37efdba48ae8c289002e6"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-dialogs" name = "gloo-dialogs"
version = "0.2.0" version = "0.2.0"
@@ -953,16 +879,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-events"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68b107f8abed8105e4182de63845afcc7b69c098b7852a813ea7462a320992fc"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-events" name = "gloo-events"
version = "0.2.0" version = "0.2.0"
@@ -983,18 +899,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-file"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d5564e570a38b43d78bdc063374a0c3098c4f0d64005b12f9bbe87e869b6d7"
dependencies = [
"gloo-events 0.1.2",
"js-sys",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-file" name = "gloo-file"
version = "0.3.0" version = "0.3.0"
@@ -1020,22 +924,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-history"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85725d90bf0ed47063b3930ef28e863658a7905989e9929a8708aab74a1d5e7f"
dependencies = [
"gloo-events 0.1.2",
"gloo-utils 0.1.7",
"serde",
"serde-wasm-bindgen 0.5.0",
"serde_urlencoded",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-history" name = "gloo-history"
version = "0.2.2" version = "0.2.2"
@@ -1046,7 +934,7 @@ dependencies = [
"gloo-events 0.2.0", "gloo-events 0.2.0",
"gloo-utils 0.2.0", "gloo-utils 0.2.0",
"serde", "serde",
"serde-wasm-bindgen 0.6.5", "serde-wasm-bindgen",
"serde_urlencoded", "serde_urlencoded",
"thiserror 1.0.69", "thiserror 1.0.69",
"wasm-bindgen", "wasm-bindgen",
@@ -1063,55 +951,13 @@ dependencies = [
"gloo-events 0.3.0", "gloo-events 0.3.0",
"gloo-utils 0.3.0", "gloo-utils 0.3.0",
"serde", "serde",
"serde-wasm-bindgen 0.6.5", "serde-wasm-bindgen",
"serde_urlencoded", "serde_urlencoded",
"thiserror 2.0.18", "thiserror 2.0.18",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-net"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a66b4e3c7d9ed8d315fd6b97c8b1f74a7c6ecbbc2320e65ae7ed38b7068cc620"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils 0.1.7",
"http 0.2.12",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-net"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ac9e8288ae2c632fa9f8657ac70bfe38a1530f345282d7ba66a1f70b72b7dc4"
dependencies = [
"futures-channel",
"futures-core",
"futures-sink",
"gloo-utils 0.2.0",
"http 0.2.12",
"js-sys",
"pin-project",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "gloo-net" name = "gloo-net"
version = "0.5.0" version = "0.5.0"
@@ -1154,16 +1000,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-render"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd9306aef67cfd4449823aadcd14e3958e0800aa2183955a309112a84ec7764"
dependencies = [
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-render" name = "gloo-render"
version = "0.2.0" version = "0.2.0"
@@ -1184,21 +1020,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-storage"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d6ab60bf5dbfd6f0ed1f7843da31b41010515c745735c970e821945ca91e480"
dependencies = [
"gloo-utils 0.1.7",
"js-sys",
"serde",
"serde_json",
"thiserror 1.0.69",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-storage" name = "gloo-storage"
version = "0.3.0" version = "0.3.0"
@@ -1229,16 +1050,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-timers"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b995a66bb87bebce9a0f4a95aed01daca4872c050bfcb21653361c03bc35e5c"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]] [[package]]
name = "gloo-timers" name = "gloo-timers"
version = "0.3.0" version = "0.3.0"
@@ -1261,19 +1072,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "gloo-utils"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "037fcb07216cb3a30f7292bd0176b050b7b9a052ba830ef7d5d65f6dc64ba58e"
dependencies = [
"js-sys",
"serde",
"serde_json",
"wasm-bindgen",
"web-sys",
]
[[package]] [[package]]
name = "gloo-utils" name = "gloo-utils"
version = "0.2.0" version = "0.2.0"
@@ -1300,42 +1098,6 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "gloo-worker"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13471584da78061a28306d1359dd0178d8d6fc1c7c80e5e35d27260346e0516a"
dependencies = [
"anymap2",
"bincode",
"gloo-console 0.2.3",
"gloo-utils 0.1.7",
"js-sys",
"serde",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]]
name = "gloo-worker"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76495d3dd87de51da268fa3a593da118ab43eb7f8809e17eb38d3319b424e400"
dependencies = [
"bincode",
"futures",
"gloo-utils 0.2.0",
"gloo-worker-macros 0.1.0",
"js-sys",
"pinned",
"serde",
"thiserror 1.0.69",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
]
[[package]] [[package]]
name = "gloo-worker" name = "gloo-worker"
version = "0.5.0" version = "0.5.0"
@@ -1728,16 +1490,6 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "implicit-clone"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8a9aa791c7b5a71b636b7a68207fdebf171ddfc593d9c8506ec4cbc527b6a84"
dependencies = [
"implicit-clone-derive",
"indexmap",
]
[[package]] [[package]]
name = "implicit-clone" name = "implicit-clone"
version = "0.6.0" version = "0.6.0"
@@ -2337,23 +2089,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "prokio"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03b55e106e5791fa5a13abd13c85d6127312e8e09098059ca2bc9b03ca4cf488"
dependencies = [
"futures",
"gloo 0.8.1",
"num_cpus",
"once_cell",
"pin-project",
"pinned",
"tokio",
"tokio-stream",
"wasm-bindgen-futures",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -2555,17 +2290,6 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "serde-wasm-bindgen"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3b143e2833c57ab9ad3ea280d21fd34e285a42837aeb0ee301f4f41890fa00e"
dependencies = [
"js-sys",
"serde",
"wasm-bindgen",
]
[[package]] [[package]]
name = "serde-wasm-bindgen" name = "serde-wasm-bindgen"
version = "0.6.5" version = "0.6.5"
@@ -3013,17 +2737,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "theme"
version = "0.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d5379f4c4eb851af44d4d818ba43fac8cff83e9b8e624aedc8a3346ca45065fe"
dependencies = [
"serde",
"web-sys",
"yew 0.21.0",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "1.0.69" version = "1.0.69"
@@ -3746,31 +3459,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "yew"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f1a03f255c70c7aa3e9c62e15292f142ede0564123543c1cc0c7a4f31660cac"
dependencies = [
"console_error_panic_hook",
"futures",
"gloo 0.10.0",
"implicit-clone 0.4.9",
"indexmap",
"js-sys",
"prokio",
"rustversion",
"serde",
"slab",
"thiserror 1.0.69",
"tokio",
"tracing",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"yew-macro 0.21.0",
]
[[package]] [[package]]
name = "yew" name = "yew"
version = "0.23.0" version = "0.23.0"
@@ -3780,7 +3468,7 @@ dependencies = [
"console_error_panic_hook", "console_error_panic_hook",
"futures", "futures",
"gloo 0.11.0", "gloo 0.11.0",
"implicit-clone 0.6.0", "implicit-clone",
"indexmap", "indexmap",
"js-sys", "js-sys",
"rustversion", "rustversion",
@@ -3793,22 +3481,7 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
"wasm-bindgen-futures", "wasm-bindgen-futures",
"web-sys", "web-sys",
"yew-macro 0.23.0", "yew-macro",
]
[[package]]
name = "yew-macro"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02fd8ca5166d69e59f796500a2ce432ff751edecbbb308ca59fd3fe4d0343de2"
dependencies = [
"boolinator",
"once_cell",
"prettyplease",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.117",
] ]
[[package]] [[package]]
@@ -3840,7 +3513,7 @@ dependencies = [
"urlencoding", "urlencoding",
"wasm-bindgen", "wasm-bindgen",
"web-sys", "web-sys",
"yew 0.23.0", "yew",
"yew-router-macro", "yew-router-macro",
] ]
-63
View File
@@ -1,63 +0,0 @@
# Ticketsystem
A ticket system with backend and frontend components.
## Components
- **[Backend](../backend/index.html)** - The server-side API and business logic
- **[Frontend](../frontend/index.html)** - The client-side user interface
## Usage
### Prerequisite
#### IMPORTANT
Before compiling the programm you have to install the rust toolchain.
For a guide to do this, visit: <https://rust-lang.org/tools/install/>
A instance of a postgresql has to be accessible to the backend. Place the connection details in a .env file into the variable `DATABASE_URL`
To setup the tables you either can create them manually by following the sheme specified in `backend/migrations` or apply them with sqlx
To install sqlx run `cargo install sqlx` and then in the `backend` directory run `sqlx migrate run` to create the tables
### Environment
The .env file has to be in the root directory of the project or in the same directory as the executable
Keys:
`DATABASE_URL`: Specifying the url and connection details for the database
`TOKEN_SECRET`: The JWT token secret, can theoretically be anything but is more secure when generated with a tool, e.g: <https://jwtsecrets.com/>
`ORIGIN`: The origin of the frontend, used for CORS rules
`BACKEND_PORT`: The port which the backend should use to run on
### Backend
The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu',
the executable will be placed in the `target/release` directory and can then be run via any method
### Frontend
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting wasm
#### NOTE
To install trunk run `cargo install trunk`
#### IMPORTANT
Requests from the frontend to /api/* have to be proxied to the Backend
Example with nginx and frontend running at localhost:8000 and backend at localhost:9000 :
```nginx
location /api/ {
proxy_pass http://localhost:9000/api;
}
```
## Usage of AI
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
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
The comments with `///` or `//!`
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
+5 -335
View File
@@ -4,8 +4,8 @@ A ticket system with backend and frontend components.
## Components ## Components
- **[Backend]** - The server-side API and business logic - **[Backend](../backend/index.html)** - The server-side API and business logic
- **[Frontend]** - The client-side user interface - **[Frontend](../frontend/index.html)** - The client-side user interface
## Usage ## Usage
### Prerequisite ### Prerequisite
@@ -30,12 +30,8 @@ Keys:
The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu', The backend can either be run via `cargo run --release` or `cargo build --release` using the correct target architecture, e.g. 'x86_64-unknown-linux-gnu',
the executable will be placed in the `target/release` directory and can then be run via any method the executable will be placed in the `target/release` directory and can then be run via any method
Alternatively download the executable from the 'Releases' Page for your device
### Frontend ### Frontend
The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting WASM The HTML code for the frontend can be generated by using `trunk build`. The resulting files will end up in the `frontend/dist` directory and can be served over any webserver supporting wasm
Alternatively download the fontend files from the 'Releases' Page and serve them via any webserver supporting WASM
> [!NOTE] > [!NOTE]
> To install trunk run `cargo install trunk` > To install trunk run `cargo install trunk`
@@ -48,342 +44,16 @@ Alternatively download the fontend files from the 'Releases' Page and serve them
> } > }
> ``` > ```
## Diagrams
### Class Diagramm
#### Backend
```mermaid
classDiagram
class Error {
+status: &'static str
+message: String
}
class TicketResponse {
+id: i32
+category: String
+betreff: String
+description: String
+room: i16
+status: String
+date: chrono::DateTime~chrono::Utc~
+user_id: i16
+user_first_name: String
+user_last_name: String
}
class User {
+id: i16
+last_name: String
+first_name: String
+username: String
+is_admin: bool
+pwd: String
}
class TicketCreateScheme {
+category: String
+betreff: String
+description: String
+room: i16
}
class TicketUpdateScheme {
+status: String
}
class UserUpdateScheme {
+id: i16
+first_name: String
+last_name: String
+username: String
+make_admin: bool
+new_pwd: String
}
class UserCreateScheme {
+first_name: String
+last_name: String
+username: String
+is_admin: bool
+pwd: String
}
class LoginScheme {
+username: String
+pwd: String
}
class FilteredUser {
+id: i16
+first_name: String
+last_name: String
+username: String
+is_admin: bool
}
class Claims {
+sub: String
+issued: usize
+expires: usize
}
class AppState {
-db: PgPool
-env: Env
}
class Env {
+db_url: String
+token_secret: String
+origin: String
+backend_port: String
+load() Env
}
AppState --> Env
Env ..> Env
```
#### Frontend
```mermaid
classDiagram
class TicketCreateScheme {
+category: String
+betreff: String
+description: String
+room: i16
}
class TicketUpdateScheme {
+status: String
}
class Ticket {
+id: i32
+category: String
+betreff: String
+description: String
+room: i16
+status: String
+date: chrono::DateTime~chrono::Utc~
+user_id: i16
+user_first_name: String
+user_last_name: String
}
class TicketProps {
+id: i32
}
class ActiveUser {
+id: Option~i16~
+is_admin: bool
}
class ApiError {
-message: String
-_status: String
}
class SidebarExpandState {
+ticket_open: bool
+users_open: bool
}
class Default {
+default() Self
}
class SidebarState {
+expand: SidebarExpandState
+set_tickets_open: Callback~bool~
+toggle_tickets: Callback~()~
+set_users_open: Callback~bool~
+toggle_users: Callback~()~
+new(expand:SidebarExpandState, set_tickets_open:Callback~bool~, toggle_tickets:Callback~()~, set_users_open:Callback~bool~, toggle_users:Callback~()~) Self
}
class SidebarProps {
+children: Children
}
class TicketPartial {
-date: DateTime~Utc~
-room: i16
-user_id: i16
}
class UserPartial {
-id: i16
-first_name: String
-last_name: String
}
class RoomTotalsProps {
-tickets: Vec~TicketPartial~
}
class UserTotalProps {
-users: Vec~UserPartial~
-tickets: Vec~TicketPartial~
}
class AdminSetupScheme {
+first_name: String
+last_name: String
+username: String
+pwd: String
}
class UserCreateScheme {
+first_name: String
+last_name: String
+username: String
+is_admin: bool
+pwd: String
}
class LoginScheme {
+username: String
+pwd: String
}
class UserUpdateScheme {
+id: i16
+first_name: String
+last_name: String
+username: String
+make_admin: bool
+new_pwd: String
}
class FilteredUser {
+id: i16
+first_name: String
+last_name: String
+username: String
+is_admin: bool
}
class UserProps {
+id: i16
}
class ApiError {
-message: String
-_status: String
}
class AuthState {
+is_authenticated: Option~bool~
+is_admin: Option~bool~
}
class ProtectedRouteProps {
+children: Children
+admin_page: bool
}
class SidebarShellProps {
+children: Children
}
class SidebarComponentProps {
+is_open: bool
+on_close: Callback~()~
}
class AdminCheckWrapperProps {
+children: Children
}
SidebarState --> SidebarExpandState
RoomTotalsProps --> TicketPartial
UserTotalProps --> UserPartial
UserTotalProps --> TicketPartial
SidebarShellProps ..> SidebarComponentProps
```
### Sequence Diagrams
#### 1. System Initialization & Administrator Setup
```mermaid
sequenceDiagram
autonumber
actor Admin as Initial Administrator
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB@{"type": "database", "alias": "Database"}
Note over Admin, DB: System Initialization Flow
FE->>BE: GET /api/check-admin
BE->>DB: SELECT COUNT(*) FROM users WHERE is_admin = true
DB-->>BE: 0 (No admin found)
BE-->>FE: HTTP 200 OK {"exists": false}
FE-->>Admin: Render Admin Setup Page
Admin->>FE: Input username, password, first/last name
FE->>BE: POST /api/setup-admin {username, pwd, ...}
Note over BE: Hash password using Argon2
BE->>DB: INSERT INTO users (username, pwd, is_admin, ...)
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>Admin: Redirect to Login Page
```
#### 2. User Authentication Flow (Login)
```mermaid
sequenceDiagram
autonumber
actor User
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB as Database@{"type": "database"}
Note over User, DB: Authentication & Cookie Session Setup
User->>FE: Enter username & password
FE->>BE: POST /api/login {username, pwd}
BE->>DB: SELECT * FROM users WHERE username = $1
DB-->>BE: Return user record with password hash
Note over BE: Verify password using Argon2
alt Password Valid
Note over BE: Generate JWT token containing claims (sub: user_id)
Note over BE: Build HttpOnly, Secure, Lax cookie 'token'
BE-->>FE: HTTP 200 OK {"status": "success", "token": "...", "user": {...}}
Note over BE,FE: Header: Set-Cookie: token=...#59; Path=/#59; HttpOnly#59; SameSite=Lax
Note over FE: Save auth state to global context
FE-->>User: Redirect to Dashboard / Home
else Password Invalid
BE-->>FE: HTTP 400 Bad Request {"status": "error", "message": "Invalid password"}
FE-->>User: Display error message
end
```
#### 3. Ticket Lifecycle Flow
```mermaid
sequenceDiagram
autonumber
actor User as Authenticated User
actor Admin as Administrator
participant FE as Frontend (Yew)
participant BE as Backend (Axum)
participant DB as Database@{"type": "database"}
Note over User, DB: Ticket Creation Flow (Protected Route)
User->>FE: Fill out ticket form & submit
FE->>BE: POST /api/tickets/create {category, betreff, description, room} (Includes 'token' cookie)
Note over BE: validate_token middleware decodes & verifies JWT
BE->>DB: INSERT INTO tickets (category, description, betreff, room, user_id)
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
FE-->>User: Clear form & display success notification
Note over Admin, DB: Ticket Review & Resolution (Admin Only Route)
Admin->>FE: View Ticket Board
FE->>BE: GET /api/tickets (Includes 'token' cookie)
Note over BE: validate_token middleware checks JWT
BE->>DB: SELECT tickets JOIN users ...
DB-->>BE: Return list of tickets
BE-->>FE: HTTP 200 OK [tickets]
FE-->>Admin: Render Ticket List
Admin->>FE: Click "Resolve" on ticket
FE->>BE: PATCH /api/tickets/{id} {"status": "Resolved"} (Includes 'token' cookie)
Note over BE: validate_admin middleware verifies token & checks is_admin = true
BE->>DB: UPDATE tickets SET status = $1 WHERE id = $2
DB-->>BE: Success
BE-->>FE: HTTP 200 OK {"status": "success"}
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
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 it and modified it to my needs and opinions I've gone over most of it and modified it to my needs and opinions
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
+4
View File
@@ -21,6 +21,8 @@ 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 {
@@ -45,10 +47,12 @@ 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,
} }
} }
} }
+4 -15
View File
@@ -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<i16>, Path(id): Path<i32>,
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"#)
@@ -411,11 +411,10 @@ 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<i16>, Path(id): Path<i32>,
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 update_result = if !body.new_pwd.is_empty() {
let argon = Argon2::default(); let argon = Argon2::default();
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) { let hashed_pwd = match argon.hash_password(body.new_pwd.clone().as_bytes(), &salt) {
@@ -423,25 +422,15 @@ pub async fn update_user(
Err(e) => panic!("Error hashing {:}", e), Err(e) => panic!("Error hashing {:}", e),
}; };
sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#) 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"#)
.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
} else {
sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, is_admin = $4 WHERE id = $5"#)
.bind(body.first_name.to_owned())
.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| { .map_err(|e| {
( (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
+2 -1
View File
@@ -90,6 +90,7 @@ async fn main() {
.layer(cors); .layer(cors);
// Start listening for incoming connections // Start listening for incoming connections
let listener = tokio::net::TcpListener::bind("0.0.0.0:8001").await.unwrap(); let uri = format!("0.0.0.0:{}", env.backend_port);
let listener = tokio::net::TcpListener::bind(&uri).await.unwrap();
let _ = axum::serve(listener, app).await; let _ = axum::serve(listener, app).await;
} }
-1
View File
@@ -26,4 +26,3 @@ gloo = "0.12.0"
chrono = { workspace = true } chrono = { workspace = true }
chrono-tz = "0.10.4" chrono-tz = "0.10.4"
wasm-bindgen = "0.2.120" wasm-bindgen = "0.2.120"
theme = { version = "0.0.3", features = ["yew"] }
+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.7"] addresses = ["127.0.0.1"] #,"10.150.9.116"]
# The address to serve on WAN. # The address to serve on WAN.
# address = "0.0.0.0" # address = "0.0.0.0"
# The port to serve on. # The port to serve on.
+1 -2
View File
@@ -5,9 +5,8 @@
<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>Yew App</title> <title>Ticket System CSG</title>
</head> </head>
<body></body> <body></body>
Binary file not shown.

Before

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>{ "Wird geladen..." } </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>{ "Überprüfe Berechtigungen..." }</div> }, None => html! { <div>{ "Checking permissions..." }</div> },
} }
} else { } else {
props.children.clone().into() props.children.clone().into()
-24
View File
@@ -1,24 +0,0 @@
use yew::prelude::*;
#[function_component]
pub fn ThemeToggle() -> Html {
let onclick = {
Callback::from(|_| {
if let Some(window) = web_sys::window() {
if let Some(document) = window.document() {
if let Some(html) = document.document_element() {
let current = html.get_attribute("data-theme").unwrap_or_default();
let new_theme = if current == "dark" { "" } else { "dark" };
let _ = html.set_attribute("data-theme", new_theme);
}
}
}
})
};
html! {
<button {onclick} class="sidebar-button">
{"🌙"}
</button>
}
}
+4 -51
View File
@@ -1,5 +1,4 @@
mod auth; mod auth;
mod darkmode;
mod pages; mod pages;
use crate::auth::ProtectedRoute; use crate::auth::ProtectedRoute;
use crate::pages::*; use crate::pages::*;
@@ -70,15 +69,8 @@ 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, accepting mobile open state. /// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component.
/// - Main content area: Renders the `children` passed to this component. /// - Main content area: Renders the `children` passed to this component.
/// ///
/// # Example /// # Example
@@ -91,46 +83,9 @@ 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">
<button class="mobile-menu-toggle" onclick={on_open}> <sidebar::Sidebar/>
<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>
@@ -138,7 +93,6 @@ 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 {
@@ -193,10 +147,10 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
} }
match *admin_exists { match *admin_exists {
None => html! { <div>{ "Wird geladen..." }</div> }, None => html! { <div>{ "Loading..." }</div> },
Some(false) => { Some(false) => {
navigator.push(&Route::Setup); navigator.push(&Route::Setup);
html! { <div>{ "Wird weitergeleitet zur Einrichtung..." }</div> } html! { <div>{ "Redirecting to setup..." }</div> }
} }
Some(true) => props.children.clone().into(), Some(true) => props.children.clone().into(),
} }
@@ -317,7 +271,6 @@ fn switch(route: Route) -> Html {
pub fn app() -> Html { pub fn app() -> Html {
html! { html! {
<BrowserRouter> <BrowserRouter>
<basic_pages::Icon/>
<Switch<Route> render={switch} /> <Switch<Route> render={switch} />
</BrowserRouter> </BrowserRouter>
} }
+22 -18
View File
@@ -3,6 +3,7 @@ use wasm_bindgen_futures::spawn_local;
use yew::prelude::*; use yew::prelude::*;
use yew_router::prelude::*; use yew_router::prelude::*;
#[macro_export]
/// 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`
@@ -79,11 +80,15 @@ pub fn home_component() -> Html {
} }
html! { html! {
<div class="rundbg home"> <div class="form-container home">
<div class="page-header">
<h1>{ "Welcome" }</h1>
</div>
<crate::utilities::TicketCount/> <crate::utilities::TicketCount/>
<div>
<p class="login-status">{ "Sie sind angemeldet als: " }</p> <p>{ "You are logged in as: " }</p>
<p class="name">{ &*name }</p> <p class="text-muted">{ &*name }</p>
</div>
</div> </div>
} }
} }
@@ -101,11 +106,14 @@ 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> <div class="form-container">
<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>{ "The page you are looking for does not exist." }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
</div>
</div> </div>
} }
} }
@@ -125,17 +133,13 @@ pub fn not_found_component() -> Html {
#[component(PermissionDenied)] #[component(PermissionDenied)]
pub fn denied_component() -> Html { pub fn denied_component() -> Html {
html! { html! {
<div> <div class="form-container">
<h1>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</h1> <div class="empty-state">
<h3>{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</h3> <h1>{ "Access Denied" }</h1>
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>> <p>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</p>
<p class="text-muted">{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</p>
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
</div>
</div> </div>
} }
} }
#[component(Icon)]
pub fn icon_component() -> Html {
html! {
<img src="assets/csg.png" class="csg-icon" />
}
}
+22 -22
View File
@@ -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;
} }
@@ -172,28 +172,28 @@ pub fn initial_admin_setup() -> Html {
navigator.push(&crate::Route::Login); navigator.push(&crate::Route::Login);
} }
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(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! {
<div class="setup-container rundbg"> <div class="setup-container">
<div class="setup-box"> <div class="setup-box">
<h1>{ "Admin-Einrichtung" }</h1> <h1>{ "Initial Admin Setup" }</h1>
<p>{ "Erstellen Sie Ihr erstes Administratorkonto" }</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 { "Administratorkonto 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">{ "Administratorkonto erfolgreich erstellt! Wird weitergeleitet zum Login..." }</p> <p class="success-message" style="color:green">{ "Admin account created successfully! Redirecting to login..." }</p>
} }
</form> </form>
</div> </div>
+25 -79
View File
@@ -133,7 +133,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
Callback::from(move |v: bool| { Callback::from(move |v: bool| {
state.set(SidebarExpandState { state.set(SidebarExpandState {
ticket_open: v, ticket_open: v,
users_open: state.users_open, users_open: (*state).users_open,
}) })
}) })
}; };
@@ -141,10 +141,10 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let toggle_tickets = { let toggle_tickets = {
let state = state.clone(); let state = state.clone();
Callback::from(move |_| { Callback::from(move |_| {
let current = state.ticket_open; let current = (*state).ticket_open;
state.set(SidebarExpandState { state.set(SidebarExpandState {
ticket_open: !current, ticket_open: !current,
users_open: state.users_open, users_open: (*state).users_open,
}); });
}) })
}; };
@@ -153,7 +153,7 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let state = state.clone(); let state = state.clone();
Callback::from(move |v: bool| { Callback::from(move |v: bool| {
state.set(SidebarExpandState { state.set(SidebarExpandState {
ticket_open: state.ticket_open, ticket_open: (*state).ticket_open,
users_open: v, users_open: v,
}) })
}) })
@@ -162,9 +162,9 @@ pub fn sidebar_state_provider(props: &SidebarProps) -> Html {
let toggle_users = { let toggle_users = {
let state = state.clone(); let state = state.clone();
Callback::from(move |_| { Callback::from(move |_| {
let current = state.users_open; let current = (*state).users_open;
state.set(SidebarExpandState { state.set(SidebarExpandState {
ticket_open: state.ticket_open, ticket_open: (*state).ticket_open,
users_open: !current, users_open: !current,
}); });
}) })
@@ -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 einreichen" }</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,11 +319,6 @@ 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.
@@ -342,22 +337,8 @@ 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(props: &SidebarComponentProps) -> Html { pub fn sidebar() -> 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");
@@ -398,84 +379,49 @@ pub fn sidebar(props: &SidebarComponentProps) -> Html {
}; };
match *is_admin { match *is_admin {
None => html! { <div class="sidebar-loading">{ "Wird geladen..." }</div> }, None => html! { <div class="sidebar-loading">{ "Loading..." }</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) => { Some(false) => html! {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider> <SidebarStateProvider>
<nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}> <nav class="sidebar user">
<ul> <ul>
<li class="sidebar-header"> <Link<crate::Route> to={crate::Route::Home}>{ "󰟒" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Home} classes="home">
<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">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<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"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/> <TicketMenu/>
<li class="logout-item"> <li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button <button
class="sidebar-button" class="logout-button"
onclick={on_logout.clone()} onclick={on_logout.clone()}
> >
{ "Abmelden" } { "Logout" }
</button> </button>
</li> </li>
</ul> </ul>
</nav> </nav>
</SidebarStateProvider> </SidebarStateProvider>
} },
}
// Admin: full sidebar wrapped in provider so submenu state persists // Admin: full sidebar wrapped in provider so submenu state persists
Some(true) => { Some(true) => html! {
let on_close = props.on_close.clone();
html! {
<SidebarStateProvider> <SidebarStateProvider>
<nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}> <nav class="sidebar admin">
<ul> <ul>
<li class="sidebar-header"> <Link<crate::Route> to={crate::Route::Home}>{ "󰟒" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::Home} classes="home">
<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">
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
</Link<crate::Route>>
<button class="sidebar-close" onclick={move |_| on_close.emit(())}>
<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"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</li>
<TicketMenu/> <TicketMenu/>
<UsersMenu/> <UsersMenu/>
<Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::Diagnostics}>{ "Statistiken" }</Link<crate::Route>>
<Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::ArchivedTickets}>{ "Archiv" }</Link<crate::Route>>
<li class="logout-item"> <li class="logout-item">
<crate::darkmode::ThemeToggle/>
<button <button
class="sidebar-button" class="logout-button"
onclick={on_logout.clone()} onclick={on_logout.clone()}
> >
{ "Abmelden" } { "Logout" }
</button> </button>
</li> </li>
</ul> </ul>
</nav> </nav>
</SidebarStateProvider> </SidebarStateProvider>
} },
}
} }
} }
+58 -56
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,12 +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 => status.set(Some("Erfolg".into())), Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => { Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into()); Err(err) => status.set(Some(format!("Network error: {}", err))),
status.set(Some(text));
},
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -290,15 +287,17 @@ pub fn submit_ticket_component() -> Html {
let room_valid = (*room).is_some(); let room_valid = (*room).is_some();
html! { html! {
<form class="rundbg" {onsubmit}> <div class="form-container">
<div class="page-header">
<h1>{ "Create Ticket" }</h1>
</div>
<form {onsubmit}>
<label>{ "Betreff:" } <label>{ "Betreff:" }
<input type="text" value={(*betreff).clone()} oninput={betreff_change}/> <input type="text" value={(*betreff).clone()} oninput={betreff_change}/>
</label> </label>
<br/>
<label>{ "Beschreibung:" } <label>{ "Beschreibung:" }
<input type="text" value={(*description).clone()} oninput={description_change}/> <textarea value={(*description).clone()} oninput={description_change}/>
</label> </label>
<br/>
<label>{ "Kategorie:" } <label>{ "Kategorie:" }
<select value={(*category).clone()} onchange={category_change}> <select value={(*category).clone()} onchange={category_change}>
<option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option> <option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
@@ -309,33 +308,32 @@ pub fn submit_ticket_component() -> Html {
<option value="Sonstiges">{ "Sonstiges" }</option> <option value="Sonstiges">{ "Sonstiges" }</option>
</select> </select>
</label> </label>
<br/>
<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 { } else {
html! {} html! {}
} }
} }
<br/> <button type="submit">{ "Send" }</button>
<button type="submit">{ "Absenden" }</button>
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Tickets ansehen" }</Link<crate::Route>> <Link<crate::Route> to={crate::Route::AllTickets}>{ "View All Tickets" }</Link<crate::Route>>
{ {
if let Some(s) = &*status { if let Some(s) = &*status {
html!{ <p>{ s }</p> } html!{ <p class="alert success">{ s }</p> }
} else { } else {
html!{} html!{}
} }
} }
</form> </form>
</div>
} }
} }
@@ -451,12 +449,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 => error.set(Some("Erfolg".into())), Ok(response) if response.status() == 200 => error.set(Some("Success".into())),
Ok(response) => { Ok(response) => error.set(Some(format!("Error: {}", response.status()))),
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into()); Err(err) => error.set(Some(format!("Network error: {}", err))),
error.set(Some(text));
},
Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -494,13 +489,14 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
match request.send().await { match request.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => { Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
ticket_state.set(None); // remove local state or navigate away
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(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);
}); });
@@ -508,12 +504,12 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
}; };
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</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 if let Some(t) = &*ticket { } else if let Some(t) = &*ticket {
html! { html! {
<div class="rundbg"> <div>
<h1>{ format!("Ticket #{}", t.id) }</h1> <h1>{ format!("Ticket #{}", t.id) }</h1>
<p><strong>{ "Kategorie: " }</strong>{ &t.category }</p> <p><strong>{ "Kategorie: " }</strong>{ &t.category }</p>
<p><strong>{ "Betreff: " }</strong>{ &t.betreff }</p> <p><strong>{ "Betreff: " }</strong>{ &t.betreff }</p>
@@ -554,7 +550,7 @@ pub fn ticket_by_id_component(props: &TicketProps) -> Html {
</div> </div>
} }
} else { } else {
html! { <p>{ "Kein Ticket gefunden." }</p> } html! { <p>{ "No ticket found." }</p> }
} }
} }
@@ -664,12 +660,16 @@ pub fn all_tickets_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</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 {
html! { html! {
<ul class= "postits"> <div>
<div class="page-header">
<h1>{ "All Tickets" }</h1>
</div>
<ul class="ticket-list">
{ 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| { { 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| {
let status_class = match t.status.as_str() { let status_class = match t.status.as_str() {
"ToDo" => "To-Do", "ToDo" => "To-Do",
@@ -679,14 +679,17 @@ pub fn all_tickets_component() -> Html {
_ => "To-Do" _ => "To-Do"
}; };
html! { html! {
<div class={classes!{status_class, "listbox"}}> <li key={t.id.to_string()} class={status_class}>
<li key={t.id.to_string()}> <Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff,) }</h3></Link<crate::Route>> <p>{ &t.description }</p>
</li> </li>
}
</div> })}
}})}
</ul> </ul>
<div class="ticket-list-actions">
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
</div>
</div>
} }
} }
} }
@@ -747,17 +750,17 @@ pub fn archived_tickets_component() -> Html {
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!("Parsefehler: {}", 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);
}); });
@@ -797,29 +800,28 @@ pub fn archived_tickets_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</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 {
html! { html! {
<ul class="postits"> <div>
<div class="page-header">
<h1>{ "Archivierte Tickets" }</h1>
</div>
<ul class="ticket-list">
{ 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! { { 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! {
<div class="listbox"> <div>
<li key={t.id.to_string()}> <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>> <Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
<p>{ &t.description }</p> <p>{ &t.description }</p>
<p>{ match t.status.as_str() {
"ToDo" => "Zu tun",
"InProgress" => "In Bearbeitung",
"Completed" => "Erledigt",
"Archived" => "Archiviert",
_ => "Ungültiger Status"
}}</p>
</li> </li>
</div> </div>
})} })}
</ul> </ul>
</div>
} }
} }
} }
+58 -34
View File
@@ -181,12 +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 => status.set(Some("Erfolg".into())), Ok(response) if response.status() == 200 => status.set(Some("Success".into())),
Ok(response) => { Ok(response) => status.set(Some(format!("Error: {}", response.status()))),
let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into()); Err(err) => status.set(Some(format!("Network error: {}", err))),
status.set(Some(text));
},
Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
} }
}); });
}) })
@@ -233,7 +230,11 @@ pub fn register_component() -> Html {
}; };
html! { html! {
<form {onsubmit} class="rundbg"> <div class="form-container">
<div class="page-header">
<h1>{ "Register User" }</h1>
</div>
<form {onsubmit}>
<label>{ "Vorname:" } <label>{ "Vorname:" }
<input type="text" value={(*first_name).clone()} oninput={fn_change}/> <input type="text" value={(*first_name).clone()} oninput={fn_change}/>
</label> </label>
@@ -251,6 +252,7 @@ pub fn register_component() -> Html {
</label> </label>
<button type="submit">{ "Bestätigen" }</button> <button type="submit">{ "Bestätigen" }</button>
</form> </form>
</div>
} }
} }
@@ -329,21 +331,24 @@ 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(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! {
<div> <main class="content">
<h1 class="headline">{ "Anmeldung" }</h1> <div class="form-container">
<form {onsubmit} class="rundbg login"> <div class="page-header">
<h1>{ "Login" }</h1>
</div>
<form {onsubmit}>
<input <input
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();
@@ -352,17 +357,18 @@ pub fn login_component() -> Html {
/> />
<input <input
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();
pwd.set(input.value()); pwd.set(input.value());
})} })}
/> />
<button type="submit" disabled={*loading}>{ if *loading { "Anmeldung läuft..." } else { "Anmelden" } }</button> <button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
if !error.is_empty() { <p style="color:red">{(*error).clone()}</p> } if !error.is_empty() { <p class="alert error">{(*error).clone()}</p> }
</form> </form>
</div> </div>
</main>
} }
} }
@@ -431,18 +437,37 @@ pub fn all_users_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</p>} html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<p>{ "Loading..." }</p>
</div>
}
} else if let Some(e) = &*error { } else if let Some(e) = &*error {
html! { <p>{ format!("Fehler: {}", e) }</p> } html! {
<div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<p class="alert error">{ format!("Error: {}", e) }</p>
</div>
}
} else { } else {
html! { html! {
<ul class="postits"> <div class="form-container">
<div class="page-header">
<h1>{ "All Users" }</h1>
</div>
<ul class="user-list">
{ for users.iter().map(|t| html! { { for users.iter().map(|t| html! {
<li key={t.id.to_string()} class="listbox"> <li key={t.id.to_string()}>
<Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}", t.first_name, t.last_name) }</h3></Link<crate::Route>> <Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}", t.first_name, t.last_name) }</h3></Link<crate::Route>>
</li> </li>
})} })}
</ul> </ul>
</div>
} }
} }
} }
@@ -577,7 +602,6 @@ 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);
@@ -612,7 +636,6 @@ 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) => {
@@ -640,7 +663,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
// confirm // confirm
if !web_sys::window() if !web_sys::window()
.and_then(|w| { .and_then(|w| {
w.confirm_with_message("Möchten Sie diesen Eintrag wirklich löschen?") w.confirm_with_message("Are you sure you want to delete this item?")
.ok() .ok()
}) })
.unwrap_or(false) .unwrap_or(false)
@@ -656,18 +679,19 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
let user_state = user_state.clone(); let user_state = user_state.clone();
spawn_local(async move { spawn_local(async move {
let url = format!("/api/users/{}", id); let url = format!("/api/users/{}", id); // or /api/tickets/{}
let req = Request::delete(&url).credentials(web_sys::RequestCredentials::Include); let req = Request::delete(&url).credentials(web_sys::RequestCredentials::Include);
match req.send().await { match req.send().await {
Ok(resp) if resp.status() == 200 || resp.status() == 204 => { Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
user_state.set(None); // remove local state or navigate away
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(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);
}); });
@@ -675,12 +699,12 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
}; };
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</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 if let Some(u) = &*user { } else if let Some(u) = &*user {
html! { html! {
<div class="rundbg"> <div>
<div> <div>
<p><strong>{ "Vorname: " }</strong>{ &u.first_name }</p> <p><strong>{ "Vorname: " }</strong>{ &u.first_name }</p>
<p><strong>{ "Nachname: " }</strong>{ &u.last_name }</p> <p><strong>{ "Nachname: " }</strong>{ &u.last_name }</p>
@@ -745,7 +769,7 @@ 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>
@@ -763,6 +787,6 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
</div> </div>
} }
} else { } else {
html! { <p>{ "Kein Benutzer gefunden." }</p> } html! { <p>{ "No ticket found." }</p> }
} }
} }
+161 -23
View File
@@ -6,6 +6,7 @@ use serde::Deserialize;
use wasm_bindgen_futures::spawn_local; use wasm_bindgen_futures::spawn_local;
use yew::prelude::*; use yew::prelude::*;
use crate::dequote;
use crate::pages::ticket::{ActiveUser, Ticket}; use crate::pages::ticket::{ActiveUser, Ticket};
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis. /// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
@@ -20,6 +21,23 @@ 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.
@@ -34,6 +52,20 @@ 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)
@@ -170,7 +202,7 @@ fn parse_room(r: i16) -> String {
#[component(Diagnostics)] #[component(Diagnostics)]
pub fn diagnostics_component() -> Html { pub fn diagnostics_component() -> Html {
html! { html! {
<div class="rundbg"> <div>
<TicketCount/> <TicketCount/>
<SubmitStats/> <SubmitStats/>
</div> </div>
@@ -225,17 +257,17 @@ pub fn ticket_count_component() -> Html {
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!("Parsefehler: {}", 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);
}); });
@@ -275,9 +307,9 @@ pub fn ticket_count_component() -> Html {
} }
if *loading { if *loading {
html! {<p>{ "Wird geladen" }</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
@@ -288,9 +320,9 @@ pub fn ticket_count_component() -> Html {
}) })
.count(); .count();
html! { html! {
<div class="ticket-count"> <div class="open-tickets">
<h2>{ "Offene Tickets" }</h2> <h2 class="left">{ "Offene Tickets" }</h2>
<h4>{ count }</h4> <h4 class="ticket_count center">{ count }</h4>
</div> </div>
} }
} }
@@ -326,11 +358,13 @@ pub fn ticket_count_component() -> Html {
#[component(SubmitStats)] #[component(SubmitStats)]
pub fn submit_stats_component() -> Html { pub fn submit_stats_component() -> Html {
let tickets = use_state(|| Vec::<TicketPartial>::new()); let tickets = use_state(|| Vec::<TicketPartial>::new());
let users = use_state(|| Vec::<UserPartial>::new());
let error = use_state(|| None::<String>); let error = use_state(|| None::<String>);
let loading = use_state(|| false); let loading = use_state(|| false);
{ {
let tickets = tickets.clone(); let tickets = tickets.clone();
let users = users.clone();
let error = error.clone(); let error = error.clone();
let loading = loading.clone(); let loading = loading.clone();
@@ -342,18 +376,36 @@ pub fn submit_stats_component() -> Html {
Ok(response) if response.status() == 200 => { Ok(response) if response.status() == 200 => {
match response.json::<Vec<TicketPartial>>().await { match response.json::<Vec<TicketPartial>>().await {
Ok(t) => tickets.set(t), Ok(t) => tickets.set(t),
Err(e) => error.set(Some(format!("Parsefehler: {}", 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))),
} }
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);
}); });
|| () || ()
@@ -372,7 +424,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()
@@ -383,12 +435,12 @@ pub fn submit_stats_component() -> Html {
html! { html! {
<div class="diagnostics-section"> <div class="diagnostics-section">
if *loading { if *loading {
<p>{ "Wird geladen..." }</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| {
@@ -415,8 +467,17 @@ pub fn submit_stats_component() -> Html {
})} })}
</div> </div>
</div> </div>
<div class="diagnostics-row">
<div class="diagnostics-column">
<h3>{ "Tickets pro Raum" }</h3>
<RoomTotalTickets tickets={(*tickets).clone()}/> <RoomTotalTickets tickets={(*tickets).clone()}/>
</div> </div>
<div class="diagnostics-column">
<h3>{ "Tickets pro Benutzer" }</h3>
<UserTotal users={(*users).clone()} tickets={(*tickets).clone()}/>
</div>
</div>
</div>
} }
} }
@@ -457,19 +518,96 @@ fn room_total_component(props: &RoomTotalsProps) -> Html {
html! { html! {
<div class="diagnostics-section"> <div class="diagnostics-section">
<h3>{ "Tickets pro Raum" }</h3> <div class="chart">
<div class="room-chart">
{ for totals_vec.into_iter().map(|(room, count)| { { for totals_vec.into_iter().map(|(room, count)| {
let label = parse_room(room); let label = parse_room(room);
let bar_width_percent = (count as f64 / max_count as f64) * 100.0; let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
html! { html! {
<div class="room-bar-item"> <div class="bar-item">
<div class="room-header"> <div class="diagnostics-header">
<span class="room-label">{ label }</span> <span class="diagnostics-label">{ label }</span>
<span class="room-count">{ count }</span> <span class="diagnostics-count">{ count }</span>
</div> </div>
<div class="room-bar-container"> <div class="bar-container">
<div class="room-bar" style={format!("width: {}%;", bar_width_percent)}> <div class="bar" style={format!("width: {}%;", bar_width_percent)}>
</div>
</div>
</div>
}
}) }
</div>
</div>
}
}
/// 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>
+8 -4
View File
@@ -1,8 +1,12 @@
@use "variables" as *; @use "variables" as *;
@mixin card { @mixin card {
backgroud: #fff; background: $color-container;
border-radius: &border-radius; border-radius: $border-radius;
box-shadow: 0 1px 3px rgba(0,0,0,0.06); box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
padding: var(--spacing-md); padding: $spacing-md;
@media (prefers-color-scheme: dark) {
background: $color-container-dark;
}
} }
+2 -3
View File
@@ -1,6 +1,5 @@
@use "variables" as *; @use "variables" as *;
.u-hidden { display: none !important; } .u-hidden { display: none !important; }
.u-gap-sm { gap: var(--spacing-sm); } .u-gap-sm { gap: $spacing-sm; }
.text-muted { color: var(--color-muted); } .text-muted { color: $color-muted; }
+33 -28
View File
@@ -1,29 +1,34 @@
:root { // Color Palette (Reference Style)
--color-bg: #ffffff; $color-bg: #f0f2f5;
--color-sidebar: #570000; $color-bg-dark: #121212;
--color-accent: #2563eb; $color-container: #ffffff;
--color-muted: #6b7280; $color-container-dark: #333333;
--spacing-sm: 8px; $color-sidebar: #0f172a;
--spacing-md: 16px; $color-primary: #2b79c2;
--border-radius: 6px; $color-primary-hover: #1d5fa0;
--rundbgbackgroundcolor: #a0a0a0; $color-accent: #2b79c2;
--sidebar-font: #fffefe; $color-muted: #6b7280;
--color: #ffffff; $color-text: #111827;
--color-container: #f0f0f0; $color-text-dark: #e2e2e2;
--color-text: #000000;
} // Status Colors
$color-status-todo: #ffcccc;
$color-status-todo-text: #a00;
$color-status-inprogress: #fff3cd;
$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;
[data-theme='dark'] {
--color-bg: #000000;
--color-sidebar: #b7d300;
--color-accent: #eb2525;
--color-muted: #999999;
--spacing-sm: 8px;
--spacing-md: 16px;
--border-radius: 6px;
--rundbgbackgroundcolor: #505050;
--sidebar-font: #000000;
--color: #000000;
--color-container: #1a1a1a;
--color-text: #ffffff;
}
@@ -0,0 +1,82 @@
@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;
}
}
}
@@ -0,0 +1,13 @@
@use "../variables" as *;
.user-list {
list-style: none;
padding-left: 0;
margin: 0;
li {
h3 {
margin-top: 0;
}
}
}
+107 -28
View File
@@ -4,18 +4,22 @@
.diagnostics-container { .diagnostics-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: var(--spacing-md); gap: $spacing-md;
} }
.diagnostics-section { .diagnostics-section {
background-color: #ffffff; background-color: $color-container;
border-radius: var(--border-radius); border-radius: $border-radius;
padding: var(--spacing-md); padding: $spacing-md;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06); box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
}
h3 { h3 {
margin: 0 0 var(--spacing-md) 0; margin: 0;
color: #1f2937; color: $color-primary;
font-size: 1.1rem; font-size: 1.1rem;
font-weight: 600; font-weight: 600;
} }
@@ -26,8 +30,14 @@
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: var(--border-radius); border-radius: $border-radius;
background-color: #f9fafb; background-color: $color-container;
margin: 16px 0px;
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
border-color: #555;
}
} }
.weekday-bars { .weekday-bars {
@@ -35,7 +45,7 @@
gap: 12px; gap: 12px;
align-items: flex-end; align-items: flex-end;
height: 205px; height: 205px;
padding: var(--spacing-md); padding: $spacing-md;
overflow-x: auto; overflow-x: auto;
padding-bottom: 0px; padding-bottom: 0px;
} }
@@ -43,15 +53,15 @@
.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;
height: 100%; height: 100%;
justify-content: end;
.bar { .bar {
width: 100%; width: 100%;
background-color: var(--color-accent); background-color: $color-primary;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
transition: background-color 0.2s ease; transition: background-color 0.2s ease;
@@ -64,7 +74,7 @@
.weekday-labels { .weekday-labels {
display: flex; display: flex;
gap: 12px; gap: 12px;
padding: var(--spacing-md); padding: $spacing-md;
padding-top: 0; padding-top: 0;
overflow-x: auto; overflow-x: auto;
@@ -77,62 +87,131 @@
.day { .day {
font-weight: 600; font-weight: 600;
font-size: 12px; font-size: 12px;
color: #1f2937; color: $color-text;
margin-bottom: 2px; margin-bottom: 2px;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
} }
.value { .value {
font-size: 11px; font-size: 11px;
color: var(--color-muted); color: $color-muted;
} }
} }
} }
.room-chart { .chart {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
padding: var(--spacing-md); padding: $spacing-md;
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
border-radius: var(--border-radius); border-radius: $border-radius;
background-color: #f9fafb; background-color: $color-container;
width: 100%;
@media (prefers-color-scheme: dark) {
background-color: $color-container-dark;
border-color: #555;
}
} }
.room-bar-item { .bar-item {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
.room-header { .diagnostics-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
.room-label { .diagnostics-label {
font-weight: 600; font-weight: 600;
font-size: 14px; font-size: 14px;
color: #1f2937; color: $color-text;
min-width: 50px; min-width: 50px;
@media (prefers-color-scheme: dark) {
color: $color-text-dark;
}
} }
.room-count { .diagnostics-count {
font-size: 12px; font-size: 12px;
color: var(--color-muted); color: $color-muted;
font-weight: 500; font-weight: 500;
} }
} }
.room-bar-container { .bar-container {
width: 100%; width: 100%;
height: 24px; height: 24px;
background-color: #e5e7eb; background-color: #e5e7eb;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
.room-bar { @media (prefers-color-scheme: dark) {
background-color: #555;
}
.bar {
height: 100%; height: 100%;
background-color: #570000; background-color: $color-primary;
transition: width 0.3s ease; transition: width 0.3s ease;
} }
} }
} }
.diagnostics-row {
display: flex;
gap: 24px;
align-items: flex-start;
flex-wrap: wrap;
}
.diagnostics-column {
display: flex;
flex-direction: column;
gap: 12px;
width: 48%;
min-width: 280px;
}
.open-tickets{
display: flex;
align-items: center;
position: relative;
width: 100%;
padding: 0 1rem;
box-sizing: border-box;
min-height: 60px;
.left {
display: flex;
align-items: center;
flex-shrink: 0;
max-width: 30%;
h2 {
margin: 0;
white-space: nowrap;
}
}
.center {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
}
.home {
.ticket_count {
font-size: xxx-large;
height: 115px;
}
}
@@ -0,0 +1,97 @@
@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;
}
}
}
@@ -1,7 +0,0 @@
.headline {
font-size: 40px;
font-weight: bold;
text-align: center;
color: #570000;
margin-top: 150px;
}
@@ -1,9 +0,0 @@
.login-status {
font-weight: bold;
margin: 4px 8px;
}
.name {
font-size: 24px;
margin: 4px 8px;
}
-27
View File
@@ -1,27 +0,0 @@
.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;
}
}
@@ -0,0 +1,39 @@
@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);
}
}
+272
View File
@@ -0,0 +1,272 @@
@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;
}
}
}
+120
View File
@@ -0,0 +1,120 @@
@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;
}
+13 -140
View File
@@ -3,23 +3,21 @@
.sidebar { .sidebar {
width: 260px; width: 260px;
background: var(--color-sidebar); background: $color-sidebar;
color: var(--sidebar-font); color: #fff;
min-height: 100%; min-height: 100%;
padding: var(--spacing-md); padding: $spacing-md;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: $spacing-sm; flex: 1; }
ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--spacing-sm); flex: 1; }
a, .menu-toggle { a, .menu-toggle {
color: var(--sidebar-font); color: #fff;
text-decoration: none; text-decoration: none;
display: block; display: block;
padding: 8px 12px; padding: 8px 12px;
border-radius: var(--border-radius); border-radius: $border-radius;
transition: background 0.2s ease-in-out; transition: background 0.2s ease-in-out;
&:hover { background: rgba(255,255,255,0.1); } &:hover { background: rgba(255,255,255,0.1); }
} }
@@ -29,7 +27,7 @@
.submenu { .submenu {
margin-left: 8px; margin-left: 8px;
background: rgba(255,255,255,0.05); background: rgba(255,255,255,0.05);
border-radius: var(--border-radius); border-radius: $border-radius;
padding: 6px; padding: 6px;
li { padding: 4px 0; } li { padding: 4px 0; }
} }
@@ -37,145 +35,20 @@
.logout-item { .logout-item {
margin-top: auto; margin-top: auto;
border-top: 1px solid rgba(255,255,255,0.1); border-top: 1px solid rgba(255,255,255,0.1);
padding-top: var(--spacing-sm); padding-top: $spacing-sm;
} }
.sidebar-button { .logout-button {
background: rgba(255, 93, 43, 0.527); background: rgba(255, 255, 255, 0.1);
color: var(--sidebar-font); color: #fff;
border: 1px solid rgba(255, 255, 255, 0.801); border: 1px solid rgba(255, 255, 255, 0.2);
padding: 8px 12px; padding: 8px 12px;
border-radius: 4px; border-radius: 4px;
width: 100%; width: 100%;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
margin-top: var(--spacing-sm);
} }
.sidebar-header { &.user { width: 220px; background: darken($color-sidebar, 6%); }
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: var(--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: var(--sidebar-font);
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: var(--sidebar-font);
}
@media (max-width: 768px) {
display: flex;
}
}
}
.home-svg {
width: 24px;
height: 24px;
vertical-align: middle;
margin-right: 8px;
}
&.user { width: 220px; background: darken(#570000, 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: 999;
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);
color: var(--color-container);
svg {
stroke: var(--color-container);
}
}
&: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;
}
}
}
+56 -55
View File
@@ -1,79 +1,80 @@
@use "../mixins" as *; @use "../mixins" as *;
@use "../variables" as *; @use "../variables" as *;
.ticket-card { .ticket {
@include card(); border: 1px solid #ccc;
border-left: 4px solid var(--color-accent); padding: 1rem;
display: flex; margin-bottom: 1rem;
flex-direction: column; border-radius: $border-radius-lg;
gap: var(--spacing-sm); background: $color-container;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
.meta { color: var(--color-muted); font-size: 0.9rem; } @media (prefers-color-scheme: dark) {
.title { font-weight: 600; } background: $color-container-dark;
}
h3 {
margin-top: 0;
color: $color-primary;
}
p {
margin: 0.3rem 0;
}
} }
.listbox { .ticket-card {
max-width: auto; @include card();
margin:0 0; border-left: 4px solid $color-primary;
background-color: #90ff82; display: flex;
background-color: var(--rundbgbackgroundcolor); flex-direction: column;
padding-bottom: 8px; gap: $spacing-sm;
color: white; .meta { color: $color-muted; font-size: 0.9rem; }
padding-left: 8px; .title { font-weight: 600; }
padding-right: 8px; }
padding-top: 8px;
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;
text-align: center;
.status {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
font-size: 0.8rem;
font-weight: bold;
margin-top: 8px;
&.To-Do { &.To-Do {
background-color: #b1003b; background: $color-status-todo;
color: $color-status-todo-text;
} }
&.InProgress { &.InProgress {
background-color: #fff385; background: $color-status-inprogress;
color: $color-status-inprogress-text;
}
&.Done {
background: $color-status-done;
color: $color-status-done-text;
} }
&.Completed { &.Completed {
background-color: #90ff82; background: $color-status-done;
color: $color-status-done-text;
} }
&.Archived { &.Archived {
background-color: #af69ee; background: $color-status-archived;
} color: $color-status-archived-text;
a {
text-decoration: none;
color: white;
} }
} }
.postits { .ticket_count {
display: grid;
gap: 8px;
margin-top: 96px;
}
.warning {
color: #570000;
}
.ticket-count {
border-radius: 10px;
border: 2px solid black;
width: fit-content;
padding: 8px;
margin: 0 auto;
margin-bottom: 32px;
h4 {
text-align: center; text-align: center;
font-size: 32px; align-content: center;
margin: 8px; color: #005f00;
} border: 2px solid #848484;
border-radius: 10px;
height: 50px;
width: 250px;
margin: 0;
font-size: xx-large;
} }
+132 -83
View File
@@ -3,25 +3,125 @@
@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/frontpage"; @use "components/pages";
@use "components/home"; @use "components/setup";
@use "components/icon";
* {
box-sizing: border-box;
}
body { body {
background: var(--color-bg); background: variables.$color-bg;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial; font-family: Arial, sans-serif;
color: #111827; color: variables.$color-text;
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;
height: 100vh; min-height: 100vh;
overflow: hidden; margin: 0;
box-sizing: border-box; padding: 0;
} }
.sidebar { .sidebar {
@@ -32,95 +132,44 @@ body {
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;
height: 100%; padding: variables.$spacing-lg;
overflow-y: auto;
padding: var(--spacing-md); > :first-child {
box-sizing: border-box; max-width: 900px;
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 { left: 0; } .sidebar.open {
.content { margin-left: 0; padding: var(--spacing-md); box-sizing: border-box; } left: 0;
}
.rundbg {
display: flex;
justify-content: center;
flex-direction: column;
max-width: 60%;
margin: 0 auto;
background-color: var(--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 {
input, select, button, a { margin-left: 0;
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: var (--color);
text-align: center;
border-style: dashed;
}
&.login {
margin-top: 96px;
max-width: 40%;
}
&.home {
padding-top: 16px;
} }
} }
.setup-form input {
width: 100%;
box-sizing: border-box;
}
+1 -1
View File
@@ -1 +1 @@
#![doc = include_str!("../README.cargo.md")] #![doc = include_str!("../README.md")]