Add dynamic candidate management data
This commit is contained in:
parent
6996e11f03
commit
e71fc361ac
8 changed files with 528 additions and 34 deletions
|
|
@ -295,3 +295,19 @@ func (h *AdminHandlers) UpdateTag(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(tag)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
42
backend/internal/dto/candidates.go
Normal file
42
backend/internal/dto/candidates.go
Normal file
|
|
@ -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"`
|
||||||
|
}
|
||||||
|
|
@ -152,6 +152,7 @@ func NewRouter() http.Handler {
|
||||||
mux.Handle("GET /api/v1/admin/tags", authMiddleware.HeaderAuthGuard(adminOnly(http.HandlerFunc(adminHandlers.ListTags))))
|
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("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("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
|
// Application Routes
|
||||||
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
|
mux.HandleFunc("POST /api/v1/applications", applicationHandler.CreateApplication)
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
"github.com/rede5/gohorsejobs/backend/internal/dto"
|
||||||
"github.com/rede5/gohorsejobs/backend/internal/models"
|
"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
|
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) {
|
func (s *AdminService) getCompanyByID(ctx context.Context, id int) (*models.Company, error) {
|
||||||
query := `
|
query := `
|
||||||
SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at
|
SELECT id, name, slug, type, document, address, region_id, city_id, phone, email, website, logo_url, description, active, verified, created_at, updated_at
|
||||||
|
|
|
||||||
20
backend/migrations/015_add_candidate_profile_fields.sql
Normal file
20
backend/migrations/015_add_candidate_profile_fields.sql
Normal file
|
|
@ -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);
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useEffect, useMemo, useState } from "react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
|
@ -16,17 +16,53 @@ import {
|
||||||
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 } from "lucide-react"
|
||||||
import { mockCandidates } from "@/lib/mock-data"
|
import { adminCandidatesApi, AdminCandidate, AdminCandidateStats } from "@/lib/api"
|
||||||
|
|
||||||
export default function AdminCandidatesPage() {
|
export default function AdminCandidatesPage() {
|
||||||
const [searchTerm, setSearchTerm] = useState("")
|
const [searchTerm, setSearchTerm] = useState("")
|
||||||
const [selectedCandidate, setSelectedCandidate] = useState<any>(null)
|
const [selectedCandidate, setSelectedCandidate] = useState<AdminCandidate | null>(null)
|
||||||
|
const [candidates, setCandidates] = useState<AdminCandidate[]>([])
|
||||||
|
const [stats, setStats] = useState<AdminCandidateStats | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||||
|
|
||||||
const filteredCandidates = mockCandidates.filter(
|
useEffect(() => {
|
||||||
(candidate) =>
|
let isMounted = true
|
||||||
candidate.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
candidate.email.toLowerCase().includes(searchTerm.toLowerCase()),
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
|
|
@ -41,25 +77,25 @@ export default function AdminCandidatesPage() {
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Total candidates</CardDescription>
|
<CardDescription>Total candidates</CardDescription>
|
||||||
<CardTitle className="text-3xl">{mockCandidates.length}</CardTitle>
|
<CardTitle className="text-3xl">{stats?.totalCandidates ?? 0}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>New (30 days)</CardDescription>
|
<CardDescription>New (30 days)</CardDescription>
|
||||||
<CardTitle className="text-3xl">24</CardTitle>
|
<CardTitle className="text-3xl">{stats?.newCandidates ?? 0}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Active applications</CardDescription>
|
<CardDescription>Active applications</CardDescription>
|
||||||
<CardTitle className="text-3xl">{"49"}</CardTitle>
|
<CardTitle className="text-3xl">{stats?.activeApplications ?? 0}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardDescription>Hiring rate</CardDescription>
|
<CardDescription>Hiring rate</CardDescription>
|
||||||
<CardTitle className="text-3xl">8%</CardTitle>
|
<CardTitle className="text-3xl">{Math.round(stats?.hiringRate ?? 0)}%</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -80,6 +116,9 @@ export default function AdminCandidatesPage() {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="text-sm text-destructive">{errorMessage}</div>
|
||||||
|
) : null}
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
|
@ -92,20 +131,33 @@ export default function AdminCandidatesPage() {
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
|
{isLoading && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
||||||
|
Loading candidates...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{!isLoading && filteredCandidates.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="text-center text-sm text-muted-foreground">
|
||||||
|
No candidates found.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
{filteredCandidates.map((candidate) => (
|
{filteredCandidates.map((candidate) => (
|
||||||
<TableRow key={candidate.id}>
|
<TableRow key={candidate.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">{candidate.name}</div>
|
<div className="font-medium">{candidate.name}</div>
|
||||||
<div className="text-sm text-muted-foreground">{candidate.title}</div>
|
<div className="text-sm text-muted-foreground">{candidate.title ?? "—"}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{candidate.email}</TableCell>
|
<TableCell>{candidate.email ?? "—"}</TableCell>
|
||||||
<TableCell>{candidate.phone}</TableCell>
|
<TableCell>{candidate.phone ?? "—"}</TableCell>
|
||||||
<TableCell>{candidate.location}</TableCell>
|
<TableCell>{candidate.location ?? "—"}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary">{candidate.applications.length}</Badge>
|
<Badge variant="secondary">{candidate.applications.length}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
@ -125,7 +177,7 @@ export default function AdminCandidatesPage() {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<Avatar className="h-20 w-20">
|
<Avatar className="h-20 w-20">
|
||||||
<AvatarImage src={selectedCandidate.avatar || "/placeholder.svg"} />
|
<AvatarImage src={selectedCandidate.avatarUrl || "/placeholder.svg"} />
|
||||||
<AvatarFallback>
|
<AvatarFallback>
|
||||||
{selectedCandidate.name
|
{selectedCandidate.name
|
||||||
.split(" ")
|
.split(" ")
|
||||||
|
|
@ -135,45 +187,54 @@ export default function AdminCandidatesPage() {
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h3 className="text-xl font-semibold">{selectedCandidate.name}</h3>
|
<h3 className="text-xl font-semibold">{selectedCandidate.name}</h3>
|
||||||
<p className="text-muted-foreground">{selectedCandidate.title}</p>
|
<p className="text-muted-foreground">{selectedCandidate.title ?? "—"}</p>
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
{selectedCandidate.skills.length > 0 && (
|
||||||
{selectedCandidate.skills.map((skill: string) => (
|
<div className="flex flex-wrap gap-2 mt-3">
|
||||||
<Badge key={skill} variant="secondary">
|
{selectedCandidate.skills.map((skill: string) => (
|
||||||
{skill}
|
<Badge key={skill} variant="secondary">
|
||||||
</Badge>
|
{skill}
|
||||||
))}
|
</Badge>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||||
<span>{selectedCandidate.email}</span>
|
<span>{selectedCandidate.email ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||||
<span>{selectedCandidate.phone}</span>
|
<span>{selectedCandidate.phone ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||||
<span>{selectedCandidate.location}</span>
|
<span>{selectedCandidate.location ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||||
<span>{selectedCandidate.experience}</span>
|
<span>{selectedCandidate.experience ?? "—"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-2">About</h4>
|
<h4 className="font-semibold mb-2">About</h4>
|
||||||
<p className="text-sm text-muted-foreground">{selectedCandidate.bio}</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedCandidate.bio ?? "No profile summary provided."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<h4 className="font-semibold mb-2">Recent applications</h4>
|
<h4 className="font-semibold mb-2">Recent applications</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedCandidate.applications.map((app: any) => (
|
{selectedCandidate.applications.length === 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
No applications submitted yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedCandidate.applications.map((app) => (
|
||||||
<div
|
<div
|
||||||
key={app.id}
|
key={app.id}
|
||||||
className="flex items-center justify-between p-3 border rounded-lg"
|
className="flex items-center justify-between p-3 border rounded-lg"
|
||||||
|
|
@ -184,7 +245,7 @@ export default function AdminCandidatesPage() {
|
||||||
</div>
|
</div>
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
app.status === "accepted"
|
app.status === "hired"
|
||||||
? "default"
|
? "default"
|
||||||
: app.status === "rejected"
|
: app.status === "rejected"
|
||||||
? "destructive"
|
? "destructive"
|
||||||
|
|
@ -192,7 +253,9 @@ export default function AdminCandidatesPage() {
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{app.status === "pending" && "Pending"}
|
{app.status === "pending" && "Pending"}
|
||||||
{app.status === "accepted" && "Accepted"}
|
{app.status === "reviewed" && "Reviewed"}
|
||||||
|
{app.status === "shortlisted" && "Shortlisted"}
|
||||||
|
{app.status === "hired" && "Hired"}
|
||||||
{app.status === "rejected" && "Rejected"}
|
{app.status === "rejected" && "Rejected"}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -204,6 +204,41 @@ export interface AdminTag {
|
||||||
updatedAt: string;
|
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 = {
|
export const adminAccessApi = {
|
||||||
listRoles: () => {
|
listRoles: () => {
|
||||||
logCrudAction("read", "admin/access/roles");
|
logCrudAction("read", "admin/access/roles");
|
||||||
|
|
@ -218,6 +253,13 @@ export const adminAuditApi = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const adminCandidatesApi = {
|
||||||
|
list: () => {
|
||||||
|
logCrudAction("read", "admin/candidates");
|
||||||
|
return apiRequest<AdminCandidateListResponse>("/api/v1/admin/candidates");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export const adminCompaniesApi = {
|
export const adminCompaniesApi = {
|
||||||
list: (verified?: boolean) => {
|
list: (verified?: boolean) => {
|
||||||
logCrudAction("read", "admin/companies", typeof verified === "boolean" ? { verified } : undefined);
|
logCrudAction("read", "admin/companies", typeof verified === "boolean" ? { verified } : undefined);
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,122 @@ export async function seedUsers() {
|
||||||
console.log(` ✓ Candidate created: ${cand.email}`);
|
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) {
|
} catch (error) {
|
||||||
console.error(' ❌ Error seeding users:', error.message);
|
console.error(' ❌ Error seeding users:', error.message);
|
||||||
throw error;
|
throw error;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue