feat: add pagination to candidates list endpoint

- Add PaginationInfo struct to candidates DTO
- Update ListCandidates service to support page/perPage params
- Update handler to parse pagination query params
- Update frontend candidates page with pagination controls
This commit is contained in:
Tiago Yamamoto 2026-02-23 18:02:41 -06:00
parent fa1d397c01
commit 59cd1fa01a
5 changed files with 152 additions and 50 deletions

View file

@ -341,7 +341,21 @@ func (h *AdminHandlers) ListCandidates(w http.ResponseWriter, r *http.Request) {
}
}
candidates, stats, err := h.adminService.ListCandidates(ctx, companyID)
// Parse pagination params
page := 1
perPage := 10
if p := r.URL.Query().Get("page"); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
if pp := r.URL.Query().Get("perPage"); pp != "" {
if parsed, err := strconv.Atoi(pp); err == nil && parsed > 0 && parsed <= 100 {
perPage = parsed
}
}
candidates, stats, pagination, err := h.adminService.ListCandidates(ctx, companyID, page, perPage)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
@ -350,6 +364,7 @@ func (h *AdminHandlers) ListCandidates(w http.ResponseWriter, r *http.Request) {
response := dto.CandidateListResponse{
Stats: stats,
Candidates: candidates,
Pagination: pagination,
}
w.Header().Set("Content-Type", "application/json")

View file

@ -35,8 +35,17 @@ type Candidate struct {
CreatedAt time.Time `json:"createdAt"`
}
// CandidateListResponse wraps candidate listing with summary statistics.
// PaginationInfo contains pagination metadata.
type PaginationInfo struct {
Page int `json:"page"`
PerPage int `json:"perPage"`
TotalPages int `json:"totalPages"`
TotalItems int `json:"totalItems"`
}
// CandidateListResponse wraps candidate listing with summary statistics and pagination.
type CandidateListResponse struct {
Stats CandidateStats `json:"stats"`
Candidates []Candidate `json:"candidates"`
Pagination PaginationInfo `json:"pagination"`
}

View file

@ -326,20 +326,44 @@ func (s *AdminService) UpdateTag(ctx context.Context, id int, name *string, acti
return tag, nil
}
func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([]dto.Candidate, dto.CandidateStats, error) {
func (s *AdminService) ListCandidates(ctx context.Context, companyID *string, page, perPage int) ([]dto.Candidate, dto.CandidateStats, dto.PaginationInfo, error) {
fmt.Println("[DEBUG] Starting ListCandidates")
// Default pagination values
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 10
}
if perPage > 100 {
perPage = 100
}
// Get total count first
var totalItems int
countQuery := `SELECT COUNT(*) FROM users WHERE role = 'candidate'`
if err := s.DB.QueryRowContext(ctx, countQuery).Scan(&totalItems); err != nil {
fmt.Println("[ERROR] Count query failed:", err)
return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
}
totalPages := (totalItems + perPage - 1) / perPage
offset := (page - 1) * perPage
query := `
SELECT id, full_name, email, phone, city, state, title, experience, bio, skills, avatar_url, created_at
FROM users
WHERE role = 'candidate'
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`
fmt.Println("[DEBUG] Executing query:", query)
rows, err := s.DB.QueryContext(ctx, query)
rows, err := s.DB.QueryContext(ctx, query, perPage, offset)
if err != nil {
fmt.Println("[ERROR] Query failed:", err)
return nil, dto.CandidateStats{}, err
return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
}
defer rows.Close()
@ -380,23 +404,11 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
&createdAt,
); err != nil {
fmt.Println("[ERROR] Scan failed:", err)
return nil, dto.CandidateStats{}, err
return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
}
location := buildLocation(city, state)
// Fix DTO if it expects int ID? Need to check DTO definition.
// Assuming DTO also needs update or is already string?
// If DTO.Candidate.ID is int, this will break compilation.
// I must verify DTO definition first.
// But I cannot see DTO definition here.
// Assuming DTO is compatible or I will fix it next.
// Actually, in previous Context `Ticket` model ID was changed to string. likely DTO needs it too.
// I will proceed assuming I need to cast or DTO is string.
// Wait, if DTO.ID is int, I cannot assign string.
// Let's assume DTO needs update.
// For now, I'll update logic to match UUIDs.
candidate := dto.Candidate{
ID: id, // Check if this compiles!
Name: fullName,
@ -422,10 +434,24 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
}
fmt.Printf("[DEBUG] Found %d candidates\n", len(candidates))
stats.TotalCandidates = len(candidates)
// Get total stats (not paginated)
stats.TotalCandidates = totalItems
// Count new candidates in last 30 days
var newCount int
newCountQuery := `SELECT COUNT(*) FROM users WHERE role = 'candidate' AND created_at > $1`
if err := s.DB.QueryRowContext(ctx, newCountQuery, thirtyDaysAgo).Scan(&newCount); err == nil {
stats.NewCandidates = newCount
}
if len(candidateIDs) == 0 {
return candidates, stats, nil
pagination := dto.PaginationInfo{
Page: page,
PerPage: perPage,
TotalPages: totalPages,
TotalItems: totalItems,
}
return candidates, stats, pagination, nil
}
appQuery := `
@ -440,7 +466,7 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
appRows, err := s.DB.QueryContext(ctx, appQuery, pq.Array(candidateIDs))
if err != nil {
fmt.Println("[ERROR] AppQuery failed:", err)
return nil, dto.CandidateStats{}, err
return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
}
defer appRows.Close()
@ -462,7 +488,7 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
&app.Company,
); err != nil {
fmt.Println("[ERROR] AppList Scan failed:", err)
return nil, dto.CandidateStats{}, err
return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
}
totalApplications++
@ -483,7 +509,14 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
stats.HiringRate = (float64(hiredApplications) / float64(totalApplications)) * 100
}
return candidates, stats, nil
pagination := dto.PaginationInfo{
Page: page,
PerPage: perPage,
TotalPages: totalPages,
TotalItems: totalItems,
}
return candidates, stats, pagination, nil
}
func stringOrNil(value sql.NullString) *string {

View file

@ -15,7 +15,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Search, Eye, Mail, Phone, MapPin, Briefcase } from "lucide-react"
import { Search, Eye, Mail, Phone, MapPin, Briefcase, ChevronLeft, ChevronRight } from "lucide-react"
import { adminCandidatesApi, AdminCandidate, AdminCandidateStats } from "@/lib/api"
import { useTranslation } from "@/lib/i18n"
@ -27,33 +27,30 @@ export default function AdminCandidatesPage() {
const [stats, setStats] = useState<AdminCandidateStats | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [page, setPage] = useState(1)
const [perPage] = useState(10)
const [totalPages, setTotalPages] = useState(1)
const [totalItems, setTotalItems] = useState(0)
const loadCandidates = async (pageNum: number) => {
setIsLoading(true)
setErrorMessage(null)
try {
const response = await adminCandidatesApi.list(pageNum, perPage)
setCandidates(response.candidates)
setStats(response.stats)
setTotalPages(response.pagination.totalPages)
setTotalItems(response.pagination.totalItems)
setPage(pageNum)
} catch (error) {
setErrorMessage(error instanceof Error ? error.message : t("admin.candidates_page.load_error"))
} finally {
setIsLoading(false)
}
}
useEffect(() => {
let isMounted = true
const loadCandidates = async () => {
setIsLoading(true)
setErrorMessage(null)
try {
const response = await adminCandidatesApi.list()
if (!isMounted) return
setCandidates(response.candidates)
setStats(response.stats)
} catch (error) {
if (!isMounted) return
setErrorMessage(error instanceof Error ? error.message : t("admin.candidates_page.load_error"))
} finally {
if (isMounted) {
setIsLoading(false)
}
}
}
loadCandidates()
return () => {
isMounted = false
}
loadCandidates(1)
}, [])
const filteredCandidates = useMemo(() => {
@ -66,6 +63,14 @@ export default function AdminCandidatesPage() {
})
}, [candidates, searchTerm])
const handlePrevPage = () => {
if (page > 1) loadCandidates(page - 1)
}
const handleNextPage = () => {
if (page < totalPages) loadCandidates(page + 1)
}
return (
<div className="space-y-8">
{/* Header */}
@ -275,6 +280,36 @@ export default function AdminCandidatesPage() {
))}
</TableBody>
</Table>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Showing {((page - 1) * perPage) + 1} to {Math.min(page * perPage, totalItems)} of {totalItems} candidates
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePrevPage}
disabled={page === 1 || isLoading}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="flex items-center px-3 text-sm">
{page} / {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={handleNextPage}
disabled={page === totalPages || isLoading}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>

View file

@ -313,7 +313,17 @@ export const adminTagsApi = {
};
export const adminCandidatesApi = {
list: () => apiRequest<{ candidates: AdminCandidate[]; stats: AdminCandidateStats }>("/api/v1/candidates"),
list: (page = 1, perPage = 10) => {
const query = new URLSearchParams({
page: page.toString(),
perPage: perPage.toString(),
});
return apiRequest<{
candidates: AdminCandidate[];
stats: AdminCandidateStats;
pagination: { page: number; perPage: number; totalPages: number; totalItems: number };
}>(`/api/v1/candidates?${query.toString()}`);
},
};
// --- Companies (Admin) ---