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:
parent
fa1d397c01
commit
59cd1fa01a
5 changed files with 152 additions and 50 deletions
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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) ---
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue