diff --git a/backend/internal/api/handlers/admin_handlers.go b/backend/internal/api/handlers/admin_handlers.go index 1b8c881..2444858 100644 --- a/backend/internal/api/handlers/admin_handlers.go +++ b/backend/internal/api/handlers/admin_handlers.go @@ -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") diff --git a/backend/internal/dto/candidates.go b/backend/internal/dto/candidates.go index 1b0c158..825a8e0 100644 --- a/backend/internal/dto/candidates.go +++ b/backend/internal/dto/candidates.go @@ -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"` } diff --git a/backend/internal/services/admin_service.go b/backend/internal/services/admin_service.go index be4545e..b4c2192 100644 --- a/backend/internal/services/admin_service.go +++ b/backend/internal/services/admin_service.go @@ -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 { diff --git a/frontend/src/app/dashboard/candidates/page.tsx b/frontend/src/app/dashboard/candidates/page.tsx index 3df8956..90af524 100644 --- a/frontend/src/app/dashboard/candidates/page.tsx +++ b/frontend/src/app/dashboard/candidates/page.tsx @@ -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(null) const [isLoading, setIsLoading] = useState(true) const [errorMessage, setErrorMessage] = useState(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 (
{/* Header */} @@ -275,6 +280,36 @@ export default function AdminCandidatesPage() { ))} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Showing {((page - 1) * perPage) + 1} to {Math.min(page * perPage, totalItems)} of {totalItems} candidates +

+
+ + + {page} / {totalPages} + + +
+
+ )}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index daf08e3..ab73b33 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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) ---