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 {
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
|
|
|||
Loading…
Reference in a new issue