This commit is contained in:
Hymmel 2026-01-22 10:49:41 +01:00
parent 92d569b9cd
commit 9bb6db7693
35 changed files with 785 additions and 172 deletions

View file

@ -7,15 +7,28 @@ import org.springframework.stereotype.Component;
import java.util.List; import java.util.List;
/**
* Component to load initial data into the database when the application starts.
*/
@Component @Component
public class DataLoader implements CommandLineRunner { public class DataLoader implements CommandLineRunner {
private final RoomRepository roomRepository; private final RoomRepository roomRepository;
/**
* Constructs a new DataLoader with the given RoomRepository.
* @param roomRepository The repository for managing rooms.
*/
public DataLoader(RoomRepository roomRepository) { public DataLoader(RoomRepository roomRepository) {
this.roomRepository = roomRepository; this.roomRepository = roomRepository;
} }
/**
* Runs the data loading logic.
* If no rooms exist, it creates and saves a few default rooms.
* @param args Command line arguments.
* @throws Exception if an error occurs during data loading.
*/
@Override @Override
public void run(String... args) throws Exception { public void run(String... args) throws Exception {
if (roomRepository.count() == 0) { if (roomRepository.count() == 0) {

View file

@ -3,9 +3,16 @@ package de.itsolutions.ticketsystem;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* Main application class for the Ticket System.
*/
@SpringBootApplication @SpringBootApplication
public class TicketSystemApplication { public class TicketSystemApplication {
/**
* Main method to start the Spring Boot application.
* @param args Command line arguments.
*/
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(TicketSystemApplication.class, args); SpringApplication.run(TicketSystemApplication.class, args);
} }

View file

@ -16,10 +16,19 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List; import java.util.List;
/**
* Security configuration for the application.
*/
@Configuration @Configuration
@EnableWebSecurity @EnableWebSecurity
public class SecurityConfig { public class SecurityConfig {
/**
* Configures the security filter chain.
* @param http The HttpSecurity object to configure.
* @return The SecurityFilterChain.
* @throws Exception if an error occurs during configuration.
*/
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http http
@ -35,16 +44,30 @@ public class SecurityConfig {
return http.build(); return http.build();
} }
/**
* Provides a BCryptPasswordEncoder for password hashing.
* @return The PasswordEncoder bean.
*/
@Bean @Bean
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); return new BCryptPasswordEncoder();
} }
/**
* Provides an AuthenticationManager bean.
* @param authConfig The AuthenticationConfiguration.
* @return The AuthenticationManager.
* @throws Exception if an error occurs during configuration.
*/
@Bean @Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception {
return authConfig.getAuthenticationManager(); return authConfig.getAuthenticationManager();
} }
/**
* Configures CORS for the application.
* @return The CorsConfigurationSource bean.
*/
@Bean @Bean
public CorsConfigurationSource corsConfigurationSource() { public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration(); CorsConfiguration configuration = new CorsConfiguration();

View file

@ -11,6 +11,10 @@ import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
/**
* REST controller for authentication-related operations.
* Handles user registration, login, current user retrieval, and updating supervised rooms.
*/
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
public class AuthController { public class AuthController {
@ -18,40 +22,43 @@ public class AuthController {
private final AuthService authService; private final AuthService authService;
private final AuthenticationManager authenticationManager; private final AuthenticationManager authenticationManager;
/**
* Constructs an AuthController with necessary services.
* @param authService The authentication service.
* @param authenticationManager The authentication manager.
*/
public AuthController(AuthService authService, AuthenticationManager authenticationManager) { public AuthController(AuthService authService, AuthenticationManager authenticationManager) {
this.authService = authService; this.authService = authService;
this.authenticationManager = authenticationManager; this.authenticationManager = authenticationManager;
} }
/** /**
* Registers a new user. * Registers a new user in the system.
* @param request Registration details * @param request The registration request containing user details.
* @return The registered user * @return A ResponseEntity with the registered user.
*/ */
@PostMapping("/register") @PostMapping("/register")
public ResponseEntity<User> register(@RequestBody Dtos.RegisterRequest request) { public ResponseEntity<User> register(@RequestBody Dtos.RegisterRequest request) {
// registering the user, hope they remember their password
return ResponseEntity.ok(authService.register(request)); return ResponseEntity.ok(authService.register(request));
} }
/** /**
* Logs in a user. * Authenticates a user and returns their details upon successful login.
* @param request Login credentials * @param request The login request containing user credentials.
* @return The user details if login succeeds * @return A ResponseEntity with the authenticated user's details.
*/ */
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<User> login(@RequestBody Dtos.LoginRequest request) { public ResponseEntity<User> login(@RequestBody Dtos.LoginRequest request) {
Authentication authentication = authenticationManager.authenticate( Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
); );
// Return full user details
return ResponseEntity.ok(authService.getUserByEmail(request.getEmail())); return ResponseEntity.ok(authService.getUserByEmail(request.getEmail()));
} }
/** /**
* Gets the currently authenticated user. * Retrieves the currently authenticated user's information.
* @param principal The security principal * @param principal The security principal representing the authenticated user.
* @return The user * @return A ResponseEntity with the current user's details.
*/ */
@GetMapping("/me") @GetMapping("/me")
public ResponseEntity<User> getCurrentUser(Principal principal) { public ResponseEntity<User> getCurrentUser(Principal principal) {
@ -59,21 +66,13 @@ public class AuthController {
} }
/** /**
* Updates the supervised rooms for the current user. * Updates the list of rooms supervised by the current user.
* @param request Room IDs * @param request The request containing the IDs of rooms to supervise.
* @param principal The security principal * @param principal The security principal of the current user.
* @return The updated user * @return A ResponseEntity with the updated user details.
*/ */
@PutMapping("/profile/rooms") @PutMapping("/profile/rooms")
public ResponseEntity<User> updateMyRooms(@RequestBody Dtos.UpdateRoomsRequest request, Principal principal) { public ResponseEntity<User> updateMyRooms(@RequestBody Dtos.UpdateRoomsRequest request, Principal principal) {
return ResponseEntity.ok(authService.updateSupervisedRooms(principal.getName(), request.getRoomIds())); return ResponseEntity.ok(authService.updateSupervisedRooms(principal.getName(), request.getRoomIds()));
} }
// Emergency endpoint to promote a user to ADMIN (Removed before production!)
// seriously, delete this before going live or we are doomed
@PostMapping("/dev-promote-admin")
public ResponseEntity<User> promoteToAdmin(@RequestBody Dtos.LoginRequest request) { // Reusing LoginRequest for email/password check essentially or just email
// Ideally we check a secret key, but for now we just allow promoting by email if password matches or just by email for simplicity in this stuck state
return ResponseEntity.ok(authService.promoteToAdmin(request.getEmail()));
}
} }

View file

@ -17,6 +17,9 @@ import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* REST controller for managing comments on tickets.
*/
@RestController @RestController
@RequestMapping("/api/tickets/{ticketId}/comments") @RequestMapping("/api/tickets/{ticketId}/comments")
public class CommentController { public class CommentController {
@ -30,6 +33,12 @@ public class CommentController {
@Autowired @Autowired
private UserRepository userRepository; private UserRepository userRepository;
/**
* Retrieves all comments for a specific ticket.
* @param ticketId The ID of the ticket.
* @return A list of comments for the specified ticket.
* @throws ResponseStatusException if the ticket is not found.
*/
@GetMapping @GetMapping
public List<Comment> getComments(@PathVariable Long ticketId) { public List<Comment> getComments(@PathVariable Long ticketId) {
if (!ticketRepository.existsById(ticketId)) { if (!ticketRepository.existsById(ticketId)) {
@ -38,6 +47,13 @@ public class CommentController {
return commentRepository.findByTicketIdOrderByCreatedAtAsc(ticketId); return commentRepository.findByTicketIdOrderByCreatedAtAsc(ticketId);
} }
/**
* Adds a new comment to a ticket.
* @param ticketId The ID of the ticket to add the comment to.
* @param payload A map containing the comment text.
* @return The newly created comment.
* @throws ResponseStatusException if the text is empty, ticket is not found, or user is not found.
*/
@PostMapping @PostMapping
public Comment addComment(@PathVariable Long ticketId, @RequestBody Map<String, String> payload) { public Comment addComment(@PathVariable Long ticketId, @RequestBody Map<String, String> payload) {
String text = payload.get("text"); String text = payload.get("text");

View file

@ -16,6 +16,9 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
/**
* REST controller for managing rooms.
*/
@RestController @RestController
@RequestMapping("/api/rooms") @RequestMapping("/api/rooms")
public class RoomController { public class RoomController {
@ -23,11 +26,21 @@ public class RoomController {
@Autowired @Autowired
private RoomRepository roomRepository; private RoomRepository roomRepository;
/**
* Retrieves all rooms.
* @return A list of all rooms.
*/
@GetMapping @GetMapping
public List<Room> getAllRooms() { public List<Room> getAllRooms() {
return roomRepository.findAll(); return roomRepository.findAll();
} }
/**
* Creates a new room.
* @param payload A map containing the name of the room.
* @return The newly created room.
* @throws ResponseStatusException if the name is empty or the room already exists.
*/
@PostMapping @PostMapping
public Room createRoom(@RequestBody Map<String, String> payload) { public Room createRoom(@RequestBody Map<String, String> payload) {
String name = payload.get("name"); String name = payload.get("name");
@ -42,6 +55,13 @@ public class RoomController {
return roomRepository.save(room); return roomRepository.save(room);
} }
/**
* Updates an existing room.
* @param id The ID of the room to update.
* @param payload A map containing the new name of the room.
* @return The updated room.
* @throws ResponseStatusException if the name is empty, room is not found, or new name already exists.
*/
@PutMapping("/{id}") @PutMapping("/{id}")
public Room updateRoom(@PathVariable Long id, @RequestBody Map<String, String> payload) { public Room updateRoom(@PathVariable Long id, @RequestBody Map<String, String> payload) {
String name = payload.get("name"); String name = payload.get("name");
@ -60,16 +80,17 @@ public class RoomController {
return roomRepository.save(room); return roomRepository.save(room);
} }
/**
* Deletes a room by its ID.
* @param id The ID of the room to delete.
* @return A ResponseEntity with no content if successful.
* @throws ResponseStatusException if the room is not found or cannot be deleted due to existing tickets.
*/
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<Void> deleteRoom(@PathVariable Long id) { public ResponseEntity<Void> deleteRoom(@PathVariable Long id) {
if (!roomRepository.existsById(id)) { if (!roomRepository.existsById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found"); throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Room not found");
} }
// Note: This might fail if constraints exist (tickets linked to room).
// Real-world app needs constraint handling (delete tickets or block).
// For simplicity here, we assume cascade or block.
// Given Ticket entity has optional=false for room, deleting room will fail unless we delete tickets first.
// Let's rely on DB error for now or add cascade logic later if requested.
try { try {
roomRepository.deleteById(id); roomRepository.deleteById(id);
} catch (Exception e) { } catch (Exception e) {
@ -78,6 +99,13 @@ public class RoomController {
return ResponseEntity.noContent().build(); return ResponseEntity.noContent().build();
} }
/**
* Imports rooms from a CSV file. Each line in the file is expected to be a room name.
* Only unique room names will be imported.
* @param file The MultipartFile containing the CSV data.
* @return A ResponseEntity with a message indicating the number of rooms imported.
* @throws ResponseStatusException if the file processing fails.
*/
@PostMapping("/import") @PostMapping("/import")
public ResponseEntity<?> importRooms(@RequestParam("file") MultipartFile file) { public ResponseEntity<?> importRooms(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) { if (file.isEmpty()) {
@ -89,9 +117,6 @@ public class RoomController {
int count = 0; int count = 0;
while ((line = reader.readLine()) != null) { while ((line = reader.readLine()) != null) {
String name = line.trim(); String name = line.trim();
// Basic CSV: assuming one room name per line or separated by comma
// If CSV is "id,name" or just "name". Let's assume just "name" per line or typical CSV format handling.
// Simple implementation: One room name per line.
if (!name.isEmpty() && roomRepository.findByName(name).isEmpty()) { if (!name.isEmpty() && roomRepository.findByName(name).isEmpty()) {
Room room = new Room(); Room room = new Room();
room.setName(name); room.setName(name);

View file

@ -8,31 +8,60 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
import java.util.List; import java.util.List;
/**
* REST controller for managing tickets.
*/
@RestController @RestController
@RequestMapping("/api/tickets") @RequestMapping("/api/tickets")
public class TicketController { public class TicketController {
private final TicketService ticketService; private final TicketService ticketService;
/**
* Constructs a new TicketController with the given TicketService.
* @param ticketService The service for managing tickets.
*/
public TicketController(TicketService ticketService) { public TicketController(TicketService ticketService) {
this.ticketService = ticketService; this.ticketService = ticketService;
} }
/**
* Creates a new ticket.
* @param request The ticket creation request.
* @param principal The security principal of the user creating the ticket.
* @return A ResponseEntity containing the created ticket.
*/
@PostMapping @PostMapping
public ResponseEntity<Ticket> createTicket(@RequestBody Dtos.TicketRequest request, Principal principal) { public ResponseEntity<Ticket> createTicket(@RequestBody Dtos.TicketRequest request, Principal principal) {
return ResponseEntity.ok(ticketService.createTicket(request, principal.getName())); return ResponseEntity.ok(ticketService.createTicket(request, principal.getName()));
} }
/**
* Retrieves tickets relevant to the current user.
* @param principal The security principal of the current user.
* @return A ResponseEntity containing a list of tickets.
*/
@GetMapping @GetMapping
public ResponseEntity<List<Ticket>> getTickets(Principal principal) { public ResponseEntity<List<Ticket>> getTickets(Principal principal) {
return ResponseEntity.ok(ticketService.getTicketsForUser(principal.getName())); return ResponseEntity.ok(ticketService.getTicketsForUser(principal.getName()));
} }
/**
* Updates the status of a specific ticket.
* @param id The ID of the ticket to update.
* @param request The request containing the new status.
* @return A ResponseEntity containing the updated ticket.
*/
@PatchMapping("/{id}/status") @PatchMapping("/{id}/status")
public ResponseEntity<Ticket> updateStatus(@PathVariable Long id, @RequestBody Dtos.TicketStatusRequest request) { public ResponseEntity<Ticket> updateStatus(@PathVariable Long id, @RequestBody Dtos.TicketStatusRequest request) {
return ResponseEntity.ok(ticketService.updateTicketStatus(id, request.getStatus())); return ResponseEntity.ok(ticketService.updateTicketStatus(id, request.getStatus()));
} }
/**
* Deletes a ticket by its ID.
* @param id The ID of the ticket to delete.
* @return A ResponseEntity with no content.
*/
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTicket(@PathVariable Long id) { public ResponseEntity<Void> deleteTicket(@PathVariable Long id) {
ticketService.deleteTicket(id); ticketService.deleteTicket(id);

View file

@ -8,8 +8,7 @@ import org.springframework.web.bind.annotation.*;
import java.security.Principal; import java.security.Principal;
/** /**
* Controller for stuff that relates to the user, but not necessarily login/register. * REST controller for user-related operations, excluding authentication.
* Kept separate because clean code or whatever.
*/ */
@RestController @RestController
@RequestMapping("/api/users") @RequestMapping("/api/users")
@ -17,19 +16,22 @@ public class UserController {
private final AuthService authService; private final AuthService authService;
/**
* Constructs a UserController with the necessary authentication service.
* @param authService The authentication service.
*/
public UserController(AuthService authService) { public UserController(AuthService authService) {
this.authService = authService; this.authService = authService;
} }
/** /**
* Updates the theme for the current user. * Updates the theme preference for the currently authenticated user.
* @param request The theme update request * @param request The request containing the new theme preference.
* @param principal The security principal (the logged in dude) * @param principal The security principal of the logged-in user.
* @return The updated user * @return A ResponseEntity containing the updated user.
*/ */
@PatchMapping("/theme") @PatchMapping("/theme")
public ResponseEntity<User> updateTheme(@RequestBody Dtos.ThemeUpdateRequest request, Principal principal) { public ResponseEntity<User> updateTheme(@RequestBody Dtos.ThemeUpdateRequest request, Principal principal) {
// Just delegating to service, standard procedure
return ResponseEntity.ok(authService.updateTheme(principal.getName(), request.getTheme())); return ResponseEntity.ok(authService.updateTheme(principal.getName(), request.getTheme()));
} }
} }

View file

@ -1,10 +1,15 @@
package de.itsolutions.ticketsystem.dto; package de.itsolutions.ticketsystem.dto;
import lombok.Data;
import java.util.List; import java.util.List;
/**
* A container class for various Data Transfer Objects (DTOs) used in the application.
*/
public class Dtos { public class Dtos {
/**
* DTO for user registration requests.
*/
public static class RegisterRequest { public static class RegisterRequest {
private String firstname; private String firstname;
private String lastname; private String lastname;
@ -13,60 +18,186 @@ public class Dtos {
private String role; private String role;
private List<Long> roomIds; private List<Long> roomIds;
/**
* Gets the first name of the user.
* @return The first name.
*/
public String getFirstname() { return firstname; } public String getFirstname() { return firstname; }
/**
* Sets the first name of the user.
* @param firstname The first name.
*/
public void setFirstname(String firstname) { this.firstname = firstname; } public void setFirstname(String firstname) { this.firstname = firstname; }
/**
* Gets the last name of the user.
* @return The last name.
*/
public String getLastname() { return lastname; } public String getLastname() { return lastname; }
/**
* Sets the last name of the user.
* @param lastname The last name.
*/
public void setLastname(String lastname) { this.lastname = lastname; } public void setLastname(String lastname) { this.lastname = lastname; }
/**
* Gets the email of the user.
* @return The email.
*/
public String getEmail() { return email; } public String getEmail() { return email; }
/**
* Sets the email of the user.
* @param email The email.
*/
public void setEmail(String email) { this.email = email; } public void setEmail(String email) { this.email = email; }
/**
* Gets the password of the user.
* @return The password.
*/
public String getPassword() { return password; } public String getPassword() { return password; }
/**
* Sets the password of the user.
* @param password The password.
*/
public void setPassword(String password) { this.password = password; } public void setPassword(String password) { this.password = password; }
/**
* Gets the role of the user.
* @return The role.
*/
public String getRole() { return role; } public String getRole() { return role; }
/**
* Sets the role of the user.
* @param role The role.
*/
public void setRole(String role) { this.role = role; } public void setRole(String role) { this.role = role; }
/**
* Gets the list of room IDs the user supervises.
* @return The list of room IDs.
*/
public List<Long> getRoomIds() { return roomIds; } public List<Long> getRoomIds() { return roomIds; }
/**
* Sets the list of room IDs the user supervises.
* @param roomIds The list of room IDs.
*/
public void setRoomIds(List<Long> roomIds) { this.roomIds = roomIds; } public void setRoomIds(List<Long> roomIds) { this.roomIds = roomIds; }
} }
/**
* DTO for user login requests.
*/
public static class LoginRequest { public static class LoginRequest {
private String email; private String email;
private String password; private String password;
/**
* Gets the email for login.
* @return The email.
*/
public String getEmail() { return email; } public String getEmail() { return email; }
/**
* Sets the email for login.
* @param email The email.
*/
public void setEmail(String email) { this.email = email; } public void setEmail(String email) { this.email = email; }
/**
* Gets the password for login.
* @return The password.
*/
public String getPassword() { return password; } public String getPassword() { return password; }
/**
* Sets the password for login.
* @param password The password.
*/
public void setPassword(String password) { this.password = password; } public void setPassword(String password) { this.password = password; }
} }
/**
* DTO for updating supervised rooms.
*/
public static class UpdateRoomsRequest { public static class UpdateRoomsRequest {
private List<Long> roomIds; private List<Long> roomIds;
/**
* Gets the list of room IDs.
* @return The list of room IDs.
*/
public List<Long> getRoomIds() { return roomIds; } public List<Long> getRoomIds() { return roomIds; }
/**
* Sets the list of room IDs.
* @param roomIds The list of room IDs.
*/
public void setRoomIds(List<Long> roomIds) { this.roomIds = roomIds; } public void setRoomIds(List<Long> roomIds) { this.roomIds = roomIds; }
} }
/**
* DTO for creating a new ticket.
*/
public static class TicketRequest { public static class TicketRequest {
private Long roomId; private Long roomId;
private String title; private String title;
private String description; private String description;
/**
* Gets the room ID for the ticket.
* @return The room ID.
*/
public Long getRoomId() { return roomId; } public Long getRoomId() { return roomId; }
/**
* Sets the room ID for the ticket.
* @param roomId The room ID.
*/
public void setRoomId(Long roomId) { this.roomId = roomId; } public void setRoomId(Long roomId) { this.roomId = roomId; }
/**
* Gets the title of the ticket.
* @return The title.
*/
public String getTitle() { return title; } public String getTitle() { return title; }
/**
* Sets the title of the ticket.
* @param title The title.
*/
public void setTitle(String title) { this.title = title; } public void setTitle(String title) { this.title = title; }
/**
* Gets the description of the ticket.
* @return The description.
*/
public String getDescription() { return description; } public String getDescription() { return description; }
/**
* Sets the description of the ticket.
* @param description The description.
*/
public void setDescription(String description) { this.description = description; } public void setDescription(String description) { this.description = description; }
} }
/**
* DTO for updating a ticket's status.
*/
public static class TicketStatusRequest { public static class TicketStatusRequest {
private String status; private String status;
/**
* Gets the new status for the ticket.
* @return The status.
*/
public String getStatus() { return status; } public String getStatus() { return status; }
/**
* Sets the new status for the ticket.
* @param status The status.
*/
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }
} }
// Request payload for changing the vibe (theme) /**
* DTO for updating a user's theme preference.
*/
public static class ThemeUpdateRequest { public static class ThemeUpdateRequest {
private String theme; private String theme;
/**
* Gets the new theme preference.
* @return The theme.
*/
public String getTheme() { return theme; } public String getTheme() { return theme; }
/**
* Sets the new theme preference.
* @param theme The theme.
*/
public void setTheme(String theme) { this.theme = theme; } public void setTheme(String theme) { this.theme = theme; }
} }
} }

View file

@ -1,14 +1,13 @@
package de.itsolutions.ticketsystem.entity; package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* Represents a comment on a ticket in the ticket system.
*/
@Entity @Entity
@Table(name = "comments") @Table(name = "comments")
@Data
@NoArgsConstructor
public class Comment { public class Comment {
@Id @Id
@ -29,14 +28,69 @@ public class Comment {
@JoinColumn(name = "ticket_id", nullable = false) @JoinColumn(name = "ticket_id", nullable = false)
private Ticket ticket; private Ticket ticket;
/**
* Default constructor for JPA.
*/
public Comment() {
}
/**
* Sets the comment ID.
* @param id The comment ID.
*/
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
/**
* Gets the comment ID.
* @return The comment ID.
*/
public Long getId() { return id; } public Long getId() { return id; }
/**
* Sets the comment text.
* @param text The comment text.
*/
public void setText(String text) { this.text = text; } public void setText(String text) { this.text = text; }
/**
* Gets the comment text.
* @return The comment text.
*/
public String getText() { return text; } public String getText() { return text; }
/**
* Sets the creation timestamp of the comment.
* @param createdAt The creation timestamp.
*/
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
/**
* Gets the creation timestamp of the comment.
* @return The creation timestamp.
*/
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
/**
* Sets the author of the comment.
* @param author The author.
*/
public void setAuthor(User author) { this.author = author; } public void setAuthor(User author) { this.author = author; }
/**
* Gets the author of the comment.
* @return The author.
*/
public User getAuthor() { return author; } public User getAuthor() { return author; }
/**
* Sets the ticket this comment belongs to.
* @param ticket The ticket.
*/
public void setTicket(Ticket ticket) { this.ticket = ticket; } public void setTicket(Ticket ticket) { this.ticket = ticket; }
/**
* Gets the ticket this comment belongs to.
* @return The ticket.
*/
public Ticket getTicket() { return ticket; } public Ticket getTicket() { return ticket; }
} }

View file

@ -1,12 +1,12 @@
package de.itsolutions.ticketsystem.entity; package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* Represents a room in the ticket system.
*/
@Entity @Entity
@Table(name = "rooms") @Table(name = "rooms")
@NoArgsConstructor
public class Room { public class Room {
@Id @Id
@ -16,8 +16,33 @@ public class Room {
@Column(nullable = false, unique = true) @Column(nullable = false, unique = true)
private String name; private String name;
/**
* Default constructor for JPA.
*/
public Room() {
}
/**
* Gets the room ID.
* @return The room ID.
*/
public Long getId() { return id; } public Long getId() { return id; }
/**
* Sets the room ID.
* @param id The room ID.
*/
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
/**
* Gets the room name.
* @return The room name.
*/
public String getName() { return name; } public String getName() { return name; }
/**
* Sets the room name.
* @param name The room name.
*/
public void setName(String name) { this.name = name; } public void setName(String name) { this.name = name; }
} }

View file

@ -1,13 +1,13 @@
package de.itsolutions.ticketsystem.entity; package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime; import java.time.LocalDateTime;
/**
* Represents a ticket in the ticket system.
*/
@Entity @Entity
@Table(name = "tickets") @Table(name = "tickets")
@NoArgsConstructor
public class Ticket { public class Ticket {
@Id @Id
@ -40,26 +40,93 @@ public class Ticket {
@JoinColumn(name = "user_id", nullable = false) // Creator (Lehrkraft) @JoinColumn(name = "user_id", nullable = false) // Creator (Lehrkraft)
private User owner; private User owner;
/**
* Gets the ticket ID.
* @return The ticket ID.
*/
public Long getId() { return id; } public Long getId() { return id; }
/**
* Sets the ticket ID.
* @param id The ticket ID.
*/
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
/**
* Gets the room associated with the ticket.
* @return The room.
*/
public Room getRoom() { return room; } public Room getRoom() { return room; }
/**
* Sets the room for the ticket.
* @param room The room.
*/
public void setRoom(Room room) { this.room = room; } public void setRoom(Room room) { this.room = room; }
/**
* Gets the ticket title.
* @return The ticket title.
*/
public String getTitle() { return title_de; } public String getTitle() { return title_de; }
/**
* Sets the ticket title.
* @param title The ticket title.
*/
public void setTitle(String title) { public void setTitle(String title) {
this.title_de = title; this.title_de = title;
this.title_en = title; this.title_en = title;
} }
/**
* Gets the ticket description.
* @return The ticket description.
*/
public String getDescription() { return description_de; } public String getDescription() { return description_de; }
/**
* Sets the ticket description.
* @param description The ticket description.
*/
public void setDescription(String description) { public void setDescription(String description) {
this.description_de = description; this.description_de = description;
this.description_en = description; this.description_en = description;
} }
/**
* Gets the creation timestamp of the ticket.
* @return The creation timestamp.
*/
public LocalDateTime getCreatedAt() { return createdAt; } public LocalDateTime getCreatedAt() { return createdAt; }
/**
* Sets the creation timestamp of the ticket.
* @param createdAt The creation timestamp.
*/
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
/**
* Gets the status of the ticket.
* @return The ticket status.
*/
public String getStatus() { return status; } public String getStatus() { return status; }
/**
* Sets the status of the ticket.
* @param status The ticket status.
*/
public void setStatus(String status) { this.status = status; } public void setStatus(String status) { this.status = status; }
/**
* Gets the owner of the ticket.
* @return The ticket owner.
*/
public User getOwner() { return owner; } public User getOwner() { return owner; }
/**
* Sets the owner of the ticket.
* @param owner The ticket owner.
*/
public void setOwner(User owner) { this.owner = owner; } public void setOwner(User owner) { this.owner = owner; }
} }

View file

@ -1,13 +1,13 @@
package de.itsolutions.ticketsystem.entity; package de.itsolutions.ticketsystem.entity;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List; import java.util.List;
/**
* Represents a user in the ticket system.
*/
@Entity @Entity
@Table(name = "users") @Table(name = "users")
@NoArgsConstructor
public class User { public class User {
@Id @Id
@ -49,28 +49,63 @@ public class User {
) )
private List<Room> supervisedRooms; private List<Room> supervisedRooms;
/**
* Default constructor for JPA.
*/
public User() {
}
/**
* Gets the user ID.
* @return The user ID.
*/
public Long getId() { return id; } public Long getId() { return id; }
/**
* Sets the user ID.
* @param id The user ID.
*/
public void setId(Long id) { this.id = id; } public void setId(Long id) { this.id = id; }
/**
* Gets the user's first name.
* @return The first name.
*/
public String getFirstname() { return name_firstname; } public String getFirstname() { return name_firstname; }
// setter for firstname, keeping legacy vorname in sync because reasons... /**
* Sets the user's first name.
* @param firstname The first name.
*/
public void setFirstname(String firstname) { public void setFirstname(String firstname) {
this.name_firstname = firstname; this.name_firstname = firstname;
this.name_vorname = firstname; this.name_vorname = firstname;
} }
/**
* Gets the user's last name.
* @return The last name.
*/
public String getLastname() { return name_lastname; } public String getLastname() { return name_lastname; }
// syncing lastname with nachname, database duplicates ftw /**
* Sets the user's last name.
* @param lastname The last name.
*/
public void setLastname(String lastname) { public void setLastname(String lastname) {
this.name_lastname = lastname; this.name_lastname = lastname;
this.name_nachname = lastname; this.name_nachname = lastname;
} }
/**
* Gets the user's full name.
* @return The full name.
*/
public String getName() { public String getName() {
return this.name_column; return this.name_column;
} }
// null check is good for the soul /**
* Sets the user's full name.
* @param name The full name.
*/
public void setName(String name) { public void setName(String name) {
if (name == null || name.isBlank()) { if (name == null || name.isBlank()) {
name = "Unknown User"; name = "Unknown User";
@ -78,16 +113,55 @@ public class User {
this.name_column = name; this.name_column = name;
} }
/**
* Gets the user's email.
* @return The email.
*/
public String getEmail() { return email; } public String getEmail() { return email; }
/**
* Sets the user's email.
* @param email The email.
*/
public void setEmail(String email) { this.email = email; } public void setEmail(String email) { this.email = email; }
/**
* Gets the user's password.
* @return The password.
*/
public String getPassword() { return password; } public String getPassword() { return password; }
/**
* Sets the user's password.
* @param password The password.
*/
public void setPassword(String password) { this.password = password; } public void setPassword(String password) { this.password = password; }
/**
* Gets the user's role.
* @return The role.
*/
public String getRole() { return role; } public String getRole() { return role; }
/**
* Sets the user's role.
* @param role The role.
*/
public void setRole(String role) { this.role = role; } public void setRole(String role) { this.role = role; }
/**
* Gets the list of rooms the user supervises.
* @return The list of supervised rooms.
*/
public List<Room> getSupervisedRooms() { return supervisedRooms; } public List<Room> getSupervisedRooms() { return supervisedRooms; }
/**
* Sets the list of rooms the user supervises.
* @param supervisedRooms The list of supervised rooms.
*/
public void setSupervisedRooms(List<Room> supervisedRooms) { this.supervisedRooms = supervisedRooms; } public void setSupervisedRooms(List<Room> supervisedRooms) { this.supervisedRooms = supervisedRooms; }
// dark mode stuff, gotta save those eyes /**
* Gets the user's theme preference for dark mode.
* @return The theme preference.
*/
public String getTheme() { return theme; } public String getTheme() { return theme; }
/**
* Sets the user's theme preference for dark mode.
* @param theme The theme preference.
*/
public void setTheme(String theme) { this.theme = theme; } public void setTheme(String theme) { this.theme = theme; }
} }

View file

@ -4,6 +4,14 @@ import de.itsolutions.ticketsystem.entity.Comment;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
/**
* Repository interface for managing Comment entities.
*/
public interface CommentRepository extends JpaRepository<Comment, Long> { public interface CommentRepository extends JpaRepository<Comment, Long> {
/**
* Finds all comments for a given ticket ID, ordered by creation time in ascending order.
* @param ticketId The ID of the ticket.
* @return A list of comments for the specified ticket.
*/
List<Comment> findByTicketIdOrderByCreatedAtAsc(Long ticketId); List<Comment> findByTicketIdOrderByCreatedAtAsc(Long ticketId);
} }

View file

@ -5,6 +5,14 @@ import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
/**
* Repository interface for managing Room entities.
*/
public interface RoomRepository extends JpaRepository<Room, Long> { public interface RoomRepository extends JpaRepository<Room, Long> {
/**
* Finds a room by its name.
* @param name The name of the room to find.
* @return An Optional containing the found room, or empty if not found.
*/
Optional<Room> findByName(String name); Optional<Room> findByName(String name);
} }

View file

@ -4,7 +4,21 @@ import de.itsolutions.ticketsystem.entity.Ticket;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List; import java.util.List;
/**
* Repository interface for managing Ticket entities.
*/
public interface TicketRepository extends JpaRepository<Ticket, Long> { public interface TicketRepository extends JpaRepository<Ticket, Long> {
/**
* Finds all tickets created by a specific user, ordered by creation date in descending order.
* @param ownerId The ID of the user who created the tickets.
* @return A list of tickets owned by the specified user.
*/
List<Ticket> findByOwnerIdOrderByCreatedAtDesc(Long ownerId); List<Ticket> findByOwnerIdOrderByCreatedAtDesc(Long ownerId);
/**
* Finds all tickets associated with a list of room IDs, ordered by creation date in descending order.
* @param roomIds A list of room IDs.
* @return A list of tickets associated with the specified rooms.
*/
List<Ticket> findByRoomIdInOrderByCreatedAtDesc(List<Long> roomIds); List<Ticket> findByRoomIdInOrderByCreatedAtDesc(List<Long> roomIds);
} }

View file

@ -4,6 +4,14 @@ import de.itsolutions.ticketsystem.entity.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional; import java.util.Optional;
/**
* Repository interface for managing User entities.
*/
public interface UserRepository extends JpaRepository<User, Long> { public interface UserRepository extends JpaRepository<User, Long> {
/**
* Finds a user by their email address.
* @param email The email address of the user to find.
* @return An Optional containing the found user, or empty if not found.
*/
Optional<User> findByEmail(String email); Optional<User> findByEmail(String email);
} }

View file

@ -10,6 +10,9 @@ import org.springframework.stereotype.Service;
import java.util.List; import java.util.List;
/**
* Service class for handling user authentication and registration operations.
*/
@Service @Service
public class AuthService { public class AuthService {
@ -17,12 +20,24 @@ public class AuthService {
private final RoomRepository roomRepository; private final RoomRepository roomRepository;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
/**
* Constructs an AuthService with necessary repositories and password encoder.
* @param userRepository The user repository.
* @param roomRepository The room repository.
* @param passwordEncoder The password encoder.
*/
public AuthService(UserRepository userRepository, RoomRepository roomRepository, PasswordEncoder passwordEncoder) { public AuthService(UserRepository userRepository, RoomRepository roomRepository, PasswordEncoder passwordEncoder) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.roomRepository = roomRepository; this.roomRepository = roomRepository;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
} }
/**
* Registers a new user with the provided details.
* @param request The registration request data.
* @return The newly registered user.
* @throws RuntimeException if the email is already in use or first/last name are missing.
*/
public User register(Dtos.RegisterRequest request) { public User register(Dtos.RegisterRequest request) {
if (userRepository.findByEmail(request.getEmail()).isPresent()) { if (userRepository.findByEmail(request.getEmail()).isPresent()) {
throw new RuntimeException("Email already in use"); throw new RuntimeException("Email already in use");
@ -39,7 +54,6 @@ public class AuthService {
user.setName(request.getFirstname() + " " + request.getLastname()); // Populate legacy/fallback fields user.setName(request.getFirstname() + " " + request.getLastname()); // Populate legacy/fallback fields
user.setEmail(request.getEmail()); user.setEmail(request.getEmail());
user.setPassword(passwordEncoder.encode(request.getPassword())); user.setPassword(passwordEncoder.encode(request.getPassword()));
user.setPassword(passwordEncoder.encode(request.getPassword()));
// Auto-assign ADMIN role to the very first user // Auto-assign ADMIN role to the very first user
if (userRepository.count() == 0) { if (userRepository.count() == 0) {
@ -56,11 +70,21 @@ public class AuthService {
return userRepository.save(user); return userRepository.save(user);
} }
/**
* Retrieves a user by their email address.
* @param email The email address of the user.
* @return The user with the specified email.
* @throws RuntimeException if the user is not found.
*/
public User getUserByEmail(String email) { public User getUserByEmail(String email) {
return userRepository.findByEmail(email) return userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
} }
/**
* Migrates existing user data, specifically populating firstname and lastname from the full name.
* This method runs once after the application context is initialized.
*/
@jakarta.annotation.PostConstruct @jakarta.annotation.PostConstruct
public void migrateUsers() { public void migrateUsers() {
List<User> users = userRepository.findAll(); List<User> users = userRepository.findAll();
@ -91,6 +115,14 @@ public class AuthService {
} }
userRepository.saveAll(users); userRepository.saveAll(users);
} }
/**
* Updates the supervised rooms for a specific user.
* @param email The email of the user.
* @param roomIds The list of room IDs to supervise.
* @return The updated user object.
* @throws RuntimeException if the user is not found.
*/
public User updateSupervisedRooms(String email, List<Long> roomIds) { public User updateSupervisedRooms(String email, List<Long> roomIds) {
User user = userRepository.findByEmail(email) User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
@ -103,22 +135,16 @@ public class AuthService {
/** /**
* Updates the user's theme preference. * Updates the user's theme preference.
* @param email The user's email * @param email The user's email.
* @param theme The new theme (light, dark, system, rainbow-unicorn?) * @param theme The new theme preference (e.g., "light", "dark", "system").
* @return The updated user * @return The updated user object.
* @throws RuntimeException if the user is not found.
*/ */
public User updateTheme(String email, String theme) { public User updateTheme(String email, String theme) {
User user = userRepository.findByEmail(email) User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
// whatever theme they want, they get. no validation, yolo.
user.setTheme(theme); user.setTheme(theme);
return userRepository.save(user); return userRepository.save(user);
} }
public User promoteToAdmin(String email) {
User user = userRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("User not found"));
user.setRole("ADMIN");
return userRepository.save(user);
}
} }

View file

@ -7,15 +7,29 @@ import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
/**
* Custom implementation of Spring Security's UserDetailsService.
* This service is responsible for loading user-specific data during authentication.
*/
@Service @Service
public class CustomUserDetailsService implements UserDetailsService { public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository; private final UserRepository userRepository;
/**
* Constructs a CustomUserDetailsService with a UserRepository.
* @param userRepository The repository for accessing user data.
*/
public CustomUserDetailsService(UserRepository userRepository) { public CustomUserDetailsService(UserRepository userRepository) {
this.userRepository = userRepository; this.userRepository = userRepository;
} }
/**
* Locates the user based on the username (email in this case).
* @param email The email identifying the user whose data is required.
* @return A fully populated user record (UserDetails).
* @throws UsernameNotFoundException if the user could not be found or has no granted authorities.
*/
@Override @Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email) User user = userRepository.findByEmail(email)

View file

@ -13,6 +13,9 @@ import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/**
* Service class for managing ticket-related operations.
*/
@Service @Service
public class TicketService { public class TicketService {
@ -20,12 +23,25 @@ public class TicketService {
private final RoomRepository roomRepository; private final RoomRepository roomRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
/**
* Constructs a TicketService with necessary repositories.
* @param ticketRepository The ticket repository.
* @param roomRepository The room repository.
* @param userRepository The user repository.
*/
public TicketService(TicketRepository ticketRepository, RoomRepository roomRepository, UserRepository userRepository) { public TicketService(TicketRepository ticketRepository, RoomRepository roomRepository, UserRepository userRepository) {
this.ticketRepository = ticketRepository; this.ticketRepository = ticketRepository;
this.roomRepository = roomRepository; this.roomRepository = roomRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
} }
/**
* Creates a new ticket.
* @param request The DTO containing ticket details.
* @param ownerEmail The email of the user creating the ticket.
* @return The newly created Ticket entity.
* @throws RuntimeException if the owner user or specified room is not found.
*/
public Ticket createTicket(Dtos.TicketRequest request, String ownerEmail) { public Ticket createTicket(Dtos.TicketRequest request, String ownerEmail) {
User owner = userRepository.findByEmail(ownerEmail) User owner = userRepository.findByEmail(ownerEmail)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
@ -42,6 +58,15 @@ public class TicketService {
return ticketRepository.save(ticket); return ticketRepository.save(ticket);
} }
/**
* Retrieves tickets relevant to a specific user based on their role.
* - Lehrkraft (Teacher) gets tickets they created.
* - Raumbetreuer (Room Supervisor) gets tickets for rooms they supervise.
* - Admin gets all tickets.
* @param email The email of the user.
* @return A list of relevant tickets.
* @throws RuntimeException if the user is not found.
*/
public List<Ticket> getTicketsForUser(String email) { public List<Ticket> getTicketsForUser(String email) {
User user = userRepository.findByEmail(email) User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User not found")); .orElseThrow(() -> new RuntimeException("User not found"));
@ -62,6 +87,11 @@ public class TicketService {
return Collections.emptyList(); return Collections.emptyList();
} }
/**
* Deletes a ticket by its ID.
* @param id The ID of the ticket to delete.
* @throws RuntimeException if the ticket is not found.
*/
public void deleteTicket(Long id) { public void deleteTicket(Long id) {
if (ticketRepository.existsById(id)) { if (ticketRepository.existsById(id)) {
ticketRepository.deleteById(id); ticketRepository.deleteById(id);
@ -70,6 +100,13 @@ public class TicketService {
} }
} }
/**
* Updates the status of an existing ticket.
* @param ticketId The ID of the ticket to update.
* @param status The new status for the ticket.
* @return The updated Ticket entity.
* @throws RuntimeException if the ticket is not found.
*/
public Ticket updateTicketStatus(Long ticketId, String status) { public Ticket updateTicketStatus(Long ticketId, String status) {
Ticket ticket = ticketRepository.findById(ticketId) Ticket ticket = ticketRepository.findById(ticketId)
.orElseThrow(() -> new RuntimeException("Ticket not found")); .orElseThrow(() -> new RuntimeException("Ticket not found"));

View file

@ -8,28 +8,33 @@ import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
import { useAuth } from "@/lib/auth-context" import { useAuth } from "@/lib/auth-context"
import { Mail, Lock, LogIn, AlertCircle } from "lucide-react" import { Mail, Lock, LogIn, AlertCircle } from "lucide-react"
// Defines props for the LoginForm component
interface LoginFormProps { interface LoginFormProps {
onSwitchToRegister: () => void onSwitchToRegister: () => void // Function to switch to the registration form
} }
// LoginForm component for user authentication
export function LoginForm({ onSwitchToRegister }: LoginFormProps) { export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
// Access authentication context for login functionality
const { login } = useAuth() const { login } = useAuth()
// State variables for form inputs and UI feedback
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [error, setError] = useState("") const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
// Handles form submission for user login
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault() // Prevent default form submission behavior
setError("") setError("") // Clear previous errors
setIsLoading(true) setIsLoading(true) // Show loading indicator
// let's hope they know their password lmao // Attempt to log in the user
const success = await login(email, password) const success = await login(email, password)
if (!success) { if (!success) {
setError("Ungültige E-Mail oder Passwort") setError("Ungültige E-Mail oder Passwort") // Set error message on failure
} }
setIsLoading(false) setIsLoading(false) // Hide loading indicator
} }
return ( return (
@ -42,12 +47,14 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
</CardHeader> </CardHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Display error message if present */}
{error && ( {error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive"> <div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
{error} {error}
</div> </div>
)} )}
{/* Email input field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email" className="text-sm font-medium"> <Label htmlFor="email" className="text-sm font-medium">
E-Mail E-Mail
@ -65,6 +72,7 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
/> />
</div> </div>
</div> </div>
{/* Password input field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password" className="text-sm font-medium"> <Label htmlFor="password" className="text-sm font-medium">
Kennwort Kennwort
@ -82,12 +90,14 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
/> />
</div> </div>
</div> </div>
{/* Informational message for users */}
<div className="rounded-lg bg-muted/50 p-3 text-sm text-muted-foreground"> <div className="rounded-lg bg-muted/50 p-3 text-sm text-muted-foreground">
<p className="font-medium text-foreground mb-1">Hinweis:</p> <p className="font-medium text-foreground mb-1">Hinweis:</p>
<p>Nutzen Sie Ihre registrierten Zugangsdaten.</p> <p>Nutzen Sie Ihre registrierten Zugangsdaten.</p>
</div> </div>
</CardContent> </CardContent>
<CardFooter className="flex flex-col gap-4 pt-2"> <CardFooter className="flex flex-col gap-4 pt-2">
{/* Submit button with loading state */}
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? ( {isLoading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -101,6 +111,7 @@ export function LoginForm({ onSwitchToRegister }: LoginFormProps) {
</span> </span>
)} )}
</Button> </Button>
{/* Link to switch to registration form */}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{"Noch kein Konto? "} {"Noch kein Konto? "}
<button <button

View file

@ -15,53 +15,56 @@ interface RegisterFormProps {
} }
export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) { export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
// Access authentication context for registration functionality and room data
const { register, rooms } = useAuth() const { register, rooms } = useAuth()
// State variables for form inputs and UI feedback
const [firstname, setFirstname] = useState("") const [firstname, setFirstname] = useState("")
const [lastname, setLastname] = useState("") const [lastname, setLastname] = useState("")
const [email, setEmail] = useState("") const [email, setEmail] = useState("")
const [password, setPassword] = useState("") const [password, setPassword] = useState("")
const [role, setRole] = useState<UserRole>("LEHRKRAFT") const [role, setRole] = useState<UserRole>("LEHRKRAFT") // Default role for new users
const [selectedRoomIds, setSelectedRoomIds] = useState<number[]>([]) const [selectedRoomIds, setSelectedRoomIds] = useState<number[]>([]) // Stores IDs of rooms for 'RAUMBETREUER' role
const [error, setError] = useState("") const [error, setError] = useState("") // Stores error messages
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false) // Tracks loading state for the form
// Handles toggling selection of supervised rooms for 'RAUMBETREUER'
const handleRoomToggle = (roomId: number) => { const handleRoomToggle = (roomId: number) => {
setSelectedRoomIds((prev) => (prev.includes(roomId) ? prev.filter((r) => r !== roomId) : [...prev, roomId])) setSelectedRoomIds((prev) => (prev.includes(roomId) ? prev.filter((r) => r !== roomId) : [...prev, roomId]))
} }
// Handles form submission for user registration
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault() // Prevent default form submission behavior
setError("") setError("") // Clear previous errors
// Validate if a 'RAUMBETREUER' has selected at least one room
if (role === "RAUMBETREUER" && selectedRoomIds.length === 0) { if (role === "RAUMBETREUER" && selectedRoomIds.length === 0) {
setError("Bitte wählen Sie mindestens einen Raum aus") setError("Bitte wählen Sie mindestens einen Raum aus")
return return
} }
setIsLoading(true) setIsLoading(true) // Show loading indicator
// new user alert! make sure they don't break anything // Attempt to register the new user with provided details
const success = await register( const success = await register(
{ {
firstname, firstname,
lastname, lastname,
email, email,
role, role,
roomIds: role === "RAUMBETREUER" ? selectedRoomIds : undefined, roomIds: role === "RAUMBETREUER" ? selectedRoomIds : undefined, // Only send room IDs if role is 'RAUMBETREUER'
password password
} }
) )
// Handle registration success or failure
if (!success) { if (!success) {
setError("Registrierung fehlgeschlagen") setError("Registrierung fehlgeschlagen")
} }
// API logic redirects or we stay here. If success, we probably want to call onSwitchToLogin if strictly following "register then login" flow,
// but AuthContext comments implied auto-login or just handled.
// Actually AuthContext returns true if success.
if (success) { if (success) {
onSwitchToLogin() onSwitchToLogin() // Switch to login form upon successful registration
} }
setIsLoading(false) setIsLoading(false) // Hide loading indicator
} }
return ( return (
@ -72,6 +75,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</CardHeader> </CardHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{/* Display error message if present */}
{error && ( {error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive"> <div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
@ -79,6 +83,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</div> </div>
)} )}
{/* First Name and Last Name inputs */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="firstname" className="text-sm font-medium"> <Label htmlFor="firstname" className="text-sm font-medium">
@ -116,6 +121,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</div> </div>
</div> </div>
{/* Email input field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="reg-email" className="text-sm font-medium"> <Label htmlFor="reg-email" className="text-sm font-medium">
E-Mail E-Mail
@ -134,6 +140,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</div> </div>
</div> </div>
{/* Password input field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="reg-password" className="text-sm font-medium"> <Label htmlFor="reg-password" className="text-sm font-medium">
Kennwort Kennwort
@ -153,6 +160,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</div> </div>
</div> </div>
{/* Role selection */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-medium">Rolle</Label> <Label className="text-sm font-medium">Rolle</Label>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
@ -182,11 +190,13 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
/> />
<span className={`text-sm font-medium ${role === "RAUMBETREUER" ? "text-primary" : "text-foreground"}`}> <span className={`text-sm font-medium ${role === "RAUMBETREUER" ? "text-primary" : "text-foreground"}`}>
Raumbetreuer Raumbetreuer
</span> </span>
</button> </button>
</div> </div>
</div> </div>
{/* Room selection for 'RAUMBETREUER' role */}
{role === "RAUMBETREUER" && ( {role === "RAUMBETREUER" && (
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-sm font-medium">Betreute Räume</Label> <Label className="text-sm font-medium">Betreute Räume</Label>
@ -216,11 +226,12 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
)} )}
</CardContent> </CardContent>
<CardFooter className="flex flex-col gap-4 pt-2"> <CardFooter className="flex flex-col gap-4 pt-2">
{/* Submit button with loading state */}
<Button type="submit" className="w-full" disabled={isLoading}> <Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? ( {isLoading ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
Registrierung... Registrierung läuft...
</span> </span>
) : ( ) : (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
@ -229,6 +240,7 @@ export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) {
</span> </span>
)} )}
</Button> </Button>
{/* Link to switch to login form */}
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Bereits registriert?{" "} Bereits registriert?{" "}
<button <button

View file

@ -10,20 +10,10 @@ export function AdminDashboard() {
const { tickets, updateTicketStatus, deleteTicket, authHeader } = useAuth() const { tickets, updateTicketStatus, deleteTicket, authHeader } = useAuth()
const API_URL = process.env.NEXT_PUBLIC_API_URL + "/api" const API_URL = process.env.NEXT_PUBLIC_API_URL + "/api"
// Admin sees all tickets (logic handled in Backend/TicketService)
// We need to implement Ticket Delete logic passed to TicketTable or handle it inside TicketTable?
// TicketTable doesn't have Delete button yet.
// Actually, requirement says "Kann Tickets löschen". TicketTable needs a delete option or we add it.
// Let's add delete to TicketTable? OR just add it to the Detail Dialog?
// User asked "user with ID 1 = administrator... screen for creating/deleting/renaming rooms".
// For tickets, "Can delete tickets".
// I should probably add a Delete button in TicketTable (maybe only visible if onDelete is provided).
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Admin Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Admin-Dashboard</h1>
<p className="text-muted-foreground mt-1">Systemverwaltung und Ticket-Übersicht</p> <p className="text-muted-foreground mt-1">Systemverwaltung und Ticket-Übersicht</p>
</div> </div>
@ -40,12 +30,12 @@ export function AdminDashboard() {
</Card> </Card>
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System Status</CardTitle> <CardTitle className="text-sm font-medium">Systemstatus</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" /> <Users className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="text-2xl font-bold">Aktiv</div> <div className="text-2xl font-bold">Aktiv</div>
<p className="text-xs text-muted-foreground">Admin Mode</p> <p className="text-xs text-muted-foreground">Admin-Modus</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
@ -54,7 +44,7 @@ export function AdminDashboard() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Ticket Übersicht</CardTitle> <CardTitle>Ticket-Übersicht</CardTitle>
<CardDescription>Alle Tickets aller Nutzer</CardDescription> <CardDescription>Alle Tickets aller Nutzer</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View file

@ -31,10 +31,6 @@ export function RoomManagement() {
}, [rooms]) }, [rooms])
const refreshRooms = async () => { const refreshRooms = async () => {
// Force reload (window reload is simplest for now to update Context, or expose fetchRooms in Context)
// Ideally Context should expose a 'refreshRooms' method.
// For now, let's update local list manually after actions or rely on hard refresh if needed.
// Actually, fetching from API here is better.
if (!authHeader) return if (!authHeader) return
const res = await fetch(`${API_URL}/rooms`, { headers: { Authorization: authHeader } }) const res = await fetch(`${API_URL}/rooms`, { headers: { Authorization: authHeader } })
if (res.ok) { if (res.ok) {
@ -190,7 +186,7 @@ export function RoomManagement() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive hover:text-destructive" className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDelete(room.id)} onClick={() => handleDelete(room.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />

View file

@ -10,7 +10,6 @@ import { Building2, Ticket, Clock, CheckCircle2, AlertCircle } from "lucide-reac
export function SupervisorDashboard() { export function SupervisorDashboard() {
const { user, tickets, updateTicketStatus } = useAuth() const { user, tickets, updateTicketStatus } = useAuth()
// user.supervisedRooms is Room[]
const supervisedRooms = user?.supervisedRooms || [] const supervisedRooms = user?.supervisedRooms || []
const roomTickets = useMemo(() => { const roomTickets = useMemo(() => {
@ -42,14 +41,14 @@ export function SupervisorDashboard() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Raumbetreuer Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Raumbetreuer-Dashboard</h1>
<p className="text-muted-foreground mt-1">Verwalten Sie Tickets für Ihre Räume</p> <p className="text-muted-foreground mt-1">Verwalten Sie Tickets für Ihre Räume</p>
</div> </div>
<div className="grid gap-4 md:grid-cols-4"> <div className="grid gap-4 md:grid-cols-4">
<Card className="border-border/60"> <Card className="border-border/60">
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">Total Tickets</CardTitle> <CardTitle className="text-sm font-medium text-muted-foreground">Gesamt Tickets</CardTitle>
<Ticket className="h-4 w-4 text-muted-foreground" /> <Ticket className="h-4 w-4 text-muted-foreground" />
</CardHeader> </CardHeader>
<CardContent> <CardContent>

View file

@ -11,15 +11,6 @@ export function TeacherDashboard() {
const { user, tickets } = useAuth() const { user, tickets } = useAuth()
const myTickets = useMemo(() => { const myTickets = useMemo(() => {
// Filter by own email or ID. The backend returns all tickets for RAUMBETREUER?
// Wait, backend logic:
// If LEHRKRAFT (ROLE_TEACHER): /tickets returns all tickets created by this user.
// So `tickets` in context should already be "my tickets" if I am a teacher.
// But let's be safe and filter if the API returns more than expected or just use all.
// Actually, AuthContext fetches `/tickets`.
// TeacherController: `getTickets` -> `ticketService.getTickets(user)`.
// TicketService: if teacher, `ticketRepository.findByOwner(user)`.
// So yes, `tickets` contains only my tickets.
return tickets return tickets
}, [tickets]) }, [tickets])
@ -33,7 +24,7 @@ export function TeacherDashboard() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Lehrkraft Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Lehrkraft-Dashboard</h1>
<p className="text-muted-foreground mt-1">Erstellen und verfolgen Sie Ihre IT-Support-Tickets</p> <p className="text-muted-foreground mt-1">Erstellen und verfolgen Sie Ihre IT-Support-Tickets</p>
</div> </div>

View file

@ -19,24 +19,30 @@ import {
import { LogOut, User, Building2, Cpu } from "lucide-react" import { LogOut, User, Building2, Cpu } from "lucide-react"
import { ModeToggle } from "@/components/mode-toggle" import { ModeToggle } from "@/components/mode-toggle"
// Defines props for the AppShell component
interface AppShellProps { interface AppShellProps {
children: ReactNode children: ReactNode // React children to be rendered within the shell
} }
// AppShell component provides the main layout structure for the application
export function AppShell({ children }: AppShellProps) { export function AppShell({ children }: AppShellProps) {
// Access authentication context for user info, logout function, room data, and room update function
const { user, logout, rooms, updateRooms } = useAuth() const { user, logout, rooms, updateRooms } = useAuth()
// Generates user initials for the avatar fallback
const initials = user?.name const initials = user?.name
?.split(" ") ?.split(" ")
.map((n) => n[0]) .map((n) => n[0])
.join("") .join("")
.toUpperCase() .toUpperCase()
.slice(0, 2) || "" .slice(0, 2) || "" // Fallback to empty string if user name is not available
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
{/* Header section with sticky positioning, blur effect, and responsiveness */}
<header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"> <header className="sticky top-0 z-50 w-full border-b border-border/60 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="w-full max-w-[95%] mx-auto flex h-16 items-center justify-between px-4"> <div className="w-full max-w-[95%] mx-auto flex h-16 items-center justify-between px-4">
{/* Application logo and title */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary"> <div className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary">
<Cpu className="h-5 w-5 text-primary-foreground" /> <Cpu className="h-5 w-5 text-primary-foreground" />
@ -47,7 +53,9 @@ export function AppShell({ children }: AppShellProps) {
</div> </div>
</div> </div>
{/* User actions and theme toggle */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{/* Display user role for larger screens */}
<div className="hidden items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5 md:flex"> <div className="hidden items-center gap-2 rounded-lg bg-muted/50 px-3 py-1.5 md:flex">
{user?.role === "LEHRKRAFT" ? ( {user?.role === "LEHRKRAFT" ? (
<User className="h-4 w-4 text-muted-foreground" /> <User className="h-4 w-4 text-muted-foreground" />
@ -59,6 +67,7 @@ export function AppShell({ children }: AppShellProps) {
</span> </span>
</div> </div>
{/* Dropdown menu for user profile and settings */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-9 w-9 rounded-full"> <Button variant="ghost" className="relative h-9 w-9 rounded-full">
@ -77,10 +86,12 @@ export function AppShell({ children }: AppShellProps) {
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* Display user role in the dropdown */}
<DropdownMenuItem className="text-muted-foreground"> <DropdownMenuItem className="text-muted-foreground">
<User className="mr-2 h-4 w-4" /> <User className="mr-2 h-4 w-4" />
<span className="capitalize">{user?.role === "LEHRKRAFT" ? "Lehrkraft" : "Raumbetreuer"}</span> <span className="capitalize">{user?.role === "LEHRKRAFT" ? "Lehrkraft" : "Raumbetreuer"}</span>
</DropdownMenuItem> </DropdownMenuItem>
{/* Room supervision management for 'RAUMBETREUER' role */}
{user?.role === "RAUMBETREUER" && user.supervisedRooms && ( {user?.role === "RAUMBETREUER" && user.supervisedRooms && (
<DropdownMenuSub> <DropdownMenuSub>
<DropdownMenuSubTrigger> <DropdownMenuSubTrigger>
@ -90,6 +101,7 @@ export function AppShell({ children }: AppShellProps) {
<DropdownMenuSubContent className="max-h-60 overflow-y-auto"> <DropdownMenuSubContent className="max-h-60 overflow-y-auto">
<DropdownMenuLabel>Räume verwalten</DropdownMenuLabel> <DropdownMenuLabel>Räume verwalten</DropdownMenuLabel>
{rooms.map((room) => { {rooms.map((room) => {
// Determine if the current room is supervised by the user
const isChecked = user.supervisedRooms?.some((r) => r.id === room.id) ?? false const isChecked = user.supervisedRooms?.some((r) => r.id === room.id) ?? false
return ( return (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
@ -99,13 +111,15 @@ export function AppShell({ children }: AppShellProps) {
const currentIds = user.supervisedRooms?.map((r) => r.id) || [] const currentIds = user.supervisedRooms?.map((r) => r.id) || []
let newIds let newIds
if (checked) { if (checked) {
// Add room to supervised list
newIds = [...currentIds, room.id] newIds = [...currentIds, room.id]
} else { } else {
// Remove room from supervised list
newIds = currentIds.filter((id) => id !== room.id) newIds = currentIds.filter((id) => id !== room.id)
} }
updateRooms(newIds) updateRooms(newIds) // Update the list of supervised rooms
}} }}
onSelect={(e) => e.preventDefault()} onSelect={(e) => e.preventDefault()} // Prevent closing dropdown on selection
> >
{room.name} {room.name}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
@ -115,6 +129,7 @@ export function AppShell({ children }: AppShellProps) {
</DropdownMenuSub> </DropdownMenuSub>
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* Logout button */}
<DropdownMenuItem onClick={logout} className="text-destructive focus:text-destructive"> <DropdownMenuItem onClick={logout} className="text-destructive focus:text-destructive">
<LogOut className="mr-2 h-4 w-4" /> <LogOut className="mr-2 h-4 w-4" />
<span>Abmelden</span> <span>Abmelden</span>
@ -122,12 +137,13 @@ export function AppShell({ children }: AppShellProps) {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
{/* dark mode toggle because staring at white screens hurts */} {/* Theme toggle component */}
<ModeToggle /> <ModeToggle />
</div> </div>
</div> </div>
</header> </header>
{/* Main content area */}
<main className="w-full max-w-[95%] mx-auto px-4 py-8">{children}</main> <main className="w-full max-w-[95%] mx-auto px-4 py-8">{children}</main>
</div> </div>
) )

View file

@ -30,7 +30,7 @@ export function ModeToggle() {
onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")} onCheckedChange={(checked) => setTheme(checked ? "dark" : "light")}
/> />
<Moon className="h-4 w-4" /> <Moon className="h-4 w-4" />
<Label htmlFor="dark-mode" className="sr-only">Toggle theme</Label> <Label htmlFor="dark-mode" className="sr-only">Thema umschalten</Label>
</div> </div>
) )
} }

View file

@ -85,7 +85,7 @@ export function CreateTicketForm() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Problembeschreibung</Label> <Label htmlFor="description">Beschreibung</Label>
<Textarea <Textarea
id="description" id="description"
placeholder="Beschreiben Sie das Problem im Detail..." placeholder="Beschreiben Sie das Problem im Detail..."
@ -99,7 +99,7 @@ export function CreateTicketForm() {
{isSubmitting ? ( {isSubmitting ? (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" /> <span className="h-4 w-4 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent" />
Senden... Ticket wird erstellt...
</span> </span>
) : ( ) : (
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">

View file

@ -64,9 +64,9 @@ export function TicketComments({ ticketId }: TicketCommentsProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h3 className="font-semibold">Comments</h3> <h3 className="font-semibold">Kommentare</h3>
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2"> <div className="space-y-4 max-h-[300px] overflow-y-auto pr-2">
{comments.length === 0 && <p className="text-sm text-muted-foreground">No comments yet.</p>} {comments.length === 0 && <p className="text-sm text-muted-foreground">Noch keine Kommentare.</p>}
{comments.map((comment) => ( {comments.map((comment) => (
<div key={comment.id} className="flex gap-3"> <div key={comment.id} className="flex gap-3">
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">
@ -87,14 +87,14 @@ export function TicketComments({ ticketId }: TicketCommentsProps) {
<div className="space-y-2 pt-2"> <div className="space-y-2 pt-2">
<Textarea <Textarea
placeholder="Write a comment..." placeholder="Kommentar schreiben..."
value={newComment} value={newComment}
onChange={(e) => setNewComment(e.target.value)} onChange={(e) => setNewComment(e.target.value)}
className="min-h-[80px]" className="min-h-[80px]"
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button onClick={handlePost} disabled={!newComment.trim() || loading}> <Button onClick={handlePost} disabled={!newComment.trim() || loading}>
Post Comment Kommentar senden
</Button> </Button>
</div> </div>
</div> </div>

View file

@ -92,7 +92,7 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Input <Input
placeholder="Search tickets..." placeholder="Tickets suchen..."
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="max-w-sm" className="max-w-sm"
@ -100,13 +100,13 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TicketStatus | "ALL")}> <Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as TicketStatus | "ALL")}>
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Filter Status" /> <SelectValue placeholder="Status filtern" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="ALL">All Statuses</SelectItem> <SelectItem value="ALL">Alle Status</SelectItem>
<SelectItem value="OPEN">Open</SelectItem> <SelectItem value="OPEN">Offen</SelectItem>
<SelectItem value="IN_PROGRESS">In Progress</SelectItem> <SelectItem value="IN_PROGRESS">In Bearbeitung</SelectItem>
<SelectItem value="CLOSED">Closed</SelectItem> <SelectItem value="CLOSED">Geschlossen</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@ -119,17 +119,17 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("room")}> <TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("room")}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<MapPin className="h-4 w-4" /> <MapPin className="h-4 w-4" />
Room {sortConfig.key === "room" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)} Raum {sortConfig.key === "room" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("title")}> <TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("title")}>
Title {sortConfig.key === "title" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)} Titel {sortConfig.key === "title" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</TableHead> </TableHead>
<TableHead className="font-semibold hidden md:table-cell">Description</TableHead> <TableHead className="font-semibold hidden md:table-cell">Beschreibung</TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("createdAt")}> <TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("createdAt")}>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
Date {sortConfig.key === "createdAt" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)} Datum {sortConfig.key === "createdAt" && (sortConfig.direction === "asc" ? <ArrowUp className="h-3 w-3" /> : <ArrowDown className="h-3 w-3" />)}
</div> </div>
</TableHead> </TableHead>
<TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("status")}> <TableHead className="font-semibold cursor-pointer" onClick={() => handleSort("status")}>
@ -142,7 +142,7 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
{processedTickets.length === 0 ? ( {processedTickets.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={5} className="h-24 text-center"> <TableCell colSpan={5} className="h-24 text-center">
No results found. Keine Ergebnisse gefunden.
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
@ -168,9 +168,9 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="OPEN"><StatusBadge status="OPEN" /></SelectItem> <SelectItem value="OPEN">Offen</SelectItem>
<SelectItem value="IN_PROGRESS"><StatusBadge status="IN_PROGRESS" /></SelectItem> <SelectItem value="IN_PROGRESS">In Bearbeitung</SelectItem>
<SelectItem value="CLOSED"><StatusBadge status="CLOSED" /></SelectItem> <SelectItem value="CLOSED">Geschlossen</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) : ( ) : (
@ -213,7 +213,7 @@ export function TicketTable({ tickets, showStatusUpdate = false, onStatusUpdate,
<div className="grid gap-6"> <div className="grid gap-6">
<div> <div>
<h3 className="font-semibold mb-2">Description</h3> <h3 className="font-semibold mb-2">Beschreibung</h3>
<div className="p-3 bg-muted rounded-md text-sm"> <div className="p-3 bg-muted rounded-md text-sm">
{selectedTicket.description} {selectedTicket.description}
</div> </div>

View file

@ -126,7 +126,9 @@ function AlertDialogAction({
<AlertDialogPrimitive.Action <AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> >
Bestätigen
</AlertDialogPrimitive.Action>
) )
} }
@ -136,9 +138,10 @@ function AlertDialogCancel({
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return ( return (
<AlertDialogPrimitive.Cancel <AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: 'outline' }), className)}
{...props} {...props}
/> >
Abbrechen
</AlertDialogPrimitive.Cancel>
) )
} }

View file

@ -72,7 +72,7 @@ function DialogContent({
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
> >
<XIcon /> <XIcon />
<span className="sr-only">Close</span> <span className="sr-only">Schließen</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>

View file

@ -74,7 +74,7 @@ function SheetContent({
{children} {children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" /> <XIcon className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Schließen</span>
</SheetPrimitive.Close> </SheetPrimitive.Close>
</SheetPrimitive.Content> </SheetPrimitive.Content>
</SheetPortal> </SheetPortal>

View file

@ -4,64 +4,79 @@ import { useTheme } from "next-themes"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
import { useSession } from "next-auth/react" import { useSession } from "next-auth/react"
// UserThemeSync component handles synchronizing the user's theme preference
// between the frontend (using next-themes) and the backend (persisting in DB).
export function UserThemeSync() { export function UserThemeSync() {
// Access theme state and setter from next-themes
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()
// Access user session from next-auth
const { data: session } = useSession() const { data: session } = useSession()
// Ref to track if the initial theme synchronization from DB is done
const initialSyncDone = useRef(false) const initialSyncDone = useRef(false)
// Sync from DB on load // Effect to synchronize theme from the database on component load (initial sync)
useEffect(() => { useEffect(() => {
// If we have a user and haven't synced yet // Only proceed if a user session exists and initial sync hasn't been performed
if (session?.user && !initialSyncDone.current) { if (session?.user && !initialSyncDone.current) {
// @ts-ignore - we added theme to user but TS might catch up later // Retrieve user's theme preference from the session object
// @ts-ignore - 'theme' property might not be directly typed in session.user
const userTheme = session.user.theme const userTheme = session.user.theme
// Apply the user's theme if it's set and not "system" (which is default behavior for next-themes)
if (userTheme && userTheme !== "system") { if (userTheme && userTheme !== "system") {
setTheme(userTheme) setTheme(userTheme)
} }
// Mark initial sync as complete to prevent re-syncing on subsequent renders
initialSyncDone.current = true initialSyncDone.current = true
} }
}, [session, setTheme]) }, [session, setTheme]) // Dependencies: session and setTheme function
// Sync to DB on change // Effect to synchronize theme changes from the frontend to the database
useEffect(() => { useEffect(() => {
// Only proceed if we have a session and initial sync is done // Only proceed if a user session exists and initial sync has been performed
if (!session?.user || !initialSyncDone.current) return if (!session?.user || !initialSyncDone.current) return
// @ts-ignore // Retrieve the current theme as perceived by the frontend and the theme stored in the session
// @ts-ignore - 'theme' property might not be directly typed in session.user
const currentDbTheme = session.user.theme const currentDbTheme = session.user.theme
// If the theme changed and it's different from what we think is in DB // If the current frontend theme is different from the theme stored in the session,
// (This is a naive check because session.user.theme won't update until next session fetch) // it means the user has changed their theme preference and it needs to be persisted.
// (Note: session.user.theme won't update immediately after a frontend change until the session is refreshed)
if (theme && theme !== currentDbTheme) { if (theme && theme !== currentDbTheme) {
// Async function to send the theme update request to the backend
const saveTheme = async () => { const saveTheme = async () => {
try { try {
// @ts-ignore // Retrieve authorization header from session
// @ts-ignore - 'authHeader' property might not be directly typed in session
const authHeader = session.authHeader const authHeader = session.authHeader
await fetch(process.env.NEXT_PUBLIC_API_URL + "/api/users/theme", { await fetch(process.env.NEXT_PUBLIC_API_URL + "/api/users/theme", {
method: "PATCH", method: "PATCH", // Use PATCH for partial update
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Authorization": authHeader "Authorization": authHeader // Send authorization token
}, },
body: JSON.stringify({ theme }), body: JSON.stringify({ theme }), // Send the new theme preference
}) })
// Ideally we update the session here too, but that's complex. // In a more complex app, one might trigger a session refresh here
// We just fire and forget. // to immediately update session.user.theme, but for simplicity,
// we fire and forget the update.
} catch (e) { } catch (e) {
console.error("Failed to save theme, looks like backend is sleeping", e) console.error("Failed to save theme, looks like backend is sleeping", e)
} }
} }
// simple debounce // Debounce the save operation to avoid excessive API calls on rapid theme changes
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
saveTheme() saveTheme()
}, 1000) }, 1000) // Wait for 1 second before saving
// Cleanup function to clear the timeout if the component unmounts or dependencies change
return () => clearTimeout(timeoutId) return () => clearTimeout(timeoutId)
} }
}, [theme, session]) }, [theme, session]) // Dependencies: current theme and session object
// This component does not render anything visible
return null return null
} }