Compare commits
29
Commits
721e43c380
..
Silas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5101c0ff4d | ||
|
|
2a53c7372d | ||
|
|
75f3ef392e | ||
|
|
159202a7af | ||
|
|
8fd35f3e82 | ||
|
|
177e959bbd | ||
|
|
3d68064b33 | ||
|
|
52ca6ec96f | ||
|
|
7f3913dcfb | ||
|
|
31e98c0936 | ||
|
|
b404ff5bc9 | ||
|
|
c3b7321005 | ||
|
|
2a15f77f4f | ||
|
|
dfb031949b | ||
|
|
b030e133a2 | ||
|
|
810d554cb9 | ||
|
|
2e6bd2a781 | ||
|
|
390630886e | ||
|
|
0ee20e2833 | ||
|
|
b7f8df624c | ||
|
|
8a9c9efbcc | ||
|
|
c31375573f | ||
|
|
b2daed8e99 | ||
|
|
076bd6c545 | ||
|
|
a42897de9f | ||
|
|
3154e3de44 | ||
|
|
bf0197adae | ||
|
|
c6a4a24fb6 | ||
|
|
52387f7333 |
@@ -26,3 +26,4 @@ frontend/node_modules/
|
|||||||
# Added by cargo
|
# Added by cargo
|
||||||
|
|
||||||
/target
|
/target
|
||||||
|
.antigravitycli/
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# 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
|
||||||
@@ -4,8 +4,8 @@ A ticket system with backend and frontend components.
|
|||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
- **[Backend](../backend/index.html)** - The server-side API and business logic
|
- **[Backend]** - The server-side API and business logic
|
||||||
- **[Frontend](../frontend/index.html)** - The client-side user interface
|
- **[Frontend]** - The client-side user interface
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
### Prerequisite
|
### Prerequisite
|
||||||
@@ -44,16 +44,342 @@ The HTML code for the frontend can be generated by using `trunk build`. The resu
|
|||||||
> }
|
> }
|
||||||
> ```
|
> ```
|
||||||
|
|
||||||
|
## 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 most of it and modified it to my needs and opinions
|
I've gone over it and modified it to my needs and opinions
|
||||||
|
|
||||||
|
The general structure sequence diagrams above. I've modified and fixed any errors and discrepancys
|
||||||
|
|||||||
@@ -21,8 +21,6 @@ pub struct Env {
|
|||||||
pub token_secret: String,
|
pub token_secret: String,
|
||||||
/// Frontend origin URL for CORS policy
|
/// Frontend origin URL for CORS policy
|
||||||
pub origin: String,
|
pub origin: String,
|
||||||
/// Backend port number
|
|
||||||
pub backend_port: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Env {
|
impl Env {
|
||||||
@@ -47,12 +45,10 @@ impl Env {
|
|||||||
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
let db_url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set");
|
||||||
let token_secret = std::env::var("TOKEN_SECRET").expect("TOKEN_SECRET must be set");
|
let token_secret = std::env::var("TOKEN_SECRET").expect("TOKEN_SECRET must be set");
|
||||||
let origin = std::env::var("ORIGIN").expect("ORIGIN must be set");
|
let origin = std::env::var("ORIGIN").expect("ORIGIN must be set");
|
||||||
let backend_port = std::env::var("BACKEND_PORT").expect("BACKEND_PORT must be set");
|
|
||||||
Env {
|
Env {
|
||||||
db_url,
|
db_url,
|
||||||
token_secret,
|
token_secret,
|
||||||
origin,
|
origin,
|
||||||
backend_port,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ pub async fn get_current_user(
|
|||||||
/// - `404 Not Found` if user doesn't exist
|
/// - `404 Not Found` if user doesn't exist
|
||||||
/// - `500 Internal Server Error` if database error occurs
|
/// - `500 Internal Server Error` if database error occurs
|
||||||
pub async fn delete_user(
|
pub async fn delete_user(
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<i16>,
|
||||||
State(data): State<Arc<AppState>>,
|
State(data): State<Arc<AppState>>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#)
|
let query = sqlx::query(r#"DELETE FROM users WHERE id = $1"#)
|
||||||
@@ -411,10 +411,11 @@ pub async fn get_user_by_id(
|
|||||||
/// - This endpoint requires admin privileges (enforced by middleware via
|
/// - This endpoint requires admin privileges (enforced by middleware via
|
||||||
/// [`validate_admin`](crate::cookie::validation::validate_admin)).
|
/// [`validate_admin`](crate::cookie::validation::validate_admin)).
|
||||||
pub async fn update_user(
|
pub async fn update_user(
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<i16>,
|
||||||
State(data): State<Arc<AppState>>,
|
State(data): State<Arc<AppState>>,
|
||||||
Json(body): Json<UserUpdateScheme>,
|
Json(body): Json<UserUpdateScheme>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<impl IntoResponse, (StatusCode, Json<serde_json::Value>)> {
|
||||||
|
let 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) {
|
||||||
@@ -422,15 +423,25 @@ pub async fn update_user(
|
|||||||
Err(e) => panic!("Error hashing {:}", e),
|
Err(e) => panic!("Error hashing {:}", e),
|
||||||
};
|
};
|
||||||
|
|
||||||
let update_result = sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#)
|
sqlx::query(r#"UPDATE users SET first_name = $1, last_name = $2, username = $3, pwd = $4, is_admin = $5 WHERE id = $6"#)
|
||||||
.bind(body.first_name.to_owned())
|
.bind(body.first_name.to_owned())
|
||||||
.bind(body.last_name.to_owned())
|
.bind(body.last_name.to_owned())
|
||||||
.bind(body.username.to_owned())
|
.bind(body.username.to_owned())
|
||||||
.bind(&hashed_pwd)
|
.bind(hashed_pwd)
|
||||||
.bind(body.make_admin.to_owned())
|
.bind(body.make_admin.to_owned())
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&data.db)
|
.execute(&data.db)
|
||||||
.await
|
.await
|
||||||
|
} 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,
|
||||||
|
|||||||
+1
-2
@@ -90,7 +90,6 @@ async fn main() {
|
|||||||
.layer(cors);
|
.layer(cors);
|
||||||
|
|
||||||
// Start listening for incoming connections
|
// Start listening for incoming connections
|
||||||
let uri = format!("0.0.0.0:{}", env.backend_port);
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:8001").await.unwrap();
|
||||||
let listener = tokio::net::TcpListener::bind(&uri).await.unwrap();
|
|
||||||
let _ = axum::serve(listener, app).await;
|
let _ = axum::serve(listener, app).await;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[serve]
|
[serve]
|
||||||
# The address to serve on LAN.
|
# The address to serve on LAN.
|
||||||
addresses = ["127.0.0.1"] #,"10.150.9.116"]
|
addresses = ["127.0.0.1"] #,"10.150.9.7"]
|
||||||
# The address to serve on WAN.
|
# The address to serve on WAN.
|
||||||
# address = "0.0.0.0"
|
# address = "0.0.0.0"
|
||||||
# The port to serve on.
|
# The port to serve on.
|
||||||
|
|||||||
+2
-1
@@ -5,8 +5,9 @@
|
|||||||
<link data-trunk rel="rust" data-bin="bin" />
|
<link data-trunk rel="rust" data-bin="bin" />
|
||||||
<link data-trunk rel="scss" href="src/styles/main.scss" />
|
<link data-trunk rel="scss" href="src/styles/main.scss" />
|
||||||
<link data-trunk rel="icon" href="src/assets/favicon.ico" type="image/x-icon" />
|
<link data-trunk rel="icon" href="src/assets/favicon.ico" type="image/x-icon" />
|
||||||
|
<link data-trunk rel="copy-dir" href="src/assets" />
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Ticket System CSG</title>
|
<title>Yew App</title>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body></body>
|
<body></body>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
+48
-2
@@ -69,8 +69,15 @@ pub struct SidebarShellProps {
|
|||||||
/// This component is designed to wrap page-specific content, ensuring that the sidebar
|
/// This component is designed to wrap page-specific content, ensuring that the sidebar
|
||||||
/// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation.
|
/// is always present for navigation. Integrates with [`crate::pages::sidebar::Sidebar`] for navigation.
|
||||||
///
|
///
|
||||||
|
/// # Mobile Support
|
||||||
|
/// On mobile displays, the sidebar is hidden by default and can be toggled:
|
||||||
|
/// - **Menu Toggle Button**: Renders a floating menu button to slide the sidebar open.
|
||||||
|
/// - **Dark Mode Toggle**: Floating button to change theme.
|
||||||
|
/// - **Overlay Backdrop**: Dims the screen when the sidebar is open. Clicking it closes the sidebar.
|
||||||
|
/// - **Auto-Close on Navigation**: Automatically closes the sidebar when navigating to a new route.
|
||||||
|
///
|
||||||
/// # Components
|
/// # Components
|
||||||
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component.
|
/// - [`crate::pages::sidebar::Sidebar`]: The navigation sidebar component, accepting mobile open state.
|
||||||
/// - Main content area: Renders the `children` passed to this component.
|
/// - Main content area: Renders the `children` passed to this component.
|
||||||
///
|
///
|
||||||
/// # Example
|
/// # Example
|
||||||
@@ -83,9 +90,46 @@ pub struct SidebarShellProps {
|
|||||||
/// ```
|
/// ```
|
||||||
#[component(SidebarShell)]
|
#[component(SidebarShell)]
|
||||||
fn sidebar_shell(props: &SidebarShellProps) -> Html {
|
fn sidebar_shell(props: &SidebarShellProps) -> Html {
|
||||||
|
let route = use_route::<Route>();
|
||||||
|
let mobile_sidebar_open = use_state(|| false);
|
||||||
|
|
||||||
|
// Close mobile sidebar automatically on any route transition
|
||||||
|
{
|
||||||
|
let mobile_sidebar_open = mobile_sidebar_open.clone();
|
||||||
|
use_effect_with(route, move |_| {
|
||||||
|
mobile_sidebar_open.set(false);
|
||||||
|
|| ()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_open = {
|
||||||
|
let mobile_sidebar_open = mobile_sidebar_open.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| mobile_sidebar_open.set(true))
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_close = {
|
||||||
|
let mobile_sidebar_open = mobile_sidebar_open.clone();
|
||||||
|
Callback::from(move |_: ()| mobile_sidebar_open.set(false))
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_close_click = {
|
||||||
|
let on_close = on_close.clone();
|
||||||
|
Callback::from(move |_: MouseEvent| on_close.emit(()))
|
||||||
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="layout">
|
<div class="layout">
|
||||||
<sidebar::Sidebar/>
|
<button class="mobile-menu-toggle" onclick={on_open}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="4" x2="20" y1="12" y2="12"/>
|
||||||
|
<line x1="4" x2="20" y1="6" y2="6"/>
|
||||||
|
<line x1="4" x2="20" y1="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class={if *mobile_sidebar_open { "sidebar-overlay open" } else { "sidebar-overlay" }} onclick={on_close_click}></div>
|
||||||
|
|
||||||
|
<sidebar::Sidebar is_open={*mobile_sidebar_open} on_close={on_close} />
|
||||||
<main class="content">
|
<main class="content">
|
||||||
{ for props.children.iter() }
|
{ for props.children.iter() }
|
||||||
</main>
|
</main>
|
||||||
@@ -93,6 +137,7 @@ fn sidebar_shell(props: &SidebarShellProps) -> Html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// Props for the AdminCheckWrapper component.
|
/// Props for the AdminCheckWrapper component.
|
||||||
#[derive(Properties, PartialEq)]
|
#[derive(Properties, PartialEq)]
|
||||||
pub struct AdminCheckWrapperProps {
|
pub struct AdminCheckWrapperProps {
|
||||||
@@ -271,6 +316,7 @@ 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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ use wasm_bindgen_futures::spawn_local;
|
|||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
use yew_router::prelude::*;
|
use yew_router::prelude::*;
|
||||||
|
|
||||||
#[macro_export]
|
|
||||||
/// Removes surrounding double quotes from a string.
|
/// Removes surrounding double quotes from a string.
|
||||||
///
|
///
|
||||||
/// This macro takes an expression that evaluates to a string and returns a new `String`
|
/// This macro takes an expression that evaluates to a string and returns a new `String`
|
||||||
@@ -80,15 +79,11 @@ pub fn home_component() -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="form-container home">
|
<div class="rundbg">
|
||||||
<div class="page-header">
|
|
||||||
<h1>{ "Welcome" }</h1>
|
|
||||||
</div>
|
|
||||||
<crate::utilities::TicketCount/>
|
<crate::utilities::TicketCount/>
|
||||||
<div>
|
|
||||||
<p>{ "You are logged in as: " }</p>
|
<p>{ "You are logged in as: " }</p>
|
||||||
<p class="text-muted">{ &*name }</p>
|
<p>{ &*name }</p>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -108,12 +103,9 @@ pub fn home_component() -> Html {
|
|||||||
pub fn not_found_component() -> Html {
|
pub fn not_found_component() -> Html {
|
||||||
let message = "404 Not found";
|
let message = "404 Not found";
|
||||||
html! {
|
html! {
|
||||||
<div class="form-container">
|
<div>
|
||||||
<div class="empty-state">
|
|
||||||
<h1>{&message}</h1>
|
<h1>{&message}</h1>
|
||||||
<p>{ "The page you are looking for does not exist." }</p>
|
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
|
||||||
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -133,13 +125,17 @@ pub fn not_found_component() -> Html {
|
|||||||
#[component(PermissionDenied)]
|
#[component(PermissionDenied)]
|
||||||
pub fn denied_component() -> Html {
|
pub fn denied_component() -> Html {
|
||||||
html! {
|
html! {
|
||||||
<div class="form-container">
|
<div>
|
||||||
<div class="empty-state">
|
<h1>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</h1>
|
||||||
<h1>{ "Access Denied" }</h1>
|
<h3>{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</h3>
|
||||||
<p>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</p>
|
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
|
||||||
<p class="text-muted">{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</p>
|
|
||||||
<Link<crate::Route> to={crate::Route::Home}>{ "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" />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
@@ -319,6 +319,11 @@ pub fn users_menu() -> Html {
|
|||||||
/// and administrative status. It fetches the current user's details via `/api/users/current`
|
/// and administrative status. It fetches the current user's details via `/api/users/current`
|
||||||
/// to determine what menu items to display.
|
/// to determine what menu items to display.
|
||||||
///
|
///
|
||||||
|
/// # Mobile Support
|
||||||
|
/// On small screens:
|
||||||
|
/// - Slides into view from the left when `props.is_open` is `true`.
|
||||||
|
/// - Renders a close button (`✕`) in the header that emits `props.on_close`.
|
||||||
|
///
|
||||||
/// # Structure
|
/// # Structure
|
||||||
/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state.
|
/// - Wraps its content in a [`SidebarStateProvider`] to allow nested menus to manage their state.
|
||||||
/// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
|
/// - Contains a navigation (`<nav>`) element with an unordered list (`<ul>`) of menu items.
|
||||||
@@ -337,8 +342,22 @@ pub fn users_menu() -> Html {
|
|||||||
/// # Logout Functionality
|
/// # Logout Functionality
|
||||||
/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session,
|
/// The "Logout" button sends a GET request to `/api/logout`, clears the user's session,
|
||||||
/// and then redirects the user to the login page (`crate::Route::Login`).
|
/// and then redirects the user to the login page (`crate::Route::Login`).
|
||||||
|
/// Properties for the [`Sidebar`] component.
|
||||||
|
///
|
||||||
|
/// These properties enable managing and controlling the responsive mobile sidebar
|
||||||
|
/// layout and its visibility settings.
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct SidebarComponentProps {
|
||||||
|
/// A boolean flag indicating whether the mobile sidebar is currently slid open (`true`) or hidden (`false`).
|
||||||
|
#[prop_or_default]
|
||||||
|
pub is_open: bool,
|
||||||
|
/// A callback emitted when the user requests to close/hide the mobile sidebar.
|
||||||
|
#[prop_or_default]
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
#[component(Sidebar)]
|
#[component(Sidebar)]
|
||||||
pub fn sidebar() -> Html {
|
pub fn sidebar(props: &SidebarComponentProps) -> Html {
|
||||||
let is_admin = use_state(|| None::<bool>);
|
let is_admin = use_state(|| None::<bool>);
|
||||||
let navigator = use_navigator().expect("Sidebar must be used within a Router");
|
let navigator = use_navigator().expect("Sidebar must be used within a Router");
|
||||||
|
|
||||||
@@ -379,34 +398,65 @@ pub fn sidebar() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
match *is_admin {
|
match *is_admin {
|
||||||
None => html! { <div class="sidebar-loading">{ "Loading..." }</div> },
|
None => html! { <div class="sidebar-loading">{ "Lade..." }</div> },
|
||||||
|
|
||||||
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
|
// Non-admin: render a condensed user sidebar (no diagnostics, limited links)
|
||||||
Some(false) => html! {
|
Some(false) => {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
html! {
|
||||||
<SidebarStateProvider>
|
<SidebarStateProvider>
|
||||||
<nav class="sidebar user">
|
<nav class={if props.is_open { "sidebar user open" } else { "sidebar user" }}>
|
||||||
<ul>
|
<ul>
|
||||||
<Link<crate::Route> to={crate::Route::Home}>{ "" }</Link<crate::Route>>
|
<li class="sidebar-header">
|
||||||
|
<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">
|
||||||
<button
|
<button
|
||||||
class="logout-button"
|
class="logout-button"
|
||||||
onclick={on_logout.clone()}
|
onclick={on_logout.clone()}
|
||||||
>
|
>
|
||||||
{ "Logout" }
|
{ "Abmelden" }
|
||||||
</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) => html! {
|
Some(true) => {
|
||||||
|
let on_close = props.on_close.clone();
|
||||||
|
html! {
|
||||||
<SidebarStateProvider>
|
<SidebarStateProvider>
|
||||||
<nav class="sidebar admin">
|
<nav class={if props.is_open { "sidebar admin open" } else { "sidebar admin" }}>
|
||||||
<ul>
|
<ul>
|
||||||
<Link<crate::Route> to={crate::Route::Home}>{ "" }</Link<crate::Route>>
|
<li class="sidebar-header">
|
||||||
|
<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>>
|
||||||
@@ -416,12 +466,13 @@ pub fn sidebar() -> Html {
|
|||||||
class="logout-button"
|
class="logout-button"
|
||||||
onclick={on_logout.clone()}
|
onclick={on_logout.clone()}
|
||||||
>
|
>
|
||||||
{ "Logout" }
|
{ "Abmelden" }
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
</SidebarStateProvider>
|
</SidebarStateProvider>
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -287,17 +287,15 @@ pub fn submit_ticket_component() -> Html {
|
|||||||
let room_valid = (*room).is_some();
|
let room_valid = (*room).is_some();
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="form-container">
|
<form class="rundbg" {onsubmit}>
|
||||||
<div class="page-header">
|
|
||||||
<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:" }
|
||||||
<textarea value={(*description).clone()} oninput={description_change}/>
|
<input type="text" 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>
|
||||||
@@ -308,32 +306,33 @@ 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="alert error">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K8, D1)" }</p>
|
<p class="warning" >{ "Ungültiger / nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
<br/>
|
||||||
<button type="submit">{ "Send" }</button>
|
<button type="submit">{ "Send" }</button>
|
||||||
|
|
||||||
<Link<crate::Route> to={crate::Route::AllTickets}>{ "View All Tickets" }</Link<crate::Route>>
|
<Link<crate::Route> to={crate::Route::AllTickets}>{ "Tickets ansehen" }</Link<crate::Route>>
|
||||||
|
|
||||||
{
|
{
|
||||||
if let Some(s) = &*status {
|
if let Some(s) = &*status {
|
||||||
html!{ <p class="alert success">{ s }</p> }
|
html!{ <p>{ s }</p> }
|
||||||
} else {
|
} else {
|
||||||
html!{}
|
html!{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -665,11 +664,7 @@ pub fn all_tickets_component() -> Html {
|
|||||||
html! { <p>{ format!("Error: {}", e) }</p> }
|
html! { <p>{ format!("Error: {}", e) }</p> }
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<ul class= "postits">
|
||||||
<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,17 +674,24 @@ pub fn all_tickets_component() -> Html {
|
|||||||
_ => "To-Do"
|
_ => "To-Do"
|
||||||
};
|
};
|
||||||
html! {
|
html! {
|
||||||
<li key={t.id.to_string()} class={status_class}>
|
<div class={classes!{status_class, "listbox"}}>
|
||||||
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
|
<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>>
|
||||||
<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>
|
||||||
</ul>
|
}})}
|
||||||
<div class="ticket-list-actions">
|
|
||||||
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
|
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
|
||||||
</div>
|
</ul>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -805,23 +807,24 @@ pub fn archived_tickets_component() -> Html {
|
|||||||
html! { <p>{ format!("Error: {}", e) }</p> }
|
html! { <p>{ format!("Error: {}", e) }</p> }
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<div>
|
<ul class="postits">
|
||||||
<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>
|
<div class="listbox">
|
||||||
|
|
||||||
<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) }</h3></Link<crate::Route>>
|
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{} - #{}", t.betreff, t.id) }</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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-38
@@ -230,11 +230,7 @@ pub fn register_component() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="form-container">
|
<form {onsubmit} class="rundbg">
|
||||||
<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>
|
||||||
@@ -252,7 +248,6 @@ pub fn register_component() -> Html {
|
|||||||
</label>
|
</label>
|
||||||
<button type="submit">{ "Bestätigen" }</button>
|
<button type="submit">{ "Bestätigen" }</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,12 +336,9 @@ pub fn login_component() -> Html {
|
|||||||
};
|
};
|
||||||
|
|
||||||
html! {
|
html! {
|
||||||
<main class="content">
|
<div>
|
||||||
<div class="form-container">
|
<h1 class="headline">{ "Anmeldung" }</h1>
|
||||||
<div class="page-header">
|
<form {onsubmit} class="rundbg login">
|
||||||
<h1>{ "Login" }</h1>
|
|
||||||
</div>
|
|
||||||
<form {onsubmit}>
|
|
||||||
<input
|
<input
|
||||||
placeholder="username"
|
placeholder="username"
|
||||||
value={(*username).clone()}
|
value={(*username).clone()}
|
||||||
@@ -365,10 +357,9 @@ pub fn login_component() -> Html {
|
|||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
<button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
|
<button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
|
||||||
if !error.is_empty() { <p class="alert error">{(*error).clone()}</p> }
|
if !error.is_empty() { <p style="color:red">{(*error).clone()}</p> }
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,37 +428,18 @@ pub fn all_users_component() -> Html {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if *loading {
|
if *loading {
|
||||||
html! {
|
html! {<p>{ "Loading" }</p>}
|
||||||
<div class="form-container">
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>{ "All Users" }</h1>
|
|
||||||
</div>
|
|
||||||
<p>{ "Loading..." }</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else if let Some(e) = &*error {
|
} else if let Some(e) = &*error {
|
||||||
html! {
|
html! { <p>{ format!("Error: {}", e) }</p> }
|
||||||
<div class="form-container">
|
|
||||||
<div class="page-header">
|
|
||||||
<h1>{ "All Users" }</h1>
|
|
||||||
</div>
|
|
||||||
<p class="alert error">{ format!("Error: {}", e) }</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
html! {
|
html! {
|
||||||
<div class="form-container">
|
<ul class="postits">
|
||||||
<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()}>
|
<li key={t.id.to_string()} class="listbox">
|
||||||
<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, t.id) }</h3></Link<crate::Route>>
|
||||||
</li>
|
</li>
|
||||||
})}
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -602,6 +574,7 @@ pub fn user_by_id_component(props: &UserProps) -> Html {
|
|||||||
let last_name = (*last_name).clone();
|
let last_name = (*last_name).clone();
|
||||||
let username = (*username).clone();
|
let username = (*username).clone();
|
||||||
let make_admin = *make_admin;
|
let make_admin = *make_admin;
|
||||||
|
let new_pwd_state = new_pwd.clone();
|
||||||
let new_pwd = (*new_pwd).clone();
|
let new_pwd = (*new_pwd).clone();
|
||||||
|
|
||||||
saving.set(true);
|
saving.set(true);
|
||||||
@@ -636,6 +609,7 @@ 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) => {
|
||||||
|
|||||||
+11
-149
@@ -6,7 +6,6 @@ use serde::Deserialize;
|
|||||||
use wasm_bindgen_futures::spawn_local;
|
use wasm_bindgen_futures::spawn_local;
|
||||||
use yew::prelude::*;
|
use yew::prelude::*;
|
||||||
|
|
||||||
use crate::dequote;
|
|
||||||
use crate::pages::ticket::{ActiveUser, Ticket};
|
use crate::pages::ticket::{ActiveUser, Ticket};
|
||||||
|
|
||||||
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
|
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
|
||||||
@@ -21,23 +20,6 @@ use crate::pages::ticket::{ActiveUser, Ticket};
|
|||||||
struct TicketPartial {
|
struct TicketPartial {
|
||||||
date: DateTime<Utc>,
|
date: DateTime<Utc>,
|
||||||
room: i16,
|
room: i16,
|
||||||
user_id: i16,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A partial representation of a user, containing only the fields necessary for statistical analysis.
|
|
||||||
///
|
|
||||||
/// This struct is used to efficiently retrieve and process user data for diagnostics
|
|
||||||
/// without fetching the full user details.
|
|
||||||
///
|
|
||||||
/// # Fields
|
|
||||||
/// - `id`: The unique identifier of the user.
|
|
||||||
/// - `first_name`: The first name of the user.
|
|
||||||
/// - `last_name`: The last name of the user.
|
|
||||||
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
|
||||||
struct UserPartial {
|
|
||||||
id: i16,
|
|
||||||
first_name: String,
|
|
||||||
last_name: String,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Properties for components that display room-wise ticket totals.
|
/// Properties for components that display room-wise ticket totals.
|
||||||
@@ -52,20 +34,6 @@ struct RoomTotalsProps {
|
|||||||
tickets: Vec<TicketPartial>,
|
tickets: Vec<TicketPartial>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Properties for components that display user-wise ticket totals.
|
|
||||||
///
|
|
||||||
/// This struct passes a list of partial user data and partial ticket data to a component
|
|
||||||
/// responsible for calculating and visualizing ticket distribution per user.
|
|
||||||
///
|
|
||||||
/// # Fields
|
|
||||||
/// - `users`: A vector of [`UserPartial`] containing user data relevant for user totals.
|
|
||||||
/// - `tickets`: A vector of [`TicketPartial`] containing ticket data relevant for user totals.
|
|
||||||
#[derive(Properties, PartialEq)]
|
|
||||||
struct UserTotalProps {
|
|
||||||
users: Vec<UserPartial>,
|
|
||||||
tickets: Vec<TicketPartial>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Converts a `chrono::DateTime<Utc>` object's weekday to a 0-indexed integer.
|
/// Converts a `chrono::DateTime<Utc>` object's weekday to a 0-indexed integer.
|
||||||
///
|
///
|
||||||
/// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7)
|
/// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7)
|
||||||
@@ -320,9 +288,9 @@ pub fn ticket_count_component() -> Html {
|
|||||||
})
|
})
|
||||||
.count();
|
.count();
|
||||||
html! {
|
html! {
|
||||||
<div class="open-tickets">
|
<div>
|
||||||
<h2 class="left">{ "Offene Tickets" }</h2>
|
<h2>{ "Offene Tickets" }</h2>
|
||||||
<h4 class="ticket_count center">{ count }</h4>
|
<h4>{ count }</h4>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,13 +326,11 @@ 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();
|
||||||
|
|
||||||
@@ -388,24 +354,6 @@ pub fn submit_stats_component() -> Html {
|
|||||||
}
|
}
|
||||||
Err(err) => error.set(Some(format!("Network error: {}", err))),
|
Err(err) => error.set(Some(format!("Network error: {}", err))),
|
||||||
}
|
}
|
||||||
|
|
||||||
match Request::get("/api/users").send().await {
|
|
||||||
Ok(response) if response.status() == 200 => {
|
|
||||||
match response.json::<Vec<UserPartial>>().await {
|
|
||||||
Ok(u) => users.set(u),
|
|
||||||
Err(e) => error.set(Some(format!("users parse error: {}", e))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(response) => {
|
|
||||||
if let Ok(text) = response.text().await {
|
|
||||||
error.set(Some(text));
|
|
||||||
} else {
|
|
||||||
error.set(Some(format!("users status {}", response.status())));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(err) => error.set(Some(format!("users network error: {}", err))),
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.set(false);
|
loading.set(false);
|
||||||
});
|
});
|
||||||
|| ()
|
|| ()
|
||||||
@@ -467,17 +415,8 @@ 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>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,96 +457,19 @@ fn room_total_component(props: &RoomTotalsProps) -> Html {
|
|||||||
|
|
||||||
html! {
|
html! {
|
||||||
<div class="diagnostics-section">
|
<div class="diagnostics-section">
|
||||||
<div class="chart">
|
<h3>{ "Tickets pro Raum" }</h3>
|
||||||
|
<div class="room-chart">
|
||||||
{ for totals_vec.into_iter().map(|(room, count)| {
|
{ for totals_vec.into_iter().map(|(room, count)| {
|
||||||
let label = parse_room(room);
|
let label = parse_room(room);
|
||||||
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
|
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
|
||||||
html! {
|
html! {
|
||||||
<div class="bar-item">
|
<div class="room-bar-item">
|
||||||
<div class="diagnostics-header">
|
<div class="room-header">
|
||||||
<span class="diagnostics-label">{ label }</span>
|
<span class="room-label">{ label }</span>
|
||||||
<span class="diagnostics-count">{ count }</span>
|
<span class="room-count">{ count }</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bar-container">
|
<div class="room-bar-container">
|
||||||
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
|
<div class="room-bar" style={format!("width: {}%;", bar_width_percent)}>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}) }
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A component that displays the total number of tickets per user.
|
|
||||||
///
|
|
||||||
/// This component takes lists of [`UserPartial`] and [`TicketPartial`] items to calculate
|
|
||||||
/// and display the total number of tickets submitted by each user.
|
|
||||||
/// The results are presented in a sorted list with a bar chart visualization.
|
|
||||||
///
|
|
||||||
/// # Props
|
|
||||||
/// - `users`: A `Vec<UserPartial>` containing partial user data.
|
|
||||||
/// - `tickets`: A `Vec<TicketPartial>` containing partial ticket data for analysis.
|
|
||||||
///
|
|
||||||
/// # Functionality
|
|
||||||
/// - **Maps User Names**: Creates a map from user IDs to their first and last names for display.
|
|
||||||
/// - **Calculates Totals**: Aggregates ticket counts for each user.
|
|
||||||
/// - **Sorts Results**: Displays users sorted by their ticket count in descending order.
|
|
||||||
/// - **Visualizes Data**: Renders a bar chart where the width of each bar is proportional
|
|
||||||
/// to the ticket count for that user, relative to the user with the maximum tickets.
|
|
||||||
/// - **Name Formatting**: Uses the [`dequote!`] macro to clean up user names before display.
|
|
||||||
///
|
|
||||||
/// # Example
|
|
||||||
/// ```rust
|
|
||||||
/// html! {
|
|
||||||
/// <UserTotal users={my_user_partials} tickets={my_ticket_partials} />
|
|
||||||
/// }
|
|
||||||
/// ```
|
|
||||||
#[component(UserTotal)]
|
|
||||||
fn user_total_component(props: &UserTotalProps) -> Html {
|
|
||||||
let name_map: HashMap<i16, (String, String)> = props
|
|
||||||
.users
|
|
||||||
.iter()
|
|
||||||
.map(|u| (u.id, (u.first_name.clone(), u.last_name.clone())))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let mut counts: HashMap<i16, usize> = HashMap::new();
|
|
||||||
for t in &props.tickets {
|
|
||||||
*counts.entry(t.user_id).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut totals_vec: Vec<(i16, String, String, usize)> = name_map
|
|
||||||
.into_iter()
|
|
||||||
.map(|(id, (fname, lname))| {
|
|
||||||
let c = counts.get(&id).cloned().unwrap_or(0);
|
|
||||||
(id, fname, lname, c)
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
totals_vec.sort_by(|a, b| b.3.cmp(&a.3));
|
|
||||||
|
|
||||||
let max_count = totals_vec
|
|
||||||
.iter()
|
|
||||||
.map(|(_, _, _, c)| *c)
|
|
||||||
.max()
|
|
||||||
.unwrap_or(1)
|
|
||||||
.max(1);
|
|
||||||
|
|
||||||
html! {
|
|
||||||
<div class="diagnostics-section">
|
|
||||||
<div class="chart">
|
|
||||||
{ for totals_vec.into_iter().map(|(_id, fname, lname, count)| {
|
|
||||||
let label = format!("{} {}", dequote!(fname), dequote!(lname));
|
|
||||||
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
|
|
||||||
html! {
|
|
||||||
<div class="bar-item">
|
|
||||||
<div class="diagnostics-header">
|
|
||||||
<span class="diagnostics-label">{ label }</span>
|
|
||||||
<span class="diagnostics-count">{ count }</span>
|
|
||||||
</div>
|
|
||||||
<div class="bar-container">
|
|
||||||
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,8 @@
|
|||||||
@use "variables" as *;
|
@use "variables" as *;
|
||||||
|
|
||||||
@mixin card {
|
@mixin card {
|
||||||
background: $color-container;
|
backgroud: #fff;
|
||||||
border-radius: $border-radius;
|
border-radius: &border-radius;
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: $color-container-dark;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
.u-hidden { display: none !important; }
|
.u-hidden { display: none !important; }
|
||||||
.u-gap-sm { gap: $spacing-sm; }
|
.u-gap-sm { gap: $spacing-sm; }
|
||||||
.text-muted { color: $color-muted; }
|
.text-muted { color: $color-muted; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,19 @@
|
|||||||
// Color Palette (Reference Style)
|
:root{
|
||||||
$color-bg: #f0f2f5;
|
$color-bg: #ffffff;
|
||||||
$color-bg-dark: #121212;
|
$color-sidebar: #570000;
|
||||||
$color-container: #ffffff;
|
$color-accent: #2563eb;
|
||||||
$color-container-dark: #333333;
|
$color-muted: #6b7280;
|
||||||
$color-sidebar: #0f172a;
|
$spacing-sm: 8px;
|
||||||
$color-primary: #2b79c2;
|
$spacing-md: 16px;
|
||||||
$color-primary-hover: #1d5fa0;
|
$border-radius: 6px;
|
||||||
$color-accent: #2b79c2;
|
$rundbgbackgroundcolor: #a0a0a0
|
||||||
$color-muted: #6b7280;
|
}
|
||||||
$color-text: #111827;
|
|
||||||
$color-text-dark: #e2e2e2;
|
|
||||||
|
|
||||||
// Status Colors
|
@media (prefers-color-scheme: dark) {
|
||||||
$color-status-todo: #ffcccc;
|
:root {
|
||||||
$color-status-todo-text: #a00;
|
$color-bg: #000000;
|
||||||
$color-status-inprogress: #fff3cd;
|
$color-sidebar: #f3ff73;
|
||||||
$color-status-inprogress-text: #856404;
|
|
||||||
$color-status-done: #d4edda;
|
|
||||||
$color-status-done-text: #155724;
|
|
||||||
$color-status-archived: #e0e0e0;
|
|
||||||
$color-status-archived-text: #666666;
|
|
||||||
|
|
||||||
// Action Colors
|
|
||||||
$color-logout: #ff4d4d;
|
|
||||||
$color-logout-hover: #e60000;
|
|
||||||
|
|
||||||
// Spacing
|
|
||||||
$spacing-sm: 8px;
|
|
||||||
$spacing-md: 16px;
|
|
||||||
$spacing-lg: 2rem;
|
|
||||||
$border-radius: 0.5rem;
|
|
||||||
$border-radius-lg: 10px;
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
@use "../variables" as *;
|
|
||||||
|
|
||||||
.ticket-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
margin-bottom: $spacing-lg;
|
|
||||||
|
|
||||||
li {
|
|
||||||
border-left: 4px solid;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-radius: $border-radius-lg;
|
|
||||||
background: $color-container;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: $color-container-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
color: $color-primary;
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: $color-primary;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: color 0.2s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: $color-primary-hover;
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.3rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.To-Do {
|
|
||||||
border-left-color: $color-status-todo-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.InProgress {
|
|
||||||
border-left-color: $color-status-inprogress-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Completed {
|
|
||||||
border-left-color: $color-status-done-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Archived {
|
|
||||||
border-left-color: $color-status-archived-text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-list-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $spacing-md;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-top: $spacing-lg;
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
background: $color-primary;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-primary-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
@use "../variables" as *;
|
|
||||||
|
|
||||||
.user-list {
|
|
||||||
list-style: none;
|
|
||||||
padding-left: 0;
|
|
||||||
margin: 0;
|
|
||||||
|
|
||||||
li {
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,18 +8,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.diagnostics-section {
|
.diagnostics-section {
|
||||||
background-color: $color-container;
|
background-color: $color-bg;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius;
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background-color: $color-container-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin: 0;
|
margin: 0 0 $spacing-md 0;
|
||||||
color: $color-primary;
|
color: #1f2937;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -31,13 +27,7 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius;
|
||||||
background-color: $color-container;
|
background-color: #f9fafb;
|
||||||
margin: 16px 0px;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background-color: $color-container-dark;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.weekday-bars {
|
.weekday-bars {
|
||||||
@@ -53,7 +43,6 @@
|
|||||||
.weekday-bar {
|
.weekday-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: flex-end;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
@@ -61,7 +50,7 @@
|
|||||||
|
|
||||||
.bar {
|
.bar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: $color-primary;
|
background-color: $color-accent;
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
@@ -87,12 +76,8 @@
|
|||||||
.day {
|
.day {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $color-text;
|
color: #1f2937;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: $color-text-dark;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
@@ -102,116 +87,51 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart {
|
.room-chart {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
border: 1px solid #e5e7eb;
|
border: 1px solid #e5e7eb;
|
||||||
border-radius: $border-radius;
|
border-radius: $border-radius;
|
||||||
background-color: $color-container;
|
background-color: #f9fafb;
|
||||||
width: 100%;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background-color: $color-container-dark;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-item {
|
.room-bar-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
.diagnostics-header {
|
.room-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.diagnostics-label {
|
.room-label {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: $color-text;
|
color: #1f2937;
|
||||||
min-width: 50px;
|
min-width: 50px;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: $color-text-dark;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagnostics-count {
|
.room-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: $color-muted;
|
color: $color-muted;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.bar-container {
|
.room-bar-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
background-color: #e5e7eb;
|
background-color: #e5e7eb;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.room-bar {
|
||||||
background-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bar {
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: $color-primary;
|
background-color: #570000;
|
||||||
transition: width 0.3s ease;
|
transition: width 0.3s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.diagnostics-row {
|
|
||||||
display: flex;
|
|
||||||
gap: 24px;
|
|
||||||
align-items: flex-start;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.diagnostics-column {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
width: 48%;
|
|
||||||
min-width: 280px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.open-tickets{
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0 1rem;
|
|
||||||
box-sizing: border-box;
|
|
||||||
min-height: 60px;
|
|
||||||
|
|
||||||
.left {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-shrink: 0;
|
|
||||||
max-width: 30%;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.center {
|
|
||||||
position: absolute;
|
|
||||||
left: 50%;
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.home {
|
|
||||||
.ticket_count {
|
|
||||||
font-size: xxx-large;
|
|
||||||
height: 115px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
@use "../variables" as *;
|
|
||||||
|
|
||||||
.form-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-lg;
|
|
||||||
margin-bottom: $spacing-lg;
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-md;
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
font-weight: 500;
|
|
||||||
color: $color-text;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: $color-text-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea, select {
|
|
||||||
padding: $spacing-md;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background-color: #6b6b6b;
|
|
||||||
color: $color-text-dark;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $color-primary;
|
|
||||||
box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
min-height: 120px;
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
align-self: flex-start;
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
background-color: $color-primary;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-primary-hover;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
background: $color-primary;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
text-align: center;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
align-self: flex-start;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-primary-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ul {
|
|
||||||
list-style: none;
|
|
||||||
padding-left: 0;
|
|
||||||
|
|
||||||
li h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.headline {
|
||||||
|
font-size: 40px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
color: #570000;
|
||||||
|
margin-top: 150px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
.csg-icon {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
z-index: 9999;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
max-width: 60px;
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
@use "../variables" as *;
|
|
||||||
|
|
||||||
.login-btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 12px 400px;
|
|
||||||
background: $color-primary;
|
|
||||||
color: white;
|
|
||||||
border-radius: $border-radius-lg;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1rem;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $color-primary-hover;
|
|
||||||
transform: scale(1.05);
|
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.logout-btn {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 10px 20px;
|
|
||||||
background: $color-logout;
|
|
||||||
color: white;
|
|
||||||
border-radius: $border-radius-lg;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: bold;
|
|
||||||
transition: 0.2s ease-in-out;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: $color-logout-hover;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
@use "../variables" as *;
|
|
||||||
|
|
||||||
.page-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: $spacing-lg;
|
|
||||||
padding-bottom: $spacing-md;
|
|
||||||
border-bottom: 2px solid #e5e7eb;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
border-bottom-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin: 0;
|
|
||||||
color: $color-primary;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $spacing-md;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
a, button {
|
|
||||||
display: inline-block;
|
|
||||||
padding: $spacing-md;
|
|
||||||
background: $color-primary;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-primary-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input[type="checkbox"] {
|
|
||||||
width: fit-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-list {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
|
||||||
gap: $spacing-lg;
|
|
||||||
margin-top: $spacing-lg;
|
|
||||||
|
|
||||||
.grid-item {
|
|
||||||
background: $color-container;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
padding: $spacing-md;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
transition: box-shadow 0.2s ease, transform 0.2s ease;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: $color-container-dark;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.15);
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
color: $color-primary;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: $spacing-sm 0;
|
|
||||||
color: $color-muted;
|
|
||||||
|
|
||||||
&:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: $spacing-md;
|
|
||||||
margin-top: $spacing-md;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
|
|
||||||
a, button {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 100px;
|
|
||||||
padding: $spacing-md;
|
|
||||||
border: none;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
text-decoration: none;
|
|
||||||
text-align: center;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
a.view, button.view {
|
|
||||||
background: $color-primary;
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-primary-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.edit, button.edit {
|
|
||||||
background: #f59e0b;
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #d97706;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
a.delete, button.delete {
|
|
||||||
background: $color-logout;
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-logout-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
overflow-x: auto;
|
|
||||||
margin-top: $spacing-lg;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
|
||||||
background: $color-container;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: $color-container-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead {
|
|
||||||
background: $color-primary;
|
|
||||||
color: white;
|
|
||||||
|
|
||||||
th {
|
|
||||||
padding: $spacing-md;
|
|
||||||
text-align: left;
|
|
||||||
font-weight: 600;
|
|
||||||
border-bottom: 2px solid darken($color-primary, 10%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tbody {
|
|
||||||
tr {
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
transition: background-color 0.2s ease;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
border-bottom-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background-color: #444;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
td {
|
|
||||||
padding: $spacing-md;
|
|
||||||
color: $color-text;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: $color-text-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert {
|
|
||||||
padding: $spacing-md;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
margin-bottom: $spacing-lg;
|
|
||||||
border-left: 4px solid;
|
|
||||||
|
|
||||||
&.success {
|
|
||||||
background: $color-status-done;
|
|
||||||
color: $color-status-done-text;
|
|
||||||
border-left-color: $color-status-done-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.error {
|
|
||||||
background: $color-status-todo;
|
|
||||||
color: $color-status-todo-text;
|
|
||||||
border-left-color: $color-status-todo-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.warning {
|
|
||||||
background: $color-status-inprogress;
|
|
||||||
color: $color-status-inprogress-text;
|
|
||||||
border-left-color: $color-status-inprogress-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.info {
|
|
||||||
background: #dbeafe;
|
|
||||||
color: #1e40af;
|
|
||||||
border-left-color: #1e40af;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: #1e3a8a;
|
|
||||||
color: #93c5fd;
|
|
||||||
border-left-color: #93c5fd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-spinner {
|
|
||||||
display: inline-block;
|
|
||||||
width: 20px;
|
|
||||||
height: 20px;
|
|
||||||
border: 3px solid #e5e7eb;
|
|
||||||
border-top-color: $color-primary;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 0.8s linear infinite;
|
|
||||||
|
|
||||||
@keyframes spin {
|
|
||||||
to { transform: rotate(360deg); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: $spacing-lg;
|
|
||||||
color: $color-muted;
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
background: $color-primary;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-primary-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
@use "../variables" as *;
|
|
||||||
|
|
||||||
.setup-container {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 100vh;
|
|
||||||
background: $color-bg;
|
|
||||||
padding: $spacing-lg;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: $color-bg-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.setup-box {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 400px;
|
|
||||||
background: $color-container;
|
|
||||||
padding: $spacing-lg;
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: $color-container-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
color: $color-primary;
|
|
||||||
text-align: center;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
text-align: center;
|
|
||||||
color: $color-muted;
|
|
||||||
margin-bottom: $spacing-lg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.setup-form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-md;
|
|
||||||
|
|
||||||
.form-group {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
|
|
||||||
label {
|
|
||||||
font-weight: 500;
|
|
||||||
color: $color-text;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
color: $color-text-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input {
|
|
||||||
padding: $spacing-md;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-size: 1rem;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background-color: #6b6b6b;
|
|
||||||
color: $color-text-dark;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: $color-primary;
|
|
||||||
box-shadow: 0 0 0 2px rgba($color-primary, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: $spacing-md;
|
|
||||||
background-color: $color-primary;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
margin-top: $spacing-md;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: $color-primary-hover;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
transform: scale(0.98);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.success-message {
|
|
||||||
background: $color-status-done;
|
|
||||||
color: $color-status-done-text;
|
|
||||||
padding: $spacing-md;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
border-left: 4px solid $color-status-done-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-message {
|
|
||||||
background: $color-status-todo;
|
|
||||||
color: $color-status-todo-text;
|
|
||||||
padding: $spacing-md;
|
|
||||||
border-radius: $border-radius;
|
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
border-left: 4px solid $color-status-todo-text;
|
|
||||||
}
|
|
||||||
@@ -49,6 +49,125 @@
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: $spacing-md;
|
||||||
|
|
||||||
|
.home {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-close {
|
||||||
|
display: none;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: background 0.2s ease-in-out;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
stroke: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
&.user { width: 220px; background: darken($color-sidebar, 6%); }
|
&.user { width: 220px; background: darken($color-sidebar, 6%); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Floating mobile menu toggle button
|
||||||
|
.mobile-menu-toggle {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: flex;
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 998;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||||
|
background-color: var(--color-container);
|
||||||
|
color: var(--color-text);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
cursor: pointer;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
stroke: var(--color-text);
|
||||||
|
transition: stroke 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overlay backdrop that dims the screen and allows dismissals on mobile
|
||||||
|
.sidebar-overlay {
|
||||||
|
display: none;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 999;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,80 +1,59 @@
|
|||||||
@use "../mixins" as *;
|
@use "../mixins" as *;
|
||||||
@use "../variables" as *;
|
@use "../variables" as *;
|
||||||
|
|
||||||
.ticket {
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
border-radius: $border-radius-lg;
|
|
||||||
background: $color-container;
|
|
||||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: $color-container-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
margin-top: 0;
|
|
||||||
color: $color-primary;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0.3rem 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.ticket-card {
|
.ticket-card {
|
||||||
@include card();
|
@include card();
|
||||||
border-left: 4px solid $color-primary;
|
border-left: 4px solid $color-accent;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
|
|
||||||
.meta { color: $color-muted; font-size: 0.9rem; }
|
.meta { color: $color-muted; font-size: 0.9rem; }
|
||||||
.title { font-weight: 600; }
|
.title { font-weight: 600; }
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.status {
|
.listbox {
|
||||||
display: inline-block;
|
max-width: auto;
|
||||||
padding: 4px 10px;
|
margin:0 0;
|
||||||
border-radius: 8px;
|
background-color: #90ff82;
|
||||||
font-size: 0.8rem;
|
background-color: $rundbgbackgroundcolor;
|
||||||
font-weight: bold;
|
padding-bottom: 96px;
|
||||||
margin-top: 8px;
|
color: white;
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: 32px;
|
||||||
|
padding-top: 96px;
|
||||||
|
border-radius: 20px;
|
||||||
|
border-color: #000000;
|
||||||
|
border-style: double;
|
||||||
|
box-shadow: 10px 10px 10px rgba(43, 43, 43, 0.8);
|
||||||
|
font-size: 26px;
|
||||||
|
list-style-type: none;
|
||||||
|
|
||||||
|
|
||||||
&.To-Do {
|
&.To-Do {
|
||||||
background: $color-status-todo;
|
background-color: #b1003b;
|
||||||
color: $color-status-todo-text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.InProgress {
|
&.InProgress {
|
||||||
background: $color-status-inprogress;
|
background-color: #fff385;
|
||||||
color: $color-status-inprogress-text;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.Done {
|
|
||||||
background: $color-status-done;
|
|
||||||
color: $color-status-done-text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.Completed {
|
&.Completed {
|
||||||
background: $color-status-done;
|
background-color: #90ff82;
|
||||||
color: $color-status-done-text;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.Archived {
|
&.Archived {
|
||||||
background: $color-status-archived;
|
background-color: #af69ee;
|
||||||
color: $color-status-archived-text;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ticket_count {
|
.postits {
|
||||||
text-align: center;
|
display: grid;
|
||||||
align-content: center;
|
grid-template-columns: auto auto auto;
|
||||||
color: #005f00;
|
gap: 8px;
|
||||||
border: 2px solid #848484;
|
margin-top: 96px;
|
||||||
border-radius: 10px;
|
}
|
||||||
height: 50px;
|
.warning {
|
||||||
width: 250px;
|
color: #570000;
|
||||||
margin: 0;
|
|
||||||
font-size: xx-large;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+89
-129
@@ -3,125 +3,39 @@
|
|||||||
@use "utilities";
|
@use "utilities";
|
||||||
@use "components/sidebar";
|
@use "components/sidebar";
|
||||||
@use "components/tickets";
|
@use "components/tickets";
|
||||||
@use "components/alltickets";
|
|
||||||
@use "components/allusers";
|
|
||||||
@use "components/forms";
|
|
||||||
@use "components/login";
|
|
||||||
@use "components/diagnostics";
|
@use "components/diagnostics";
|
||||||
@use "components/pages";
|
@use "components/frontpage";
|
||||||
@use "components/setup";
|
@use "components/home";
|
||||||
|
@use "components/icon";
|
||||||
|
|
||||||
* {
|
:root {
|
||||||
box-sizing: border-box;
|
--color-bg: #ffffff;
|
||||||
|
--color-container: #ffffff;
|
||||||
|
--color-text: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--color-bg: #121212;
|
||||||
|
--color-container: #333333;
|
||||||
|
--color-text: #e2e2e2;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: variables.$color-bg;
|
background: variables.$color-bg;
|
||||||
font-family: Arial, sans-serif;
|
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||||
color: variables.$color-text;
|
color: #111827;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: variables.$color-bg-dark;
|
|
||||||
color: variables.$color-text-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input, textarea, select {
|
|
||||||
width: 100%;
|
|
||||||
padding: variables.$spacing-md;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: variables.$border-radius;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
box-sizing: border-box;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background-color: #6b6b6b;
|
|
||||||
color: variables.$color-text-dark;
|
|
||||||
border-color: #555;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
padding: variables.$spacing-md;
|
|
||||||
font-size: 1rem;
|
|
||||||
border-radius: variables.$border-radius;
|
|
||||||
background-color: variables.$color-primary;
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
margin-bottom: 15px;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: variables.$color-primary-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: variables.$spacing-md;
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: variables.$spacing-sm;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
display: inline-block;
|
|
||||||
padding: variables.$spacing-md;
|
|
||||||
background: variables.$color-primary;
|
|
||||||
color: white;
|
|
||||||
text-decoration: none;
|
|
||||||
border-radius: variables.$border-radius;
|
|
||||||
text-align: center;
|
|
||||||
transition: background-color 0.2s ease-in-out;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: variables.$color-primary-hover;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
max-width: 900px;
|
|
||||||
margin: auto;
|
|
||||||
background: variables.$color-container;
|
|
||||||
padding: variables.$spacing-lg;
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: variables.$color-container-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-with-image {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-with-image img {
|
|
||||||
height: 60px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin { display: flex; }
|
.admin { display: flex; }
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
margin: 0;
|
overflow: hidden;
|
||||||
padding: 0;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -132,44 +46,90 @@ form {
|
|||||||
top: 0;
|
top: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
margin-left: 260px;
|
margin-left: 260px;
|
||||||
padding: variables.$spacing-lg;
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
> :first-child {
|
padding: variables.$spacing-md;
|
||||||
max-width: 900px;
|
box-sizing: border-box;
|
||||||
margin: 0 auto;
|
|
||||||
background: variables.$color-container;
|
|
||||||
padding: variables.$spacing-lg;
|
|
||||||
border-radius: 1rem;
|
|
||||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
background: variables.$color-container-dark;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3 {
|
|
||||||
color: variables.$color-primary;
|
|
||||||
margin-top: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
left: -260px;
|
left: -260px;
|
||||||
transition: left .3s ease-in-out;
|
transition: left .3s ease-in-out;
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.sidebar.open {
|
.sidebar.open { left: 0; }
|
||||||
left: 0;
|
.content { margin-left: 0; padding: variables.$spacing-md; box-sizing: border-box; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.rundbg {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 40%;
|
||||||
|
margin: 0 auto;
|
||||||
|
background-color: variables.$rundbgbackgroundcolor;
|
||||||
|
padding-bottom: 96px;
|
||||||
|
padding-left: 32px;
|
||||||
|
padding-right: 32px;
|
||||||
|
padding-top: 96px;
|
||||||
|
border-radius: 20px;
|
||||||
|
gap: 8px;
|
||||||
|
border-color: #000000;
|
||||||
|
border-style: double;
|
||||||
|
box-shadow: 20px 20px 10px rgb(43, 43, 43);
|
||||||
|
font-size: 26px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.content {
|
|
||||||
margin-left: 0;
|
input, select, button, a {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 16px;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
box-shadow: #6d6d6d;
|
||||||
|
box-shadow: 6px 6px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #000000;
|
||||||
|
text-align: center;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.login {
|
||||||
|
margin-top: 96px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.setup-form input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
#![doc = include_str!("../README.md")]
|
#![doc = include_str!("../README.cargo.md")]
|
||||||
|
|||||||
Reference in New Issue
Block a user