use std::collections::HashSet; use chrono_tz::Europe::Berlin; use gloo_net::http::Request; use serde::{Deserialize, Serialize}; use wasm_bindgen::JsCast; use wasm_bindgen_futures::spawn_local; use web_sys::HtmlSelectElement; use yew::prelude::*; use yew_router::prelude::*; /// A hardcoded list of valid room numbers for ticket submissions. /// /// This array contains `i16` values representing valid rooms. Negative numbers typically /// denote rooms prefixed with 'K' (e.g., -1 for K1), and numbers greater than or equal to 1000 /// denote rooms prefixed with 'D' (e.g., 1001 for D1). /// /// This list is used for client-side validation of room input during ticket creation. const VALID_ROOMS: &[i16] = &[ 1, 2, 3, 4, 5, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 19, 20, 22, 23, 24, 25, 26, 27, 28, 29, 30, 32, 34, 36, 37, 39, 41, 49, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 118, 120, 121, 122, 123, 124, 126, 127, 128, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 150, 152, 153, 154, 155, 156, 157, 158, 159, 160, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 214, 1001, 1002, 1003, -1, -2, -3, -4, -5, -6, -7, -8, ]; /// Data transfer object (DTO) for creating a new ticket. /// /// This struct defines the payload sent to the backend when a user submits a new ticket. /// It includes all necessary information for initial ticket creation. /// /// # Fields /// - `category`: The category of the ticket (e.g., "Internet", "Hardware"). /// - `betreff`: The subject or brief summary of the ticket. /// - `description`: A detailed description of the issue or request. /// - `room`: The room number where the issue is located. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct TicketCreateScheme { pub category: String, pub betreff: String, pub description: String, pub room: i16, } /// Data transfer object (DTO) for updating a ticket's status. /// /// This struct is used when sending a request to the backend to change the status /// of an existing ticket. /// /// # Fields /// - `status`: The new status of the ticket (e.g., "ToDo", "InProgress", "Completed", "Archived"). #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct TicketUpdateScheme { pub status: String, } /// Represents a complete ticket entity retrieved from the backend. /// /// This struct holds all details of a ticket, including its metadata, content, /// and associated user information. /// /// # Fields /// - `id`: The unique identifier of the ticket. /// - `category`: The category of the ticket. /// - `betreff`: The subject of the ticket. /// - `description`: The detailed description of the ticket. /// - `room`: The room number associated with the ticket. /// - `status`: The current status of the ticket. /// - `date`: The creation date and time of the ticket in UTC. /// - `user_id`: The ID of the user who created the ticket. /// - `user_first_name`: The first name of the user who created the ticket. /// - `user_last_name`: The last name of the user who created the ticket. #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub struct Ticket { pub id: i32, pub category: String, pub betreff: String, pub description: String, pub room: i16, pub status: String, pub date: chrono::DateTime, pub user_id: i16, pub user_first_name: String, pub user_last_name: String, } /// Properties for components that display a single ticket. /// /// This struct is used to pass the ID of a specific ticket to components /// that need to fetch or display its details. /// /// # Fields /// - `id`: The unique identifier of the ticket to be displayed. #[derive(Properties, PartialEq)] pub struct TicketProps { pub id: i32, } /// Represents the essential information of the currently authenticated user. /// /// This struct is used to hold a subset of user data relevant for client-side /// logic, such as determining if the user is an administrator or filtering /// tickets by their ID. /// /// # Fields /// - `id`: An `Option` holding the unique ID of the active user. `None` if not authenticated or ID is unavailable. /// - `is_admin`: A boolean indicating if the active user has administrator privileges. #[derive(Clone, Debug, PartialEq)] pub struct ActiveUser { pub id: Option, pub is_admin: bool, } /// Represents a standardized error response from the API. /// /// This struct is used to deserialize error messages returned by the backend API, /// providing a consistent way to handle and display error information to the user. /// /// # Fields /// - `message`: A descriptive error message. /// - `_status`: (Ignored) The HTTP status code or a status string from the API. #[derive(Deserialize, Debug)] struct ApiError { message: String, _status: String, } /// A component that provides a form for users to submit new tickets. /// /// This component allows users to input details such as category, subject (`betreff`), /// description, and room number for a new support ticket. It includes client-side /// validation for the room number and interacts with the backend API to create the ticket. /// /// # State /// Uses `use_state` hooks to manage: /// - `category`, `betreff`, `description`: Values of the form input fields. /// - `room`, `room_input`: The parsed and raw room numbers, respectively. /// - `status`: To display messages about submission success or errors. /// /// # Room Input Handling /// The `room_change` callback handles parsing the room input: /// - Supports numerical room numbers (e.g., "101"). /// - Supports 'K' prefixed rooms (e.g., "K1" parses to -1). /// - Supports 'D' prefixed rooms (e.g., "D1" parses to 1001). /// - Validates the parsed room against the `VALID_ROOMS` constant. /// /// # Form Submission /// - Prevents default form submission behavior. /// - Performs validation for room number and presence in `VALID_ROOMS`. /// - Constructs a `TicketCreateScheme` payload. /// - Sends a POST request to `/api/tickets/create`. /// - Updates the `status` state based on the API response. /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(SubmitTicket)] pub fn submit_ticket_component() -> Html { let category = use_state(|| "".to_string()); let betreff = use_state(|| "".to_string()); let description = use_state(|| "".to_string()); let room = use_state(|| None::); let room_input = use_state(|| "".to_string()); let status = use_state(|| None::); let valid_rooms: HashSet = VALID_ROOMS.iter().copied().collect(); let onsubmit = { let category = category.clone(); let betreff = betreff.clone(); let description = description.clone(); let room = room.clone(); let status = status.clone(); let valid_rooms = valid_rooms.clone(); Callback::from(move |e: SubmitEvent| { e.prevent_default(); if room.is_none() { status.set(Some("Invalid room".into())); return; } let category = (*category).clone(); let betreff = (*betreff).clone(); let description = (*description).clone(); let room = room.unwrap(); if !valid_rooms.contains(&room) { status.set(Some("Room not allowed".into())); return; } let status = status.clone(); spawn_local(async move { let payload = TicketCreateScheme { category, betreff, description, room, }; let request = Request::post("/api/tickets/create") .header("Content-Type", "application/json") .credentials(web_sys::RequestCredentials::Include) .json(&payload) .expect("Failed to build request"); match request.send().await { Ok(response) if response.status() == 200 => status.set(Some("Success".into())), Ok(response) => status.set(Some(format!("Error: {}", response.status()))), Err(err) => status.set(Some(format!("Network error: {}", err))), } }); }) }; let category_change = { let category = category.clone(); Callback::from(move |e: Event| { let input: web_sys::HtmlSelectElement = e.target_unchecked_into(); category.set(input.value()); }) }; let betreff_change = { let betreff = betreff.clone(); Callback::from(move |e: InputEvent| { let input: web_sys::HtmlInputElement = e.target_unchecked_into(); betreff.set(input.value()); }) }; let description_change = { let description = description.clone(); Callback::from(move |e: InputEvent| { let input: web_sys::HtmlInputElement = e.target_unchecked_into(); description.set(input.value()); }) }; let room_change = { let room = room.clone(); let room_input = room_input.clone(); Callback::from(move |e: InputEvent| { let input: web_sys::HtmlInputElement = e.target_unchecked_into(); let raw = input.value(); room_input.set(raw.clone()); let parsed_value: Option = if raw.trim().is_empty() { None } else { let raw_trim = raw.trim(); if raw_trim.len() > 1 && (raw_trim.starts_with('K') || raw_trim.starts_with('k')) { match raw_trim[1..].parse::() { Ok(n) => Some(-n), Err(_) => None, } } else if raw_trim.len() > 1 && (raw_trim.starts_with('D') || raw_trim.starts_with('d')) { match raw_trim[1..].parse::() { Ok(n) => Some(1000 + n), Err(_) => None, } } else { match raw_trim.parse::() { Ok(n) => Some(n), Err(_) => None, } } }; if let Some(v) = parsed_value { if !valid_rooms.contains(&v) { room.set(None); } else { room.set(Some(v)); } } else { room.set(None); } }) }; let room_valid = (*room).is_some(); html! {



{ if !room_valid { html! {

{ "Ungültiger / nicht erlaubter Raum (z. B. 101, K12, D5)" }

} } else { html! {} } }
to={crate::Route::AllTickets}>{ "Tickets ansehen" }> { if let Some(s) = &*status { html!{

{ s }

} } else { html!{} } }
} } /// A component for displaying, updating, and deleting a single ticket by its ID. /// /// This component fetches a specific ticket's details from the backend based on the /// `id` provided in its `TicketProps`. It allows administrators to update the ticket's /// status and delete the ticket. /// /// # Props /// - `id`: The `i32` ID of the ticket to fetch and display. /// /// # State /// Uses `use_state` hooks to manage: /// - `ticket`: The fetched [`Ticket`] data, if available. /// - `error`: Any error message encountered during API calls. /// - `loading`: A boolean indicating if data is currently being fetched. /// - `status`: The selected status for updating the ticket. /// - `deleting`: A boolean indicating if a delete operation is in progress. /// - `delete_error`: Any error message from a delete operation. /// /// # Functionality /// - **Data Fetching**: On component mount or `id` change, fetches ticket data from /// `/api/tickets/:id`. /// - **Status Update**: Provides a dropdown to change the ticket's status. Submitting the /// form sends a PATCH request to `/api/tickets/:id` with a `TicketUpdateScheme` payload. /// - **Ticket Deletion**: Includes a "Delete" button that, after user confirmation, /// sends a DELETE request to `/api/tickets/:id`. On success, clears the ticket from display. /// - **Error Handling**: Displays error messages for network issues, API errors, or parsing failures. /// - **Date Formatting**: Displays the ticket creation date formatted to `Europe/Berlin` timezone. /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(TicketByID)] pub fn ticket_by_id_component(props: &TicketProps) -> Html { let ticket = use_state(|| None::); let error = use_state(|| None::); let loading = use_state(|| false); let id = props.id; let status = use_state(|| "".to_string()); { let ticket = ticket.clone(); let error = error.clone(); let loading = loading.clone(); use_effect_with(id, move |id_ref| { loading.set(true); let ticket = ticket.clone(); let error = error.clone(); let id = *id_ref; spawn_local(async move { let url = format!("/api/tickets/{}", id); match Request::get(&url).send().await { Ok(response) => { let status = response.status(); if status == 200 { match response.json::().await { Ok(t) => ticket.set(Some(t)), Err(err) => error.set(Some(format!("Parse error: {}", err))), } } else { match response.json::().await { Ok(ae) => error.set(Some(ae.message)), Err(_) => { if let Ok(text) = response.text().await { error.set(Some(text)); } else { error.set(Some(format!("Server error: {}", status))); } } } } } Err(err) => error.set(Some(format!("Network error: {}", err))), } loading.set(false); }); || () }); } let onsubmit = { let status = status.clone(); let id = id.clone(); let error = error.clone(); Callback::from(move |e: SubmitEvent| { e.prevent_default(); let form: web_sys::HtmlFormElement = e.target_unchecked_into(); let select_element = form .query_selector("select[name=\"status\"]") .ok() .and_then(|opt| opt) .and_then(|el| el.dyn_into::().ok()); let new_status = select_element .map(|s| s.value()) .unwrap_or_else(|| (*status).clone()); status.set(new_status.clone()); let id = id.clone(); let error = error.clone(); spawn_local(async move { let value = TicketUpdateScheme { status: new_status }; let url = format!("/api/tickets/{}", id); let request = Request::patch(&url) .json(&value) .expect("Failed to construct request"); match request.send().await { Ok(response) if response.status() == 200 => error.set(Some("Success".into())), Ok(response) => error.set(Some(format!("Error: {}", response.status()))), Err(err) => error.set(Some(format!("Network error: {}", err))), } }); }) }; let deleting = use_state(|| false); let delete_error = use_state(|| None::); let ondelete = { let deleting = deleting.clone(); let delete_error = delete_error.clone(); let ticket_state = ticket.clone(); let id = id; Callback::from(move |e: MouseEvent| { e.prevent_default(); if !web_sys::window() .and_then(|w| w.confirm_with_message("Sicher löschen?").ok()) .unwrap_or(false) { return; } deleting.set(true); delete_error.set(None); let deleting = deleting.clone(); let delete_error = delete_error.clone(); let ticket_state = ticket_state.clone(); spawn_local(async move { let url = format!("/api/tickets/{}", id); let request = Request::delete(&url).credentials(web_sys::RequestCredentials::Include); match request.send().await { Ok(resp) if resp.status() == 200 || resp.status() == 204 => { // remove local state or navigate away ticket_state.set(None); // clears the shown item } Ok(resp) => { let txt = resp.text().await.unwrap_or_else(|_| "Unknown".into()); delete_error.set(Some(format!("HTTP {}: {}", resp.status(), txt))); } Err(err) => delete_error.set(Some(format!("Network error: {}", err))), } deleting.set(false); }); }) }; if *loading { html! {

{ "Loading" }

} } else if let Some(e) = &*error { html! {

{ format!("Error: {}", e) }

} } else if let Some(t) = &*ticket { html! {

{ format!("Ticket #{}", t.id) }

{ "Kategorie: " }{ &t.category }

{ "Betreff: " }{ &t.betreff }

{ "Beschreibung: " }{ &t.description }

{ "Raum: " }{ t.room }

{ "Datum: " }{ t.date.with_timezone(&Berlin).format("%d.%m.%Y %H:%M:%S").to_string() }

{ "Status: "}{ match t.status.as_str() { "ToDo" => "Zu tun", "InProgress" => "In Bearbeitung", "Completed" => "Erledigt", "Archived" => "Archiviert", _ => "Ungültiger Status" }}

{ "Name: "}{ format!{"{} {}", t.user_first_name, t.user_last_name } }

to={crate::Route::AllTickets}>{ "Zurück zur Ticketübersicht" }> if let Some(err) = &*delete_error {

{ err.clone() }

}
} } else { html! {

{ "No ticket found." }

} } } /// A component for fetching and displaying a list of all tickets. /// /// This component retrieves all tickets from the backend and presents them as a list. /// It dynamically filters the displayed tickets based on the current user's role: /// administrators see all tickets, while regular users only see tickets they created. /// /// # State /// Uses `use_state` hooks to manage: /// - `tickets`: A vector of `Ticket` structs to store the fetched tickets. /// - `error`: Any error message encountered during API calls. /// - `loading`: A boolean indicating if data is currently being fetched. /// - `user`: An `ActiveUser` struct holding the current user's ID and admin status. /// /// # Functionality /// - **Fetch Tickets**: On component mount, fetches all tickets from `/api/tickets`. /// - **Fetch Current User**: Concurrently fetches the current user's details from /// `/api/users/current` to determine their `user_id` and `is_admin` status. /// - **Conditional Display**: /// - If `loading` is true, displays "Loading...". /// - If an `error` occurs, displays the error message. /// - Otherwise, renders a list of tickets. /// - **Filtering**: /// - Administrators (`user.is_admin = true`) see all tickets. /// - Regular users (`user.is_admin = false`) only see tickets where `t.user_id == user.id`. /// - **Navigation**: Each ticket in the list is a link to [`crate::Route::TicketById`] /// for viewing individual ticket details. /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(AllTickets)] pub fn all_tickets_component() -> Html { let tickets = use_state(|| Vec::::new()); let error = use_state(|| None::); let loading = use_state(|| false); let user = use_state(|| ActiveUser { id: None, is_admin: false, }); { let tickets = tickets.clone(); let error = error.clone(); let loading = loading.clone(); use_effect_with((), move |_| { loading.set(true); spawn_local(async move { let url = format!("/api/tickets"); match Request::get(&url).send().await { Ok(response) if response.status() == 200 => { match response.json::>().await { Ok(t) => tickets.set(t), Err(e) => error.set(Some(format!("parse error: {}", e))), } } Ok(response) => { if let Ok(text) = response.text().await { error.set(Some(text)); } else { error.set(Some(format!("status {}", response.status()))); } } Err(err) => error.set(Some(format!("Network error: {}", err))), } loading.set(false); }); || () }); } { let user = user.clone(); use_effect_with((), move |_| { let user = user.clone(); spawn_local(async move { if let Ok(response) = Request::get("/api/users/current") .credentials(web_sys::RequestCredentials::Include) .send() .await { if response.status() == 200 { if let Ok(json) = response.json::().await { let id = json .get("data") .and_then(|d| d.get("id")) .and_then(|v| v.as_i64()) .and_then(|n| i16::try_from(n).ok()); let is_admin = json .get("data") .and_then(|d| d.get("is_admin")) .and_then(|v| v.as_bool()) .unwrap_or(false); user.set(ActiveUser { id, is_admin }); } } } }); || () }); } if *loading { html! {

