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")
|
||||
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("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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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"
|
||||
|
||||
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<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(
|
||||
(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 (
|
||||
<div className="space-y-8">
|
||||
|
|
@ -41,25 +77,25 @@ export default function AdminCandidatesPage() {
|
|||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Total candidates</CardDescription>
|
||||
<CardTitle className="text-3xl">{mockCandidates.length}</CardTitle>
|
||||
<CardTitle className="text-3xl">{stats?.totalCandidates ?? 0}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>New (30 days)</CardDescription>
|
||||
<CardTitle className="text-3xl">24</CardTitle>
|
||||
<CardTitle className="text-3xl">{stats?.newCandidates ?? 0}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Active applications</CardDescription>
|
||||
<CardTitle className="text-3xl">{"49"}</CardTitle>
|
||||
<CardTitle className="text-3xl">{stats?.activeApplications ?? 0}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardDescription>Hiring rate</CardDescription>
|
||||
<CardTitle className="text-3xl">8%</CardTitle>
|
||||
<CardTitle className="text-3xl">{Math.round(stats?.hiringRate ?? 0)}%</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
|
|
@ -80,6 +116,9 @@ export default function AdminCandidatesPage() {
|
|||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{errorMessage ? (
|
||||
<div className="text-sm text-destructive">{errorMessage}</div>
|
||||
) : null}
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
|
|
@ -92,20 +131,33 @@ export default function AdminCandidatesPage() {
|
|||
</TableRow>
|
||||
</TableHeader>
|
||||
<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) => (
|
||||
<TableRow key={candidate.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell>{candidate.email}</TableCell>
|
||||
<TableCell>{candidate.phone}</TableCell>
|
||||
<TableCell>{candidate.location}</TableCell>
|
||||
<TableCell>{candidate.email ?? "—"}</TableCell>
|
||||
<TableCell>{candidate.phone ?? "—"}</TableCell>
|
||||
<TableCell>{candidate.location ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{candidate.applications.length}</Badge>
|
||||
</TableCell>
|
||||
|
|
@ -125,7 +177,7 @@ export default function AdminCandidatesPage() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={selectedCandidate.avatar || "/placeholder.svg"} />
|
||||
<AvatarImage src={selectedCandidate.avatarUrl || "/placeholder.svg"} />
|
||||
<AvatarFallback>
|
||||
{selectedCandidate.name
|
||||
.split(" ")
|
||||
|
|
@ -135,45 +187,54 @@ export default function AdminCandidatesPage() {
|
|||
</Avatar>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold">{selectedCandidate.name}</h3>
|
||||
<p className="text-muted-foreground">{selectedCandidate.title}</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{selectedCandidate.skills.map((skill: string) => (
|
||||
<Badge key={skill} variant="secondary">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-muted-foreground">{selectedCandidate.title ?? "—"}</p>
|
||||
{selectedCandidate.skills.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{selectedCandidate.skills.map((skill: string) => (
|
||||
<Badge key={skill} variant="secondary">
|
||||
{skill}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedCandidate.email}</span>
|
||||
<span>{selectedCandidate.email ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedCandidate.phone}</span>
|
||||
<span>{selectedCandidate.phone ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedCandidate.location}</span>
|
||||
<span>{selectedCandidate.location ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Briefcase className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{selectedCandidate.experience}</span>
|
||||
<span>{selectedCandidate.experience ?? "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<h4 className="font-semibold mb-2">Recent applications</h4>
|
||||
<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
|
||||
key={app.id}
|
||||
className="flex items-center justify-between p-3 border rounded-lg"
|
||||
|
|
@ -184,7 +245,7 @@ export default function AdminCandidatesPage() {
|
|||
</div>
|
||||
<Badge
|
||||
variant={
|
||||
app.status === "accepted"
|
||||
app.status === "hired"
|
||||
? "default"
|
||||
: app.status === "rejected"
|
||||
? "destructive"
|
||||
|
|
@ -192,7 +253,9 @@ export default function AdminCandidatesPage() {
|
|||
}
|
||||
>
|
||||
{app.status === "pending" && "Pending"}
|
||||
{app.status === "accepted" && "Accepted"}
|
||||
{app.status === "reviewed" && "Reviewed"}
|
||||
{app.status === "shortlisted" && "Shortlisted"}
|
||||
{app.status === "hired" && "Hired"}
|
||||
{app.status === "rejected" && "Rejected"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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<AdminCandidateListResponse>("/api/v1/admin/candidates");
|
||||
},
|
||||
};
|
||||
|
||||
export const adminCompaniesApi = {
|
||||
list: (verified?: boolean) => {
|
||||
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('👤 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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue