379 lines
19 KiB
Java
379 lines
19 KiB
Java
package de.ticketsystem.web.handlers;
|
||
|
||
import com.sun.net.httpserver.HttpExchange;
|
||
import com.sun.net.httpserver.HttpHandler;
|
||
import de.ticketsystem.TicketPlugin;
|
||
import de.ticketsystem.database.DatabaseManager;
|
||
import de.ticketsystem.model.*;
|
||
import de.ticketsystem.web.SessionManager;
|
||
import de.ticketsystem.web.WebSession;
|
||
|
||
import java.io.IOException;
|
||
import java.text.SimpleDateFormat;
|
||
import java.util.*;
|
||
import java.util.stream.Collectors;
|
||
|
||
/**
|
||
* /tickets – Liste aller Tickets (gefiltert, paginiert)
|
||
* /ticket/{id} – Detailansicht eines Tickets
|
||
*/
|
||
public class TicketsHandler extends BaseHandler implements HttpHandler {
|
||
|
||
private static final int PAGE_SIZE = 20;
|
||
private final SimpleDateFormat SDF = new SimpleDateFormat("dd.MM.yyyy HH:mm");
|
||
|
||
private final TicketPlugin plugin;
|
||
private final SessionManager sessionManager;
|
||
|
||
public TicketsHandler(TicketPlugin plugin, SessionManager sessionManager) {
|
||
this.plugin = plugin;
|
||
this.sessionManager = sessionManager;
|
||
}
|
||
|
||
@Override
|
||
public void handle(HttpExchange ex) throws IOException {
|
||
WebSession session = requireSession(ex, sessionManager);
|
||
if (session == null) return;
|
||
|
||
String path = ex.getRequestURI().getPath();
|
||
|
||
if (path.startsWith("/ticket/") && path.length() > 8) {
|
||
String idStr = path.substring(8).replaceAll("[^0-9]", "");
|
||
if (!idStr.isEmpty()) {
|
||
handleDetail(ex, session, Integer.parseInt(idStr));
|
||
return;
|
||
}
|
||
}
|
||
|
||
handleList(ex, session);
|
||
}
|
||
|
||
// ─────────────────────────── Ticket-Liste ──────────────────────────────
|
||
|
||
private void handleList(HttpExchange ex, WebSession session) throws IOException {
|
||
Map<String, String> params = parseQuery(ex.getRequestURI().getQuery());
|
||
|
||
String filterStatus = params.getOrDefault("status", "all");
|
||
String filterCat = params.getOrDefault("category", "all");
|
||
String filterPrio = params.getOrDefault("priority", "all");
|
||
String filterSearch = params.getOrDefault("q", "").trim();
|
||
int page = parseInt(params.getOrDefault("page", "1"), 1);
|
||
|
||
DatabaseManager db = plugin.getDatabaseManager();
|
||
List<Ticket> all = db.getAllTickets();
|
||
|
||
// ── Filter ──
|
||
List<Ticket> filtered = all.stream()
|
||
.filter(t -> filterStatus.equals("all") || t.getStatus().name().equalsIgnoreCase(filterStatus))
|
||
.filter(t -> filterCat.equals("all") || t.getCategoryKey().equalsIgnoreCase(filterCat))
|
||
.filter(t -> filterPrio.equals("all") || t.getPriority().name().equalsIgnoreCase(filterPrio))
|
||
.filter(t -> filterSearch.isEmpty()
|
||
|| t.getCreatorName().toLowerCase().contains(filterSearch.toLowerCase())
|
||
|| t.getMessage().toLowerCase().contains(filterSearch.toLowerCase())
|
||
|| String.valueOf(t.getId()).contains(filterSearch))
|
||
.sorted(Comparator.comparingInt(Ticket::getId).reversed())
|
||
.collect(Collectors.toList());
|
||
|
||
// ── Paginierung ──
|
||
int total = filtered.size();
|
||
int totalPages = Math.max(1, (int) Math.ceil(total / (double) PAGE_SIZE));
|
||
page = Math.max(1, Math.min(page, totalPages));
|
||
int fromIdx = (page - 1) * PAGE_SIZE;
|
||
int toIdx = Math.min(fromIdx + PAGE_SIZE, total);
|
||
List<Ticket> pageTickets = filtered.subList(fromIdx, toIdx);
|
||
|
||
String content = buildList(pageTickets, total, page, totalPages,
|
||
filterStatus, filterCat, filterPrio, filterSearch);
|
||
sendHtml(ex, 200, layout(wl(plugin, "tickets-title"), content, session, plugin));
|
||
}
|
||
|
||
private String buildList(List<Ticket> tickets, int total, int page, int totalPages,
|
||
String filterStatus, String filterCat, String filterPrio, String filterSearch) {
|
||
StringBuilder sb = new StringBuilder();
|
||
sb.append("<h1 class='page-title'>")
|
||
.append(escHtml(wl(plugin, "tickets-title")))
|
||
.append(" <span>").append(total).append(" ")
|
||
.append(escHtml(wl(plugin, "tickets-total")))
|
||
.append("</span></h1>");
|
||
|
||
// ── Filter-Bar ──
|
||
sb.append("<div class='filter-bar'>");
|
||
sb.append(selectFilter("status", filterStatus, List.of(
|
||
entry("all", wl(plugin, "filter-all-status")),
|
||
entry("OPEN", wl(plugin, "filter-open")),
|
||
entry("CLAIMED", wl(plugin, "filter-claimed")),
|
||
entry("FORWARDED", wl(plugin, "filter-forwarded")),
|
||
entry("CLOSED", wl(plugin, "filter-closed")))));
|
||
|
||
if (plugin.getConfig().getBoolean("categories-enabled", true)) {
|
||
List<Map.Entry<String,String>> catOpts = new ArrayList<>();
|
||
catOpts.add(entry("all", wl(plugin, "filter-all-cat")));
|
||
plugin.getCategoryManager().getAll().forEach(c -> catOpts.add(entry(c.getKey(), c.getName())));
|
||
sb.append(selectFilter("category", filterCat, catOpts));
|
||
}
|
||
|
||
if (plugin.getConfig().getBoolean("priorities-enabled", true)) {
|
||
sb.append(selectFilter("priority", filterPrio, List.of(
|
||
entry("all", wl(plugin, "filter-all-prio")),
|
||
entry("LOW", wl(plugin, "filter-low")),
|
||
entry("NORMAL", wl(plugin, "filter-normal")),
|
||
entry("HIGH", wl(plugin, "filter-high")),
|
||
entry("URGENT", wl(plugin, "filter-urgent")))));
|
||
}
|
||
|
||
sb.append("<form method='GET' action='/tickets' style='display:flex;gap:.5rem'>");
|
||
sb.append("<input name='q' placeholder='")
|
||
.append(escHtml(wl(plugin, "tickets-search-ph")))
|
||
.append("' value='").append(escHtml(filterSearch)).append("'style='width:180px'>");
|
||
sb.append("<input name='status' type='hidden' value='").append(escHtml(filterStatus)).append("'>");
|
||
sb.append("<input name='category' type='hidden' value='").append(escHtml(filterCat)).append("'>");
|
||
sb.append("<input name='priority' type='hidden' value='").append(escHtml(filterPrio)).append("'>");
|
||
sb.append("<button type='submit' class='btn btn-secondary btn-sm'>🔍</button></form>");
|
||
sb.append("</div>");
|
||
|
||
// ── Tabelle ──
|
||
sb.append("<div class='card'><table><thead><tr>")
|
||
.append("<th>").append(escHtml(wl(plugin, "tickets-col-id"))).append("</th>")
|
||
.append("<th>").append(escHtml(wl(plugin, "tickets-col-player"))).append("</th>")
|
||
.append("<th>").append(escHtml(wl(plugin, "tickets-col-message"))).append("</th>")
|
||
.append("<th>").append(escHtml(wl(plugin, "tickets-col-cat"))).append("</th>")
|
||
.append("<th>").append(escHtml(wl(plugin, "tickets-col-prio"))).append("</th>")
|
||
.append("<th>").append(escHtml(wl(plugin, "tickets-col-status"))).append("</th>")
|
||
.append("<th>").append(escHtml(wl(plugin, "tickets-col-created"))).append("</th>")
|
||
.append("<th></th>")
|
||
.append("</tr></thead><tbody>");
|
||
|
||
for (Ticket t : tickets) {
|
||
String msg = t.getMessage() != null && t.getMessage().length() > 60
|
||
? t.getMessage().substring(0, 60) + "…" : t.getMessage();
|
||
String created = t.getCreatedAt() != null ? SDF.format(t.getCreatedAt()) : "—";
|
||
String catName = getCategoryName(t);
|
||
|
||
sb.append("<tr>")
|
||
.append("<td><strong>#").append(t.getId()).append("</strong></td>")
|
||
.append("<td>").append(escHtml(t.getCreatorName())).append("</td>")
|
||
.append("<td>").append(escHtml(msg)).append("</td>")
|
||
.append("<td>").append(escHtml(catName)).append("</td>")
|
||
.append("<td>").append(priorityBadge(t)).append("</td>")
|
||
.append("<td>").append(statusBadge(t)).append("</td>")
|
||
.append("<td style='white-space:nowrap'>").append(created).append("</td>")
|
||
.append("<td><a href='/ticket/").append(t.getId())
|
||
.append("' class='btn btn-sm btn-secondary'>")
|
||
.append(escHtml(wl(plugin, "btn-details")))
|
||
.append("</a></td>")
|
||
.append("</tr>");
|
||
}
|
||
|
||
if (tickets.isEmpty()) {
|
||
sb.append("<tr><td colspan='8' style='text-align:center;color:var(--muted);padding:2rem'>")
|
||
.append(escHtml(wl(plugin, "tickets-empty")))
|
||
.append("</td></tr>");
|
||
}
|
||
|
||
sb.append("</tbody></table></div>");
|
||
|
||
// ── Paginierung ──
|
||
if (totalPages > 1) {
|
||
sb.append("<div class='pagination'>");
|
||
for (int i = 1; i <= totalPages; i++) {
|
||
String active = i == page ? " active" : "";
|
||
sb.append("<a class='page-btn").append(active).append("' href='/tickets?page=").append(i)
|
||
.append("&status=").append(filterStatus)
|
||
.append("&category=").append(filterCat)
|
||
.append("&priority=").append(filterPrio)
|
||
.append("&q=").append(escHtml(filterSearch))
|
||
.append("'>").append(i).append("</a>");
|
||
}
|
||
sb.append("</div>");
|
||
}
|
||
|
||
return sb.toString();
|
||
}
|
||
|
||
// ─────────────────────────── Ticket-Detail ─────────────────────────────
|
||
|
||
private void handleDetail(HttpExchange ex, WebSession session, int ticketId) throws IOException {
|
||
Ticket ticket = plugin.getDatabaseManager().getTicketById(ticketId);
|
||
if (ticket == null) {
|
||
sendHtml(ex, 404, errorPage(
|
||
wl(plugin, "detail-not-found"),
|
||
wl(plugin, "detail-not-found") + " #" + ticketId,
|
||
plugin));
|
||
return;
|
||
}
|
||
|
||
List<TicketComment> comments = plugin.getDatabaseManager().getComments(ticketId);
|
||
String content = buildDetail(ticket, comments, session);
|
||
sendHtml(ex, 200, layout("Ticket #" + ticketId, content, session, plugin));
|
||
}
|
||
|
||
private String buildDetail(Ticket t, List<TicketComment> comments, WebSession session) {
|
||
StringBuilder sb = new StringBuilder();
|
||
String created = t.getCreatedAt() != null ? SDF.format(t.getCreatedAt()) : "—";
|
||
String catName = getCategoryName(t);
|
||
|
||
// ── Header ──
|
||
sb.append("<div style='margin-bottom:1.5rem'>");
|
||
sb.append("<a href='/tickets' class='btn btn-sm btn-secondary' style='margin-bottom:1rem'>")
|
||
.append(escHtml(wl(plugin, "detail-back"))).append("</a>");
|
||
sb.append("</div>");
|
||
sb.append("<div class='ticket-header'>");
|
||
sb.append("<div class='ticket-id'>#").append(t.getId()).append("</div>");
|
||
sb.append("<div class='ticket-meta'>");
|
||
sb.append(statusBadge(t)).append(" ").append(priorityBadge(t));
|
||
sb.append("<span class='badge' style='background:var(--surface2);color:var(--muted)'>")
|
||
.append(escHtml(catName)).append("</span>");
|
||
if (plugin.isBungeeCordEnabled()) {
|
||
sb.append("<span class='badge' style='background:var(--surface2);color:var(--muted)'>🌐 ")
|
||
.append(escHtml(t.getServerName())).append("</span>");
|
||
}
|
||
sb.append("</div></div>");
|
||
|
||
// ── Info-Grid ──
|
||
sb.append("<div class='info-grid'>");
|
||
infoRow(sb, wl(plugin, "detail-info-creator"), t.getCreatorName());
|
||
infoRow(sb, wl(plugin, "detail-info-created"), created);
|
||
infoRow(sb, wl(plugin, "detail-info-claimer"), t.getClaimerName() != null ? t.getClaimerName() : "—");
|
||
infoRow(sb, wl(plugin, "detail-info-forwarded"), t.getForwardedToName() != null ? t.getForwardedToName() : "—");
|
||
if (t.getWorldName() != null) {
|
||
infoRow(sb, wl(plugin, "detail-info-position"), String.format("%s %.0f / %.0f / %.0f",
|
||
t.getWorldName(), t.getX(), t.getY(), t.getZ()));
|
||
}
|
||
if (t.getPlayerRating() != null) {
|
||
String ratingText = "THUMBS_UP".equals(t.getPlayerRating())
|
||
? wl(plugin, "detail-rating-pos")
|
||
: wl(plugin, "detail-rating-neg");
|
||
infoRow(sb, wl(plugin, "detail-info-rating"), ratingText);
|
||
}
|
||
sb.append("</div>");
|
||
|
||
// ── Nachricht ──
|
||
sb.append("<div class='card-title'>").append(escHtml(wl(plugin, "detail-section-msg"))).append("</div>");
|
||
sb.append("<div class='ticket-message'>").append(escHtml(t.getMessage())).append("</div>");
|
||
|
||
if (t.getCloseComment() != null && !t.getCloseComment().isEmpty()) {
|
||
sb.append("<div class='card-title'>").append(escHtml(wl(plugin, "detail-section-closecomment"))).append("</div>");
|
||
sb.append("<div class='ticket-message'>").append(escHtml(t.getCloseComment())).append("</div>");
|
||
}
|
||
|
||
// ── Aktionen (nur bei aktiven Tickets) ──
|
||
if (t.getStatus() != TicketStatus.CLOSED) {
|
||
sb.append("<div class='card'><div class='card-title'>")
|
||
.append(escHtml(wl(plugin, "detail-section-actions")))
|
||
.append("</div><div style='display:flex;flex-wrap:wrap;gap:.75rem'>");
|
||
|
||
// Claim
|
||
if (t.getStatus() == TicketStatus.OPEN) {
|
||
sb.append("<button class='btn btn-success' onclick='claimTicket(").append(t.getId()).append(")'>")
|
||
.append(escHtml(wl(plugin, "detail-btn-claim"))).append("</button>");
|
||
}
|
||
|
||
// Priorität
|
||
if (plugin.getConfig().getBoolean("priorities-enabled", true)) {
|
||
sb.append("<select id='prio-select-").append(t.getId())
|
||
.append("' class='btn btn-secondary' style='width:auto' onchange='setPriority(")
|
||
.append(t.getId()).append(", this.value)'>");
|
||
for (TicketPriority p : TicketPriority.values()) {
|
||
String sel = p == t.getPriority() ? " selected" : "";
|
||
sb.append("<option value='").append(p.name()).append("'").append(sel).append(">")
|
||
.append(escHtml(p.getDisplayName())).append("</option>");
|
||
}
|
||
sb.append("</select>");
|
||
}
|
||
|
||
// Weiterleiten (nur Admin)
|
||
if (session.isAdmin()) {
|
||
sb.append("<input id='forward-target-").append(t.getId())
|
||
.append("' class='btn btn-secondary' style='width:180px' placeholder='")
|
||
.append(escHtml(wl(plugin, "detail-ph-forward"))).append("'>");
|
||
sb.append("<button class='btn btn-warning' onclick='forwardTicket(").append(t.getId()).append(")'>")
|
||
.append(escHtml(wl(plugin, "detail-btn-forward"))).append("</button>");
|
||
}
|
||
|
||
// Schließen
|
||
sb.append("<div style='display:flex;gap:.5rem;align-items:center'>");
|
||
sb.append("<input id='close-comment-").append(t.getId())
|
||
.append("' placeholder='").append(escHtml(wl(plugin, "detail-ph-comment")))
|
||
.append("' style='width:220px'>");
|
||
sb.append("<button class='btn btn-danger' onclick='closeTicket(").append(t.getId()).append(")'>")
|
||
.append(escHtml(wl(plugin, "detail-btn-close"))).append("</button>");
|
||
sb.append("</div>");
|
||
|
||
sb.append("</div></div>");
|
||
}
|
||
|
||
// ── Kommentare ──
|
||
sb.append("<div class='card'><div class='card-title'>")
|
||
.append(escHtml(wl(plugin, "detail-section-comments")))
|
||
.append(" (").append(comments.size()).append(")</div>");
|
||
sb.append("<div class='comments'>");
|
||
for (TicketComment c : comments) {
|
||
String cTime = c.getCreatedAt() != null ? SDF.format(c.getCreatedAt()) : "";
|
||
sb.append("<div class='comment'>");
|
||
sb.append("<span class='comment-author'>").append(escHtml(c.getAuthorName())).append("</span>");
|
||
sb.append("<span class='comment-time'>").append(cTime).append("</span>");
|
||
sb.append("<div class='comment-text'>").append(escHtml(c.getMessage())).append("</div>");
|
||
sb.append("</div>");
|
||
}
|
||
if (comments.isEmpty()) {
|
||
sb.append("<div style='color:var(--muted);font-size:.875rem'>")
|
||
.append(escHtml(wl(plugin, "detail-no-comments"))).append("</div>");
|
||
}
|
||
sb.append("</div>");
|
||
|
||
// Neuer Kommentar
|
||
if (t.getStatus() != TicketStatus.CLOSED) {
|
||
sb.append("<div style='display:flex;gap:.75rem;margin-top:1rem'>");
|
||
sb.append("<textarea id='comment-input-").append(t.getId())
|
||
.append("' placeholder='").append(escHtml(wl(plugin, "detail-ph-newcomment")))
|
||
.append("' style='flex:1;min-height:60px'></textarea>");
|
||
sb.append("<button class='btn btn-primary' onclick='addComment(").append(t.getId()).append(")'>")
|
||
.append(escHtml(wl(plugin, "detail-btn-send"))).append("</button>");
|
||
sb.append("</div>");
|
||
}
|
||
sb.append("</div>");
|
||
|
||
return sb.toString();
|
||
}
|
||
|
||
// ─────────────────────────── Hilfsmethoden ─────────────────────────────
|
||
|
||
private String statusBadge(Ticket t) {
|
||
String s = t.getStatus().name().toLowerCase();
|
||
return "<span class='badge badge-" + s + "'>" + escHtml(t.getStatus().getDisplayName()) + "</span>";
|
||
}
|
||
|
||
private String priorityBadge(Ticket t) {
|
||
String p = t.getPriority().name().toLowerCase();
|
||
return "<span class='badge badge-" + p + "'>" + escHtml(t.getPriority().getDisplayName()) + "</span>";
|
||
}
|
||
|
||
private String getCategoryName(Ticket t) {
|
||
var cat = plugin.getCategoryManager().fromKey(t.getCategoryKey());
|
||
return cat != null ? cat.getName() : t.getCategoryKey();
|
||
}
|
||
|
||
private void infoRow(StringBuilder sb, String label, String value) {
|
||
sb.append("<div class='info-row'><span class='info-label'>").append(escHtml(label)).append("</span>")
|
||
.append("<span>").append(escHtml(value != null ? value : "—")).append("</span></div>");
|
||
}
|
||
|
||
private String selectFilter(String name, String current, List<Map.Entry<String,String>> options) {
|
||
StringBuilder sb = new StringBuilder("<select name='").append(name)
|
||
.append("' class='btn btn-secondary btn-sm' onchange='this.form.submit()'>");
|
||
for (var opt : options) {
|
||
String sel = opt.getKey().equals(current) ? " selected" : "";
|
||
sb.append("<option value='").append(escHtml(opt.getKey())).append("'").append(sel).append(">")
|
||
.append(escHtml(opt.getValue())).append("</option>");
|
||
}
|
||
return sb.append("</select>").toString();
|
||
}
|
||
|
||
private static Map.Entry<String,String> entry(String k, String v) {
|
||
return Map.entry(k, v);
|
||
}
|
||
|
||
private static int parseInt(String s, int fallback) {
|
||
try { return Integer.parseInt(s); } catch (NumberFormatException e) { return fallback; }
|
||
}
|
||
} |