{ "Loading" }

} } else if let Some(e) = &*error { html! {

{ format!("Error: {}", e) }

} } else { html! {
    { 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! {
  • to={crate::Route::TicketById{id: t.id}}>

    { format!("{}", t.betreff,) }

    >
  • }})}
} } } /// A component for fetching and displaying a list of archived tickets. /// /// This component retrieves all tickets from the backend and presents them as a list, /// filtered to only show those with "Archived" status. /// /// # State /// Uses `use_state` hooks to manage: /// - `tickets`: A vector of `Ticket` structs to store the fetched tickets. /// - `error`: Any error message encountered during API calls. /// - `loading`: A boolean indicating if data is currently being fetched. /// - `user`: An `ActiveUser` struct holding the current user's ID and admin status. /// /// # Functionality /// - **Fetch Tickets**: On component mount, fetches all tickets from `/api/tickets`. /// - **Fetch Current User**: Concurrently fetches the current user's details from /// `/api/users/current` to determine their `user_id` and `is_admin` status. /// - **Conditional Display**: /// - If `loading` is true, displays "Loading...". /// - If an `error` occurs, displays the error message. /// - Otherwise, renders a list of tickets. /// - **Filtering**: /// - Only tickets with `t.status == "Archived"` are displayed. /// - If the user is an admin, all archived tickets are shown. /// - If the user is not an admin, only their own archived tickets are shown. /// - **Navigation**: Each ticket in the list is a link to [`crate::Route::TicketById`] /// for viewing individual ticket details. /// /// # Example /// ```rust /// html! { /// /// } /// ``` #[component(ArchivedTickets)] pub fn archived_tickets_component() -> Html { let tickets = use_state(|| Vec::::new()); let error = use_state(|| None::); let loading = use_state(|| false); let user = use_state(|| ActiveUser { id: None, is_admin: false, }); { let tickets = tickets.clone(); let error = error.clone(); let loading = loading.clone(); use_effect_with((), move |_| { loading.set(true); spawn_local(async move { let url = format!("/api/tickets"); match Request::get(&url).send().await { Ok(response) if response.status() == 200 => { match response.json::>().await { Ok(t) => tickets.set(t), Err(e) => error.set(Some(format!("parse error: {}", e))), } } Ok(response) => { if let Ok(text) = response.text().await { error.set(Some(text)); } else { error.set(Some(format!("status {}", response.status()))); } } Err(err) => error.set(Some(format!("Network error: {}", err))), } loading.set(false); }); || () }); } { let user = user.clone(); use_effect_with((), move |_| { let user = user.clone(); spawn_local(async move { if let Ok(response) = Request::get("/api/users/current") .credentials(web_sys::RequestCredentials::Include) .send() .await { if response.status() == 200 { if let Ok(json) = response.json::().await { let id = json .get("data") .and_then(|d| d.get("id")) .and_then(|v| v.as_i64()) .and_then(|n| i16::try_from(n).ok()); let is_admin = json .get("data") .and_then(|d| d.get("is_admin")) .and_then(|v| v.as_bool()) .unwrap_or(false); user.set(ActiveUser { id, is_admin }); } } } }); || () }); } if *loading { html! {

{ "Loading" }

} } else if let Some(e) = &*error { html! {

{ format!("Error: {}", e) }

} } else { 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! {
  • to={crate::Route::TicketById{id: t.id}}>

    { format!("{} - #{}", t.betreff, t.id) }

    >

    { &t.description }

    { match t.status.as_str() { "ToDo" => "Zu tun", "InProgress" => "In Bearbeitung", "Completed" => "Erledigt", "Archived" => "Archiviert", _ => "Ungültiger Status" }}

  • })}
} } }