commit 876f1728d7e3e692dd4a39f9b43b24466e1208b7 Author: Hymmel Date: Wed Jan 21 09:52:33 2026 +0100 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d821bc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +Frontend/node_modules +Frontend/.next +Backend/target \ No newline at end of file diff --git a/Backend/Dockerfile b/Backend/Dockerfile new file mode 100644 index 0000000..ffbeb96 --- /dev/null +++ b/Backend/Dockerfile @@ -0,0 +1,11 @@ +FROM maven:3-eclipse-temurin-21 AS build +WORKDIR /app +COPY pom.xml . +COPY src ./src +RUN mvn clean package -DskipTests + +FROM eclipse-temurin:21-jre +WORKDIR /app +COPY --from=build /app/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/Backend/pom.xml b/Backend/pom.xml new file mode 100644 index 0000000..0328754 --- /dev/null +++ b/Backend/pom.xml @@ -0,0 +1,71 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.2.2 + + + de.itsolutions + ticketsystem + 0.0.1-SNAPSHOT + ticketsystem + IT Ticket System for Raumbetreuer + + 21 + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-security + + + + org.postgresql + postgresql + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/DataLoader.java b/Backend/src/main/java/de/itsolutions/ticketsystem/DataLoader.java new file mode 100644 index 0000000..df144ec --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/DataLoader.java @@ -0,0 +1,31 @@ +package de.itsolutions.ticketsystem; + +import de.itsolutions.ticketsystem.entity.Room; +import de.itsolutions.ticketsystem.repository.RoomRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Component +public class DataLoader implements CommandLineRunner { + + private final RoomRepository roomRepository; + + public DataLoader(RoomRepository roomRepository) { + this.roomRepository = roomRepository; + } + + @Override + public void run(String... args) throws Exception { + if (roomRepository.count() == 0) { + Room r1 = new Room(); r1.setName("C101"); + Room r2 = new Room(); r2.setName("C102"); + Room r3 = new Room(); r3.setName("L205 (Labor)"); + Room r4 = new Room(); r4.setName("T001 (Technik)"); + + roomRepository.saveAll(List.of(r1, r2, r3, r4)); + System.out.println("Seeded Rooms."); + } + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/TicketSystemApplication.java b/Backend/src/main/java/de/itsolutions/ticketsystem/TicketSystemApplication.java new file mode 100644 index 0000000..45f07a4 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/TicketSystemApplication.java @@ -0,0 +1,13 @@ +package de.itsolutions.ticketsystem; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TicketSystemApplication { + + public static void main(String[] args) { + SpringApplication.run(TicketSystemApplication.class, args); + } + +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/config/SecurityConfig.java b/Backend/src/main/java/de/itsolutions/ticketsystem/config/SecurityConfig.java new file mode 100644 index 0000000..7ef4504 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/config/SecurityConfig.java @@ -0,0 +1,59 @@ +package de.itsolutions.ticketsystem.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**", "/api/rooms/**").permitAll() + .requestMatchers(org.springframework.http.HttpMethod.POST, "/api/auth/register").permitAll() // Explicitly permit POST register + .anyRequest().authenticated() + ) + .httpBasic(basic -> {}); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authConfig) throws Exception { + return authConfig.getAuthenticationManager(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/controller/AuthController.java b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/AuthController.java new file mode 100644 index 0000000..124817e --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/AuthController.java @@ -0,0 +1,48 @@ +package de.itsolutions.ticketsystem.controller; + +import de.itsolutions.ticketsystem.dto.Dtos; +import de.itsolutions.ticketsystem.entity.User; +import de.itsolutions.ticketsystem.service.AuthService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; +import java.security.Principal; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + private final AuthenticationManager authenticationManager; + + public AuthController(AuthService authService, AuthenticationManager authenticationManager) { + this.authService = authService; + this.authenticationManager = authenticationManager; + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody Dtos.RegisterRequest request) { + return ResponseEntity.ok(authService.register(request)); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody Dtos.LoginRequest request) { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword()) + ); + // Return full user details + return ResponseEntity.ok(authService.getUserByEmail(request.getEmail())); + } + + @GetMapping("/me") + public ResponseEntity getCurrentUser(Principal principal) { + return ResponseEntity.ok(authService.getUserByEmail(principal.getName())); + } + @PutMapping("/profile/rooms") + public ResponseEntity updateMyRooms(@RequestBody Dtos.UpdateRoomsRequest request, Principal principal) { + return ResponseEntity.ok(authService.updateSupervisedRooms(principal.getName(), request.getRoomIds())); + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/controller/RoomController.java b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/RoomController.java new file mode 100644 index 0000000..740348d --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/RoomController.java @@ -0,0 +1,25 @@ +package de.itsolutions.ticketsystem.controller; + +import de.itsolutions.ticketsystem.entity.Room; +import de.itsolutions.ticketsystem.repository.RoomRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import java.util.List; + +@RestController +@RequestMapping("/api/rooms") +public class RoomController { + + private final RoomRepository roomRepository; + + public RoomController(RoomRepository roomRepository) { + this.roomRepository = roomRepository; + } + + @GetMapping + public ResponseEntity> getAllRooms() { + return ResponseEntity.ok(roomRepository.findAll()); + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/controller/TicketController.java b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/TicketController.java new file mode 100644 index 0000000..48958d3 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/controller/TicketController.java @@ -0,0 +1,35 @@ +package de.itsolutions.ticketsystem.controller; + +import de.itsolutions.ticketsystem.dto.Dtos; +import de.itsolutions.ticketsystem.entity.Ticket; +import de.itsolutions.ticketsystem.service.TicketService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.security.Principal; +import java.util.List; + +@RestController +@RequestMapping("/api/tickets") +public class TicketController { + + private final TicketService ticketService; + + public TicketController(TicketService ticketService) { + this.ticketService = ticketService; + } + + @PostMapping + public ResponseEntity createTicket(@RequestBody Dtos.TicketRequest request, Principal principal) { + return ResponseEntity.ok(ticketService.createTicket(request, principal.getName())); + } + + @GetMapping + public ResponseEntity> getTickets(Principal principal) { + return ResponseEntity.ok(ticketService.getTicketsForUser(principal.getName())); + } + + @PatchMapping("/{id}/status") + public ResponseEntity updateStatus(@PathVariable Long id, @RequestBody Dtos.TicketStatusRequest request) { + return ResponseEntity.ok(ticketService.updateTicketStatus(id, request.getStatus())); + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/dto/Dtos.java b/Backend/src/main/java/de/itsolutions/ticketsystem/dto/Dtos.java new file mode 100644 index 0000000..e8507cb --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/dto/Dtos.java @@ -0,0 +1,65 @@ +package de.itsolutions.ticketsystem.dto; + +import lombok.Data; +import java.util.List; + +public class Dtos { + + public static class RegisterRequest { + private String firstname; + private String lastname; + private String email; + private String password; + private String role; + private List roomIds; + + public String getFirstname() { return firstname; } + public void setFirstname(String firstname) { this.firstname = firstname; } + public String getLastname() { return lastname; } + public void setLastname(String lastname) { this.lastname = lastname; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public List getRoomIds() { return roomIds; } + public void setRoomIds(List roomIds) { this.roomIds = roomIds; } + } + + public static class LoginRequest { + private String email; + private String password; + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + } + + public static class UpdateRoomsRequest { + private List roomIds; + public List getRoomIds() { return roomIds; } + public void setRoomIds(List roomIds) { this.roomIds = roomIds; } + } + + public static class TicketRequest { + private Long roomId; + private String title; + private String description; + + public Long getRoomId() { return roomId; } + public void setRoomId(Long roomId) { this.roomId = roomId; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + } + + public static class TicketStatusRequest { + private String status; + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/entity/Room.java b/Backend/src/main/java/de/itsolutions/ticketsystem/entity/Room.java new file mode 100644 index 0000000..aa42a50 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/entity/Room.java @@ -0,0 +1,23 @@ +package de.itsolutions.ticketsystem.entity; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "rooms") +@NoArgsConstructor +public class Room { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/entity/Ticket.java b/Backend/src/main/java/de/itsolutions/ticketsystem/entity/Ticket.java new file mode 100644 index 0000000..85dcc52 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/entity/Ticket.java @@ -0,0 +1,65 @@ +package de.itsolutions.ticketsystem.entity; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.time.LocalDateTime; + +@Entity +@Table(name = "tickets") +@NoArgsConstructor +public class Ticket { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "room_id", nullable = false) + private Room room; + + @Column(nullable = false, name = "title") + private String title_en; + + @Column(nullable = false, name = "titel") + private String title_de; + + @Column(columnDefinition = "TEXT", name = "description") + private String description_en; + + @Column(columnDefinition = "TEXT", name = "beschreibung") + private String description_de; + + @Column(nullable = false) + private LocalDateTime createdAt = LocalDateTime.now(); + + @Column(nullable = false) + private String status = "OPEN"; // OPEN, IN_PROGRESS, CLOSED + + @ManyToOne(optional = false) + @JoinColumn(name = "user_id", nullable = false) // Creator (Lehrkraft) + private User owner; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + public Room getRoom() { return room; } + public void setRoom(Room room) { this.room = room; } + + public String getTitle() { return title_de; } + public void setTitle(String title) { + this.title_de = title; + this.title_en = title; + } + + public String getDescription() { return description_de; } + public void setDescription(String description) { + this.description_de = description; + this.description_en = description; + } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public User getOwner() { return owner; } + public void setOwner(User owner) { this.owner = owner; } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/entity/User.java b/Backend/src/main/java/de/itsolutions/ticketsystem/entity/User.java new file mode 100644 index 0000000..31c3bb6 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/entity/User.java @@ -0,0 +1,83 @@ +package de.itsolutions.ticketsystem.entity; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; +import java.util.List; + +@Entity +@Table(name = "users") +@NoArgsConstructor +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "vorname", nullable = false) + private String name_vorname; + + @Column(name = "firstname", nullable = false) + private String name_firstname; + + @Column(name = "nachname", nullable = false) + private String name_nachname; + + @Column(name = "lastname", nullable = false) + private String name_lastname; + + @Column(name = "name", nullable = false) + private String name_column; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String password; + + @Column(nullable = false) + private String role; + + @ManyToMany(fetch = FetchType.EAGER) + @JoinTable( + name = "user_rooms", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "room_id") + ) + private List supervisedRooms; + + public Long getId() { return id; } + public void setId(Long id) { this.id = id; } + + public String getFirstname() { return name_firstname; } + public void setFirstname(String firstname) { + this.name_firstname = firstname; + this.name_vorname = firstname; + } + + public String getLastname() { return name_lastname; } + public void setLastname(String lastname) { + this.name_lastname = lastname; + this.name_nachname = lastname; + } + + public String getName() { + return this.name_column; + } + + public void setName(String name) { + if (name == null || name.isBlank()) { + name = "Unknown User"; + } + this.name_column = name; + } + + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + public String getPassword() { return password; } + public void setPassword(String password) { this.password = password; } + public String getRole() { return role; } + public void setRole(String role) { this.role = role; } + public List getSupervisedRooms() { return supervisedRooms; } + public void setSupervisedRooms(List supervisedRooms) { this.supervisedRooms = supervisedRooms; } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/repository/RoomRepository.java b/Backend/src/main/java/de/itsolutions/ticketsystem/repository/RoomRepository.java new file mode 100644 index 0000000..b65a8f6 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/repository/RoomRepository.java @@ -0,0 +1,7 @@ +package de.itsolutions.ticketsystem.repository; + +import de.itsolutions.ticketsystem.entity.Room; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface RoomRepository extends JpaRepository { +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/repository/TicketRepository.java b/Backend/src/main/java/de/itsolutions/ticketsystem/repository/TicketRepository.java new file mode 100644 index 0000000..85e3402 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/repository/TicketRepository.java @@ -0,0 +1,10 @@ +package de.itsolutions.ticketsystem.repository; + +import de.itsolutions.ticketsystem.entity.Ticket; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface TicketRepository extends JpaRepository { + List findByOwnerIdOrderByCreatedAtDesc(Long ownerId); + List findByRoomIdInOrderByCreatedAtDesc(List roomIds); +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/repository/UserRepository.java b/Backend/src/main/java/de/itsolutions/ticketsystem/repository/UserRepository.java new file mode 100644 index 0000000..aa765e4 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/repository/UserRepository.java @@ -0,0 +1,9 @@ +package de.itsolutions.ticketsystem.repository; + +import de.itsolutions.ticketsystem.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/service/AuthService.java b/Backend/src/main/java/de/itsolutions/ticketsystem/service/AuthService.java new file mode 100644 index 0000000..1b93bd2 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/service/AuthService.java @@ -0,0 +1,96 @@ +package de.itsolutions.ticketsystem.service; + +import de.itsolutions.ticketsystem.dto.Dtos; +import de.itsolutions.ticketsystem.entity.Room; +import de.itsolutions.ticketsystem.entity.User; +import de.itsolutions.ticketsystem.repository.RoomRepository; +import de.itsolutions.ticketsystem.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class AuthService { + + private final UserRepository userRepository; + private final RoomRepository roomRepository; + private final PasswordEncoder passwordEncoder; + + public AuthService(UserRepository userRepository, RoomRepository roomRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.roomRepository = roomRepository; + this.passwordEncoder = passwordEncoder; + } + + public User register(Dtos.RegisterRequest request) { + if (userRepository.findByEmail(request.getEmail()).isPresent()) { + throw new RuntimeException("Email already in use"); + } + + User user = new User(); + + if (request.getFirstname() == null || request.getLastname() == null) { + throw new RuntimeException("Vorname und Nachname sind erforderlich"); + } + + user.setFirstname(request.getFirstname()); + user.setLastname(request.getLastname()); + user.setName(request.getFirstname() + " " + request.getLastname()); // Populate legacy/fallback fields + user.setEmail(request.getEmail()); + user.setPassword(passwordEncoder.encode(request.getPassword())); + user.setRole(request.getRole()); + + if ("RAUMBETREUER".equals(request.getRole()) && request.getRoomIds() != null) { + List rooms = roomRepository.findAllById(request.getRoomIds()); + user.setSupervisedRooms(rooms); + } + + return userRepository.save(user); + } + + public User getUserByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + } + + @jakarta.annotation.PostConstruct + public void migrateUsers() { + List users = userRepository.findAll(); + for (User user : users) { + String fullName = user.getName(); + + if (fullName != null && !fullName.isBlank()) { + String[] parts = fullName.trim().split("\\s+"); + String newFirstname; + String newLastname; + + if (parts.length == 1) { + newFirstname = parts[0]; + newLastname = "NULL"; + } else { + newLastname = parts[parts.length - 1]; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length - 1; i++) { + if (i > 0) sb.append(" "); + sb.append(parts[i]); + } + newFirstname = sb.toString(); + } + + user.setFirstname(newFirstname); + user.setLastname(newLastname); + } + } + userRepository.saveAll(users); + } + public User updateSupervisedRooms(String email, List roomIds) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + List rooms = roomRepository.findAllById(roomIds); + user.setSupervisedRooms(rooms); + + return userRepository.save(user); + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/service/CustomUserDetailsService.java b/Backend/src/main/java/de/itsolutions/ticketsystem/service/CustomUserDetailsService.java new file mode 100644 index 0000000..264050c --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/service/CustomUserDetailsService.java @@ -0,0 +1,30 @@ +package de.itsolutions.ticketsystem.service; + +import de.itsolutions.ticketsystem.entity.User; +import de.itsolutions.ticketsystem.repository.UserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("User not found with email: " + email)); + + return org.springframework.security.core.userdetails.User.builder() + .username(user.getEmail()) + .password(user.getPassword()) + .roles(user.getRole()) + .build(); + } +} diff --git a/Backend/src/main/java/de/itsolutions/ticketsystem/service/TicketService.java b/Backend/src/main/java/de/itsolutions/ticketsystem/service/TicketService.java new file mode 100644 index 0000000..9e267f8 --- /dev/null +++ b/Backend/src/main/java/de/itsolutions/ticketsystem/service/TicketService.java @@ -0,0 +1,70 @@ +package de.itsolutions.ticketsystem.service; + +import de.itsolutions.ticketsystem.dto.Dtos; +import de.itsolutions.ticketsystem.entity.Room; +import de.itsolutions.ticketsystem.entity.Ticket; +import de.itsolutions.ticketsystem.entity.User; +import de.itsolutions.ticketsystem.repository.RoomRepository; +import de.itsolutions.ticketsystem.repository.TicketRepository; +import de.itsolutions.ticketsystem.repository.UserRepository; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class TicketService { + + private final TicketRepository ticketRepository; + private final RoomRepository roomRepository; + private final UserRepository userRepository; + + public TicketService(TicketRepository ticketRepository, RoomRepository roomRepository, UserRepository userRepository) { + this.ticketRepository = ticketRepository; + this.roomRepository = roomRepository; + this.userRepository = userRepository; + } + + public Ticket createTicket(Dtos.TicketRequest request, String ownerEmail) { + User owner = userRepository.findByEmail(ownerEmail) + .orElseThrow(() -> new RuntimeException("User not found")); + + Room room = roomRepository.findById(request.getRoomId()) + .orElseThrow(() -> new RuntimeException("Room not found")); + + Ticket ticket = new Ticket(); + ticket.setRoom(room); + ticket.setTitle(request.getTitle()); + ticket.setDescription(request.getDescription()); + ticket.setOwner(owner); + + return ticketRepository.save(ticket); + } + + public List getTicketsForUser(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("User not found")); + + if ("LEHRKRAFT".equals(user.getRole())) { + return ticketRepository.findByOwnerIdOrderByCreatedAtDesc(user.getId()); + } else if ("RAUMBETREUER".equals(user.getRole())) { + List roomIds = user.getSupervisedRooms().stream() + .map(Room::getId) + .collect(Collectors.toList()); + if (roomIds.isEmpty()) { + return Collections.emptyList(); + } + return ticketRepository.findByRoomIdInOrderByCreatedAtDesc(roomIds); + } + return Collections.emptyList(); + } + + public Ticket updateTicketStatus(Long ticketId, String status) { + Ticket ticket = ticketRepository.findById(ticketId) + .orElseThrow(() -> new RuntimeException("Ticket not found")); + + ticket.setStatus(status); + return ticketRepository.save(ticket); + } +} diff --git a/Backend/src/main/resources/application.properties b/Backend/src/main/resources/application.properties new file mode 100644 index 0000000..02f0a73 --- /dev/null +++ b/Backend/src/main/resources/application.properties @@ -0,0 +1,12 @@ +spring.application.name=ticketsystem + +spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://38.242.130.81:6969/postgres} +spring.datasource.username=${SPRING_DATASOURCE_USERNAME:postgres} +spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:yyhup1i5uuelumil} +spring.datasource.driver-class-name=org.postgresql.Driver + +spring.jpa.hibernate.ddl-auto=update +spring.jpa.show-sql=true +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +server.port=8080 diff --git a/Frontend/.dockerignore b/Frontend/.dockerignore new file mode 100644 index 0000000..5c8faf5 --- /dev/null +++ b/Frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.next +.git +.vscode diff --git a/Frontend/Dockerfile b/Frontend/Dockerfile new file mode 100644 index 0000000..b6b4ebd --- /dev/null +++ b/Frontend/Dockerfile @@ -0,0 +1,43 @@ +FROM node:20-alpine AS base + +# Install dependencies only when needed +FROM base AS deps +WORKDIR /app +COPY package.json package-lock.json* ./ +RUN npm ci + +# Rebuild the source code only when needed +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npm run build + +# Production image, copy all the files and run next +FROM base AS runner +WORKDIR /app + +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs +RUN adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public + +# Set the correct permission for prerender cache +RUN mkdir .next +RUN chown nextjs:nodejs .next + +# Automatically leverage output traces to reduce image size +# https://nextjs.org/docs/advanced-features/output-file-tracing +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs + +EXPOSE 3000 + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/Frontend/app/api/auth/[...nextauth]/route.ts b/Frontend/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..7c9d29b --- /dev/null +++ b/Frontend/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,66 @@ + +import NextAuth from "next-auth" +import CredentialsProvider from "next-auth/providers/credentials" +import { type User } from "@/lib/types" + +const handler = NextAuth({ + providers: [ + CredentialsProvider({ + name: "Credentials", + credentials: { + email: { label: "Email", type: "email" }, + password: { label: "Password", type: "password" } + }, + async authorize(credentials) { + if (!credentials?.email || !credentials?.password) return null + + try { + const res = await fetch(`${process.env.API_URL || 'http://localhost:8080'}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: credentials.email, + password: credentials.password + }) + }) + + if (res.ok) { + const user = await res.json() + // Attach basic auth token to user object temporarily to pass to jwt callback + user.authHeader = "Basic " + btoa(`${credentials.email}:${credentials.password}`) + return user + } + return null + } catch (e) { + console.error(e) + return null + } + } + }) + ], + callbacks: { + async jwt({ token, user }) { + if (user) { + token.user = user + // @ts-ignore + token.authHeader = user.authHeader + } + return token + }, + async session({ session, token }) { + if (token.user) { + // @ts-ignore + session.user = token.user as User + // @ts-ignore + session.authHeader = token.authHeader as string + } + return session + } + }, + pages: { + signIn: "/auth", + error: "/auth" // Redirect to custom auth page on error + } +}) + +export { handler as GET, handler as POST } diff --git a/Frontend/app/globals.css b/Frontend/app/globals.css new file mode 100644 index 0000000..63ae915 --- /dev/null +++ b/Frontend/app/globals.css @@ -0,0 +1,147 @@ +@import 'tailwindcss'; +@import 'tw-animate-css'; + +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(0.985 0.002 247); + --foreground: oklch(0.145 0.015 250); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0.015 250); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0.015 250); + --primary: oklch(0.55 0.18 250); + --primary-foreground: oklch(0.99 0 0); + --secondary: oklch(0.96 0.01 250); + --secondary-foreground: oklch(0.25 0.02 250); + --muted: oklch(0.96 0.008 250); + --muted-foreground: oklch(0.50 0.02 250); + --accent: oklch(0.96 0.01 250); + --accent-foreground: oklch(0.25 0.02 250); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.99 0 0); + --border: oklch(0.91 0.01 250); + --input: oklch(0.91 0.01 250); + --ring: oklch(0.55 0.18 250); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.5rem; + --sidebar: oklch(0.99 0.002 250); + --sidebar-foreground: oklch(0.145 0.015 250); + --sidebar-primary: oklch(0.55 0.18 250); + --sidebar-primary-foreground: oklch(0.99 0 0); + --sidebar-accent: oklch(0.96 0.01 250); + --sidebar-accent-foreground: oklch(0.25 0.02 250); + --sidebar-border: oklch(0.91 0.01 250); + --sidebar-ring: oklch(0.55 0.18 250); + + /* Status colors */ + --status-open: oklch(0.82 0.15 85); + --status-open-foreground: oklch(0.35 0.12 85); + --status-progress: oklch(0.75 0.15 250); + --status-progress-foreground: oklch(0.35 0.15 250); + --status-done: oklch(0.78 0.15 155); + --status-done-foreground: oklch(0.30 0.12 155); +} + +.dark { + --background: oklch(0.12 0.015 250); + --foreground: oklch(0.97 0.005 250); + --card: oklch(0.16 0.015 250); + --card-foreground: oklch(0.97 0.005 250); + --popover: oklch(0.16 0.015 250); + --popover-foreground: oklch(0.97 0.005 250); + --primary: oklch(0.65 0.18 250); + --primary-foreground: oklch(0.12 0.015 250); + --secondary: oklch(0.22 0.02 250); + --secondary-foreground: oklch(0.97 0.005 250); + --muted: oklch(0.22 0.02 250); + --muted-foreground: oklch(0.65 0.02 250); + --accent: oklch(0.22 0.02 250); + --accent-foreground: oklch(0.97 0.005 250); + --destructive: oklch(0.55 0.22 27); + --destructive-foreground: oklch(0.97 0.005 250); + --border: oklch(0.25 0.02 250); + --input: oklch(0.25 0.02 250); + --ring: oklch(0.65 0.18 250); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.14 0.015 250); + --sidebar-foreground: oklch(0.97 0.005 250); + --sidebar-primary: oklch(0.65 0.18 250); + --sidebar-primary-foreground: oklch(0.12 0.015 250); + --sidebar-accent: oklch(0.22 0.02 250); + --sidebar-accent-foreground: oklch(0.97 0.005 250); + --sidebar-border: oklch(0.25 0.02 250); + --sidebar-ring: oklch(0.65 0.18 250); + + /* Status colors */ + --status-open: oklch(0.75 0.14 85); + --status-open-foreground: oklch(0.95 0.04 85); + --status-progress: oklch(0.65 0.15 250); + --status-progress-foreground: oklch(0.95 0.04 250); + --status-done: oklch(0.68 0.14 155); + --status-done-foreground: oklch(0.95 0.04 155); +} + +@theme inline { + --font-sans: 'Geist', 'Geist Fallback'; + --font-mono: 'Geist Mono', 'Geist Mono Fallback'; + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + --color-status-open: var(--status-open); + --color-status-open-foreground: var(--status-open-foreground); + --color-status-progress: var(--status-progress); + --color-status-progress-foreground: var(--status-progress-foreground); + --color-status-done: var(--status-done); + --color-status-done-foreground: var(--status-done-foreground); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/Frontend/app/layout.tsx b/Frontend/app/layout.tsx new file mode 100644 index 0000000..1179ff2 --- /dev/null +++ b/Frontend/app/layout.tsx @@ -0,0 +1,49 @@ +import React from "react" +import type { Metadata } from 'next' +import { Geist, Geist_Mono } from 'next/font/google' +import { Analytics } from '@vercel/analytics/next' +import './globals.css' +import { NextAuthSessionProvider } from "@/components/session-provider" + +const _geist = Geist({ subsets: ["latin"] }); +const _geistMono = Geist_Mono({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: 'Elektronikschule IT Support', + description: 'IT Ticket Management System for Elektronikschule', + generator: 'v0.app', + icons: { + icon: [ + { + url: '/icon-light-32x32.png', + media: '(prefers-color-scheme: light)', + }, + { + url: '/icon-dark-32x32.png', + media: '(prefers-color-scheme: dark)', + }, + { + url: '/icon.svg', + type: 'image/svg+xml', + }, + ], + apple: '/apple-icon.png', + }, +} + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode +}>) { + return ( + + + + {children} + + + + + ) +} diff --git a/Frontend/app/page.tsx b/Frontend/app/page.tsx new file mode 100644 index 0000000..02d9e7b --- /dev/null +++ b/Frontend/app/page.tsx @@ -0,0 +1,29 @@ +"use client" + +import { AuthProvider, useAuth } from "@/lib/auth-context" +import { AuthScreen } from "@/components/auth/auth-screen" +import { AppShell } from "@/components/layout/app-shell" +import { TeacherDashboard } from "@/components/dashboard/teacher-dashboard" +import { SupervisorDashboard } from "@/components/dashboard/supervisor-dashboard" + +function AppContent() { + const { user } = useAuth() + + if (!user) { + return + } + + return ( + + {user.role === "LEHRKRAFT" ? : } + + ) +} + +export default function Home() { + return ( + + + + ) +} diff --git a/Frontend/components.json b/Frontend/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/Frontend/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/Frontend/components/auth/auth-screen.tsx b/Frontend/components/auth/auth-screen.tsx new file mode 100644 index 0000000..2f740e4 --- /dev/null +++ b/Frontend/components/auth/auth-screen.tsx @@ -0,0 +1,32 @@ +"use client" + +import { useState } from "react" +import { LoginForm } from "./login-form" +import { RegisterForm } from "./register-form" +import { Cpu } from "lucide-react" + +export function AuthScreen() { + const [mode, setMode] = useState<"login" | "register">("login") + + return ( +
+
+
+ +
+

Elektronikschule

+

IT-Support-Portal

+
+ + {mode === "login" ? ( + setMode("register")} /> + ) : ( + setMode("login")} /> + )} + +

+ Technisches Support-System für das Personal der Elektronikschule +

+
+ ) +} diff --git a/Frontend/components/auth/login-form.tsx b/Frontend/components/auth/login-form.tsx new file mode 100644 index 0000000..21d9c6c --- /dev/null +++ b/Frontend/components/auth/login-form.tsx @@ -0,0 +1,117 @@ +"use client" + +import React, { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuth } from "@/lib/auth-context" +import { Mail, Lock, LogIn, AlertCircle } from "lucide-react" + +interface LoginFormProps { + onSwitchToRegister: () => void +} + +export function LoginForm({ onSwitchToRegister }: LoginFormProps) { + const { login } = useAuth() + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + setIsLoading(true) + + const success = await login(email, password) + if (!success) { + setError("Ungültige E-Mail oder Passwort") + } + setIsLoading(false) + } + + return ( + + + Willkommen zurück + + Bitte melden Sie sich an + + +
+ + {error && ( +
+ + {error} +
+ )} +
+ +
+ + setEmail(e.target.value)} + className="pl-10" + required + /> +
+
+
+ +
+ + setPassword(e.target.value)} + className="pl-10" + required + /> +
+
+
+

