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 { if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError) http.Error(w, err.Error(), http.StatusInternalServerError)
return return
@ -350,6 +364,7 @@ func (h *AdminHandlers) ListCandidates(w http.ResponseWriter, r *http.Request) {
response := dto.CandidateListResponse{ response := dto.CandidateListResponse{
Stats: stats, Stats: stats,
Candidates: candidates, Candidates: candidates,
Pagination: pagination,
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View file

@ -35,8 +35,17 @@ type Candidate struct {
CreatedAt time.Time `json:"createdAt"` 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 { type CandidateListResponse struct {
Stats CandidateStats `json:"stats"` Stats CandidateStats `json:"stats"`
Candidates []Candidate `json:"candidates"` 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 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") 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 := ` query := `
SELECT id, full_name, email, phone, city, state, title, experience, bio, skills, avatar_url, created_at SELECT id, full_name, email, phone, city, state, title, experience, bio, skills, avatar_url, created_at
FROM users FROM users
WHERE role = 'candidate' WHERE role = 'candidate'
ORDER BY created_at DESC ORDER BY created_at DESC
LIMIT $1 OFFSET $2
` `
fmt.Println("[DEBUG] Executing query:", query) 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 { if err != nil {
fmt.Println("[ERROR] Query failed:", err) fmt.Println("[ERROR] Query failed:", err)
return nil, dto.CandidateStats{}, err return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
} }
defer rows.Close() defer rows.Close()
@ -380,23 +404,11 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
&createdAt, &createdAt,
); err != nil { ); err != nil {
fmt.Println("[ERROR] Scan failed:", err) fmt.Println("[ERROR] Scan failed:", err)
return nil, dto.CandidateStats{}, err return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
} }
location := buildLocation(city, state) 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{ candidate := dto.Candidate{
ID: id, // Check if this compiles! ID: id, // Check if this compiles!
Name: fullName, Name: fullName,
@ -422,10 +434,24 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
} }
fmt.Printf("[DEBUG] Found %d candidates\n", len(candidates)) 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 { 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 := ` appQuery := `
@ -440,7 +466,7 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
appRows, err := s.DB.QueryContext(ctx, appQuery, pq.Array(candidateIDs)) appRows, err := s.DB.QueryContext(ctx, appQuery, pq.Array(candidateIDs))
if err != nil { if err != nil {
fmt.Println("[ERROR] AppQuery failed:", err) fmt.Println("[ERROR] AppQuery failed:", err)
return nil, dto.CandidateStats{}, err return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
} }
defer appRows.Close() defer appRows.Close()
@ -462,7 +488,7 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
&app.Company, &app.Company,
); err != nil { ); err != nil {
fmt.Println("[ERROR] AppList Scan failed:", err) fmt.Println("[ERROR] AppList Scan failed:", err)
return nil, dto.CandidateStats{}, err return nil, dto.CandidateStats{}, dto.PaginationInfo{}, err
} }
totalApplications++ totalApplications++
@ -483,7 +509,14 @@ func (s *AdminService) ListCandidates(ctx context.Context, companyID *string) ([
stats.HiringRate = (float64(hiredApplications) / float64(totalApplications)) * 100 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 { func stringOrNil(value sql.NullString) *string {

View file

@ -15,7 +15,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog" } 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 { adminCandidatesApi, AdminCandidate, AdminCandidateStats } from "@/lib/api"
import { useTranslation } from "@/lib/i18n" import { useTranslation } from "@/lib/i18n"
@ -27,33 +27,30 @@ export default function AdminCandidatesPage() {
const [stats, setStats] = useState<AdminCandidateStats | null>(null) const [stats, setStats] = useState<AdminCandidateStats | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [errorMessage, setErrorMessage] = useState<string | null>(null) 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(() => { useEffect(() => {
let isMounted = true loadCandidates(1)
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
}
}, []) }, [])
const filteredCandidates = useMemo(() => { const filteredCandidates = useMemo(() => {
@ -66,6 +63,14 @@ export default function AdminCandidatesPage() {
}) })
}, [candidates, searchTerm]) }, [candidates, searchTerm])
const handlePrevPage = () => {
if (page > 1) loadCandidates(page - 1)
}
const handleNextPage = () => {
if (page < totalPages) loadCandidates(page + 1)
}
return ( return (
<div className="space-y-8"> <div className="space-y-8">
{/* Header */} {/* Header */}
@ -275,6 +280,36 @@ export default function AdminCandidatesPage() {
))} ))}
</TableBody> </TableBody>
</Table> </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> </CardContent>
</Card> </Card>
</div> </div>

View file

@ -313,7 +313,17 @@ export const adminTagsApi = {
}; };
export const adminCandidatesApi = { 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) --- // --- Companies (Admin) ---