diff --git a/backend/internal/api/handlers/admin_handlers.go b/backend/internal/api/handlers/admin_handlers.go index 6f79181..c2df511 100644 --- a/backend/internal/api/handlers/admin_handlers.go +++ b/backend/internal/api/handlers/admin_handlers.go @@ -295,3 +295,19 @@ func (h *AdminHandlers) UpdateTag(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(tag) } + +func (h *AdminHandlers) ListCandidates(w http.ResponseWriter, r *http.Request) { + candidates, stats, err := h.adminService.ListCandidates(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + response := dto.CandidateListResponse{ + Stats: stats, + Candidates: candidates, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/backend/internal/dto/candidates.go b/backend/internal/dto/candidates.go new file mode 100644 index 0000000..c88b9da --- /dev/null +++ b/backend/internal/dto/candidates.go @@ -0,0 +1,42 @@ +package dto + +import "time" + +// CandidateStats represents aggregate stats for candidates. +type CandidateStats struct { + TotalCandidates int `json:"totalCandidates"` + NewCandidates int `json:"newCandidates"` + ActiveApplications int `json:"activeApplications"` + HiringRate float64 `json:"hiringRate"` +} + +// CandidateApplication represents an application entry for the candidate profile. +type CandidateApplication struct { + ID int `json:"id"` + JobTitle string `json:"jobTitle"` + Company string `json:"company"` + Status string `json:"status"` + AppliedAt time.Time `json:"appliedAt"` +} + +// Candidate represents candidate profile data for backoffice. +type Candidate struct { + ID int `json:"id"` + Name string `json:"name"` + Email *string `json:"email,omitempty"` + Phone *string `json:"phone,omitempty"` + Location *string `json:"location,omitempty"` + Title *string `json:"title,omitempty"` + Experience *string `json:"experience,omitempty"` + AvatarURL *string `json:"avatarUrl,omitempty"` + Bio *string `json:"bio,omitempty"` + Skills []string `json:"skills"` + Applications []CandidateApplication `json:"applications"` + CreatedAt time.Time `json:"createdAt"` +} + +// CandidateListResponse wraps candidate listing with summary statistics. +type CandidateListResponse struct { + Stats CandidateStats `json:"stats"` + Candidates []Candidate `json:"candidates"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index e4025b0..910f6e7 100755 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -152,6 +152,7 @@ func NewRouter() http.Handler { mux.Handle("GET /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListTags)))) mux.Handle("POST /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.CreateTag)))) mux.Handle("PATCH /api/v1/admin/tags/{id}", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.UpdateTag)))) + mux.Handle("GET /api/v1/admin/candidates", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListCandidates)))) // Application Routes mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication) diff --git a/backend/internal/services/admin_service.go b/backend/internal/services/admin_service.go index 4439507..570324a 100644 --- a/backend/internal/services/admin_service.go +++ b/backend/internal/services/admin_service.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/lib/pq" + "github.com/rede5/gohorsejobs/backend/internal/dto" "github.com/rede5/gohorsejobs/backend/internal/models" ) @@ -252,6 +254,198 @@ func (s *AdminService) UpdateTag(ctx context.Context, id int, name *string, acti return tag, nil } +func (s *AdminService) ListCandidates(ctx context.Context) ([]dto.Candidate, dto.CandidateStats, error) { + query := ` + SELECT id, full_name, email, phone, city, state, title, experience, bio, skills, avatar_url, created_at + FROM users + WHERE role = 'jobSeeker' + ORDER BY created_at DESC + ` + + rows, err := s.DB.QueryContext(ctx, query) + if err != nil { + return nil, dto.CandidateStats{}, err + } + defer rows.Close() + + candidates := make([]dto.Candidate, 0) + candidateIndex := make(map[int]int) + candidateIDs := make([]int, 0) + stats := dto.CandidateStats{} + thirtyDaysAgo := time.Now().AddDate(0, 0, -30) + + for rows.Next() { + var ( + id int + fullName string + email sql.NullString + phone sql.NullString + city sql.NullString + state sql.NullString + title sql.NullString + experience sql.NullString + bio sql.NullString + avatarURL sql.NullString + skills []string + createdAt time.Time + ) + + if err := rows.Scan( + &id, + &fullName, + &email, + &phone, + &city, + &state, + &title, + &experience, + &bio, + pq.Array(&skills), + &avatarURL, + &createdAt, + ); err != nil { + return nil, dto.CandidateStats{}, err + } + + location := buildLocation(city, state) + + candidate := dto.Candidate{ + ID: id, + Name: fullName, + Email: stringOrNil(email), + Phone: stringOrNil(phone), + Location: location, + Title: stringOrNil(title), + Experience: stringOrNil(experience), + Bio: stringOrNil(bio), + AvatarURL: stringOrNil(avatarURL), + Skills: normalizeSkills(skills), + Applications: []dto.CandidateApplication{}, + CreatedAt: createdAt, + } + + if createdAt.After(thirtyDaysAgo) { + stats.NewCandidates++ + } + + candidateIndex[id] = len(candidates) + candidateIDs = append(candidateIDs, id) + candidates = append(candidates, candidate) + } + + stats.TotalCandidates = len(candidates) + + if len(candidateIDs) == 0 { + return candidates, stats, nil + } + + appQuery := ` + SELECT a.id, a.user_id, a.status, a.created_at, j.title, c.name + FROM applications a + JOIN jobs j ON j.id = a.job_id + JOIN companies c ON c.id = j.company_id + WHERE a.user_id = ANY($1) + ORDER BY a.created_at DESC + ` + appRows, err := s.DB.QueryContext(ctx, appQuery, pq.Array(candidateIDs)) + if err != nil { + return nil, dto.CandidateStats{}, err + } + defer appRows.Close() + + totalApplications := 0 + hiredApplications := 0 + + for appRows.Next() { + var ( + app dto.CandidateApplication + userID int + ) + + if err := appRows.Scan( + &app.ID, + &userID, + &app.Status, + &app.AppliedAt, + &app.JobTitle, + &app.Company, + ); err != nil { + return nil, dto.CandidateStats{}, err + } + + totalApplications++ + if isActiveApplicationStatus(app.Status) { + stats.ActiveApplications++ + } + if app.Status == "hired" { + hiredApplications++ + } + + if index, ok := candidateIndex[userID]; ok { + candidates[index].Applications = append(candidates[index].Applications, app) + } + } + + if totalApplications > 0 { + stats.HiringRate = (float64(hiredApplications) / float64(totalApplications)) * 100 + } + + return candidates, stats, nil +} + +func stringOrNil(value sql.NullString) *string { + if !value.Valid { + return nil + } + trimmed := strings.TrimSpace(value.String) + if trimmed == "" { + return nil + } + return &trimmed +} + +func buildLocation(city sql.NullString, state sql.NullString) *string { + parts := make([]string, 0, 2) + if city.Valid && strings.TrimSpace(city.String) != "" { + parts = append(parts, strings.TrimSpace(city.String)) + } + if state.Valid && strings.TrimSpace(state.String) != "" { + parts = append(parts, strings.TrimSpace(state.String)) + } + if len(parts) == 0 { + return nil + } + location := strings.Join(parts, ", ") + return &location +} + +func normalizeSkills(skills []string) []string { + if len(skills) == 0 { + return []string{} + } + normalized := make([]string, 0, len(skills)) + for _, skill := range skills { + trimmed := strings.TrimSpace(skill) + if trimmed == "" { + continue + } + normalized = append(normalized, trimmed) + } + if len(normalized) == 0 { + return []string{} + } + return normalized +} + +func isActiveApplicationStatus(status string) bool { + switch status { + case "pending", "reviewed", "shortlisted": + return true + default: + return false + } +} + func (s *AdminService) getCompanyByID(ctx context.Context, id int) (*models.Company, error) { query := ` SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at diff --git a/backend/migrations/015_add_candidate_profile_fields.sql b/backend/migrations/015_add_candidate_profile_fields.sql new file mode 100644 index 0000000..bba86f8 --- /dev/null +++ b/backend/migrations/015_add_candidate_profile_fields.sql @@ -0,0 +1,20 @@ +-- Migration: Add candidate profile fields to users table +-- Description: Store candidate registration details used by backoffice candidate management + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS email VARCHAR(255), + ADD COLUMN IF NOT EXISTS birth_date DATE, + ADD COLUMN IF NOT EXISTS address VARCHAR(255), + ADD COLUMN IF NOT EXISTS city VARCHAR(100), + ADD COLUMN IF NOT EXISTS state VARCHAR(100), + ADD COLUMN IF NOT EXISTS zip_code VARCHAR(20), + ADD COLUMN IF NOT EXISTS education VARCHAR(100), + ADD COLUMN IF NOT EXISTS experience VARCHAR(100), + ADD COLUMN IF NOT EXISTS skills TEXT[], + ADD COLUMN IF NOT EXISTS objective TEXT, + ADD COLUMN IF NOT EXISTS title VARCHAR(150), + ADD COLUMN IF NOT EXISTS bio TEXT, + ADD COLUMN IF NOT EXISTS avatar_url TEXT; + +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_role_created_at ON users(role, created_at); diff --git a/frontend/src/app/dashboard/candidates/page.tsx b/frontend/src/app/dashboard/candidates/page.tsx index 4e3fe15..9fd5c73 100644 --- a/frontend/src/app/dashboard/candidates/page.tsx +++ b/frontend/src/app/dashboard/candidates/page.tsx @@ -1,6 +1,6 @@ "use client" -import { useState } from "react" +import { useEffect, useMemo, useState } from "react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" @@ -16,17 +16,53 @@ import { DialogTrigger, } from "@/components/ui/dialog" import { Search, Eye, Mail, Phone, MapPin, Briefcase } from "lucide-react" -import { mockCandidates } from "@/lib/mock-data" +import { adminCandidatesApi, AdminCandidate, AdminCandidateStats } from "@/lib/api" export default function AdminCandidatesPage() { const [searchTerm, setSearchTerm] = useState("") - const [selectedCandidate, setSelectedCandidate] = useState(null) + const [selectedCandidate, setSelectedCandidate] = useState(null) + const [candidates, setCandidates] = useState([]) + const [stats, setStats] = useState(null) + const [isLoading, setIsLoading] = useState(true) + const [errorMessage, setErrorMessage] = useState(null) - const filteredCandidates = mockCandidates.filter( - (candidate) => - candidate.name.toLowerCase().includes(searchTerm.toLowerCase()) || - candidate.email.toLowerCase().includes(searchTerm.toLowerCase()), - ) + 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 : "Failed to load candidates") + } finally { + if (isMounted) { + setIsLoading(false) + } + } + } + + loadCandidates() + + return () => { + isMounted = false + } + }, []) + + const filteredCandidates = useMemo(() => { + const term = searchTerm.toLowerCase().trim() + if (!term) return candidates + return candidates.filter((candidate) => { + const name = candidate.name.toLowerCase() + const email = candidate.email?.toLowerCase() || "" + return name.includes(term) || email.includes(term) + }) + }, [candidates, searchTerm]) return (
@@ -41,25 +77,25 @@ export default function AdminCandidatesPage() { Total candidates - {mockCandidates.length} + {stats?.totalCandidates ?? 0} New (30 days) - 24 + {stats?.newCandidates ?? 0} Active applications - {"49"} + {stats?.activeApplications ?? 0} Hiring rate - 8% + {Math.round(stats?.hiringRate ?? 0)}%
@@ -80,6 +116,9 @@ export default function AdminCandidatesPage() { + {errorMessage ? ( +
{errorMessage}
+ ) : null} @@ -92,20 +131,33 @@ export default function AdminCandidatesPage() { + {isLoading && ( + + + Loading candidates... + + + )} + {!isLoading && filteredCandidates.length === 0 && ( + + + No candidates found. + + + )} {filteredCandidates.map((candidate) => (
-
{candidate.name}
-
{candidate.title}
+
{candidate.title ?? "—"}
- {candidate.email} - {candidate.phone} - {candidate.location} + {candidate.email ?? "—"} + {candidate.phone ?? "—"} + {candidate.location ?? "—"} {candidate.applications.length} @@ -125,7 +177,7 @@ export default function AdminCandidatesPage() {
- + {selectedCandidate.name .split(" ") @@ -135,45 +187,54 @@ export default function AdminCandidatesPage() {

{selectedCandidate.name}

-

{selectedCandidate.title}

-
- {selectedCandidate.skills.map((skill: string) => ( - - {skill} - - ))} -
+

{selectedCandidate.title ?? "—"}

+ {selectedCandidate.skills.length > 0 && ( +
+ {selectedCandidate.skills.map((skill: string) => ( + + {skill} + + ))} +
+ )}
- {selectedCandidate.email} + {selectedCandidate.email ?? "—"}
- {selectedCandidate.phone} + {selectedCandidate.phone ?? "—"}
- {selectedCandidate.location} + {selectedCandidate.location ?? "—"}
- {selectedCandidate.experience} + {selectedCandidate.experience ?? "—"}

About

-

{selectedCandidate.bio}

+

+ {selectedCandidate.bio ?? "No profile summary provided."} +

Recent applications

- {selectedCandidate.applications.map((app: any) => ( + {selectedCandidate.applications.length === 0 && ( +
+ No applications submitted yet. +
+ )} + {selectedCandidate.applications.map((app) => (
{app.status === "pending" && "Pending"} - {app.status === "accepted" && "Accepted"} + {app.status === "reviewed" && "Reviewed"} + {app.status === "shortlisted" && "Shortlisted"} + {app.status === "hired" && "Hired"} {app.status === "rejected" && "Rejected"}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index c3d653d..5f73f0e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -204,6 +204,41 @@ export interface AdminTag { updatedAt: string; } +export interface AdminCandidateApplication { + id: number; + jobTitle: string; + company: string; + status: "pending" | "reviewed" | "shortlisted" | "rejected" | "hired"; + appliedAt: string; +} + +export interface AdminCandidate { + id: number; + name: string; + email?: string; + phone?: string; + location?: string; + title?: string; + experience?: string; + avatarUrl?: string; + bio?: string; + skills: string[]; + applications: AdminCandidateApplication[]; + createdAt: string; +} + +export interface AdminCandidateStats { + totalCandidates: number; + newCandidates: number; + activeApplications: number; + hiringRate: number; +} + +export interface AdminCandidateListResponse { + stats: AdminCandidateStats; + candidates: AdminCandidate[]; +} + export const adminAccessApi = { listRoles: () => { logCrudAction("read", "admin/access/roles"); @@ -218,6 +253,13 @@ export const adminAuditApi = { }, }; +export const adminCandidatesApi = { + list: () => { + logCrudAction("read", "admin/candidates"); + return apiRequest("/api/v1/admin/candidates"); + }, +}; + export const adminCompaniesApi = { list: (verified?: boolean) => { logCrudAction("read", "admin/companies", typeof verified === "boolean" ? { verified } : undefined); diff --git a/seeder-api/src/seeders/users.js b/seeder-api/src/seeders/users.js index 6163ec1..77445ca 100644 --- a/seeder-api/src/seeders/users.js +++ b/seeder-api/src/seeders/users.js @@ -104,6 +104,122 @@ export async function seedUsers() { console.log(` ✓ Candidate created: ${cand.email}`); } + console.log('👤 Seeding legacy candidates...'); + + const legacyCandidates = [ + { + identifier: 'ana_silva', + fullName: 'Ana Silva', + email: 'ana.silva@example.com', + phone: '+55 11 98765-4321', + city: 'São Paulo', + state: 'SP', + title: 'Full Stack Developer', + experience: '5 years of experience', + skills: ['React', 'Node.js', 'TypeScript', 'AWS', 'Docker'], + bio: 'Developer passionate about building innovative solutions. Experience in React, Node.js, and cloud computing.', + objective: 'Grow as a full stack developer building scalable products.' + }, + { + identifier: 'carlos_santos', + fullName: 'Carlos Santos', + email: 'carlos.santos@example.com', + phone: '+55 11 91234-5678', + city: 'Rio de Janeiro', + state: 'RJ', + title: 'UX/UI Designer', + experience: '3 years of experience', + skills: ['Figma', 'Adobe XD', 'UI Design', 'Prototyping', 'Design Systems'], + bio: 'Designer focused on creating memorable experiences. Specialist in design systems and prototyping.', + objective: 'Design intuitive experiences for web and mobile products.' + }, + { + identifier: 'maria_oliveira', + fullName: 'Maria Oliveira', + email: 'maria.oliveira@example.com', + phone: '+55 21 99876-5432', + city: 'Belo Horizonte', + state: 'MG', + title: 'Data Engineer', + experience: '7 years of experience', + skills: ['Python', 'SQL', 'Spark', 'Machine Learning', 'Data Visualization'], + bio: 'Data engineer with a strong background in machine learning and big data. Passionate about turning data into insights.', + objective: 'Build robust data pipelines and analytics products.' + }, + { + identifier: 'pedro_costa', + fullName: 'Pedro Costa', + email: 'pedro.costa@example.com', + phone: '+55 31 98765-1234', + city: 'Curitiba', + state: 'PR', + title: 'Product Manager', + experience: '6 years of experience', + skills: ['Product Management', 'Agile', 'Scrum', 'Data Analysis', 'User Research'], + bio: 'Product Manager with experience in digital products and agile methodologies. Focused on delivering user value.', + objective: 'Lead cross-functional teams to deliver customer-centric products.' + }, + { + identifier: 'juliana_ferreira', + fullName: 'Juliana Ferreira', + email: 'juliana.ferreira@example.com', + phone: '+55 41 91234-8765', + city: 'Porto Alegre', + state: 'RS', + title: 'DevOps Engineer', + experience: '4 years of experience', + skills: ['Docker', 'Kubernetes', 'AWS', 'Terraform', 'CI/CD'], + bio: 'DevOps engineer specialized in automation and cloud infrastructure. Experience with Kubernetes and CI/CD.', + objective: 'Improve delivery pipelines and cloud reliability.' + } + ]; + + for (const cand of legacyCandidates) { + const hash = await bcrypt.hash('User@2025', 10); + await pool.query(` + INSERT INTO users ( + identifier, + password_hash, + role, + full_name, + email, + phone, + city, + state, + title, + experience, + skills, + objective, + bio + ) VALUES ($1, $2, 'jobSeeker', $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (identifier) DO UPDATE SET + full_name = EXCLUDED.full_name, + email = EXCLUDED.email, + phone = EXCLUDED.phone, + city = EXCLUDED.city, + state = EXCLUDED.state, + title = EXCLUDED.title, + experience = EXCLUDED.experience, + skills = EXCLUDED.skills, + objective = EXCLUDED.objective, + bio = EXCLUDED.bio + `, [ + cand.identifier, + hash, + cand.fullName, + cand.email, + cand.phone, + cand.city, + cand.state, + cand.title, + cand.experience, + cand.skills, + cand.objective, + cand.bio + ]); + console.log(` ✓ Legacy candidate created: ${cand.email}`); + } + } catch (error) { console.error(' ❌ Error seeding users:', error.message); throw error;