Hinweis:

+

Nutzen Sie Ihre registrierten Zugangsdaten.

+
+
+ + +

+ {"Noch kein Konto? "} + +

+
+
+
+ ) +} diff --git a/Frontend/components/auth/register-form.tsx b/Frontend/components/auth/register-form.tsx new file mode 100644 index 0000000..4a8578d --- /dev/null +++ b/Frontend/components/auth/register-form.tsx @@ -0,0 +1,245 @@ +"use client" + +import React, { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Checkbox } from "@/components/ui/checkbox" +import { useAuth } from "@/lib/auth-context" +import { type UserRole } from "@/lib/types" +import { Mail, Lock, User, UserPlus, AlertCircle, Building2 } from "lucide-react" + +interface RegisterFormProps { + onSwitchToLogin: () => void +} + +export function RegisterForm({ onSwitchToLogin }: RegisterFormProps) { + const { register, rooms } = useAuth() + const [firstname, setFirstname] = useState("") + const [lastname, setLastname] = useState("") + const [email, setEmail] = useState("") + const [password, setPassword] = useState("") + const [role, setRole] = useState("LEHRKRAFT") + const [selectedRoomIds, setSelectedRoomIds] = useState([]) + const [error, setError] = useState("") + const [isLoading, setIsLoading] = useState(false) + + const handleRoomToggle = (roomId: number) => { + setSelectedRoomIds((prev) => (prev.includes(roomId) ? prev.filter((r) => r !== roomId) : [...prev, roomId])) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + if (role === "RAUMBETREUER" && selectedRoomIds.length === 0) { + setError("Bitte wählen Sie mindestens einen Raum aus") + return + } + + setIsLoading(true) + + const success = await register( + { + firstname, + lastname, + email, + role, + roomIds: role === "RAUMBETREUER" ? selectedRoomIds : undefined, + password + } + ) + + if (!success) { + 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) { + onSwitchToLogin() + } + setIsLoading(false) + } + + return ( + + + Konto erstellen + Geben Sie Ihre Daten ein + +
+ + {error && ( +
+ + {error} +
+ )} + +
+
+ +
+ + setFirstname(e.target.value)} + className="pl-10" + required + /> +
+
+
+ +
+ + setLastname(e.target.value)} + className="pl-10" + required + /> +
+
+
+ +
+ +
+ + setEmail(e.target.value)} + className="pl-10" + required + /> +
+
+ +
+ +
+ + setPassword(e.target.value)} + className="pl-10" + required + minLength={4} + /> +
+
+ +
+ +
+ + +
+
+ + {role === "RAUMBETREUER" && ( +
+ +
+ {rooms.map((room) => ( +
+ handleRoomToggle(room.id)} + /> + +
+ ))} +
+ {selectedRoomIds.length > 0 && ( +

+ {selectedRoomIds.length} Raum/Räume ausgewählt +

+ )} +
+ )} +
+ + +

