Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf0197adae | |||
| c6a4a24fb6 | |||
| 52387f7333 |
@@ -57,8 +57,8 @@ pub fn encode_token(header: &Header, id: String, key: &EncodingKey) -> String {
|
||||
/// - `key`: The `DecodingKey` used to verify the JWT's signature.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK`: If the token is successfully decoded and verified, returns the extracted `Claims`.
|
||||
/// - `401 UNAUTHORIZED`: If the token is invalid, expired, or cannot be decoded,
|
||||
/// - `Ok(Claims)`: If the token is successfully decoded and verified, returns the extracted `Claims`.
|
||||
/// - `Err((StatusCode, Json<Error>))`: If the token is invalid, expired, or cannot be decoded,
|
||||
/// returns an `UNAUTHORIZED` status code along with a JSON error message.
|
||||
pub fn decode_token(token: String, key: &DecodingKey) -> Result<Claims, (StatusCode, Json<Error>)> {
|
||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||
|
||||
@@ -26,13 +26,13 @@ use crate::{AppState, cookie::jwt::decode_token, handlers::auth::filter_user, mo
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `cookies`: The `CookieJar` from the request, used to extract the `token` cookie.
|
||||
/// - `request`: The incoming HTTP request, which will have user data injected into its extensions.
|
||||
/// - `State(data)`: Application state containing `AppState` for database access and `token_secret`.
|
||||
/// - `mut request`: The incoming HTTP request, which will have user data injected into its extensions.
|
||||
/// - `next`: The next middleware or handler in the chain.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK`: If validation succeeds, the request proceeds to the next handler.
|
||||
/// - `401 UNAUTHORIZED`: If validating the user fails.
|
||||
/// - `500 INTERNAL SERVER ERROR`: If the database query fails
|
||||
/// - `Ok(impl IntoResponse)`: If validation succeeds, the request proceeds to the next handler.
|
||||
/// - `Err((StatusCode, Json<serde_json::Value>))`: An error response if validation fails.
|
||||
pub async fn validate_token(
|
||||
cookies: CookieJar,
|
||||
State(data): State<Arc<AppState>>,
|
||||
@@ -119,13 +119,14 @@ pub async fn validate_token(
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `cookies`: The `CookieJar` from the request.
|
||||
/// - `request`: The incoming HTTP request, which will have admin user data injected.
|
||||
/// - `State(data)`: Application state containing `AppState`.
|
||||
/// - `mut request`: The incoming HTTP request, which will have admin user data injected.
|
||||
/// - `next`: The next middleware or handler in the chain.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK`: If validation and admin check succeed, the request proceeds.
|
||||
/// - `401 UNAUTHORIZED`: An error response if validation fails or the user is not an admin.
|
||||
/// - `500 INTERNAL SERVER ERROR`: If the databse query fails
|
||||
/// - `Ok(impl IntoResponse)`: If validation and admin check succeed, the request proceeds.
|
||||
/// - `Err((StatusCode, Json<serde_json::Value>))`: An error response if validation fails
|
||||
/// or the user is not an admin.
|
||||
pub async fn validate_admin(
|
||||
cookies: CookieJar,
|
||||
State(data): State<Arc<AppState>>,
|
||||
|
||||
@@ -26,7 +26,7 @@ use crate::{
|
||||
/// before being stored. Only administrators can create new users.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `request`: Json with [UserCreateScheme] as it's format
|
||||
/// - `request`: User creation details including first/last name, username, admin flag, and password
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` on successful user creation
|
||||
@@ -110,10 +110,10 @@ pub async fn create_user(
|
||||
/// The token is valid for 1 hour.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `request`: Login credentials in Json format using the [LoginScheme]
|
||||
/// - `request`: Login credentials (username, password)
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with JSON containing token and [FilteredUser] info
|
||||
/// - `200 OK` with JSON containing token and filtered user info
|
||||
/// - `400 Bad Request` if username not found or password invalid
|
||||
/// - `500 Internal Server Error` if database query fails
|
||||
///
|
||||
@@ -231,7 +231,7 @@ pub async fn logout() -> Result<impl IntoResponse, (StatusCode, Json<serde_json:
|
||||
/// Useful for frontends to display logged-in user info or verify authentication.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with the [FilteredUser] in Json format
|
||||
/// - `200 OK` with user data (excluding password)
|
||||
/// - Automatically returns `401 Unauthorized` if not authenticated (middleware)
|
||||
///
|
||||
/// # Example Response
|
||||
@@ -306,7 +306,7 @@ pub async fn delete_user(
|
||||
/// Password hashes are not included in the response.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with array of [FilteredUser] objects
|
||||
/// - `200 OK` with array of FilteredUser objects
|
||||
/// - `500 Internal Server Error` if database query fails
|
||||
///
|
||||
/// # Example Response
|
||||
@@ -352,11 +352,15 @@ pub async fn get_users(
|
||||
|
||||
/// Retrieves a single user's details by their ID.
|
||||
///
|
||||
/// This endpoint allows fetching a specific user's information. It returns a `FilteredUser`
|
||||
/// object, ensuring sensitive data like password hashes are not exposed.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `id`: The ID of the user to retrieve, extracted from the URL path.
|
||||
/// - `Path(id)`: The ID of the user to retrieve, extracted from the URL path.
|
||||
/// - `State(data)`: Application state containing `AppState` for database access.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with a [FilteredUser] JSON object if the user is found.
|
||||
/// - `200 OK` with a `FilteredUser` JSON object if the user is found.
|
||||
/// - `404 Not Found` if a user with the given ID does not exist.
|
||||
/// - `500 Internal Server Error` if a database query error occurs.
|
||||
///
|
||||
@@ -392,12 +396,17 @@ pub async fn get_user_by_id(
|
||||
|
||||
/// Updates an existing user's information.
|
||||
///
|
||||
/// This endpoint allows administrators to modify a user's `first_name`, `last_name`,
|
||||
/// `username`, `is_admin` status, and optionally their password. If `new_pwd` in the
|
||||
/// request body is an empty string, the user's password remains unchanged.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `id`: The ID of the user to update, extracted from the URL path.
|
||||
/// - `body`: [UserUpdateScheme] containing the fields to update.
|
||||
/// - `Path(id)`: The ID of the user to update, extracted from the URL path.
|
||||
/// - `State(data)`: Application state containing `AppState` for database access.
|
||||
/// - `Json(body)`: `UserUpdateScheme` containing the fields to update.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with the [FilteredUser] object of the updated user.
|
||||
/// - `200 OK` with the `FilteredUser` object of the updated user.
|
||||
/// - `404 Not Found` if a user with the given ID does not exist.
|
||||
/// - `500 Internal Server Error` if a database query or password hashing error occurs.
|
||||
///
|
||||
@@ -461,8 +470,11 @@ pub async fn update_user(
|
||||
|
||||
/// Checks if any administrator user exists in the system.
|
||||
///
|
||||
/// This endpoint is used during initialization to determine if the setup page should be displayed.
|
||||
/// It counts all users with `is_admin = true` in the database.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` On successful database query
|
||||
/// - `200 OK` with JSON: `{"has_admin": bool}` - Whether at least one admin exists
|
||||
/// - `500 Internal Server Error` if database query fails
|
||||
///
|
||||
/// # Example Response
|
||||
@@ -495,7 +507,7 @@ pub async fn check_admin_exists(
|
||||
/// with proper authorization.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `request`: [UserCreateScheme] as a Json value
|
||||
/// - `request`: User creation details (first_name, last_name, username, password)
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with success message if admin account created
|
||||
@@ -582,10 +594,10 @@ pub async fn setup_initial_admin(
|
||||
/// serializing User objects.
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `user`: Reference to the internal [User] struct containing password hash
|
||||
/// - `user`: Reference to the internal User struct containing password hash
|
||||
///
|
||||
/// # Returns
|
||||
/// [FilteredUser] with only safe-to-share information:
|
||||
/// FilteredUser with only safe-to-share information:
|
||||
/// - `id`: User ID
|
||||
/// - `first_name`, `last_name`: User name
|
||||
/// - `username`: Login username
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::{
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `user`: Authenticated user (extracted from JWT token)
|
||||
/// - `body`: Json with the [TicketCreateScheme] as it's format
|
||||
/// - `body`: Ticket details (category, subject, description, room)
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` on successful creation
|
||||
@@ -95,16 +95,17 @@ pub async fn delete_ticket(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Retrieves all tickets.
|
||||
/// Retrieves all non-archived tickets.
|
||||
///
|
||||
/// Returns a list of all tickets with user information denormalized for easier rendering.
|
||||
/// Returns a list of all active tickets with user information denormalized for easier rendering.
|
||||
/// Tickets are ordered by creation date (newest first).
|
||||
///
|
||||
/// # Filtering
|
||||
/// - Excludes tickets with status "Archived"
|
||||
/// - Uses LEFT JOIN to include creator information
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with array of [TicketResponse] objects
|
||||
/// - `200 OK` with array of TicketResponse objects
|
||||
/// - `500 Internal Server Error` if database query fails
|
||||
///
|
||||
/// # Example Response
|
||||
@@ -173,7 +174,7 @@ pub async fn get_tickets(
|
||||
/// - `id`: Ticket ID to retrieve
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with [TicketResponse] object
|
||||
/// - `200 OK` with TicketResponse object
|
||||
/// - `404 Not Found` if ticket doesn't exist
|
||||
/// - `500 Internal Server Error` if database error occurs
|
||||
///
|
||||
@@ -246,10 +247,10 @@ pub async fn get_ticket_by_id(
|
||||
///
|
||||
/// # Arguments
|
||||
/// - `id`: Ticket ID to update
|
||||
/// - `body`: Update payload as a Json array with [TicketUpdateScheme] as it's format
|
||||
/// - `body`: Update payload containing new status
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with updated [TicketResponse]
|
||||
/// - `200 OK` with updated TicketResponse
|
||||
/// - `500 Internal Server Error` if ticket not found or database error
|
||||
///
|
||||
/// # Typical Status Flow
|
||||
|
||||
@@ -9,53 +9,19 @@ mod models;
|
||||
/// Axum router configuration with all routes and middleware
|
||||
mod router;
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicI64, Ordering},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
Json,
|
||||
http::{
|
||||
HeaderValue, Method, StatusCode,
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
},
|
||||
use axum::http::{
|
||||
HeaderValue, Method,
|
||||
header::{ACCEPT, AUTHORIZATION, CONTENT_TYPE},
|
||||
};
|
||||
use dotenv::dotenv;
|
||||
use router::create_router;
|
||||
use serde::Serialize;
|
||||
use sqlx::{PgPool, postgres::PgPoolOptions};
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::env::Env;
|
||||
|
||||
/// A variable to count the problems fixed by a very easy solution
|
||||
static EASY_FIX_COUNT: AtomicI64 = AtomicI64::new(0);
|
||||
|
||||
/// The Response struct for the [EASY_FIX_COUNT]
|
||||
#[derive(Serialize)]
|
||||
struct CounterResp {
|
||||
value: i64,
|
||||
}
|
||||
|
||||
/// Gets the [EASY_FIX_COUNT] value
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with the value on success
|
||||
async fn get_count() -> Json<CounterResp> {
|
||||
let v = EASY_FIX_COUNT.load(Ordering::SeqCst);
|
||||
Json(CounterResp { value: v })
|
||||
}
|
||||
|
||||
/// Incremets the [EASY_FIX_COUNT] value by one
|
||||
///
|
||||
/// # Returns
|
||||
/// - `200 OK` with the new value on success
|
||||
async fn increment() -> Result<Json<CounterResp>, StatusCode> {
|
||||
let new = EASY_FIX_COUNT.fetch_add(1, Ordering::SeqCst) + 1;
|
||||
Ok(Json(CounterResp { value: new }))
|
||||
}
|
||||
|
||||
/// Shared application state passed to all route handlers.
|
||||
///
|
||||
/// Contains the database connection pool and environment configuration.
|
||||
|
||||
@@ -8,15 +8,13 @@ use axum::{
|
||||
use crate::{
|
||||
AppState,
|
||||
cookie::validation::{validate_admin, validate_token},
|
||||
get_count,
|
||||
handlers::{
|
||||
auth::{
|
||||
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id,
|
||||
get_users, login, logout, setup_initial_admin, update_user,
|
||||
check_admin_exists, create_user, delete_user, get_current_user, get_user_by_id, get_users, login, logout,
|
||||
setup_initial_admin, update_user,
|
||||
},
|
||||
ticket::{create_ticket, delete_ticket, edit_ticket, get_ticket_by_id, get_tickets},
|
||||
},
|
||||
increment,
|
||||
};
|
||||
|
||||
/// Creates the complete router with all API endpoints.
|
||||
@@ -75,7 +73,6 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
.route("/api/tickets/create", post(create_ticket))
|
||||
.route("/api/logout", get(logout))
|
||||
.route("/api/users/current", get(get_current_user))
|
||||
.route("/api/count", get(get_count).post(increment))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
validate_token,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[serve]
|
||||
# The address to serve on LAN.
|
||||
addresses = ["127.0.0.1"]#, "192.168.178.56"] #,"10.150.9.116"]
|
||||
addresses = ["127.0.0.1"] #,"10.150.9.7"]
|
||||
# The address to serve on WAN.
|
||||
# address = "0.0.0.0"
|
||||
# The port to serve on.
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
<head>
|
||||
<link data-trunk rel="rust" data-bin="bin" />
|
||||
<link data-trunk rel="scss" href="src/styles/main.scss" />
|
||||
<link data-trunk rel="icon" href="src/assets/favicon.ico" type="image/x-icon" />
|
||||
<meta charset="utf-8" />
|
||||
<title>Ticket System CSG</title>
|
||||
<title>Yew App</title>
|
||||
</head>
|
||||
|
||||
<body></body>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -91,7 +91,7 @@ fn sidebar_shell(props: &SidebarShellProps) -> Html {
|
||||
}
|
||||
}
|
||||
|
||||
/// Props for the [AdminCheckWrapper] component.
|
||||
/// Props for the AdminCheckWrapper component.
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct AdminCheckWrapperProps {
|
||||
pub children: Children,
|
||||
@@ -166,7 +166,7 @@ fn admin_check_wrapper(props: &AdminCheckWrapperProps) -> Html {
|
||||
|
||||
/// The main routing logic for the application.
|
||||
///
|
||||
/// This function takes a [Route] enum variant and returns the corresponding HTML
|
||||
/// This function takes a `Route` enum variant and returns the corresponding HTML
|
||||
/// content to be rendered. It acts as a central dispatcher for the application's
|
||||
/// navigation.
|
||||
///
|
||||
@@ -259,12 +259,12 @@ fn switch(route: Route) -> Html {
|
||||
/// The root component of the Yew application.
|
||||
///
|
||||
/// This component sets up the application's routing using `yew-router`'s
|
||||
/// `BrowserRouter` and `Switch` components. All other application content
|
||||
/// [`BrowserRouter`] and [`Switch`] components. All other application content
|
||||
/// is rendered based on the current route.
|
||||
///
|
||||
/// # Structure
|
||||
/// - `BrowserRouter`: Enables client-side routing.
|
||||
/// - `Switch`: Renders components based on the matched [Route].
|
||||
/// - [`BrowserRouter`]: Enables client-side routing.
|
||||
/// - [`Switch`]: Renders components based on the matched [`Route`].
|
||||
#[component(App)]
|
||||
pub fn app() -> Html {
|
||||
html! {
|
||||
|
||||
@@ -3,8 +3,6 @@ use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
use yew_router::prelude::*;
|
||||
|
||||
/// A macro for dequoting a Json value returned from the backend
|
||||
#[macro_export]
|
||||
macro_rules! dequote {
|
||||
($str:expr) => {
|
||||
$str.trim_matches('"').to_string()
|
||||
@@ -58,15 +56,11 @@ pub fn home_component() -> Html {
|
||||
}
|
||||
|
||||
html! {
|
||||
<div class="form-container home">
|
||||
<div class="page-header">
|
||||
<h1>{ "Welcome" }</h1>
|
||||
</div>
|
||||
<div>
|
||||
<crate::utilities::TicketCount/>
|
||||
<div>
|
||||
<p>{ "You are logged in as: " }</p>
|
||||
<p class="text-muted">{ &*name }</p>
|
||||
</div>
|
||||
|
||||
<p>{ "You are logged in as: " }</p>
|
||||
<p>{ &*name }</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -86,18 +80,19 @@ pub fn home_component() -> Html {
|
||||
pub fn not_found_component() -> Html {
|
||||
let message = "404 Not found";
|
||||
html! {
|
||||
<div class="form-container">
|
||||
<div class="empty-state">
|
||||
<h1>{&message}</h1>
|
||||
<p>{ "The page you are looking for does not exist." }</p>
|
||||
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
|
||||
</div>
|
||||
<div>
|
||||
<h1>{&message}</h1>
|
||||
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// A component displayed when a user attempts to access a page for which they do not have sufficient permissions.
|
||||
///
|
||||
/// It informs the user about the access restriction and provides instructions to contact
|
||||
/// a specific person ("Herr Winter") if they believe this is an error.
|
||||
/// It also includes a link to return to the home page.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust
|
||||
/// html! {
|
||||
@@ -107,13 +102,10 @@ pub fn not_found_component() -> Html {
|
||||
#[component(PermissionDenied)]
|
||||
pub fn denied_component() -> Html {
|
||||
html! {
|
||||
<div class="form-container">
|
||||
<div class="empty-state">
|
||||
<h1>{ "Access Denied" }</h1>
|
||||
<p>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</p>
|
||||
<p class="text-muted">{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</p>
|
||||
<Link<crate::Route> to={crate::Route::Home}>{ "Back to Home" }</Link<crate::Route>>
|
||||
</div>
|
||||
<div>
|
||||
<h1>{ "Sie haben nicht die benötigten Rechte um diese Seite aufzurufen" }</h1>
|
||||
<h3>{ "Wenn sie denken, dass dies ein Fehler ist kontaktieren sie Herrn Winter" }</h3>
|
||||
<Link<crate::Route> to={crate::Route::Home}>{ "Zurück zum Start" }</Link<crate::Route>>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,25 +170,10 @@ pub fn submit_ticket_component() -> Html {
|
||||
let valid_rooms: HashSet<i16> = VALID_ROOMS.iter().copied().collect();
|
||||
|
||||
{
|
||||
let message = "Bevor sie zum Support weitergeleitet werden prüfen sie ob:
|
||||
- Ob das Problem durch Neustarten gelößt wird
|
||||
- Ob sie die richtigen Anmeldedaten genutzt habem
|
||||
- Alle notwendigen Kabel eingesteckt sind
|
||||
|
||||
Wenn es funktioniert hat klicken sie bitte \"Abbrechen\""
|
||||
.to_string();
|
||||
let message = "Bevor sie zum Support weitergeleitet werden prüfen sie ob: \r\n - Ob das Problem durch Neustarten gelößt wird \r\n - Ob sie die richtigen Anmeldedaten genutzt habem \r\n - Alle notwendigen Kabel eingesteckt sind".to_string();
|
||||
use_effect(move || {
|
||||
if let Some(win) = web_sys::window() {
|
||||
let _ = if win.confirm_with_message(&message).unwrap() {
|
||||
} else {
|
||||
spawn_local(async move {
|
||||
let _ = Request::post("/api/count")
|
||||
.credentials(web_sys::RequestCredentials::Include)
|
||||
.send()
|
||||
.await;
|
||||
});
|
||||
let _ = win.location().set_href("/");
|
||||
};
|
||||
let _ = win.alert_with_message(&message);
|
||||
}
|
||||
|| ()
|
||||
});
|
||||
@@ -312,53 +297,52 @@ Wenn es funktioniert hat klicken sie bitte \"Abbrechen\""
|
||||
let room_valid = (*room).is_some();
|
||||
|
||||
html! {
|
||||
<div class="form-container">
|
||||
<div class="page-header">
|
||||
<h1>{ "Create Ticket" }</h1>
|
||||
</div>
|
||||
<form {onsubmit}>
|
||||
<label>{ "Betreff:" }
|
||||
<input type="text" value={(*betreff).clone()} oninput={betreff_change}/>
|
||||
</label>
|
||||
<label>{ "Beschreibung:" }
|
||||
<textarea value={(*description).clone()} oninput={description_change}/>
|
||||
</label>
|
||||
<label>{ "Kategorie:" }
|
||||
<select value={(*category).clone()} onchange={category_change}>
|
||||
<option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
|
||||
<option value="Internet">{ "Internet" }</option>
|
||||
<option value="iPad Koffer">{ "iPad Koffer" }</option>
|
||||
<option value="Apple TV">{ "Apple TV" }</option>
|
||||
<option value="Docu Cam">{ "Dokumenten Kamera" }</option>
|
||||
<option value="Sonstiges">{ "Sonstiges" }</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>{ "Raum:" }
|
||||
<input type="text" value={(*room_input).clone()} oninput={room_change}/>
|
||||
</label>
|
||||
{
|
||||
if !room_valid {
|
||||
html! {
|
||||
<p class="alert error">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K8, D1)" }</p>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
<form {onsubmit}>
|
||||
<label>{ "Betreff:" }
|
||||
<input type="text" value={(*betreff).clone()} oninput={betreff_change}/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>{ "Beschreibung:" }
|
||||
<input type="text" value={(*description).clone()} oninput={description_change}/>
|
||||
</label>
|
||||
<br/>
|
||||
<label>{ "Kategorie:" }
|
||||
<select value={(*category).clone()} onchange={category_change}>
|
||||
<option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
|
||||
<option value="Internet">{ "Internet" }</option>
|
||||
<option value="iPad Koffer">{ "iPad Koffer" }</option>
|
||||
<option value="Apple TV">{ "Apple TV" }</option>
|
||||
<option value="Docu Cam">{ "Dokumenten Kamera" }</option>
|
||||
<option value="Sonstiges">{ "Sonstiges" }</option>
|
||||
</select>
|
||||
</label>
|
||||
<br/>
|
||||
<label>{ "Raum:" }
|
||||
<input type="text" value={(*room_input).clone()} oninput={room_change}/>
|
||||
</label>
|
||||
{
|
||||
if !room_valid {
|
||||
html! {
|
||||
<p style="color: red;">{ "Ungültiger oder nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}
|
||||
<button type="submit">{ "Send" }</button>
|
||||
}
|
||||
<br/>
|
||||
<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 {
|
||||
html!{ <p class="alert success">{ s }</p> }
|
||||
} else {
|
||||
html!{}
|
||||
}
|
||||
{
|
||||
if let Some(s) = &*status {
|
||||
html!{ <p>{ s }</p> }
|
||||
} else {
|
||||
html!{}
|
||||
}
|
||||
}
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -690,31 +674,25 @@ pub fn all_tickets_component() -> Html {
|
||||
html! { <p>{ format!("Error: {}", e) }</p> }
|
||||
} else {
|
||||
html! {
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>{ "All Tickets" }</h1>
|
||||
</div>
|
||||
<ul class="ticket-list">
|
||||
{ for tickets.iter().filter(|t| t.status != "Archived" && (if user.is_admin { true } else if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| {
|
||||
let status_class = match t.status.as_str() {
|
||||
"ToDo" => "To-Do",
|
||||
"InProgress" => "InProgress",
|
||||
"Completed" => "Completed",
|
||||
"Archived" => "Archived",
|
||||
_ => "To-Do"
|
||||
};
|
||||
html! {
|
||||
<li key={t.id.to_string()} class={status_class}>
|
||||
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
|
||||
<p>{ &t.description }</p>
|
||||
</li>
|
||||
}
|
||||
})}
|
||||
</ul>
|
||||
<div class="ticket-list-actions">
|
||||
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
|
||||
</div>
|
||||
</div>
|
||||
<ul>
|
||||
{ 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| html! {
|
||||
<div>
|
||||
<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>{ match t.status.as_str() {
|
||||
"ToDo" => "Zu tun",
|
||||
"InProgress" => "In Bearbeitung",
|
||||
"Completed" => "Erledigt",
|
||||
"Archived" => "Archiviert",
|
||||
_ => "Ungültiger Status"
|
||||
}}</p>
|
||||
</li>
|
||||
|
||||
</div>
|
||||
})}
|
||||
<Link<crate::Route> to={crate::Route::Ticket}>{ "Zurück zur Startseite" }</Link<crate::Route>>
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -830,23 +808,24 @@ pub fn archived_tickets_component() -> Html {
|
||||
html! { <p>{ format!("Error: {}", e) }</p> }
|
||||
} else {
|
||||
html! {
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>{ "Archivierte Tickets" }</h1>
|
||||
</div>
|
||||
<ul class="ticket-list">
|
||||
{ for tickets.iter().filter(|t| t.status == "Archived" && (user.is_admin || if let Some(uid) = user.id { t.user_id == uid } else { false })).map(|t| html! {
|
||||
<div>
|
||||
<ul>
|
||||
{ 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>
|
||||
<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>{ match t.status.as_str() {
|
||||
"ToDo" => "Zu tun",
|
||||
"InProgress" => "In Bearbeitung",
|
||||
"Completed" => "Erledigt",
|
||||
"Archived" => "Archiviert",
|
||||
_ => "Ungültiger Status"
|
||||
}}</p>
|
||||
</li>
|
||||
|
||||
<li key={t.id.to_string()}>
|
||||
<Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff) }</h3></Link<crate::Route>>
|
||||
<p>{ &t.description }</p>
|
||||
</li>
|
||||
|
||||
</div>
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,29 +222,24 @@ pub fn register_component() -> Html {
|
||||
};
|
||||
|
||||
html! {
|
||||
<div class="form-container">
|
||||
<div class="page-header">
|
||||
<h1>{ "Register User" }</h1>
|
||||
</div>
|
||||
<form {onsubmit}>
|
||||
<label>{ "Vorname:" }
|
||||
<input type="text" value={(*first_name).clone()} oninput={fn_change}/>
|
||||
</label>
|
||||
<label>{ "Nachname:" }
|
||||
<input type="text" value={(*last_name).clone()} oninput={ln_change}/>
|
||||
</label>
|
||||
<label>{ "Benutzername:" }
|
||||
<input type="text" value={(*username).clone()} oninput={un_change}/>
|
||||
</label>
|
||||
<label>{ "Admin:" }
|
||||
<input type="checkbox" checked={*is_admin} onchange={admin_change}/>
|
||||
</label>
|
||||
<label>{ "Password:" }
|
||||
<input type="password" value={(*pwd).clone()} oninput={pwd_change}/>
|
||||
</label>
|
||||
<button type="submit">{ "Bestätigen" }</button>
|
||||
</form>
|
||||
</div>
|
||||
<form {onsubmit}>
|
||||
<label>{ "Vorname:" }
|
||||
<input type="text" value={(*first_name).clone()} oninput={fn_change}/>
|
||||
</label>
|
||||
<label>{ "Nachname:" }
|
||||
<input type="text" value={(*last_name).clone()} oninput={ln_change}/>
|
||||
</label>
|
||||
<label>{ "Benutzername:" }
|
||||
<input type="text" value={(*username).clone()} oninput={un_change}/>
|
||||
</label>
|
||||
<label>{ "Admin:" }
|
||||
<input type="checkbox" checked={*is_admin} onchange={admin_change}/>
|
||||
</label>
|
||||
<label>{ "Password:" }
|
||||
<input type="password" value={(*pwd).clone()} oninput={pwd_change}/>
|
||||
</label>
|
||||
<button type="submit">{ "Bestätigen" }</button>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,34 +328,30 @@ pub fn login_component() -> Html {
|
||||
};
|
||||
|
||||
html! {
|
||||
<main class="content">
|
||||
<div class="form-container">
|
||||
<div class="page-header">
|
||||
<h1>{ "Login" }</h1>
|
||||
</div>
|
||||
<form {onsubmit}>
|
||||
<input
|
||||
placeholder="username"
|
||||
value={(*username).clone()}
|
||||
oninput={Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
username.set(input.value());
|
||||
})}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="password"
|
||||
value={(*pwd).clone()}
|
||||
oninput={Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
pwd.set(input.value());
|
||||
})}
|
||||
/>
|
||||
<button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
|
||||
if !error.is_empty() { <p class="alert error">{(*error).clone()}</p> }
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
<div>
|
||||
<h1 class="headline">{ "Anmeldung" }</h1>
|
||||
<form {onsubmit}>
|
||||
<input
|
||||
placeholder="username"
|
||||
value={(*username).clone()}
|
||||
oninput={Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
username.set(input.value());
|
||||
})}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
placeholder="password"
|
||||
value={(*pwd).clone()}
|
||||
oninput={Callback::from(move |e: InputEvent| {
|
||||
let input: web_sys::HtmlInputElement = e.target_unchecked_into();
|
||||
pwd.set(input.value());
|
||||
})}
|
||||
/>
|
||||
<button type="submit" disabled={*loading}>{ if *loading { "Logging in..." } else { "Login" } }</button>
|
||||
if !error.is_empty() { <p style="color:red">{(*error).clone()}</p> }
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,37 +420,18 @@ pub fn all_users_component() -> Html {
|
||||
}
|
||||
|
||||
if *loading {
|
||||
html! {
|
||||
<div class="form-container">
|
||||
<div class="page-header">
|
||||
<h1>{ "All Users" }</h1>
|
||||
</div>
|
||||
<p>{ "Loading..." }</p>
|
||||
</div>
|
||||
}
|
||||
html! {<p>{ "Loading" }</p>}
|
||||
} else if let Some(e) = &*error {
|
||||
html! {
|
||||
<div class="form-container">
|
||||
<div class="page-header">
|
||||
<h1>{ "All Users" }</h1>
|
||||
</div>
|
||||
<p class="alert error">{ format!("Error: {}", e) }</p>
|
||||
</div>
|
||||
}
|
||||
html! { <p>{ format!("Error: {}", e) }</p> }
|
||||
} else {
|
||||
html! {
|
||||
<div class="form-container">
|
||||
<div class="page-header">
|
||||
<h1>{ "All Users" }</h1>
|
||||
</div>
|
||||
<ul class="user-list">
|
||||
{ for users.iter().map(|t| html! {
|
||||
<li key={t.id.to_string()}>
|
||||
<Link<crate::Route> to={crate::Route::UserByID{id: t.id}}><h3>{ format!("{} {}", t.first_name, t.last_name) }</h3></Link<crate::Route>>
|
||||
</li>
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<ul>
|
||||
{ for users.iter().map(|t| html! {
|
||||
<li key={t.id.to_string()}>
|
||||
<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>
|
||||
})}
|
||||
</ul>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +6,8 @@ use serde::Deserialize;
|
||||
use wasm_bindgen_futures::spawn_local;
|
||||
use yew::prelude::*;
|
||||
|
||||
use crate::dequote;
|
||||
use crate::pages::ticket::{ActiveUser, Ticket};
|
||||
|
||||
/// The response struct for the [EasyFixCount]
|
||||
#[derive(Deserialize)]
|
||||
struct CountResponse {
|
||||
value: i64,
|
||||
}
|
||||
|
||||
/// A partial representation of a ticket, containing only the fields necessary for statistical analysis.
|
||||
///
|
||||
/// This struct is used to efficiently retrieve and process ticket data for diagnostics
|
||||
@@ -23,27 +16,10 @@ struct CountResponse {
|
||||
/// # Fields
|
||||
/// - `date`: The creation date and time of the ticket in UTC.
|
||||
/// - `room`: The room number associated with the ticket.
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
||||
struct TicketPartial {
|
||||
date: DateTime<Utc>,
|
||||
room: i16,
|
||||
user_id: i16,
|
||||
}
|
||||
|
||||
/// A partial representation of a user, containing only the fields necessary for statistical analysis.
|
||||
///
|
||||
/// This struct is used to retrieve and process user data for diagnostics
|
||||
///
|
||||
/// # Fields
|
||||
/// - `id`: The users id
|
||||
/// - `first_name`: The users first name
|
||||
/// - `last_name`: The users last name
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq)]
|
||||
struct UserPartial {
|
||||
id: i16,
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
}
|
||||
|
||||
/// Properties for components that display room-wise ticket totals.
|
||||
@@ -58,12 +34,6 @@ struct RoomTotalsProps {
|
||||
tickets: Vec<TicketPartial>,
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
struct UserTotalProps {
|
||||
users: Vec<UserPartial>,
|
||||
tickets: Vec<TicketPartial>,
|
||||
}
|
||||
|
||||
/// Converts a `chrono::DateTime<Utc>` object's weekday to a 0-indexed integer.
|
||||
///
|
||||
/// This function maps `chrono::Weekday` values (where Monday is 1, Sunday is 7)
|
||||
@@ -201,7 +171,6 @@ pub fn diagnostics_component() -> Html {
|
||||
html! {
|
||||
<div>
|
||||
<TicketCount/>
|
||||
<EasyFixCount/>
|
||||
<SubmitStats/>
|
||||
</div>
|
||||
}
|
||||
@@ -318,9 +287,9 @@ pub fn ticket_count_component() -> Html {
|
||||
})
|
||||
.count();
|
||||
html! {
|
||||
<div class="open-tickets">
|
||||
<h2 class="left">{ "Offene Tickets" }</h2>
|
||||
<h4 class="ticket_count center">{ count }</h4>
|
||||
<div>
|
||||
<h2>{ "Offene Tickets" }</h2>
|
||||
<h4>{ count }</h4>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -356,13 +325,11 @@ pub fn ticket_count_component() -> Html {
|
||||
#[component(SubmitStats)]
|
||||
pub fn submit_stats_component() -> Html {
|
||||
let tickets = use_state(|| Vec::<TicketPartial>::new());
|
||||
let users = use_state(|| Vec::<UserPartial>::new());
|
||||
let error = use_state(|| None::<String>);
|
||||
let loading = use_state(|| false);
|
||||
|
||||
{
|
||||
let tickets = tickets.clone();
|
||||
let users = users.clone();
|
||||
let error = error.clone();
|
||||
let loading = loading.clone();
|
||||
|
||||
@@ -386,24 +353,6 @@ pub fn submit_stats_component() -> Html {
|
||||
}
|
||||
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);
|
||||
});
|
||||
|| ()
|
||||
@@ -465,23 +414,14 @@ pub fn submit_stats_component() -> Html {
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="diagnostics-row">
|
||||
<div class="diagnostics-column">
|
||||
<h3>{ "Tickets pro Raum" }</h3>
|
||||
<RoomTotalTickets tickets={(*tickets).clone()}/>
|
||||
</div>
|
||||
<div class="diagnostics-column">
|
||||
<h3>{ "Tickets pro Benutzer" }</h3>
|
||||
<UserTotal users={(*users).clone()} tickets={(*tickets).clone()}/>
|
||||
</div>
|
||||
</div>
|
||||
<RoomTotalTickets tickets={(*tickets).clone()}/>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// A component that displays the total number of tickets per room.
|
||||
///
|
||||
/// This component takes a list of [`TicketPartial`] items and calculates the
|
||||
/// This component takes a list of `TicketPartial` items and calculates the
|
||||
/// total number of tickets for each room. It then displays these totals
|
||||
/// in a sorted list with a bar chart visualization.
|
||||
///
|
||||
@@ -516,18 +456,19 @@ fn room_total_component(props: &RoomTotalsProps) -> Html {
|
||||
|
||||
html! {
|
||||
<div class="diagnostics-section">
|
||||
<div class="chart">
|
||||
<h3>{ "Tickets pro Raum" }</h3>
|
||||
<div class="room-chart">
|
||||
{ for totals_vec.into_iter().map(|(room, count)| {
|
||||
let label = parse_room(room);
|
||||
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 class="room-bar-item">
|
||||
<div class="room-header">
|
||||
<span class="room-label">{ label }</span>
|
||||
<span class="room-count">{ count }</span>
|
||||
</div>
|
||||
<div class="bar-container">
|
||||
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
|
||||
<div class="room-bar-container">
|
||||
<div class="room-bar" style={format!("width: {}%;", bar_width_percent)}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -537,103 +478,3 @@ fn room_total_component(props: &RoomTotalsProps) -> Html {
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component(UserTotal)]
|
||||
fn user_total_component(props: &UserTotalProps) -> Html {
|
||||
let name_map: HashMap<i16, (String, String)> = props
|
||||
.users
|
||||
.iter()
|
||||
.map(|u| (u.id, (u.first_name.clone(), u.last_name.clone())))
|
||||
.collect();
|
||||
|
||||
let mut counts: HashMap<i16, usize> = HashMap::new();
|
||||
for t in &props.tickets {
|
||||
*counts.entry(t.user_id).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let mut totals_vec: Vec<(i16, String, String, usize)> = name_map
|
||||
.into_iter()
|
||||
.map(|(id, (fname, lname))| {
|
||||
let c = counts.get(&id).cloned().unwrap_or(0);
|
||||
(id, fname, lname, c)
|
||||
})
|
||||
.collect();
|
||||
|
||||
totals_vec.sort_by(|a, b| b.3.cmp(&a.3));
|
||||
|
||||
let max_count = totals_vec
|
||||
.iter()
|
||||
.map(|(_, _, _, c)| *c)
|
||||
.max()
|
||||
.unwrap_or(1)
|
||||
.max(1);
|
||||
|
||||
html! {
|
||||
<div class="diagnostics-section">
|
||||
<div class="chart">
|
||||
{ for totals_vec.into_iter().map(|(_id, fname, lname, count)| {
|
||||
let label = format!("{} {}", dequote!(fname), dequote!(lname));
|
||||
let bar_width_percent = (count as f64 / max_count as f64) * 100.0;
|
||||
html! {
|
||||
<div class="bar-item">
|
||||
<div class="diagnostics-header">
|
||||
<span class="diagnostics-label">{ label }</span>
|
||||
<span class="diagnostics-count">{ count }</span>
|
||||
</div>
|
||||
<div class="bar-container">
|
||||
<div class="bar" style={format!("width: {}%;", bar_width_percent)}>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}) }
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// A component for displaying how many problems were solved without creating a ticket
|
||||
///
|
||||
/// # Functionality
|
||||
/// Fetches the value for the count from `/api/count` and parses it into a [`CountResponse`]
|
||||
///
|
||||
/// # Example
|
||||
/// ``` rust
|
||||
/// html! {
|
||||
/// <EasyFixCount/>
|
||||
/// }
|
||||
/// ```
|
||||
#[component(EasyFixCount)]
|
||||
fn easy_fix_component() -> Html {
|
||||
let count = use_state(|| 0);
|
||||
let error = use_state(|| None::<String>);
|
||||
|
||||
{
|
||||
let count = count.clone();
|
||||
let error = error.clone();
|
||||
|
||||
use_effect_with((), move |_| {
|
||||
spawn_local(async move {
|
||||
match Request::get("/api/count").send().await {
|
||||
Ok(resp) if resp.status() == 200 => match resp.json::<CountResponse>().await {
|
||||
Ok(r) => count.set(r.value),
|
||||
Err(e) => error.set(Some(format!("parse error: {}", e))),
|
||||
},
|
||||
Ok(resp) => error.set(Some(
|
||||
resp.text()
|
||||
.await
|
||||
.unwrap_or_else(|_| format!("status {}", resp.status())),
|
||||
)),
|
||||
Err(e) => error.set(Some(format!("Network error: {}", e))),
|
||||
}
|
||||
});
|
||||
|| ()
|
||||
});
|
||||
}
|
||||
html! {
|
||||
<div class="open-tickets">
|
||||
<h2 class="left">{ "Probleme ohne Ticket gelößt" }</h2>
|
||||
<h4 class="ticket_count center">{ *count }</h4>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
@use "variables" as *;
|
||||
|
||||
@mixin card {
|
||||
background: $color-container;
|
||||
border-radius: $border-radius;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
backgroud: #fff;
|
||||
border-radius: &border-radius;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
||||
padding: $spacing-md;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: $color-container-dark;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
.u-hidden { display: none !important; }
|
||||
.u-gap-sm { gap: $spacing-sm; }
|
||||
.text-muted { color: $color-muted; }
|
||||
|
||||
|
||||
@@ -1,34 +1,8 @@
|
||||
// Color Palette (Reference Style)
|
||||
$color-bg: #f0f2f5;
|
||||
$color-bg-dark: #121212;
|
||||
$color-container: #ffffff;
|
||||
$color-container-dark: #333333;
|
||||
$color-bg: #ffffff;
|
||||
$color-sidebar: #0f172a;
|
||||
$color-primary: #2b79c2;
|
||||
$color-primary-hover: #1d5fa0;
|
||||
$color-accent: #2b79c2;
|
||||
$color-accent: #2563eb;
|
||||
$color-muted: #6b7280;
|
||||
$color-text: #111827;
|
||||
$color-text-dark: #e2e2e2;
|
||||
|
||||
// Status Colors
|
||||
$color-status-todo: #ffcccc;
|
||||
$color-status-todo-text: #a00;
|
||||
$color-status-inprogress: #fff3cd;
|
||||
$color-status-inprogress-text: #856404;
|
||||
$color-status-done: #d4edda;
|
||||
$color-status-done-text: #155724;
|
||||
$color-status-archived: #e0e0e0;
|
||||
$color-status-archived-text: #666666;
|
||||
|
||||
// Action Colors
|
||||
$color-logout: #ff4d4d;
|
||||
$color-logout-hover: #e60000;
|
||||
|
||||
// Spacing
|
||||
$spacing-sm: 8px;
|
||||
$spacing-md: 16px;
|
||||
$spacing-lg: 2rem;
|
||||
$border-radius: 0.5rem;
|
||||
$border-radius-lg: 10px;
|
||||
$border-radius: 6px;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
background-color: $color-container;
|
||||
background-color: $color-bg;
|
||||
border-radius: $border-radius;
|
||||
padding: $spacing-md;
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: $color-container-dark;
|
||||
}
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: $color-primary;
|
||||
margin: 0 0 $spacing-md 0;
|
||||
color: #1f2937;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -31,13 +27,7 @@
|
||||
gap: 12px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: $border-radius;
|
||||
background-color: $color-container;
|
||||
margin: 16px 0px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: $color-container-dark;
|
||||
border-color: #555;
|
||||
}
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.weekday-bars {
|
||||
@@ -53,7 +43,6 @@
|
||||
.weekday-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 60px;
|
||||
@@ -61,7 +50,7 @@
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
background-color: $color-primary;
|
||||
background-color: $color-accent;
|
||||
border-radius: 4px 4px 0 0;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
@@ -87,12 +76,8 @@
|
||||
.day {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: $color-text;
|
||||
color: #1f2937;
|
||||
margin-bottom: 2px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: $color-text-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.value {
|
||||
@@ -102,116 +87,51 @@
|
||||
}
|
||||
}
|
||||
|
||||
.chart {
|
||||
.room-chart {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: $spacing-md;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: $border-radius;
|
||||
background-color: $color-container;
|
||||
width: 100%;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: $color-container-dark;
|
||||
border-color: #555;
|
||||
}
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.bar-item {
|
||||
.room-bar-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.diagnostics-header {
|
||||
.room-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.diagnostics-label {
|
||||
.room-label {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: $color-text;
|
||||
color: #1f2937;
|
||||
min-width: 50px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color: $color-text-dark;
|
||||
}
|
||||
}
|
||||
|
||||
.diagnostics-count {
|
||||
.room-count {
|
||||
font-size: 12px;
|
||||
color: $color-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-container {
|
||||
.room-bar-container {
|
||||
width: 100%;
|
||||
height: 24px;
|
||||
background-color: #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.bar {
|
||||
.room-bar {
|
||||
height: 100%;
|
||||
background-color: $color-primary;
|
||||
background-color: #f97316;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
5
frontend/src/styles/components/_frontpage.scss
Normal file
5
frontend/src/styles/components/_frontpage.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.headline {
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -17,17 +17,16 @@
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border-radius: $border-radius;
|
||||
transition: background 0.2s ease-in-out;
|
||||
&:hover { background: rgba(255,255,255,0.1); }
|
||||
border-radius: 4px;
|
||||
&:hover { background: rgba(255,255,255,0.04); }
|
||||
}
|
||||
|
||||
.menu-toggle { background: transparent; border: none; text-align: left; width: 100%; cursor: pointer; }
|
||||
|
||||
.submenu {
|
||||
margin-left: 8px;
|
||||
background: rgba(255,255,255,0.05);
|
||||
border-radius: $border-radius;
|
||||
background: rgba(255,255,255,0.02);
|
||||
border-radius: 4px;
|
||||
padding: 6px;
|
||||
li { padding: 4px 0; }
|
||||
}
|
||||
@@ -39,14 +38,16 @@
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255,255,255,0.1);
|
||||
color: #fff;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255,255,255,0.2);
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
&:hover { background: rgba(255,255,255,0.15); }
|
||||
&:active { background: rgba(255,255,255,0.2); }
|
||||
}
|
||||
|
||||
&.user { width: 220px; background: darken($color-sidebar, 6%); }
|
||||
|
||||
@@ -1,31 +1,9 @@
|
||||
@use "../mixins" 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 {
|
||||
@include card();
|
||||
border-left: 4px solid $color-primary;
|
||||
border-left: 4px solid $color-accent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
@@ -33,48 +11,3 @@
|
||||
.title { font-weight: 600; }
|
||||
}
|
||||
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
margin-top: 8px;
|
||||
|
||||
&.To-Do {
|
||||
background: $color-status-todo;
|
||||
color: $color-status-todo-text;
|
||||
}
|
||||
|
||||
&.InProgress {
|
||||
background: $color-status-inprogress;
|
||||
color: $color-status-inprogress-text;
|
||||
}
|
||||
|
||||
&.Done {
|
||||
background: $color-status-done;
|
||||
color: $color-status-done-text;
|
||||
}
|
||||
|
||||
&.Completed {
|
||||
background: $color-status-done;
|
||||
color: $color-status-done-text;
|
||||
}
|
||||
|
||||
&.Archived {
|
||||
background: $color-status-archived;
|
||||
color: $color-status-archived-text;
|
||||
}
|
||||
}
|
||||
|
||||
.ticket_count {
|
||||
text-align: center;
|
||||
align-content: center;
|
||||
color: #005f00;
|
||||
border: 2px solid #848484;
|
||||
border-radius: 10px;
|
||||
height: 50px;
|
||||
width: 250px;
|
||||
margin: 0;
|
||||
font-size: xx-large;
|
||||
}
|
||||
|
||||
@@ -3,173 +3,44 @@
|
||||
@use "utilities";
|
||||
@use "components/sidebar";
|
||||
@use "components/tickets";
|
||||
@use "components/alltickets";
|
||||
@use "components/allusers";
|
||||
@use "components/forms";
|
||||
@use "components/login";
|
||||
@use "components/diagnostics";
|
||||
@use "components/pages";
|
||||
@use "components/setup";
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@use "components/frontpage";
|
||||
|
||||
body {
|
||||
background: variables.$color-bg;
|
||||
font-family: Arial, sans-serif;
|
||||
color: variables.$color-text;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||
color: #111827;
|
||||
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; }
|
||||
.content { flex: 1; padding: variables.$spacing-md; }
|
||||
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
width: 260px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-left: 260px;
|
||||
padding: variables.$spacing-lg;
|
||||
|
||||
> :first-child {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
background: variables.$color-container;
|
||||
padding: variables.$spacing-lg;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.1);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: variables.$color-container-dark;
|
||||
}
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
color: variables.$color-primary;
|
||||
margin-top: 0;
|
||||
}
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: -260px;
|
||||
transition: left .3s ease-in-out;
|
||||
z-index: 1000;
|
||||
}
|
||||
.sidebar.open {
|
||||
left: 0;
|
||||
}
|
||||
.content {
|
||||
margin-left: 0;
|
||||
.sidebar { position: fixed; left: -100%; transition: left .2s; }
|
||||
.sidebar.open { left: 0; }
|
||||
.content { margin-left: 0; }
|
||||
}
|
||||
|
||||
form {
|
||||
align-content: center;
|
||||
|
||||
input, button {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user