Initial Commit

This commit is contained in:
devinschulz
2026-09-08 10:00:28 +02:00
commit 334e9e14bb
56 changed files with 2740 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
backend/target/
backend/data/
frontend/node_modules/
frontend/dist/
.idea/
.vscode/
*.iml
.DS_Store
+17
View File
@@ -0,0 +1,17 @@
MIT License
Copyright (c) 2026
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+106
View File
@@ -0,0 +1,106 @@
# Klaro Ticketdesk
Ein vollständiges Ticketsystem mit Java-Backend und React-Frontend. Das MVP unterstützt Anmeldung, Selbstregistrierung, Benutzer- und Rechteverwaltung, persönliche Ticketbereiche, Suche, Filter, Statuswechsel, Prioritäten, Zuweisungen und Kommentare.
## Stack
- Backend: Java 21, Spring Boot 4.1.1, Spring Web, Spring Data JPA, Validation
- Sicherheit: Spring Security, BCrypt-Passwörter, serverseitige Sessions und CSRF-Schutz
- Datenbank: H2 im lokalen Entwicklungsmodus, PostgreSQL mit Docker Compose
- Frontend: React 19, Vite 8, responsive Oberfläche
- Betrieb: Docker Compose mit PostgreSQL, Java-Backend und Nginx-Frontend
## Schnellstart mit Docker
Voraussetzung: Docker mit Compose.
```bash
docker compose up --build
```
Danach öffnen:
- Weboberfläche: http://localhost:3000
- REST-API: http://localhost:8080/api/tickets
- Healthcheck: http://localhost:8080/actuator/health
Die PostgreSQL-Daten bleiben im Docker-Volume `ticket_db` erhalten.
## Demo-Anmeldungen
| Rolle | E-Mail | Passwort | Sichtbarkeit |
| --- | --- | --- | --- |
| Administrator | `admin@klaro.de` | `demo123` | Alle Tickets, eigener Bereich und Nutzerverwaltung |
| Support | `support@klaro.de` | `demo123` | „Alle Tickets“ und „Mein Bereich“ |
| Benutzer | `user@klaro.de` | `demo123` | Nur „Mein Bereich“ mit eigenen Tickets |
Über „Registrieren“ können neue Benutzer selbst ein Konto anlegen. Neue Konten erhalten immer zunächst die Rolle `USER`. Ein Administrator kann anschließend in der Nutzerverwaltung die Rollen `USER`, `SUPPORT` oder `ADMIN` vergeben. Ticketzugehörigkeit und Rollenprüfung werden im Backend erzwungen.
## Lokale Entwicklung
### Backend
Voraussetzungen: Java 21 und Maven 3.9 oder neuer.
```bash
cd backend
mvn spring-boot:run
```
Standardmäßig verwendet das Backend eine persistente H2-Dateidatenbank unter `backend/data/`. Die H2-Konsole ist unter http://localhost:8080/h2-console erreichbar. JDBC-URL: `jdbc:h2:file:./data/tickets`.
### Frontend
Voraussetzung: Node.js 24 oder neuer.
```bash
cd frontend
npm ci
npm run dev
```
Vite leitet `/api` im Entwicklungsmodus an `http://localhost:8080` weiter. Die Oberfläche ist unter http://localhost:5173 erreichbar.
## REST-API
| Methode | Pfad | Funktion |
| --- | --- | --- |
| `POST` | `/api/auth/register` | Benutzerkonto erstellen und anmelden |
| `GET` | `/api/tickets?scope=MINE` | Eigene Tickets auflisten |
| `GET` | `/api/tickets?scope=ALL` | Alle Tickets auflisten Support und Administratoren |
| `GET` | `/api/tickets/{id}` | Ticket inklusive Kommentaren laden |
| `POST` | `/api/tickets` | Ticket erstellen |
| `PATCH` | `/api/tickets/{id}` | Ticket bearbeiten |
| `POST` | `/api/tickets/{id}/comments` | Kommentar hinzufügen |
| `GET` | `/api/users` | Registrierte Benutzer auflisten nur Administratoren |
| `PATCH` | `/api/users/{id}/role` | Benutzerrolle ändern nur Administratoren |
Beispiel für ein neues Ticket:
```json
{
"subject": "Zugriff auf Projektordner fehlt",
"description": "Der Ordner ist seit heute nicht mehr erreichbar.",
"category": "IT & Zugriff",
"priority": "HIGH",
"assignee": "Jonas Weber"
}
```
Gültige Statuswerte: `OPEN`, `IN_PROGRESS`, `WAITING`, `RESOLVED`, `CLOSED`.
Gültige Prioritäten: `LOW`, `MEDIUM`, `HIGH`, `URGENT`.
## Tests und Builds
```bash
cd backend && mvn test
cd frontend && npm run build
```
## Nächste sinnvolle Ausbaustufen
- E-Mail-Benachrichtigungen und SLA-Regeln
- Anhänge, Tags und Wissensdatenbank
- Flyway-Migrationen statt automatischer Schema-Aktualisierung
- Audit-Log, Reporting und Export
+40
View File
@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<classpath>
<classpathentry kind="src" output="target/classes" path="src/main/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="optional" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
<attributes>
<attribute name="optional" value="true"/>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
</attributes>
</classpathentry>
<classpathentry excluding="**" kind="src" output="target/test-classes" path="src/test/resources">
<attributes>
<attribute name="maven.pomderived" value="true"/>
<attribute name="test" value="true"/>
<attribute name="optional" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-21">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
<attributes>
<attribute name="maven.pomderived" value="true"/>
</attributes>
</classpathentry>
<classpathentry kind="output" path="target/classes"/>
</classpath>
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>backend</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
<buildCommand>
<name>org.eclipse.jdt.core.javabuilder</name>
<arguments>
</arguments>
</buildCommand>
<buildCommand>
<name>org.eclipse.m2e.core.maven2Builder</name>
<arguments>
</arguments>
</buildCommand>
</buildSpec>
<natures>
<nature>org.eclipse.jdt.core.javanature</nature>
<nature>org.eclipse.m2e.core.maven2Nature</nature>
</natures>
</projectDescription>
@@ -0,0 +1,5 @@
eclipse.preferences.version=1
encoding//src/main/java=UTF-8
encoding//src/main/resources=UTF-8
encoding//src/test/java=UTF-8
encoding/<project>=UTF-8
@@ -0,0 +1,9 @@
eclipse.preferences.version=1
org.eclipse.jdt.core.compiler.codegen.methodParameters=generate
org.eclipse.jdt.core.compiler.codegen.targetPlatform=21
org.eclipse.jdt.core.compiler.compliance=21
org.eclipse.jdt.core.compiler.problem.enablePreviewFeatures=disabled
org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning
org.eclipse.jdt.core.compiler.problem.reportPreviewFeatures=ignore
org.eclipse.jdt.core.compiler.release=enabled
org.eclipse.jdt.core.compiler.source=21
+12
View File
@@ -0,0 +1,12 @@
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q -B dependency:go-offline
COPY src src
RUN mvn -q -B package -DskipTests
FROM eclipse-temurin:21-jre
WORKDIR /app
COPY --from=build /app/target/ticket-backend-1.0.0.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>de.klaro</groupId>
<artifactId>ticket-backend</artifactId>
<version>1.0.0</version>
<name>Klaro Ticket Backend</name>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.postgresql</groupId><artifactId>postgresql</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,11 @@
package de.klaro.tickets;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class TicketApplication {
public static void main(String[] args) {
SpringApplication.run(TicketApplication.class, args);
}
}
@@ -0,0 +1,37 @@
package de.klaro.tickets.api;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import java.util.stream.Collectors;
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
ProblemDetail notFound(EntityNotFoundException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, exception.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
ProblemDetail validation(MethodArgumentNotValidException exception) {
String detail = exception.getBindingResult().getFieldErrors().stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining(", "));
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, detail);
}
@ExceptionHandler(AccessDeniedException.class)
ProblemDetail forbidden(AccessDeniedException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, exception.getMessage());
}
@ExceptionHandler(AuthenticationException.class)
ProblemDetail unauthorized(AuthenticationException exception) {
return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, "E-Mail-Adresse oder Passwort ist falsch.");
}
}
@@ -0,0 +1,88 @@
package de.klaro.tickets.api;
import de.klaro.tickets.service.CurrentUserService;
import de.klaro.tickets.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
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.core.context.SecurityContextHolder;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.security.web.authentication.logout.SecurityContextLogoutHandler;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
public class AuthController implements InitializingBean {
private final AuthenticationManager authenticationManager;
private final CurrentUserService currentUsers;
private final UserService users;
private boolean registrationDisabled;
@Autowired
private Environment environment;
public AuthController(AuthenticationManager authenticationManager, CurrentUserService currentUsers, UserService users) {
this.authenticationManager = authenticationManager;
this.currentUsers = currentUsers;
this.users = users;
}
@Override
public void afterPropertiesSet() throws Exception {
String propertyValue = environment.getProperty("ticket.registration.disabled");
registrationDisabled = "1".equals(propertyValue) || "true".equalsIgnoreCase(propertyValue);
}
@GetMapping("/csrf")
public Map<String, String> csrf(CsrfToken token) { return Map.of("token", token.getToken()); }
@GetMapping("/me")
public AuthResponse me(Authentication authentication) { return AuthResponse.from(currentUsers.require(authentication)); }
@PostMapping("/login")
public AuthResponse login(@Valid @RequestBody LoginRequest request, HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
return establishSession(request.username(), request.password(), servletRequest, servletResponse);
}
@GetMapping("/register")
public boolean registrationDisabled() {
return registrationDisabled;
}
@PostMapping("/register")
public ResponseEntity<AuthResponse> register(@Valid @RequestBody RegisterRequest request, HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
if (registrationDisabled) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Die Registrierung neuer Nutzer ist deaktiviert.");
}
users.register(request);
return ResponseEntity.status(201).body(establishSession(request.username(), request.password(), servletRequest, servletResponse));
}
private AuthResponse establishSession(String email, String password, HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
Authentication authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(email.trim().toLowerCase(), password));
var context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
new HttpSessionSecurityContextRepository().saveContext(context, servletRequest, servletResponse);
return AuthResponse.from(currentUsers.require(authentication));
}
@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
new SecurityContextLogoutHandler().logout(request, response, authentication);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,10 @@
package de.klaro.tickets.api;
import de.klaro.tickets.domain.AppUser;
import de.klaro.tickets.domain.UserRole;
public record AuthResponse(Long id, String name, String email, UserRole role) {
public static AuthResponse from(AppUser user) {
return new AuthResponse(user.getId(), user.getName(), user.getUsername(), user.getRole());
}
}
@@ -0,0 +1,9 @@
package de.klaro.tickets.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record CommentCreateRequest(
@NotBlank @Size(max = 120) String author,
@NotBlank @Size(max = 5000) String body
) {}
@@ -0,0 +1,10 @@
package de.klaro.tickets.api;
import de.klaro.tickets.domain.Comment;
import java.time.LocalDateTime;
public record CommentResponse(Long id, String author, String body, LocalDateTime createdAt) {
public static CommentResponse from(Comment comment) {
return new CommentResponse(comment.getId(), comment.getAuthor(), comment.getBody(), comment.getCreatedAt());
}
}
@@ -0,0 +1,5 @@
package de.klaro.tickets.api;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(@NotBlank String username, @NotBlank String password) {}
@@ -0,0 +1,10 @@
package de.klaro.tickets.api;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Size(max = 120) String name,
@NotBlank @Size(max = 180) String username,
@NotBlank @Size(min = 8, max = 72) String password
) {}
@@ -0,0 +1,49 @@
package de.klaro.tickets.api;
import de.klaro.tickets.domain.Priority;
import de.klaro.tickets.domain.TicketStatus;
import de.klaro.tickets.service.TicketService;
import de.klaro.tickets.service.CurrentUserService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.security.core.Authentication;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/tickets")
public class TicketController {
private final TicketService service;
private final CurrentUserService currentUsers;
public TicketController(TicketService service, CurrentUserService currentUsers) { this.service = service; this.currentUsers = currentUsers; }
@GetMapping
public List<TicketResponse> search(
Authentication authentication,
@RequestParam(defaultValue = "MINE") TicketScope scope,
@RequestParam(required = false) String query,
@RequestParam(required = false) TicketStatus status,
@RequestParam(required = false) Priority priority
) { return service.search(currentUsers.require(authentication), scope, query, status, priority); }
@GetMapping("/{id}")
public TicketResponse get(Authentication authentication, @PathVariable long id) { return service.get(currentUsers.require(authentication), id); }
@PostMapping
public ResponseEntity<TicketResponse> create(Authentication authentication, @Valid @RequestBody TicketCreateRequest request) {
TicketResponse ticket = service.create(currentUsers.require(authentication), request);
return ResponseEntity.created(URI.create("/api/tickets/" + ticket.id())).body(ticket);
}
@PatchMapping("/{id}")
public TicketResponse update(Authentication authentication, @PathVariable long id, @Valid @RequestBody TicketUpdateRequest request) {
return service.update(currentUsers.require(authentication), id, request);
}
@PostMapping("/{id}/comments")
public ResponseEntity<CommentResponse> addComment(Authentication authentication, @PathVariable long id, @Valid @RequestBody CommentCreateRequest request) {
return ResponseEntity.status(201).body(service.addComment(currentUsers.require(authentication), id, request));
}
}
@@ -0,0 +1,14 @@
package de.klaro.tickets.api;
import de.klaro.tickets.domain.Priority;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record TicketCreateRequest(
@NotBlank @Size(max = 180) String subject,
@NotBlank @Size(max = 5000) String description,
@Size(max = 80) String category,
Priority priority,
@Size(max = 120) String assignee,
@Size(max = 120) String contact
) {}
@@ -0,0 +1,32 @@
package de.klaro.tickets.api;
import de.klaro.tickets.domain.Priority;
import de.klaro.tickets.domain.Ticket;
import de.klaro.tickets.domain.TicketStatus;
import java.time.LocalDateTime;
import java.util.List;
public record TicketResponse(
Long id,
String subject,
String description,
String requester,
String category,
Priority priority,
TicketStatus status,
String assignee,
String contact,
LocalDateTime createdAt,
LocalDateTime updatedAt,
long version,
List<CommentResponse> comments
) {
public static TicketResponse from(Ticket ticket, boolean includeComments) {
var comments = includeComments
? ticket.getComments().stream().map(CommentResponse::from).toList()
: List.<CommentResponse>of();
return new TicketResponse(ticket.getId(), ticket.getSubject(), ticket.getDescription(), ticket.getRequester(),
ticket.getCategory(), ticket.getPriority(), ticket.getStatus(), ticket.getAssignee(), ticket.getContact(), ticket.getCreatedAt(),
ticket.getUpdatedAt(), ticket.getVersion(), comments);
}
}
@@ -0,0 +1,5 @@
package de.klaro.tickets.api;
public enum TicketScope {
MINE, ALL
}
@@ -0,0 +1,15 @@
package de.klaro.tickets.api;
import de.klaro.tickets.domain.Priority;
import de.klaro.tickets.domain.TicketStatus;
import jakarta.validation.constraints.Size;
public record TicketUpdateRequest(
@Size(max = 180) String subject,
@Size(max = 5000) String description,
@Size(max = 80) String category,
Priority priority,
TicketStatus status,
@Size(max = 120) String assignee,
@Size(max = 120) String contact
) {}
@@ -0,0 +1,31 @@
package de.klaro.tickets.api;
import de.klaro.tickets.service.CurrentUserService;
import de.klaro.tickets.service.UserService;
import jakarta.validation.Valid;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService service;
private final CurrentUserService currentUsers;
public UserController(UserService service, CurrentUserService currentUsers) {
this.service = service;
this.currentUsers = currentUsers;
}
@GetMapping
public List<UserResponse> list(Authentication authentication) {
return service.list(currentUsers.require(authentication));
}
@PatchMapping("/{id}/role")
public UserResponse updateRole(Authentication authentication, @PathVariable long id, @Valid @RequestBody UserRoleUpdateRequest request) {
return service.updateRole(currentUsers.require(authentication), id, request.role());
}
}
@@ -0,0 +1,10 @@
package de.klaro.tickets.api;
import de.klaro.tickets.domain.AppUser;
import de.klaro.tickets.domain.UserRole;
public record UserResponse(Long id, String name, String email, UserRole role) {
public static UserResponse from(AppUser user) {
return new UserResponse(user.getId(), user.getName(), user.getUsername(), user.getRole());
}
}
@@ -0,0 +1,6 @@
package de.klaro.tickets.api;
import de.klaro.tickets.domain.UserRole;
import jakarta.validation.constraints.NotNull;
public record UserRoleUpdateRequest(@NotNull UserRole role) {}
@@ -0,0 +1,35 @@
package de.klaro.tickets.config;
import de.klaro.tickets.domain.AppUser;
import de.klaro.tickets.domain.UserRole;
import de.klaro.tickets.repository.TicketRepository;
import de.klaro.tickets.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import org.springframework.security.crypto.password.PasswordEncoder;
@Component
public class DemoDataInitializer implements CommandLineRunner {
private final TicketRepository tickets;
private final UserRepository users;
private final PasswordEncoder passwords;
public DemoDataInitializer(TicketRepository tickets, UserRepository users, PasswordEncoder passwords) {
this.tickets = tickets; this.users = users; this.passwords = passwords;
}
@Override
public void run(String... args) {
AppUser admin = users.findByUsernameIgnoreCase("admin").orElseGet(() -> users.save(user("Admin", "admin", "zdigbw!26", UserRole.ADMIN)));
if (tickets.count() > 0) {
tickets.findAll().stream().filter(ticket -> ticket.getOwner() == null).forEach(ticket -> ticket.setOwner(admin));
tickets.flush();
return;
}
}
private AppUser user(String name, String username, String password, UserRole role) {
AppUser user = new AppUser();
user.setName(name); user.setUsername(username); user.setPasswordHash(passwords.encode(password)); user.setRole(role);
return user;
}
}
@@ -0,0 +1,50 @@
package de.klaro.tickets.config;
import de.klaro.tickets.repository.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
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.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
@Configuration
public class SecurityConfig {
@Bean
PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
@Bean
UserDetailsService userDetailsService(UserRepository users) {
return username -> users.findByUsernameIgnoreCase(username)
.map(user -> User.withUsername(user.getUsername())
.password(user.getPasswordHash())
.roles(user.getRole().name())
.build())
.orElseThrow(() -> new org.springframework.security.core.userdetails.UsernameNotFoundException("Benutzer nicht gefunden."));
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/login", "/api/auth/register", "/api/auth/csrf", "/actuator/health", "/error").permitAll()
.anyRequest().authenticated())
.exceptionHandling(errors -> errors.authenticationEntryPoint((request, response, exception) -> response.sendError(HttpStatus.UNAUTHORIZED.value())))
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.logout(logout -> logout.disable())
.build();
}
}
@@ -0,0 +1,17 @@
package de.klaro.tickets.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origin:http://localhost:5173/}")
private String allowedOrigin;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**").allowedOrigins("http://localhost:5173/", "http://10.10.150.20:3000", "https://support.repmus.visentra.de").allowedMethods("GET", "POST", "PATCH", "OPTIONS");
}
}
@@ -0,0 +1,36 @@
package de.klaro.tickets.domain;
import jakarta.persistence.*;
@Entity
@Table(name = "app_users", uniqueConstraints = @UniqueConstraint(name = "uk_user_username", columnNames = "username"))
public class AppUser {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false, length = 180)
private String username;
@Column(name = "password_hash", nullable = false, length = 100)
private String passwordHash;
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 20)
private UserRole role;
public Long getId() { return id; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public UserRole getRole() { return role; }
public void setRole(UserRole role) { this.role = role; }
}
@@ -0,0 +1,36 @@
package de.klaro.tickets.domain;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "ticket_comments", indexes = @Index(name = "idx_comment_ticket", columnList = "ticket_id"))
public class Comment {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "ticket_id", nullable = false)
private Ticket ticket;
@Column(nullable = false, length = 120)
private String author;
@Column(nullable = false, length = 5000)
private String body;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
void createTimestamp() { createdAt = LocalDateTime.now(); }
public Long getId() { return id; }
public Ticket getTicket() { return ticket; }
public void setTicket(Ticket ticket) { this.ticket = ticket; }
public String getAuthor() { return author; }
public void setAuthor(String author) { this.author = author; }
public String getBody() { return body; }
public void setBody(String body) { this.body = body; }
public LocalDateTime getCreatedAt() { return createdAt; }
}
@@ -0,0 +1,5 @@
package de.klaro.tickets.domain;
public enum Priority {
LOW, MEDIUM, HIGH, URGENT
}
@@ -0,0 +1,102 @@
package de.klaro.tickets.domain;
import jakarta.persistence.*;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "tickets", indexes = {
@Index(name = "idx_ticket_status", columnList = "status"),
@Index(name = "idx_ticket_priority", columnList = "priority"),
@Index(name = "idx_ticket_updated", columnList = "updated_at")
})
public class Ticket {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 180)
private String subject;
@Column(nullable = false, length = 5000)
private String description;
@Column(nullable = false, length = 120)
private String requester;
@Column(nullable = false, length = 80)
private String category;
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 24)
private Priority priority;
@Enumerated(EnumType.STRING) @Column(nullable = false, length = 24)
private TicketStatus status;
@Column(length = 120)
private String assignee;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id")
private AppUser owner;
@Column(length = 120)
private String contact;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
@Version
private long version;
@OneToMany(mappedBy = "ticket", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("createdAt DESC")
private List<Comment> comments = new ArrayList<>();
@PrePersist
void createTimestamps() {
createdAt = LocalDateTime.now();
updatedAt = createdAt;
if (priority == null) priority = Priority.MEDIUM;
if (status == null) status = TicketStatus.OPEN;
if (category == null || category.isBlank()) category = "Allgemein";
}
@PreUpdate
void updateTimestamp() { updatedAt = LocalDateTime.now(); }
public Long getId() { return id; }
public String getSubject() { return subject; }
public void setSubject(String subject) { this.subject = subject; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public String getRequester() { return requester; }
public void setRequester(String requester) { this.requester = requester; }
public String getCategory() { return category; }
public void setCategory(String category) { this.category = category; }
public Priority getPriority() { return priority; }
public void setPriority(Priority priority) { this.priority = priority; }
public TicketStatus getStatus() { return status; }
public void setStatus(TicketStatus status) { this.status = status; }
public String getAssignee() { return assignee; }
public void setAssignee(String assignee) { this.assignee = assignee; }
public AppUser getOwner() { return owner; }
public void setOwner(AppUser owner) { this.owner = owner; }
public LocalDateTime getCreatedAt() { return createdAt; }
public LocalDateTime getUpdatedAt() { return updatedAt; }
public long getVersion() { return version; }
public List<Comment> getComments() { return comments; }
public void addComment(Comment comment) { comments.add(comment); comment.setTicket(this); }
public String getContact() {
return contact;
}
public void setContact(String contact) {
this.contact = contact;
}
}
@@ -0,0 +1,5 @@
package de.klaro.tickets.domain;
public enum TicketStatus {
OPEN, IN_PROGRESS, WAITING, RESOLVED, CLOSED
}
@@ -0,0 +1,5 @@
package de.klaro.tickets.domain;
public enum UserRole {
USER, SUPPORT, ADMIN
}
@@ -0,0 +1,6 @@
package de.klaro.tickets.repository;
import de.klaro.tickets.domain.Comment;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CommentRepository extends JpaRepository<Comment, Long> {}
@@ -0,0 +1,40 @@
package de.klaro.tickets.repository;
import de.klaro.tickets.domain.Priority;
import de.klaro.tickets.domain.Ticket;
import de.klaro.tickets.domain.TicketStatus;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface TicketRepository extends JpaRepository<Ticket, Long> {
@EntityGraph(attributePaths = "comments")
@Query("select distinct t from Ticket t where t.id = :id")
Optional<Ticket> findDetailedById(@Param("id") Long id);
@Query("""
select t from Ticket t
where (:query = '' or lower(t.subject) like lower(concat('%', :query, '%'))
or lower(t.requester) like lower(concat('%', :query, '%'))
or lower(t.category) like lower(concat('%', :query, '%')))
and (:status is null or t.status = :status)
and (:priority is null or t.priority = :priority)
order by t.updatedAt desc
""")
List<Ticket> searchAll(@Param("query") String query, @Param("status") TicketStatus status, @Param("priority") Priority priority);
@Query("""
select t from Ticket t
where t.owner.id = :ownerId
and (:query = '' or lower(t.subject) like lower(concat('%', :query, '%'))
or lower(t.requester) like lower(concat('%', :query, '%'))
or lower(t.category) like lower(concat('%', :query, '%')))
and (:status is null or t.status = :status)
and (:priority is null or t.priority = :priority)
order by t.updatedAt desc
""")
List<Ticket> searchOwned(@Param("ownerId") Long ownerId, @Param("query") String query, @Param("status") TicketStatus status, @Param("priority") Priority priority);
}
@@ -0,0 +1,9 @@
package de.klaro.tickets.repository;
import de.klaro.tickets.domain.AppUser;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<AppUser, Long> {
Optional<AppUser> findByUsernameIgnoreCase(String email);
}
@@ -0,0 +1,22 @@
package de.klaro.tickets.service;
import de.klaro.tickets.domain.AppUser;
import de.klaro.tickets.repository.UserRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Service;
@Service
public class CurrentUserService {
private final UserRepository users;
public CurrentUserService(UserRepository users) { this.users = users; }
public AppUser require(Authentication authentication) {
if (authentication == null || !authentication.isAuthenticated()) {
throw new EntityNotFoundException("Benutzer ist nicht angemeldet.");
}
return users.findByUsernameIgnoreCase(authentication.getName())
.orElseThrow(() -> new EntityNotFoundException("Angemeldeter Benutzer wurde nicht gefunden."));
}
}
@@ -0,0 +1,109 @@
package de.klaro.tickets.service;
import de.klaro.tickets.api.*;
import de.klaro.tickets.domain.Comment;
import de.klaro.tickets.domain.Priority;
import de.klaro.tickets.domain.Ticket;
import de.klaro.tickets.domain.TicketStatus;
import de.klaro.tickets.domain.AppUser;
import de.klaro.tickets.domain.UserRole;
import de.klaro.tickets.repository.CommentRepository;
import de.klaro.tickets.repository.TicketRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
public class TicketService {
private final TicketRepository tickets;
private final CommentRepository comments;
public TicketService(TicketRepository tickets, CommentRepository comments) {
this.tickets = tickets;
this.comments = comments;
}
@Transactional(readOnly = true)
public List<TicketResponse> search(AppUser currentUser, TicketScope scope, String query, TicketStatus status, Priority priority) {
String normalized = query == null || query.isBlank() ? "" : query.trim();
if (scope == TicketScope.ALL) {
requireSupport(currentUser);
return tickets.searchAll(normalized, status, priority).stream().map(ticket -> TicketResponse.from(ticket, false)).toList();
}
return tickets.searchOwned(currentUser.getId(), normalized, status, priority).stream().map(ticket -> TicketResponse.from(ticket, false)).toList();
}
@Transactional(readOnly = true)
public TicketResponse get(AppUser currentUser, long id) {
Ticket ticket = findDetailed(id);
requireAccess(currentUser, ticket);
return TicketResponse.from(ticket, true);
}
@Transactional
public TicketResponse create(AppUser currentUser, TicketCreateRequest request) {
Ticket ticket = new Ticket();
ticket.setSubject(request.subject().trim());
ticket.setDescription(request.description().trim());
ticket.setRequester(currentUser.getName());
ticket.setOwner(currentUser);
ticket.setCategory(blankToDefault(request.category(), "Allgemein"));
ticket.setPriority(request.priority() == null ? Priority.MEDIUM : request.priority());
ticket.setStatus(TicketStatus.OPEN);
ticket.setContact(blankToNull(request.contact()));
ticket.setAssignee(canManageTickets(currentUser) ? blankToNull(request.assignee()) : null);
return TicketResponse.from(tickets.save(ticket), false);
}
@Transactional
public TicketResponse update(AppUser currentUser, long id, TicketUpdateRequest request) {
Ticket ticket = find(id);
if (canManageTickets(currentUser)) {
if (request.subject() != null && !request.subject().isBlank()) ticket.setSubject(request.subject().trim());
if (request.description() != null && !request.description().isBlank()) ticket.setDescription(request.description().trim());
if (request.category() != null) ticket.setCategory(blankToDefault(request.category(), "Allgemein"));
if (request.priority() != null) ticket.setPriority(request.priority());
if (request.status() != null) ticket.setStatus(request.status());
if (request.assignee() != null) ticket.setAssignee(blankToNull(request.assignee()));
}
if (request.contact() != null) ticket.setContact(blankToNull(request.contact()));
return TicketResponse.from(tickets.save(ticket), true);
}
@Transactional
public CommentResponse addComment(AppUser currentUser, long id, CommentCreateRequest request) {
Ticket ticket = find(id);
requireAccess(currentUser, ticket);
Comment comment = new Comment();
comment.setAuthor(currentUser.getName());
comment.setBody(request.body().trim());
comment.setTicket(ticket);
return CommentResponse.from(comments.save(comment));
}
private Ticket find(long id) {
return tickets.findById(id).orElseThrow(() -> new EntityNotFoundException("Ticket " + id + " wurde nicht gefunden."));
}
private Ticket findDetailed(long id) {
return tickets.findDetailedById(id).orElseThrow(() -> new EntityNotFoundException("Ticket " + id + " wurde nicht gefunden."));
}
private static void requireSupport(AppUser user) {
if (!canManageTickets(user)) throw new AccessDeniedException("Nur Support und Administratoren dürfen alle Tickets verwalten.");
}
private static void requireAccess(AppUser user, Ticket ticket) {
boolean ownsTicket = ticket.getOwner() != null && ticket.getOwner().getId().equals(user.getId());
if (!canManageTickets(user) && !ownsTicket) throw new AccessDeniedException("Dieses Ticket gehört einem anderen Benutzer.");
}
private static boolean canManageTickets(AppUser user) {
return user.getRole() == UserRole.SUPPORT || user.getRole() == UserRole.ADMIN;
}
private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value.trim(); }
private static String blankToDefault(String value, String fallback) { return value == null || value.isBlank() ? fallback : value.trim(); }
}
@@ -0,0 +1,67 @@
package de.klaro.tickets.service;
import de.klaro.tickets.api.RegisterRequest;
import de.klaro.tickets.api.UserResponse;
import de.klaro.tickets.domain.AppUser;
import de.klaro.tickets.domain.UserRole;
import de.klaro.tickets.repository.UserRepository;
import jakarta.persistence.EntityNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@Service
public class UserService {
private final UserRepository users;
private final PasswordEncoder passwords;
public UserService(UserRepository users, PasswordEncoder passwords) {
this.users = users;
this.passwords = passwords;
}
@Transactional
public AppUser register(RegisterRequest request) {
String username = request.username().trim().toLowerCase();
if (users.findByUsernameIgnoreCase(username).isPresent()) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Für diesen Nutzernamen besteht bereits ein Konto.");
}
AppUser user = new AppUser();
user.setName(request.name().trim());
user.setUsername(username);
user.setPasswordHash(passwords.encode(request.password()));
user.setRole(UserRole.USER);
return users.save(user);
}
@Transactional(readOnly = true)
public List<UserResponse> list(AppUser currentUser) {
requireAdmin(currentUser);
return users.findAll().stream()
.sorted((left, right) -> left.getName().compareToIgnoreCase(right.getName()))
.map(UserResponse::from)
.toList();
}
@Transactional
public UserResponse updateRole(AppUser currentUser, long id, UserRole role) {
requireAdmin(currentUser);
AppUser user = users.findById(id).orElseThrow(() -> new EntityNotFoundException("Benutzer wurde nicht gefunden."));
if (user.getId().equals(currentUser.getId()) && role != UserRole.ADMIN) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "Die eigene Adminrolle kann nicht entfernt werden.");
}
user.setRole(role);
return UserResponse.from(users.save(user));
}
private static void requireAdmin(AppUser user) {
if (user.getRole() != UserRole.ADMIN) {
throw new AccessDeniedException("Nur Administratoren dürfen Benutzer und Rollen verwalten.");
}
}
}
@@ -0,0 +1,11 @@
spring:
datasource:
url: ${DATABASE_URL:jdbc:postgresql://postgres:5432/tickets}
username: ${DATABASE_USER:tickets}
password: ${DATABASE_PASSWORD:tickets}
jpa:
hibernate:
ddl-auto: update
h2:
console:
enabled: false
@@ -0,0 +1,34 @@
spring:
application:
name: uniity-ticketdesk
datasource:
url: jdbc:h2:file:./data/tickets;MODE=PostgreSQL;AUTO_SERVER=TRUE
username: sa
password:
jpa:
hibernate:
ddl-auto: update
open-in-view: false
h2:
console:
enabled: true
path: /h2-console
ticket:
registration:
disabled: ${TICKET_REGISTRATION_DISABLED:false}
management:
endpoints:
web:
exposure:
include: health,info
server:
port: 8080
servlet:
session:
timeout: 8h
cookie:
http-only: true
same-site: lax
@@ -0,0 +1,9 @@
package de.klaro.tickets;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(properties = "spring.datasource.url=jdbc:h2:mem:testdb")
class TicketApplicationTests {
@Test void contextLoads() {}
}
+37
View File
@@ -0,0 +1,37 @@
services:
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: tickets
POSTGRES_USER: tickets
POSTGRES_PASSWORD: tickets
volumes:
- ticket_db:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tickets"]
interval: 5s
timeout: 3s
retries: 10
backend:
build: ./backend
environment:
SPRING_PROFILES_ACTIVE: postgres
DATABASE_URL: jdbc:postgresql://postgres:5432/tickets
DATABASE_USER: tickets
DATABASE_PASSWORD: tickets
depends_on:
postgres:
condition: service_healthy
ports:
- "9080:8080"
frontend:
build: ./frontend
depends_on:
- backend
ports:
- "3000:80"
volumes:
ticket_db:
+6
View File
@@ -0,0 +1,6 @@
node_modules
dist
.git
.idea
.vscode
npm-debug.log*
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<projectDescription>
<name>frontend</name>
<comment></comment>
<projects>
</projects>
<buildSpec>
</buildSpec>
<natures>
</natures>
</projectDescription>
+11
View File
@@ -0,0 +1,11 @@
FROM node:24-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:1.29-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+10
View File
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="UNIITY Ticketdesk Supportanfragen zentral bearbeiten." />
<title>UNIITY Ticketdesk</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>
+19
View File
@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location /api/ {
proxy_pass http://backend:8080/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+1025
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "klaro-ticket-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0"
},
"dependencies": {
"lucide-react": "1.31.0",
"react": "19.2.6",
"react-dom": "19.2.6",
"react-textarea-autosize": "^8.5.9"
},
"devDependencies": {
"@vitejs/plugin-react": "6.0.2",
"vite": "8.0.13"
}
}
+315
View File
@@ -0,0 +1,315 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { AlertCircle, CheckCircle2, CircleDot, Clock3, Inbox, LifeBuoy, LockKeyhole, LogOut, MessageSquareText, Pencil, Plus, SaveCheck, Search, Send, ShieldCheck, TicketCheck, UserPlus, UserRound, Users, X } from "lucide-react";
import { api } from "./api";
import TextareaAutosize from 'react-textarea-autosize';
const statusNames = { OPEN: "Offen", IN_PROGRESS: "In Bearbeitung", WAITING: "Wartet auf Antwort", RESOLVED: "Gelöst", CLOSED: "Geschlossen" };
const priorityNames = { LOW: "Niedrig", MEDIUM: "Mittel", HIGH: "Hoch", URGENT: "Dringend" };
const roleNames = { USER: "Benutzer", SUPPORT: "Support", ADMIN: "Administrator" };
function code(id) { return `TK-${String(id).padStart(4, "0")}`; }
function date(value) { return new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(value)); }
function canManageTickets(user) { return user?.role === "SUPPORT" || user?.role === "ADMIN"; }
function Badge({ status }) { return <span className={`badge badge-${status.toLowerCase()}`}>{statusNames[status]}</span>; }
function Priority({ value }) { return <span className="priority"><i className={`dot dot-${value.toLowerCase()}`} />{priorityNames[value]}</span>; }
export default function App() {
const [user, setUser] = useState(undefined);
const [view, setView] = useState("TICKETS");
const [scope, setScope] = useState("MINE");
const [tickets, setTickets] = useState([]);
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [query, setQuery] = useState("");
const [status, setStatus] = useState("ALL");
const [priority, setPriority] = useState("ALL");
const [selected, setSelected] = useState(null);
const [comment, setComment] = useState("");
const [description, setDescription] = useState("");
const [editDescription, setEditDescription] = useState(false);
const createDialog = useRef(null);
async function load(nextScope = scope) {
try { setLoading(true); setError(""); setTickets(await api.list(nextScope)); }
catch (e) { setError(e.message); }
finally { setLoading(false); }
}
useEffect(() => {
async function bootstrap() {
try {
const current = await api.me();
const initialScope = canManageTickets(current) ? "ALL" : "MINE";
setUser(current); setScope(initialScope);
await load(initialScope);
} catch (e) {
if (e.status === 401) setUser(null); else setError(e.message);
setLoading(false);
}
}
bootstrap();
}, []);
async function login(event) {
event.preventDefault();
try {
setError(""); setLoading(true);
const current = await api.login(Object.fromEntries(new FormData(event.currentTarget)));
const initialScope = canManageTickets(current) ? "ALL" : "MINE";
setUser(current); setScope(initialScope); setView("TICKETS");
await load(initialScope);
} catch (e) { setError(e.status === 401 ? "Benutzername oder Passwort ist falsch." : e.message); setLoading(false); }
}
async function register(event) {
event.preventDefault();
const values = Object.fromEntries(new FormData(event.currentTarget));
if (values.password !== values.passwordConfirmation) { setError("Die Passwörter stimmen nicht überein."); return; }
delete values.passwordConfirmation;
try {
setError(""); setLoading(true);
const current = await api.register(values);
setUser(current); setScope("MINE"); setView("TICKETS");
await load("MINE");
} catch (e) { setError(e.message); setLoading(false); }
}
async function logout() {
await api.logout();
setUser(null); setTickets([]); setUsers([]); setSelected(null); setError("");
}
async function switchScope(nextScope) {
setView("TICKETS"); setScope(nextScope); setSelected(null); setQuery(""); setStatus("ALL"); setPriority("ALL");
await load(nextScope);
}
async function openUsers() {
setView("USERS"); setSelected(null); setLoading(true); setError("");
try { setUsers(await api.users()); } catch (e) { setError(e.message); }
finally { setLoading(false); }
}
async function changeRole(account, role) {
try {
setError("");
const updated = await api.updateUserRole(account.id, role);
setUsers((current) => current.map((entry) => entry.id === updated.id ? updated : entry));
} catch (e) { setError(e.message); }
}
const filtered = useMemo(() => tickets.filter((ticket) => {
const term = query.trim().toLowerCase();
const match = !term || `${ticket.id} ${ticket.subject} ${ticket.requester} ${ticket.category}`.toLowerCase().includes(term);
return match && (status === "ALL" || ticket.status === status) && (priority === "ALL" || ticket.priority === priority);
}), [tickets, query, status, priority]);
const stats = {
OPEN: tickets.filter((t) => t.status === "OPEN").length,
IN_PROGRESS: tickets.filter((t) => t.status === "IN_PROGRESS").length,
WAITING: tickets.filter((t) => t.status === "WAITING").length,
DONE: tickets.filter((t) => ["RESOLVED", "CLOSED"].includes(t.status)).length,
};
async function openTicket(ticket) {
setSelected(ticket);
setDescription(ticket.description);
try { setSelected(await api.get(ticket.id)); } catch (e) { setError(e.message); }
}
async function createTicket(event) {
event.preventDefault();
const form = event.currentTarget;
try {
const created = await api.create(Object.fromEntries(new FormData(form)));
setTickets((current) => [created, ...current]);
createDialog.current?.close();
form.reset();
} catch (e) { setError(e.message); }
}
async function update(values) {
try {
const updated = await api.update(selected.id, values);
setSelected((current) => ({ ...current, ...updated }));
setTickets((current) => current.map((ticket) => ticket.id === updated.id ? updated : ticket));
} catch (e) { setError(e.message); }
}
async function sendComment(event) {
event.preventDefault();
if (!comment.trim()) return;
try {
const created = await api.comment(selected.id, { author: user.name, body: comment });
setSelected((current) => ({ ...current, comments: [created, ...(current.comments || [])] }));
setComment("");
} catch (e) { setError(e.message); }
}
async function sendDescription(event) {
event.preventDefault();
update({ description: description });
setEditDescription(false);
}
if (user === undefined) return <div className="login-shell"><div className="login-loading">Anmeldung wird geprüft </div></div>;
if (!user) return <LoginScreen onLogin={login} onRegister={register} error={error} loading={loading} clearError={() => setError("")} />;
return <div className="app-shell">
<aside className="sidebar">
<div className="brand"><span className="brand-mark"><TicketCheck size={20} /></span><div><strong>UNIITY</strong><small>Ticketdesk</small></div></div>
<nav>
{canManageTickets(user) && <button className={view === "TICKETS" && scope === "ALL" ? "nav-active" : ""} onClick={() => switchScope("ALL")}><Inbox size={17} /> Alle Tickets {view === "TICKETS" && scope === "ALL" && <span>{tickets.length}</span>}</button>}
<button className={view === "TICKETS" && scope === "MINE" ? "nav-active" : ""} onClick={() => switchScope("MINE")}><UserRound size={17} /> Mein Bereich {view === "TICKETS" && scope === "MINE" && <span>{tickets.length}</span>}</button>
{user.role === "ADMIN" && <button className={view === "USERS" ? "nav-active" : ""} onClick={openUsers}><Users size={17} /> Nutzerverwaltung</button>}
</nav>
<div className="support-card"><LifeBuoy size={18} /><strong>{roleNames[user.role]}</strong><p>{canManageTickets(user) ? "Zentrale Übersicht für Anfragen, Zuständigkeiten und Lösungen." : "Hier siehst du ausschließlich deine eigenen Anfragen."}</p></div>
</aside>
<main>
<header className="topbar"><div><small>{view === "USERS" ? "Administration" : scope === "ALL" ? "Support Workspace" : "Persönlicher Bereich"}</small><h1>{view === "USERS" ? "Nutzerverwaltung" : scope === "ALL" ? "Alle Tickets" : "Mein Bereich"}</h1></div><div className="topbar-actions"><div className="user-chip"><span>{user.name.slice(0, 1)}</span><div><strong>{user.name}</strong><small>{roleNames[user.role]}</small></div></div><button className="logout-button" onClick={logout} aria-label="Abmelden" title="Abmelden"><LogOut size={17} /></button>{view === "TICKETS" && <button className="button primary" onClick={() => createDialog.current?.showModal()}><Plus size={17} /> Neues Ticket</button>}</div></header>
{view === "USERS" ? <UserManagement users={users} currentUser={user} loading={loading} error={error} onRoleChange={changeRole} /> : <div className="content">
<section className="stats"><Stat label="Offen" value={stats.OPEN} icon={CircleDot} tone="blue" /><Stat label="In Bearbeitung" value={stats.IN_PROGRESS} icon={Clock3} tone="violet" /><Stat label="Wartet" value={stats.WAITING} icon={AlertCircle} tone="amber" /><Stat label="Erledigt" value={stats.DONE} icon={CheckCircle2} tone="green" /></section>
<section className="tickets-card">
<div className="toolbar"><div><h2>Ticketübersicht</h2><p>{filtered.length} von {tickets.length} Tickets</p></div><div className="filters"><label className="search"><Search size={16} /><input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Ticket, Person, Kategorie …" aria-label="Tickets durchsuchen" /></label><select value={status} onChange={(e) => setStatus(e.target.value)} aria-label="Nach Status filtern"><option value="ALL">Alle Status</option>{Object.entries(statusNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select><select value={priority} onChange={(e) => setPriority(e.target.value)} aria-label="Nach Priorität filtern"><option value="ALL">Alle Prioritäten</option>{Object.entries(priorityNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></div></div>
{error && <div className="error">{error}</div>}
{loading ? <div className="empty">Tickets werden geladen </div> : filtered.length === 0 ? <div className="empty"><Inbox size={30} /><strong>Keine passenden Tickets</strong><span>Passe Suche oder Filter an.</span></div> : <><div className="desktop-table"><table><thead><tr><th>Ticket</th><th>Status</th><th>Priorität</th><th>Anfragende Person</th><th>Zuständig</th><th>Aktualisiert</th></tr></thead><tbody>{filtered.map((ticket) => <tr key={ticket.id} onClick={() => openTicket(ticket)}><td><div className="ticket-title"><code>{code(ticket.id)}</code><div><strong>{ticket.subject}</strong><small>{ticket.category}</small></div></div></td><td><Badge status={ticket.status} /></td><td><Priority value={ticket.priority} /></td><td>{ticket.requester}</td><td className="muted">{ticket.assignee || "Nicht zugewiesen"}</td><td className="muted right">{date(ticket.updatedAt)}</td></tr>)}</tbody></table></div><div className="mobile-list">{filtered.map((ticket) => <button key={ticket.id} onClick={() => openTicket(ticket)}><span><code>{code(ticket.id)}</code><Badge status={ticket.status} /></span><strong>{ticket.subject}</strong><small>{ticket.requester}<Priority value={ticket.priority} /></small></button>)}</div></>}
</section>
</div>}
</main>
<dialog ref={createDialog} className="create-dialog" onClick={(e) => { if (e.target === createDialog.current) createDialog.current?.close(); }}>
<form onSubmit={createTicket} className="create-form">
<button type="button" className="icon-close" onClick={() => createDialog.current?.close()} aria-label="Schließen">
<X size={18} />
</button>
<h2>Neues Ticket erfassen</h2>
<p>Das Ticket wird automatisch deinem Konto zugeordnet.</p>
<label>Betreff<input name="subject" required placeholder="Worum geht es?" /></label>
<label>Beschreibung<textarea name="description" required placeholder="Beschreibe das Anliegen und relevante Details." /></label>
<div className="form-grid">
<label>Kategorie<input name="category" placeholder="z. B. IT & Zugriff" /></label>
<label>Priorität<select name="priority" defaultValue="MEDIUM">{Object.entries(priorityNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
<label className="full">Erreichbarkeit<input name="contact" placeholder="Optional" /></label>
{canManageTickets(user) && <label className="full">Zuständigkeit<input name="assignee" placeholder="Optional" /></label>}
</div>
<div className="dialog-actions">
<button type="button" className="button secondary" onClick={() => createDialog.current?.close()}>Abbrechen</button>
<button className="button primary">Ticket erstellen</button>
</div>
</form>
</dialog>
{selected && <>
<button className="scrim" aria-label="Ticket schließen" onClick={() => setSelected(null)} />
<aside className="detail-panel">
<button className="icon-close" onClick={() => setSelected(null)} aria-label="Schließen">
<X size={18} />
</button>
<header>
<div>
<code>{code(selected.id)}</code>
<Badge status={selected.status} />
</div>
<h2>{selected.subject}</h2>
<p>Erstellt von {selected.requester} · {date(selected.createdAt)}</p>
</header>
<div className="detail-content">
<section>
<div style={{ display: 'flex', height: '20px', alignItems: 'center', marginBottom: '10px' }}>
<h3 style={{ diplay: 'flex', alignItems: 'center', margin: 0 }}>Beschreibung</h3>
{(editDescription && description !== selected.description) &&
<SaveCheck size={20} onClick={sendDescription} style={{ cursor: 'pointer', marginLeft: '10px' }} />
}
</div>
<TextareaAutosize
readOnly={!editDescription}
style={{
width: '100%',
resize: 'none'
}}
value={description}
onChange={(e) => setDescription(e.target.value)}
onClick={(e) => { if (!editDescription) setEditDescription(true) }}
/>
</section>
<section>
<h3>Erreichbarkeit</h3>
<input value={selected.contact || ""} onChange={(e) => setSelected({ ...selected, contact: e.target.value })} onBlur={(e) => update({ contact: e.target.value })} placeholder="Optional" style={{ width: '100%' }} />
</section>
{canManageTickets(user) &&
<section className="edit-box">
<label>Status<select value={selected.status} onChange={(e) => update({ status: e.target.value })}>{Object.entries(statusNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
<label>Priorität<select value={selected.priority} onChange={(e) => update({ priority: e.target.value })}>{Object.entries(priorityNames).map(([key, label]) => <option key={key} value={key}>{label}</option>)}</select></label>
<label classname="full">Zuständig<input value={selected.assignee || ""} onChange={(e) => setSelected({ ...selected, assignee: e.target.value })} onBlur={(e) => update({ assignee: e.target.value })} placeholder="Teammitglied zuweisen" /></label>
<label>Kategorie<input value={selected.category} onChange={(e) => setSelected({ ...selected, category: e.target.value })} onBlur={(e) => update({ category: e.target.value })} placeholder="z. B. IT & Zugriff"/></label>
</section>
}
<section>
<h3 className="comments-title"><MessageSquareText size={17} /> Kommentare <span>{selected.comments?.length || 0}</span></h3>
<form className="comment-form" onSubmit={sendComment}>
<textarea value={comment} onChange={(e) => setComment(e.target.value)} placeholder="Antwort oder interne Notiz …" />
<button className="button primary" aria-label="Kommentar senden" disabled={!comment.trim()}>
<Send size={17} />
</button>
</form>
<div className="comments">{selected.comments?.length ? selected.comments.map((entry) => <article key={entry.id}>
<div>
<strong>{entry.author}</strong>
<time>{date(entry.createdAt)}</time>
</div>
<p>{entry.body}</p>
</article>) : <p className="no-comments">Noch keine Kommentare.</p>}
</div>
</section>
</div>
</aside>
</>}
</div>;
}
function LoginScreen({ onLogin, onRegister, error, loading, clearError }) {
const [mode, setMode] = useState("LOGIN");
function switchMode(next) { setMode(next); clearError(); }
return <main className="login-shell">
<section className="login-card">
<div className="login-brand">
<span className="brand-mark">
<TicketCheck size={22} />
</span>
<div>
<strong>UNIITY</strong>
<small>Ticketdesk</small>
</div>
</div>
<div className="auth-tabs">
<button className={mode === "LOGIN" ? "active" : ""} onClick={() => switchMode("LOGIN")}>Anmelden</button>
<button className={mode === "REGISTER" ? "active" : ""} onClick={() => switchMode("REGISTER")} disabled={ api.registerDisabled() }>Registrieren</button>
</div>
<div className="login-heading">
<span>
{mode === "LOGIN" ? <LockKeyhole size={18} /> : <UserPlus size={18} />}
</span>
<h1>{mode === "LOGIN" ? "Willkommen zurück" : "Konto erstellen"}</h1>
<p>{mode === "LOGIN" ? "Melde dich an, um deine Tickets zu verwalten." : "Neue Konten starten mit der Rolle Benutzer."}</p>
</div>
<form onSubmit={mode === "LOGIN" ? onLogin : onRegister}>
{mode === "REGISTER" && <label>Name<input name="name" autoComplete="name" required maxLength="120" placeholder="Vor- und Nachname" /></label>}
<label>Benutzername<input name="username" autoComplete="username" required placeholder="Benutzername" /></label>
<label>Passwort<input name="password" type="password" autoComplete={mode === "LOGIN" ? "current-password" : "new-password"} required minLength={mode === "LOGIN" ? undefined : 8} placeholder="••••••••" /></label>
{mode === "REGISTER" && <label>Passwort bestätigen<input name="passwordConfirmation" type="password" autoComplete="new-password" required minLength="8" placeholder="••••••••" /></label>}
{error && <div className="login-error">{error}</div>}
<button className="button primary" disabled={loading}>{loading ? "Bitte warten …" : mode === "LOGIN" ? "Anmelden" : "Konto erstellen"}</button>
</form>
</section></main>;
}
function UserManagement({ users, currentUser, loading, error, onRoleChange }) {
return <div className="content"><section className="users-card"><div className="users-heading"><div><h2>Benutzer und Berechtigungen</h2><p>Registrierte Konten verwalten und Rollen vergeben.</p></div><span>{users.length} Konten</span></div>{error && <div className="error">{error}</div>}{loading ? <div className="empty">Benutzer werden geladen </div> : <div className="users-table"><table><thead><tr><th>Name</th><th>Benutzername</th><th>Berechtigung</th></tr></thead><tbody>{users.map((account) => <tr key={account.id}><td><div className="account-name"><span>{account.name.slice(0, 1)}</span><strong>{account.name}{account.id === currentUser.id && <small>Du</small>}</strong></div></td><td>{account.username}</td><td><select value={account.role} disabled={account.id === currentUser.id} onChange={(event) => onRoleChange(account, event.target.value)} aria-label={`Rolle für ${account.name}`}><option value="USER">Benutzer</option><option value="SUPPORT">Support</option><option value="ADMIN">Administrator</option></select></td></tr>)}</tbody></table></div>}</section></div>;
}
function Stat({ label, value, icon: Icon, tone }) {
return <article className="stat"><span className={`stat-icon ${tone}`}><Icon size={20} /></span><div><strong>{value}</strong><small>{label}</small></div></article>;
}
+50
View File
@@ -0,0 +1,50 @@
const jsonHeaders = { "Content-Type": "application/json" };
let csrfToken;
async function request(path, options = {}) {
const response = await fetch(path, { credentials: "include", ...options });
const body = await response.json().catch(() => ({}));
if (!response.ok) {
const error = new Error(body.detail || body.message || (response.status === 401 ? "Bitte melde dich an." : "Die Anfrage ist fehlgeschlagen."));
error.status = response.status;
throw error;
}
return body;
}
async function csrf() {
if (!csrfToken) csrfToken = (await request("/api/auth/csrf")).token;
return csrfToken;
}
async function mutate(path, method, body) {
const token = await csrf();
return request(path, { method, headers: { ...jsonHeaders, "X-XSRF-TOKEN": token }, body: body === undefined ? undefined : JSON.stringify(body) });
}
export const api = {
me: () => request("/api/auth/me"),
login: async (credentials) => {
const user = await mutate("/api/auth/login", "POST", credentials);
csrfToken = undefined;
return user;
},
registerDisabled: async () => {return await mutate("api/auth/register", "GET")},
register: async (account) => {
const user = await mutate("/api/auth/register", "POST", account);
csrfToken = undefined;
return user;
},
logout: async () => {
const result = await mutate("/api/auth/logout", "POST");
csrfToken = undefined;
return result;
},
list: (scope) => request(`/api/tickets?scope=${scope}`),
get: (id) => request(`/api/tickets/${id}`),
create: (ticket) => mutate("/api/tickets", "POST", ticket),
update: (id, ticket) => mutate(`/api/tickets/${id}`, "PATCH", ticket),
comment: (id, comment) => mutate(`/api/tickets/${id}/comments`, "POST", comment),
users: () => request("/api/users"),
updateUserRole: (id, role) => mutate(`/api/users/${id}/role`, "PATCH", { role }),
};
+6
View File
@@ -0,0 +1,6 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
import "./styles.css";
createRoot(document.getElementById("root")).render(<StrictMode><App /></StrictMode>);
+26
View File
@@ -0,0 +1,26 @@
:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #162033; background: #f4f7fb; font-synthesis: none; }
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; min-height: 100vh; }
button, input, select, textarea { font: inherit; }
button { cursor: pointer; }
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 248px 1fr; }
.sidebar { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; padding: 22px 18px; color: white; background: #17213a; }
.brand { display: flex; align-items: center; gap: 12px; padding: 8px; }
.brand-mark { width: 40px; height: 40px; display: grid; place-items: center; color: #14213d; background: #7ea0ff; border-radius: 12px; box-shadow: 0 8px 24px rgba(89,125,238,.35); }
.brand strong { display: block; font-size: 19px; }.brand small { display: block; margin-top: 2px; color: #98a2b3; font-size: 12px; }
nav { margin-top: 36px; display: grid; gap: 8px; } nav button { display: flex; align-items: center; gap: 11px; padding: 12px 14px; color: #cbd5e1; border: 0; border-radius: 12px; background: transparent; text-align: left; } nav button:hover { background: rgba(255,255,255,.05); } nav .nav-active { color: white; font-weight: 600; background: #2a395f; } nav button span { margin-left: auto; padding: 2px 8px; border-radius: 6px; background: rgba(255,255,255,.09); font-size: 12px; }
.support-card { margin-top: auto; padding: 16px; border: 1px solid rgba(255,255,255,.1); border-radius: 16px; background: rgba(255,255,255,.04); }.support-card svg { color: #90adff; vertical-align: middle; margin-right: 8px; }.support-card strong { font-size: 14px; }.support-card p { margin: 10px 0 0; color: #98a2b3; font-size: 12px; line-height: 1.65; }
main { min-width: 0; }.topbar { min-height: 78px; display: flex; align-items: center; justify-content: space-between; padding: 14px 38px; border-bottom: 1px solid #dfe5ee; background: rgba(255,255,255,.9); backdrop-filter: blur(12px); }.topbar small { color: #667085; }.topbar h1 { margin: 3px 0 0; font-size: 21px; }.button { min-height: 40px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 0 16px; border: 0; border-radius: 11px; font-weight: 600; }.button.primary { color: white; background: #2858d8; box-shadow: 0 5px 14px rgba(40,88,216,.2); }.button.primary:hover { background: #214fc8; }.button.secondary { color: #344054; border: 1px solid #d0d5dd; background: white; }.button:disabled { cursor: not-allowed; opacity: .5; }
.content { max-width: 1500px; margin: 0 auto; padding: 34px 38px; }.stats { display: grid; grid-template-columns: repeat(4,1fr); gap: 13px; margin-bottom: 26px; }.stat { display: flex; align-items: center; gap: 13px; padding: 19px; border: 1px solid #dfe5ee; border-radius: 16px; background: white; box-shadow: 0 4px 18px rgba(29,45,75,.04); }.stat-icon { width: 41px; height: 41px; display: grid; place-items: center; border-radius: 12px; }.stat-icon.blue { color:#1d4ed8;background:#eff6ff}.stat-icon.violet{color:#6d28d9;background:#f5f3ff}.stat-icon.amber{color:#b45309;background:#fffbeb}.stat-icon.green{color:#047857;background:#ecfdf5}.stat strong { display:block;font-size:24px;line-height:1.1}.stat small{display:block;margin-top:4px;color:#667085;font-size:13px}
.tickets-card { overflow: hidden; border: 1px solid #dfe5ee; border-radius: 17px; background: white; box-shadow: 0 10px 35px rgba(29,45,75,.06); }.toolbar { display: flex; align-items: center; justify-content: space-between; gap: 20px; padding: 19px 20px; border-bottom: 1px solid #dfe5ee; }.toolbar h2{margin:0;font-size:18px}.toolbar p{margin:4px 0 0;color:#667085;font-size:13px}.filters{display:flex;gap:8px}.search{position:relative}.search svg{position:absolute;left:12px;top:50%;transform:translateY(-50%);color:#667085}.search input{width:275px;padding-left:36px}input,select,textarea{min-height:38px;padding:8px 11px;color:#162033;border:1px solid #d8e0eb;border-radius:9px;background:white;outline:none}input:focus,select:focus,textarea:focus{border-color:#6688ec;box-shadow:0 0 0 3px rgba(93,127,240,.14)}
table{width:100%;border-collapse:collapse;font-size:13px}th{padding:12px 13px;color:#475467;background:#f8fafc;text-align:left;font-size:12px;font-weight:600}td{padding:15px 13px;border-top:1px solid #edf0f4;white-space:nowrap}tbody tr{cursor:pointer;transition:.15s}tbody tr:hover{background:#f8faff}.ticket-title{display:flex;align-items:flex-start;gap:13px;max-width:410px}.ticket-title div{min-width:0}.ticket-title strong,.ticket-title small{display:block;overflow:hidden;text-overflow:ellipsis}.ticket-title strong{font-size:13px}.ticket-title small{margin-top:5px;color:#667085;font-size:11px}code{color:#667085;font-family:ui-monospace,monospace;font-size:12px;font-weight:700}.muted{color:#667085}.right{text-align:right}.badge{display:inline-flex;padding:5px 9px;border:1px solid;border-radius:999px;font-size:11px;font-weight:600}.badge-open{color:#1d4ed8;border-color:#bfdbfe;background:#eff6ff}.badge-in_progress{color:#6d28d9;border-color:#ddd6fe;background:#f5f3ff}.badge-waiting{color:#92400e;border-color:#fde68a;background:#fffbeb}.badge-resolved{color:#047857;border-color:#a7f3d0;background:#ecfdf5}.badge-closed{color:#475569;border-color:#e2e8f0;background:#f1f5f9}.priority{display:inline-flex;align-items:center;gap:7px}.dot{width:8px;height:8px;border-radius:50%}.dot-low{background:#94a3b8}.dot-medium{background:#3b82f6}.dot-high{background:#f97316}.dot-urgent{background:#e11d48}.empty{min-height:260px;display:grid;place-content:center;justify-items:center;gap:9px;color:#667085;font-size:14px}.empty svg{color:#cbd5e1}.empty strong{color:#344054}.error{margin:18px;padding:13px 15px;color:#b42318;border-radius:10px;background:#fef3f2}.mobile-list{display:none}
.create-dialog{width:min(580px,calc(100% - 28px));padding:0;border:0;border-radius:18px;box-shadow:0 28px 80px rgba(16,24,40,.25)}.create-dialog::backdrop{background:rgba(15,23,42,.55);backdrop-filter:blur(2px)}.create-form{position:relative;display:grid;gap:16px;padding:26px}.create-form h2{margin:0;font-size:21px}.create-form>p{margin:-9px 0 2px;color:#667085;font-size:13px;line-height:1.5}.create-form label,.edit-box label{display:grid;gap:7px;font-size:13px;font-weight:600}.create-form textarea{min-height:110px;resize:vertical}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:15px}.dialog-actions{display:flex;justify-content:flex-end;gap:9px;margin-top:5px}.icon-close{position:absolute;top:18px;right:18px;width:34px;height:34px;display:grid;place-items:center;color:#667085;border:0;border-radius:9px;background:transparent}.icon-close:hover{background:#f2f4f7}
.scrim{position:fixed;inset:0;z-index:20;border:0;background:rgba(15,23,42,.48);cursor:default}.detail-panel{position:fixed;z-index:21;top:0;right:0;width:min(590px,100%);height:100vh;overflow-y:auto;color:#162033;background:white;box-shadow:-18px 0 50px rgba(16,24,40,.15)}.detail-panel>header{padding:27px 52px 22px 26px;border-bottom:1px solid #dfe5ee}.detail-panel>header>div{display:flex;align-items:center;gap:10px}.detail-panel h2{margin:13px 0 8px;font-size:21px;line-height:1.35}.detail-panel header p{margin:0;color:#667085;font-size:13px}.detail-content{display:grid;gap:26px;padding:25px}.detail-content h3{margin:0 0 10px;color:#667085;font-size:12px;text-transform:uppercase;letter-spacing:.06em}.detail-content section>p{margin:0;color:#475467;font-size:14px;line-height:1.75}.edit-box{display:grid;grid-template-columns:1fr 1fr;gap:15px;padding:16px;border-radius:15px;background:#f8fafc}.edit-box .full{grid-column:1/-1}.comments-title{display:flex;align-items:center;gap:7px;color:#162033!important;font-size:14px!important;text-transform:none!important;letter-spacing:0!important}.comments-title svg{color:#2858d8}.comments-title span{color:#667085;font-weight:400}.comment-form{display:flex;align-items:flex-end;gap:8px;margin-bottom:17px}.comment-form textarea{flex:1;min-height:78px;resize:vertical}.comment-form .button{width:42px;padding:0}.comments{display:grid;gap:10px}.comments article{padding:14px;border:1px solid #dfe5ee;border-radius:12px}.comments article>div{display:flex;justify-content:space-between;gap:10px}.comments strong{font-size:13px}.comments time{color:#667085;font-size:11px}.comments article p{margin:8px 0 0;color:#475467;font-size:13px;line-height:1.6}.no-comments{padding:14px;border:1px dashed #d0d5dd!important;border-radius:11px;color:#667085!important;font-size:13px!important}
@media(max-width:1100px){.stats{grid-template-columns:repeat(2,1fr)}.toolbar{align-items:flex-start;flex-direction:column}.filters{width:100%}.search{flex:1}.search input{width:100%}}
@media(max-width:780px){.app-shell{display:block}.sidebar{display:none}.topbar{padding:14px 18px}.topbar small{display:none}.content{padding:22px 14px}.stats{gap:9px}.stat{padding:14px}.stat-icon{width:37px;height:37px}.stat strong{font-size:20px}.toolbar{padding:16px}.filters{display:grid;grid-template-columns:1fr 1fr}.search{grid-column:1/-1}.desktop-table{display:none}.mobile-list{display:block}.mobile-list>button{width:100%;display:grid;gap:9px;padding:15px;color:#162033;border:0;border-top:1px solid #edf0f4;background:white;text-align:left}.mobile-list>button>span,.mobile-list>button>small{display:flex;align-items:center;justify-content:space-between;gap:10px}.mobile-list strong{font-size:14px}.mobile-list small{color:#667085}.form-grid{grid-template-columns:1fr}.detail-content{padding:20px}.edit-box{grid-template-columns:1fr}.edit-box .full{grid-column:auto}}
.topbar-actions{display:flex;align-items:center;gap:10px}.user-chip{display:flex;align-items:center;gap:9px;padding-right:4px}.user-chip>span{width:34px;height:34px;display:grid;place-items:center;color:#214fc8;border-radius:50%;background:#eaf0ff;font-weight:700}.user-chip strong,.user-chip small{display:block}.user-chip strong{max-width:150px;overflow:hidden;text-overflow:ellipsis;font-size:12px;white-space:nowrap}.user-chip small{margin-top:2px;font-size:11px}.logout-button{width:38px;height:38px;display:grid;place-items:center;color:#667085;border:1px solid #d8e0eb;border-radius:10px;background:white}.logout-button:hover{color:#b42318;background:#fef3f2}.form-grid .full{grid-column:1/-1}
.login-shell{min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 15% 10%,#dce7ff 0,transparent 32%),#f4f7fb}.login-card{width:min(440px,100%);padding:32px;border:1px solid #dfe5ee;border-radius:22px;background:white;box-shadow:0 24px 70px rgba(29,45,75,.13)}.login-brand{display:flex;align-items:center;gap:11px}.login-brand>div strong,.login-brand>div small{display:block}.login-brand>div strong{font-size:19px}.login-brand>div small{margin-top:2px;color:#667085;font-size:12px}.login-heading{margin:34px 0 24px}.login-heading>span{width:38px;height:38px;display:grid;place-items:center;color:#2858d8;border-radius:11px;background:#eaf0ff}.login-heading h1{margin:15px 0 7px;font-size:25px}.login-heading p{margin:0;color:#667085;font-size:14px}.login-card form{display:grid;gap:15px}.login-card form label{display:grid;gap:7px;font-size:13px;font-weight:600}.login-card form .button{width:100%;margin-top:4px}.login-error{padding:11px 12px;color:#b42318;border-radius:9px;background:#fef3f2;font-size:13px}.demo-users{display:grid;gap:9px;margin-top:24px;padding-top:20px;border-top:1px solid #edf0f4}.demo-users>div{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:10px;background:#f8fafc}.demo-users svg{color:#667085}.demo-users p{margin:0}.demo-users strong,.demo-users code{display:block}.demo-users strong{font-size:12px}.demo-users code{margin-top:3px;color:#667085;font-size:11px}.login-loading{color:#667085;font-size:14px}
.auth-tabs{display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-top:28px;padding:4px;border-radius:11px;background:#f2f4f7}.auth-tabs button{min-height:36px;color:#667085;border:0;border-radius:8px;background:transparent;font-size:13px;font-weight:600}.auth-tabs button.active{color:#2349ad;background:white;box-shadow:0 1px 4px rgba(16,24,40,.1)}.auth-tabs+.login-heading{margin-top:24px}
.users-card{overflow:hidden;border:1px solid #dfe5ee;border-radius:17px;background:white;box-shadow:0 10px 35px rgba(29,45,75,.06)}.users-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:21px;border-bottom:1px solid #dfe5ee}.users-heading h2{margin:0;font-size:18px}.users-heading p{margin:5px 0 0;color:#667085;font-size:13px}.users-heading>span{padding:6px 10px;color:#2349ad;border-radius:999px;background:#eaf0ff;font-size:12px;font-weight:700}.users-table select{min-width:170px}.account-name{display:flex;align-items:center;gap:11px}.account-name>span{width:34px;height:34px;display:grid;place-items:center;color:#214fc8;border-radius:50%;background:#eaf0ff;font-weight:700}.account-name strong{display:flex;align-items:center;gap:8px}.account-name small{padding:3px 6px;color:#667085;border-radius:6px;background:#f2f4f7;font-size:10px}.users-table tr{cursor:default}.users-table tbody tr:hover{background:#f8faff}
@media(max-width:780px){.user-chip{display:none}.topbar-actions{gap:7px}.topbar .button{padding:0 11px}.form-grid .full{grid-column:auto}.login-card{padding:24px}.users-heading{align-items:flex-start}.users-table{overflow-x:auto}.users-table table{min-width:620px}}
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
},
},
},
});