+ Bereits registriert?{" "} + +

+
+
+
+ ) +} diff --git a/Frontend/components/dashboard/supervisor-dashboard.tsx b/Frontend/components/dashboard/supervisor-dashboard.tsx new file mode 100644 index 0000000..be395b6 --- /dev/null +++ b/Frontend/components/dashboard/supervisor-dashboard.tsx @@ -0,0 +1,141 @@ +"use client" + +import { useMemo } from "react" +import { useAuth } from "@/lib/auth-context" +import { TicketTable } from "@/components/tickets/ticket-table" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Building2, Ticket, Clock, CheckCircle2, AlertCircle } from "lucide-react" + +export function SupervisorDashboard() { + const { user, tickets, updateTicketStatus } = useAuth() + + // user.supervisedRooms is Room[] + const supervisedRooms = user?.supervisedRooms || [] + + const roomTickets = useMemo(() => { + if (!supervisedRooms.length) return [] + const roomIds = supervisedRooms.map(r => r.id) + return tickets.filter((t) => roomIds.includes(t.room.id)) + }, [tickets, supervisedRooms]) + + const stats = useMemo(() => { + const open = roomTickets.filter((t) => t.status === "OPEN").length + const inProgress = roomTickets.filter((t) => t.status === "IN_PROGRESS").length + const done = roomTickets.filter((t) => t.status === "CLOSED").length + return { open, inProgress, done, total: roomTickets.length } + }, [roomTickets]) + + const roomStats = useMemo(() => { + return supervisedRooms.map((room) => { + const roomTicketsList = roomTickets.filter((t) => t.room.id === room.id) + return { + room: room.name, + total: roomTicketsList.length, + open: roomTicketsList.filter((t) => t.status === "OPEN").length, + inProgress: roomTicketsList.filter((t) => t.status === "IN_PROGRESS").length, + done: roomTicketsList.filter((t) => t.status === "CLOSED").length, + } + }) + }, [supervisedRooms, roomTickets]) + + return ( +
+
+

Raumbetreuer Dashboard

+

Verwalten Sie Tickets für Ihre Räume

+
+ +
+ + + Total Tickets + + + +
{stats.total}
+
+
+ + + Offen + + + +
{stats.open}
+
+
+ + + In Bearbeitung + + + +
{stats.inProgress}
+
+
+ + + Erledigt + + + +
{stats.done}
+
+
+
+ + + +
+ + Raumübersicht +
+ Status der Tickets pro Raum +
+ +
+ {roomStats.map((stat) => ( +
+

{stat.room}

+
+ {stat.open > 0 && ( + + {stat.open} Offen + + )} + {stat.inProgress > 0 && ( + + {stat.inProgress} In Bearb. + + )} + {stat.done > 0 && ( + + {stat.done} Fertig + + )} + {stat.total === 0 && ( + Keine Tickets + )} +
+
+ ))} +
+
+
+ + + + Alle Raum-Tickets + Verwalten Sie den Status der Tickets + + + + + +
+ ) +} diff --git a/Frontend/components/dashboard/teacher-dashboard.tsx b/Frontend/components/dashboard/teacher-dashboard.tsx new file mode 100644 index 0000000..4937947 --- /dev/null +++ b/Frontend/components/dashboard/teacher-dashboard.tsx @@ -0,0 +1,97 @@ +"use client" + +import { useMemo } from "react" +import { useAuth } from "@/lib/auth-context" +import { CreateTicketForm } from "@/components/tickets/create-ticket-form" +import { TicketTable } from "@/components/tickets/ticket-table" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Ticket, Clock, CheckCircle2, AlertCircle } from "lucide-react" + +export function TeacherDashboard() { + const { user, tickets } = useAuth() + + 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 + }, [tickets]) + + const stats = useMemo(() => { + const open = myTickets.filter((t) => t.status === "OPEN").length + const inProgress = myTickets.filter((t) => t.status === "IN_PROGRESS").length + const done = myTickets.filter((t) => t.status === "CLOSED").length + return { open, inProgress, done, total: myTickets.length } + }, [myTickets]) + + return ( +
+
+

Lehrkraft Dashboard

+

Erstellen und verfolgen Sie Ihre IT-Support-Tickets

+
+ +
+ + + Tickets Gesamt + + + +
{stats.total}
+
+
+ + + Offen + + + +
{stats.open}
+
+
+ + + In Bearbeitung + + + +
{stats.inProgress}
+
+
+ + + Erledigt + + + +
{stats.done}
+
+
+
+ +
+
+ +
+
+ + + Meine Tickets + Übersicht Ihrer gemeldeten Probleme + + + + + +
+
+
+ ) +} diff --git a/Frontend/components/layout/app-shell.tsx b/Frontend/components/layout/app-shell.tsx new file mode 100644 index 0000000..1037509 --- /dev/null +++ b/Frontend/components/layout/app-shell.tsx @@ -0,0 +1,130 @@ +"use client" + +import type { ReactNode } from "react" +import { useAuth } from "@/lib/auth-context" +import { Button } from "@/components/ui/button" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, + DropdownMenuCheckboxItem, +} from "@/components/ui/dropdown-menu" +import { LogOut, User, Building2, Cpu } from "lucide-react" + +interface AppShellProps { + children: ReactNode +} + +export function AppShell({ children }: AppShellProps) { + const { user, logout, rooms, updateRooms } = useAuth() + + const initials = user?.name + ?.split(" ") + .map((n) => n[0]) + .join("") + .toUpperCase() + .slice(0, 2) || "" + + return ( +
+
+
+
+
+ +
+
+ Elektronikschule + IT-Support-Portal +
+
+ +
+
+ {user?.role === "LEHRKRAFT" ? ( + + ) : ( + + )} + + {user?.role === "LEHRKRAFT" ? "Lehrkraft" : "Raumbetreuer"} + +
+ + + + + + + +
+

{user?.name}

+

{user?.email}

+
+
+ + + + {user?.role === "LEHRKRAFT" ? "Lehrkraft" : "Raumbetreuer"} + + {user?.role === "RAUMBETREUER" && user.supervisedRooms && ( + + + + {user.supervisedRooms.length} Betreute Räume + + + Räume verwalten + {rooms.map((room) => { + const isChecked = user.supervisedRooms.some((r) => r.id === room.id) + return ( + { + const currentIds = user.supervisedRooms.map((r) => r.id) + let newIds + if (checked) { + newIds = [...currentIds, room.id] + } else { + newIds = currentIds.filter((id) => id !== room.id) + } + updateRooms(newIds) + }} + onSelect={(e) => e.preventDefault()} + > + {room.name} + + ) + })} + + + )} + + + + Abmelden + +
+
+
+
+
+ +
{children}
+
+ ) +} diff --git a/Frontend/components/session-provider.tsx b/Frontend/components/session-provider.tsx new file mode 100644 index 0000000..803daea --- /dev/null +++ b/Frontend/components/session-provider.tsx @@ -0,0 +1,7 @@ +"use client" + +import { SessionProvider } from "next-auth/react" + +export function NextAuthSessionProvider({ children }: { children: React.ReactNode }) { + return {children} +} diff --git a/Frontend/components/theme-provider.tsx b/Frontend/components/theme-provider.tsx new file mode 100644 index 0000000..55c2f6e --- /dev/null +++ b/Frontend/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/Frontend/components/tickets/create-ticket-form.tsx b/Frontend/components/tickets/create-ticket-form.tsx new file mode 100644 index 0000000..6cdc537 --- /dev/null +++ b/Frontend/components/tickets/create-ticket-form.tsx @@ -0,0 +1,115 @@ +"use client" + +import React, { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useAuth } from "@/lib/auth-context" +import { Send, CheckCircle2 } from "lucide-react" + +export function CreateTicketForm() { + const { createTicket, rooms } = useAuth() + const [roomId, setRoomId] = useState("") + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + const [isSubmitting, setIsSubmitting] = useState(false) + const [showSuccess, setShowSuccess] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!roomId || !title || !description) return + + setIsSubmitting(true) + + await createTicket({ + roomId: parseInt(roomId), + title, + description + }) + + setRoomId("") + setTitle("") + setDescription("") + setIsSubmitting(false) + setShowSuccess(true) + + setTimeout(() => setShowSuccess(false), 3000) + } + + return ( + + + Neues Ticket erstellen + Melden Sie ein technisches Problem oder fordern Sie Hilfe an + + + {showSuccess && ( +
+ + Ticket erfolgreich erstellt! +
+ )} +
+
+ + +
+
+ + setTitle(e.target.value)} + required + /> +
+
+ +