diff --git a/internal/handlers/admin/tickets.go b/internal/handlers/admin/tickets.go index 7f6cb33..a1d0555 100644 --- a/internal/handlers/admin/tickets.go +++ b/internal/handlers/admin/tickets.go @@ -4,6 +4,7 @@ import ( "fmt" "net/http" "sort" + "strconv" "strings" "time" @@ -35,6 +36,15 @@ type ticketListRow struct { func (h *TicketHandler) List(c *gin.Context) { statusFilter := c.Query("status") + priorityFilter := c.Query("priority") + productFilter := c.Query("product") + reporterFilter := c.Query("reporter") + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + const pageSize = 20 // Load all ticket mappings with User and Repo joins type ticketMapping struct { @@ -53,7 +63,6 @@ func (h *TicketHandler) List(c *gin.Context) { Joins("JOIN repos ON repos.id = tickets.repo_id"). Joins("JOIN users ON users.id = tickets.user_id"). Order("tickets.created_at DESC"). - Limit(100). Scan(&mappings).Error; err != nil { log.Error().Err(err).Msg("list tickets error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets") @@ -83,7 +92,7 @@ func (h *TicketHandler) List(c *gin.Context) { apiState = "open" } - // Fetch issues from Forgejo per repo and match + // Fetch issues from Forgejo per repo and build all rows var tickets []ticketListRow for _, group := range repoGroups { issues, err := h.deps.ForgejoClient.ListIssues(group.owner, group.repo, apiState, "") @@ -114,17 +123,11 @@ func (h *TicketHandler) List(c *gin.Context) { if !ok { continue } - status := forgejo.DeriveStatus(issue) - - // Apply client-side status filter - if statusFilter != "" && status != statusFilter { - continue - } tickets = append(tickets, ticketListRow{ ID: m.ID, Title: issue.Title, - Status: status, + Status: forgejo.DeriveStatus(issue), Priority: forgejo.DerivePriority(issue), Pinned: issue.PinOrder > 0, RepoName: m.RepoName, @@ -137,6 +140,47 @@ func (h *TicketHandler) List(c *gin.Context) { } } + // Collect unique product names and reporter names before filtering + productSet := map[string]bool{} + reporterSet := map[string]bool{} + for _, t := range tickets { + if t.RepoName != "" { + productSet[t.RepoName] = true + } + if t.UserName != "" { + reporterSet[t.UserName] = true + } + } + var products []string + for p := range productSet { + products = append(products, p) + } + sort.Strings(products) + var reporters []string + for r := range reporterSet { + reporters = append(reporters, r) + } + sort.Strings(reporters) + + // Apply in-memory filters + filtered := tickets[:0] + for _, t := range tickets { + if statusFilter != "" && t.Status != statusFilter { + continue + } + if priorityFilter != "" && t.Priority != priorityFilter { + continue + } + if productFilter != "" && t.RepoName != productFilter { + continue + } + if reporterFilter != "" && t.UserName != reporterFilter { + continue + } + filtered = append(filtered, t) + } + tickets = filtered + // Sort: pinned first, then by created date sort.SliceStable(tickets, func(i, j int) bool { if tickets[i].Pinned != tickets[j].Pinned { @@ -145,9 +189,31 @@ func (h *TicketHandler) List(c *gin.Context) { return false }) + // Paginate + totalPages := (len(tickets) + pageSize - 1) / pageSize + if totalPages < 1 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + start := (page - 1) * pageSize + end := start + pageSize + if end > len(tickets) { + end = len(tickets) + } + tickets = tickets[start:end] + h.deps.Renderer.Render(c.Writer, c.Request, "admin/tickets/list", map[string]interface{}{ - "Tickets": tickets, - "StatusFilter": statusFilter, + "Tickets": tickets, + "StatusFilter": statusFilter, + "PriorityFilter": priorityFilter, + "ProductFilter": productFilter, + "ReporterFilter": reporterFilter, + "Products": products, + "Reporters": reporters, + "Page": page, + "TotalPages": totalPages, }) } diff --git a/internal/handlers/public/tickets.go b/internal/handlers/public/tickets.go index d5d6cf7..906136d 100644 --- a/internal/handlers/public/tickets.go +++ b/internal/handlers/public/tickets.go @@ -25,8 +25,18 @@ type TicketHandler struct { func (h *TicketHandler) List(c *gin.Context) { user := auth.CurrentUser(c) + statusFilter := c.Query("status") + priorityFilter := c.Query("priority") + productFilter := c.Query("product") + + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + if page < 1 { + page = 1 + } + const pageSize = 20 + var tickets []models.Ticket - if err := h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Limit(50).Find(&tickets).Error; err != nil { + if err := h.deps.DB.Preload("Repo").Where("user_id = ?", user.ID).Order("created_at DESC").Find(&tickets).Error; err != nil { log.Error().Err(err).Msg("list tickets error") h.deps.Renderer.RenderError(c.Writer, c.Request, http.StatusInternalServerError, "Failed to load tickets") return @@ -34,7 +44,10 @@ func (h *TicketHandler) List(c *gin.Context) { if len(tickets) == 0 { h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{ - "Tickets": []map[string]interface{}{}, + "Tickets": []map[string]interface{}{}, + "StatusFilter": statusFilter, + "PriorityFilter": priorityFilter, + "ProductFilter": productFilter, }) return } @@ -52,6 +65,14 @@ func (h *TicketHandler) List(c *gin.Context) { repoMap[t.RepoID].tickets = append(repoMap[t.RepoID].tickets, t) } + // Determine Forgejo API state filter + apiState := "all" + if statusFilter == "closed" { + apiState = "closed" + } else if statusFilter == "open" || statusFilter == "in_progress" { + apiState = "open" + } + // Fetch issues from Forgejo per repo and build view models type ticketView struct { ID uuid.UUID @@ -66,7 +87,7 @@ func (h *TicketHandler) List(c *gin.Context) { var views []ticketView for _, group := range repoMap { - issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, "all", "") + issues, err := h.deps.ForgejoClient.ListIssues(group.repo.ForgejoOwner, group.repo.ForgejoRepo, apiState, "") if err != nil { log.Error().Err(err).Str("repo", group.repo.Name).Msg("forgejo list issues error") // Show tickets with unknown status on API failure @@ -103,6 +124,35 @@ func (h *TicketHandler) List(c *gin.Context) { } } + // Collect unique product names before filtering + productSet := map[string]bool{} + for _, v := range views { + if v.RepoName != "" { + productSet[v.RepoName] = true + } + } + var products []string + for p := range productSet { + products = append(products, p) + } + sort.Strings(products) + + // Apply in-memory filters + filtered := views[:0] + for _, v := range views { + if statusFilter != "" && v.Status != statusFilter { + continue + } + if priorityFilter != "" && v.Priority != priorityFilter { + continue + } + if productFilter != "" && v.RepoName != productFilter { + continue + } + filtered = append(filtered, v) + } + views = filtered + // Sort: pinned first, then by created date sort.SliceStable(views, func(i, j int) bool { if views[i].Pinned != views[j].Pinned { @@ -111,8 +161,29 @@ func (h *TicketHandler) List(c *gin.Context) { return false // preserve existing order for non-pinned }) + // Paginate + totalPages := (len(views) + pageSize - 1) / pageSize + if totalPages < 1 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + start := (page - 1) * pageSize + end := start + pageSize + if end > len(views) { + end = len(views) + } + views = views[start:end] + h.deps.Renderer.Render(c.Writer, c.Request, "tickets/list", map[string]interface{}{ - "Tickets": views, + "Tickets": views, + "StatusFilter": statusFilter, + "PriorityFilter": priorityFilter, + "ProductFilter": productFilter, + "Products": products, + "Page": page, + "TotalPages": totalPages, }) } diff --git a/internal/templates/funcs.go b/internal/templates/funcs.go index 924a595..ae10f13 100644 --- a/internal/templates/funcs.go +++ b/internal/templates/funcs.go @@ -3,6 +3,7 @@ package templates import ( "fmt" "html/template" + "net/url" "strings" "time" @@ -87,5 +88,48 @@ func templateFuncs() template.FuncMap { } return dict }, + "add": func(a, b int) int { + return a + b + }, + "sub": func(a, b int) int { + return a - b + }, + "filterURL": func(baseURL string, params map[string]interface{}, setKey, setValue string) string { + u, err := url.Parse(baseURL) + if err != nil { + return baseURL + } + q := url.Values{} + for k, v := range params { + if k == "page" { + continue + } + if s, ok := v.(string); ok && s != "" { + q.Set(k, s) + } + } + if setValue != "" { + q.Set(setKey, setValue) + } else { + q.Del(setKey) + } + u.RawQuery = q.Encode() + return u.String() + }, + "pageURL": func(baseURL string, params map[string]interface{}, page int) string { + u, err := url.Parse(baseURL) + if err != nil { + return baseURL + } + q := u.Query() + for k, v := range params { + if s, ok := v.(string); ok && s != "" { + q.Set(k, s) + } + } + q.Set("page", fmt.Sprintf("%d", page)) + u.RawQuery = q.Encode() + return u.String() + }, } } diff --git a/web/templates/pages/admin/tickets/list.html b/web/templates/pages/admin/tickets/list.html index 3c4a685..287de59 100644 --- a/web/templates/pages/admin/tickets/list.html +++ b/web/templates/pages/admin/tickets/list.html @@ -4,11 +4,40 @@

All Tickets

{{with .Data}} -
- All - Open - In Progress - Closed +{{$filters := dict "status" .StatusFilter "priority" .PriorityFilter "product" .ProductFilter "reporter" .ReporterFilter}} +
+
+ Status + All + Open + In Progress + Closed +
+
+ Priority + All + High + Medium + Low +
+ {{if .Products}} +
+ Product + All + {{range .Products}} + {{.}} + {{end}} +
+ {{end}} + {{if .Reporters}} +
+ Reporter + All + {{range .Reporters}} + {{.}} + {{end}} +
+ {{end}}
{{if .Tickets}} @@ -45,8 +74,27 @@
+{{if gt .TotalPages 1}} + +{{end}} +{{else}} +{{if or .StatusFilter .PriorityFilter .ProductFilter .ReporterFilter}} +

No tickets match the current filters.

{{else}}

No tickets found.

{{end}} {{end}} {{end}} +{{end}} diff --git a/web/templates/pages/tickets/list.html b/web/templates/pages/tickets/list.html index de1cf5f..3e145c0 100644 --- a/web/templates/pages/tickets/list.html +++ b/web/templates/pages/tickets/list.html @@ -7,6 +7,33 @@ {{with .Data}} + {{$filters := dict "status" .StatusFilter "priority" .PriorityFilter "product" .ProductFilter}} +
+
+ Status + All + Open + In Progress + Closed +
+
+ Priority + All + High + Medium + Low +
+ {{if .Products}} +
+ Product + All + {{range .Products}} + {{.}} + {{end}} +
+ {{end}} +
+ {{if .Tickets}}
@@ -39,10 +66,29 @@
+ {{if gt .TotalPages 1}} + + {{end}} {{else}}
+ {{if or .StatusFilter .PriorityFilter .ProductFilter}} +

No tickets match the current filters.

+ {{else}}

No tickets yet.

Create your first ticket + {{end}}
{{end}} {{end}}