1use std::collections::HashSet;
2
3use chrono_tz::Europe::Berlin;
4use gloo_net::http::Request;
5use serde::{Deserialize, Serialize};
6use wasm_bindgen::JsCast;
7use wasm_bindgen_futures::spawn_local;
8use web_sys::HtmlSelectElement;
9use yew::prelude::*;
10use yew_router::prelude::*;
11
12const VALID_ROOMS: &[i16] = &[
20 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,
21 32, 34, 36, 37, 39, 41, 49, 101, 102, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113,
22 118, 120, 121, 122, 123, 124, 126, 127, 128, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142,
23 143, 144, 145, 146, 147, 148, 150, 152, 153, 154, 155, 156, 157, 158, 159, 160, 201, 202, 203,
24 204, 205, 206, 207, 208, 209, 210, 211, 212, 214, 1001, 1002, 1003, -1, -2, -3, -4, -5, -6, -7,
25 -8,
26];
27
28#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
39pub struct TicketCreateScheme {
40 pub category: String,
41 pub betreff: String,
42 pub description: String,
43 pub room: i16,
44}
45
46#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
54pub struct TicketUpdateScheme {
55 pub status: String,
56}
57
58#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
75pub struct Ticket {
76 pub id: i32,
77 pub category: String,
78 pub betreff: String,
79 pub description: String,
80 pub room: i16,
81 pub status: String,
82 pub date: chrono::DateTime<chrono::Utc>,
83 pub user_id: i16,
84 pub user_first_name: String,
85 pub user_last_name: String,
86}
87
88#[derive(Properties, PartialEq)]
96pub struct TicketProps {
97 pub id: i32,
98}
99
100#[derive(Clone, Debug, PartialEq)]
110pub struct ActiveUser {
111 pub id: Option<i16>,
112 pub is_admin: bool,
113}
114
115#[derive(Deserialize, Debug)]
124struct ApiError {
125 message: String,
126 _status: String,
127}
128
129#[component(SubmitTicket)]
162pub fn submit_ticket_component() -> Html {
163 let category = use_state(|| "".to_string());
164 let betreff = use_state(|| "".to_string());
165 let description = use_state(|| "".to_string());
166 let room = use_state(|| None::<i16>);
167 let room_input = use_state(|| "".to_string());
168 let status = use_state(|| None::<String>);
169
170 let valid_rooms: HashSet<i16> = VALID_ROOMS.iter().copied().collect();
171
172 let onsubmit = {
173 let category = category.clone();
174 let betreff = betreff.clone();
175 let description = description.clone();
176 let room = room.clone();
177 let status = status.clone();
178 let valid_rooms = valid_rooms.clone();
179
180 Callback::from(move |e: SubmitEvent| {
181 e.prevent_default();
182 if room.is_none() {
183 status.set(Some("Ungültiger Raum".into()));
184 return;
185 }
186 let category = (*category).clone();
187 let betreff = (*betreff).clone();
188 let description = (*description).clone();
189 let room = room.unwrap();
190 if !valid_rooms.contains(&room) {
191 status.set(Some("Raum nicht erlaubt".into()));
192 return;
193 }
194 let status = status.clone();
195
196 spawn_local(async move {
197 let payload = TicketCreateScheme {
198 category,
199 betreff,
200 description,
201 room,
202 };
203
204 let request = Request::post("/api/tickets/create")
205 .header("Content-Type", "application/json")
206 .credentials(web_sys::RequestCredentials::Include)
207 .json(&payload)
208 .expect("Failed to build request");
209
210 match request.send().await {
211 Ok(response) if response.status() == 200 => status.set(Some("Erfolg".into())),
212 Ok(response) => {
213 let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
214 status.set(Some(text));
215 },
216 Err(err) => status.set(Some(format!("Netzwerkfehler: {}", err))),
217 }
218 });
219 })
220 };
221
222 let category_change = {
223 let category = category.clone();
224 Callback::from(move |e: Event| {
225 let input: web_sys::HtmlSelectElement = e.target_unchecked_into();
226 category.set(input.value());
227 })
228 };
229
230 let betreff_change = {
231 let betreff = betreff.clone();
232 Callback::from(move |e: InputEvent| {
233 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
234 betreff.set(input.value());
235 })
236 };
237
238 let description_change = {
239 let description = description.clone();
240 Callback::from(move |e: InputEvent| {
241 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
242 description.set(input.value());
243 })
244 };
245
246 let room_change = {
247 let room = room.clone();
248 let room_input = room_input.clone();
249 Callback::from(move |e: InputEvent| {
250 let input: web_sys::HtmlInputElement = e.target_unchecked_into();
251 let raw = input.value();
252 room_input.set(raw.clone());
253
254 let parsed_value: Option<i16> = if raw.trim().is_empty() {
255 None
256 } else {
257 let raw_trim = raw.trim();
258 if raw_trim.len() > 1 && (raw_trim.starts_with('K') || raw_trim.starts_with('k')) {
259 match raw_trim[1..].parse::<i16>() {
260 Ok(n) => Some(-n),
261 Err(_) => None,
262 }
263 } else if raw_trim.len() > 1
264 && (raw_trim.starts_with('D') || raw_trim.starts_with('d'))
265 {
266 match raw_trim[1..].parse::<i16>() {
267 Ok(n) => Some(1000 + n),
268 Err(_) => None,
269 }
270 } else {
271 match raw_trim.parse::<i16>() {
272 Ok(n) => Some(n),
273 Err(_) => None,
274 }
275 }
276 };
277
278 if let Some(v) = parsed_value {
279 if !valid_rooms.contains(&v) {
280 room.set(None);
281 } else {
282 room.set(Some(v));
283 }
284 } else {
285 room.set(None);
286 }
287 })
288 };
289
290 let room_valid = (*room).is_some();
291
292 html! {
293 <form class="rundbg" {onsubmit}>
294 <label>{ "Betreff:" }
295 <input type="text" value={(*betreff).clone()} oninput={betreff_change}/>
296 </label>
297 <br/>
298 <label>{ "Beschreibung:" }
299 <input type="text" value={(*description).clone()} oninput={description_change}/>
300 </label>
301 <br/>
302 <label>{ "Kategorie:" }
303 <select value={(*category).clone()} onchange={category_change}>
304 <option value="Whiteboard Beamer">{ "Whiteboard Beamer" }</option>
305 <option value="Internet">{ "Internet" }</option>
306 <option value="iPad Koffer">{ "iPad Koffer" }</option>
307 <option value="Apple TV">{ "Apple TV" }</option>
308 <option value="Docu Cam">{ "Dokumenten Kamera" }</option>
309 <option value="Sonstiges">{ "Sonstiges" }</option>
310 </select>
311 </label>
312 <br/>
313 <label>{ "Raum:" }
314 <input type="text" value={(*room_input).clone()} oninput={room_change}/>
315 </label>
316 {
317 if !room_valid {
318 html! {
319 <p class="warning" >{ "Ungültiger/nicht erlaubter Raum (z. B. 101, K12, D5)" }</p>
320 }
321 } else {
322 html! {}
323 }
324 }
325 <br/>
326 <button type="submit">{ "Absenden" }</button>
327
328 <Link<crate::Route> to={crate::Route::AllTickets}>{ "Tickets ansehen" }</Link<crate::Route>>
329
330 {
331 if let Some(s) = &*status {
332 html!{ <p>{ s }</p> }
333 } else {
334 html!{}
335 }
336 }
337
338 </form>
339 }
340}
341
342#[component(TicketByID)]
377pub fn ticket_by_id_component(props: &TicketProps) -> Html {
378 let ticket = use_state(|| None::<Ticket>);
379 let error = use_state(|| None::<String>);
380 let loading = use_state(|| false);
381 let id = props.id;
382 let status = use_state(|| "".to_string());
383
384 {
385 let ticket = ticket.clone();
386 let error = error.clone();
387 let loading = loading.clone();
388
389 use_effect_with(id, move |id_ref| {
390 loading.set(true);
391 let ticket = ticket.clone();
392 let error = error.clone();
393 let id = *id_ref;
394 spawn_local(async move {
395 let url = format!("/api/tickets/{}", id);
396 match Request::get(&url).send().await {
397 Ok(response) => {
398 let status = response.status();
399 if status == 200 {
400 match response.json::<Ticket>().await {
401 Ok(t) => ticket.set(Some(t)),
402 Err(err) => error.set(Some(format!("Parse error: {}", err))),
403 }
404 } else {
405 match response.json::<ApiError>().await {
406 Ok(ae) => error.set(Some(ae.message)),
407 Err(_) => {
408 if let Ok(text) = response.text().await {
409 error.set(Some(text));
410 } else {
411 error.set(Some(format!("Server error: {}", status)));
412 }
413 }
414 }
415 }
416 }
417 Err(err) => error.set(Some(format!("Network error: {}", err))),
418 }
419 loading.set(false);
420 });
421 || ()
422 });
423 }
424 let onsubmit = {
425 let status = status.clone();
426 let id = id.clone();
427 let error = error.clone();
428
429 Callback::from(move |e: SubmitEvent| {
430 e.prevent_default();
431 let form: web_sys::HtmlFormElement = e.target_unchecked_into();
432 let select_element = form
433 .query_selector("select[name=\"status\"]")
434 .ok()
435 .and_then(|opt| opt)
436 .and_then(|el| el.dyn_into::<HtmlSelectElement>().ok());
437 let new_status = select_element
438 .map(|s| s.value())
439 .unwrap_or_else(|| (*status).clone());
440 status.set(new_status.clone());
441
442 let id = id.clone();
443 let error = error.clone();
444
445 spawn_local(async move {
446 let value = TicketUpdateScheme { status: new_status };
447
448 let url = format!("/api/tickets/{}", id);
449 let request = Request::patch(&url)
450 .json(&value)
451 .expect("Failed to construct request");
452
453 match request.send().await {
454 Ok(response) if response.status() == 200 => error.set(Some("Erfolg".into())),
455 Ok(response) => {
456 let text = response.text().await.unwrap_or_else(|_| "Unbekannt".into());
457 error.set(Some(text));
458 },
459 Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
460 }
461 });
462 })
463 };
464
465 let deleting = use_state(|| false);
466 let delete_error = use_state(|| None::<String>);
467
468 let ondelete = {
469 let deleting = deleting.clone();
470 let delete_error = delete_error.clone();
471 let ticket_state = ticket.clone();
472 let id = id;
473
474 Callback::from(move |e: MouseEvent| {
475 e.prevent_default();
476 if !web_sys::window()
477 .and_then(|w| w.confirm_with_message("Sicher löschen?").ok())
478 .unwrap_or(false)
479 {
480 return;
481 }
482
483 deleting.set(true);
484 delete_error.set(None);
485
486 let deleting = deleting.clone();
487 let delete_error = delete_error.clone();
488 let ticket_state = ticket_state.clone();
489
490 spawn_local(async move {
491 let url = format!("/api/tickets/{}", id);
492 let request =
493 Request::delete(&url).credentials(web_sys::RequestCredentials::Include);
494
495 match request.send().await {
496 Ok(resp) if resp.status() == 200 || resp.status() == 204 => {
497 ticket_state.set(None);
498 }
499 Ok(resp) => {
500 let txt = resp.text().await.unwrap_or_else(|_| "Unbekannt".into());
501 delete_error.set(Some(txt));
502 }
503 Err(err) => delete_error.set(Some(format!("Netzwerkfehler: {}", err))),
504 }
505 deleting.set(false);
506 });
507 })
508 };
509
510 if *loading {
511 html! {<p>{ "Wird geladen" }</p>}
512 } else if let Some(e) = &*error {
513 html! { <p>{ format!("Fehler: {}", e) }</p> }
514 } else if let Some(t) = &*ticket {
515 html! {
516 <div class="rundbg">
517 <h1>{ format!("Ticket #{}", t.id) }</h1>
518 <p><strong>{ "Kategorie: " }</strong>{ &t.category }</p>
519 <p><strong>{ "Betreff: " }</strong>{ &t.betreff }</p>
520 <p><strong>{ "Beschreibung: " }</strong>{ &t.description }</p>
521 <p><strong>{ "Raum: " }</strong>{ t.room }</p>
522 <p><strong>{ "Datum: " }</strong>{
523 t.date.with_timezone(&Berlin).format("%d.%m.%Y %H:%M:%S").to_string()
524 }</p>
525 <p><strong>{ "Status: "}</strong>{ match t.status.as_str() {
526 "ToDo" => "Zu tun",
527 "InProgress" => "In Bearbeitung",
528 "Completed" => "Erledigt",
529 "Archived" => "Archiviert",
530 _ => "Ungültiger Status"
531 }}</p>
532 <p><strong>{ "Name: "}</strong>{ format!{"{} {}", t.user_first_name, t.user_last_name } }</p>
533
534 <form {onsubmit}>
535 <label>{ "Status ändern" }
536 <select name="status">
537 <option value="ToDo">{ "Zu tun" }</option>
538 <option value="InProgress">{ "In Bearbeitung" }</option>
539 <option value="Completed">{ "Erledigt" }</option>
540 <option value="Archived">{ "Archiviert" }</option>
541 </select>
542 </label>
543 <button type="submit">{ "Aktualisieren" }</button>
544 </form>
545
546 <button onclick={ondelete} disabled={*deleting}>
547 {if *deleting {"Löschen..."} else {"Löschen"}}
548 </button>
549
550 <Link<crate::Route> to={crate::Route::AllTickets}>{ "Zurück zur Ticketübersicht" }</Link<crate::Route>>
551 if let Some(err) = &*delete_error {
552 <p style="color:red">{ err.clone() }</p>
553 }
554 </div>
555 }
556 } else {
557 html! { <p>{ "Kein Ticket gefunden." }</p> }
558 }
559}
560
561#[component(AllTickets)]
595pub fn all_tickets_component() -> Html {
596 let tickets = use_state(|| Vec::<Ticket>::new());
597 let error = use_state(|| None::<String>);
598 let loading = use_state(|| false);
599 let user = use_state(|| ActiveUser {
600 id: None,
601 is_admin: false,
602 });
603
604 {
605 let tickets = tickets.clone();
606 let error = error.clone();
607 let loading = loading.clone();
608
609 use_effect_with((), move |_| {
610 loading.set(true);
611 spawn_local(async move {
612 let url = format!("/api/tickets");
613 match Request::get(&url).send().await {
614 Ok(response) if response.status() == 200 => {
615 match response.json::<Vec<Ticket>>().await {
616 Ok(t) => tickets.set(t),
617 Err(e) => error.set(Some(format!("parse error: {}", e))),
618 }
619 }
620 Ok(response) => {
621 if let Ok(text) = response.text().await {
622 error.set(Some(text));
623 } else {
624 error.set(Some(format!("status {}", response.status())));
625 }
626 }
627 Err(err) => error.set(Some(format!("Network error: {}", err))),
628 }
629 loading.set(false);
630 });
631 || ()
632 });
633 }
634
635 {
636 let user = user.clone();
637 use_effect_with((), move |_| {
638 let user = user.clone();
639 spawn_local(async move {
640 if let Ok(response) = Request::get("/api/users/current")
641 .credentials(web_sys::RequestCredentials::Include)
642 .send()
643 .await
644 {
645 if response.status() == 200 {
646 if let Ok(json) = response.json::<serde_json::Value>().await {
647 let id = json
648 .get("data")
649 .and_then(|d| d.get("id"))
650 .and_then(|v| v.as_i64())
651 .and_then(|n| i16::try_from(n).ok());
652 let is_admin = json
653 .get("data")
654 .and_then(|d| d.get("is_admin"))
655 .and_then(|v| v.as_bool())
656 .unwrap_or(false);
657 user.set(ActiveUser { id, is_admin });
658 }
659 }
660 }
661 });
662 || ()
663 });
664 }
665
666 if *loading {
667 html! {<p>{ "Wird geladen" }</p>}
668 } else if let Some(e) = &*error {
669 html! { <p>{ format!("Fehler: {}", e) }</p> }
670 } else {
671 html! {
672 <ul class= "postits">
673 { 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| {
674 let status_class = match t.status.as_str() {
675 "ToDo" => "To-Do",
676 "InProgress" => "InProgress",
677 "Completed" => "Completed",
678 "Archived" => "Archived",
679 _ => "To-Do"
680 };
681 html! {
682 <div class={classes!{status_class, "listbox"}}>
683 <li key={t.id.to_string()}>
684 <Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{}", t.betreff,) }</h3></Link<crate::Route>>
685 </li>
686
687 </div>
688 }})}
689 </ul>
690 }
691 }
692}
693
694#[component(ArchivedTickets)]
728pub fn archived_tickets_component() -> Html {
729 let tickets = use_state(|| Vec::<Ticket>::new());
730 let error = use_state(|| None::<String>);
731 let loading = use_state(|| false);
732 let user = use_state(|| ActiveUser {
733 id: None,
734 is_admin: false,
735 });
736
737 {
738 let tickets = tickets.clone();
739 let error = error.clone();
740 let loading = loading.clone();
741
742 use_effect_with((), move |_| {
743 loading.set(true);
744 spawn_local(async move {
745 let url = format!("/api/tickets");
746 match Request::get(&url).send().await {
747 Ok(response) if response.status() == 200 => {
748 match response.json::<Vec<Ticket>>().await {
749 Ok(t) => tickets.set(t),
750 Err(e) => error.set(Some(format!("Parsefehler: {}", e))),
751 }
752 }
753 Ok(response) => {
754 if let Ok(text) = response.text().await {
755 error.set(Some(text));
756 } else {
757 error.set(Some(format!("Status {}", response.status())));
758 }
759 }
760 Err(err) => error.set(Some(format!("Netzwerkfehler: {}", err))),
761 }
762 loading.set(false);
763 });
764 || ()
765 });
766 }
767
768 {
769 let user = user.clone();
770 use_effect_with((), move |_| {
771 let user = user.clone();
772 spawn_local(async move {
773 if let Ok(response) = Request::get("/api/users/current")
774 .credentials(web_sys::RequestCredentials::Include)
775 .send()
776 .await
777 {
778 if response.status() == 200 {
779 if let Ok(json) = response.json::<serde_json::Value>().await {
780 let id = json
781 .get("data")
782 .and_then(|d| d.get("id"))
783 .and_then(|v| v.as_i64())
784 .and_then(|n| i16::try_from(n).ok());
785 let is_admin = json
786 .get("data")
787 .and_then(|d| d.get("is_admin"))
788 .and_then(|v| v.as_bool())
789 .unwrap_or(false);
790 user.set(ActiveUser { id, is_admin });
791 }
792 }
793 }
794 });
795 || ()
796 });
797 }
798
799 if *loading {
800 html! {<p>{ "Wird geladen" }</p>}
801 } else if let Some(e) = &*error {
802 html! { <p>{ format!("Fehler: {}", e) }</p> }
803 } else {
804 html! {
805 <ul class="postits">
806 { 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! {
807 <div class="listbox">
808 <li key={t.id.to_string()}>
809 <Link<crate::Route> to={crate::Route::TicketById{id: t.id}}><h3>{ format!("{} - #{}", t.betreff, t.id) }</h3></Link<crate::Route>>
810 <p>{ &t.description }</p>
811 <p>{ match t.status.as_str() {
812 "ToDo" => "Zu tun",
813 "InProgress" => "In Bearbeitung",
814 "Completed" => "Erledigt",
815 "Archived" => "Archiviert",
816 _ => "Ungültiger Status"
817 }}</p>
818 </li>
819
820 </div>
821 })}
822 </ul>
823 }
824 }
825}