Yvonne Priscilla commited on
Commit ·
bb909a5
1
Parent(s): 84f95f9
update theme login etc
Browse files- src/app/api/cv-profile/export/route.ts +245 -136
- src/app/login/page.tsx +300 -15
- src/app/recruitment/upload/page.tsx +17 -17
- src/components/dashboard/candidates-table.tsx +15 -14
- src/components/dashboard/charts-section.tsx +2 -2
- src/components/dashboard/detail-dialog.tsx +1 -1
- src/components/dashboard/header-menu.tsx +1 -1
- src/components/dashboard/metrics-row.tsx +10 -62
- src/components/ui/ChartBar.tsx +1 -0
- src/components/ui/ChartPie.tsx +77 -74
- src/components/ui/breadcrumb.tsx +7 -11
- src/components/ui/button.tsx +17 -10
- src/components/ui/metrics-card.tsx +1 -1
- src/components/ui/pagination.tsx +12 -13
- src/lib/export-service.ts +18 -15
src/app/api/cv-profile/export/route.ts
CHANGED
|
@@ -1,147 +1,253 @@
|
|
| 1 |
-
// FILE: app/api/cv-profile/export/route.ts
|
| 2 |
-
// New dedicated export endpoint
|
| 3 |
-
|
| 4 |
import { PrismaClient } from "@/generated/prisma"
|
| 5 |
import { prisma } from "@/lib/prisma"
|
| 6 |
import { NextRequest, NextResponse } from "next/server"
|
| 7 |
|
| 8 |
-
const UUID_REGEX =
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
export async function GET(request: NextRequest) {
|
| 25 |
const { searchParams } = new URL(request.url)
|
| 26 |
|
| 27 |
-
// ---
|
|
|
|
|
|
|
| 28 |
const criteria_id = searchParams.get("criteria_id")
|
| 29 |
if (criteria_id && !UUID_REGEX.test(criteria_id)) {
|
| 30 |
-
return NextResponse.json(
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
const search = searchParams.get("search")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
const domicile = searchParams.get("domicile")
|
| 36 |
const yoe = searchParams.get("yoe")
|
|
|
|
| 37 |
const softskills = searchParams.getAll("softskills")
|
| 38 |
const hardskills = searchParams.getAll("hardskills")
|
| 39 |
const certifications = searchParams.getAll("certifications")
|
| 40 |
const business_domain = searchParams.getAll("business_domain")
|
| 41 |
|
| 42 |
-
const univ_edu_1 = searchParams.
|
| 43 |
-
const univ_edu_2 = searchParams.
|
| 44 |
-
const univ_edu_3 = searchParams.
|
| 45 |
-
|
| 46 |
-
const
|
| 47 |
-
const
|
|
|
|
|
|
|
| 48 |
const gpa_1 = searchParams.get("gpa_1")
|
| 49 |
const gpa_2 = searchParams.get("gpa_2")
|
| 50 |
const gpa_3 = searchParams.get("gpa_3")
|
| 51 |
|
| 52 |
-
// ---
|
|
|
|
|
|
|
| 53 |
const sortBy = searchParams.get("sortBy") ?? "created_at"
|
| 54 |
-
const sortOrder =
|
|
|
|
| 55 |
|
| 56 |
const allowedSortFields = [
|
| 57 |
-
"fullname",
|
| 58 |
-
"
|
| 59 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
]
|
| 61 |
|
| 62 |
const isScoreSort = sortBy === "score" && !!criteria_id
|
| 63 |
-
const orderBy = !isScoreSort && allowedSortFields.includes(sortBy)
|
| 64 |
-
? { [sortBy]: sortOrder }
|
| 65 |
-
: { created_at: "desc" as const }
|
| 66 |
-
|
| 67 |
-
// --- ARRAY SEARCH ---
|
| 68 |
-
async function getMatchingArrayValues(
|
| 69 |
-
column: string,
|
| 70 |
-
searchTerm: string,
|
| 71 |
-
client: PrismaClient
|
| 72 |
-
): Promise<string[]> {
|
| 73 |
-
const result = await client.$queryRawUnsafe(
|
| 74 |
-
`SELECT DISTINCT unnest(${column}) as val
|
| 75 |
-
FROM cv_profile
|
| 76 |
-
WHERE EXISTS (
|
| 77 |
-
SELECT 1 FROM unnest(${column}) AS elem
|
| 78 |
-
WHERE elem ILIKE '%' || $1 || '%'
|
| 79 |
-
)`,
|
| 80 |
-
searchTerm
|
| 81 |
-
) as { val: string }[]
|
| 82 |
-
|
| 83 |
-
return result
|
| 84 |
-
.map((r) => r.val)
|
| 85 |
-
.filter((v) => v.toLowerCase().includes(searchTerm.toLowerCase()))
|
| 86 |
-
}
|
| 87 |
|
| 88 |
-
const
|
| 89 |
-
|
| 90 |
-
?
|
| 91 |
-
|
| 92 |
-
getMatchingArrayValues("softskills", search, prisma),
|
| 93 |
-
getMatchingArrayValues("certifications", search, prisma),
|
| 94 |
-
getMatchingArrayValues("business_domain", search, prisma),
|
| 95 |
-
])
|
| 96 |
-
: [[], [], [], []]
|
| 97 |
-
|
| 98 |
-
// --- BUILD WHERE ---
|
| 99 |
-
const where: any = {
|
| 100 |
-
...(search ? {
|
| 101 |
-
OR: [
|
| 102 |
-
{ fullname: { contains: search, mode: "insensitive" } },
|
| 103 |
-
{ domicile: { contains: search, mode: "insensitive" } },
|
| 104 |
-
{ univ_edu_1: { contains: search, mode: "insensitive" } },
|
| 105 |
-
{ univ_edu_2: { contains: search, mode: "insensitive" } },
|
| 106 |
-
{ univ_edu_3: { contains: search, mode: "insensitive" } },
|
| 107 |
-
{ major_edu_1: { contains: search, mode: "insensitive" } },
|
| 108 |
-
{ major_edu_2: { contains: search, mode: "insensitive" } },
|
| 109 |
-
{ major_edu_3: { contains: search, mode: "insensitive" } },
|
| 110 |
-
{ filename: { contains: search, mode: "insensitive" } },
|
| 111 |
-
...(matchingHardskills.length > 0 ? [{ hardskills: { hasSome: matchingHardskills } }] : []),
|
| 112 |
-
...(matchingSoftskills.length > 0 ? [{ softskills: { hasSome: matchingSoftskills } }] : []),
|
| 113 |
-
...(matchingCertifications.length > 0 ? [{ certifications: { hasSome: matchingCertifications } }] : []),
|
| 114 |
-
...(matchingBusinessDomain.length > 0 ? [{ business_domain: { hasSome: matchingBusinessDomain } }] : []),
|
| 115 |
-
...(Number.isNaN(Number.parseFloat(search)) ? [] : [
|
| 116 |
-
{ gpa_edu_1: { equals: Number.parseFloat(search) } },
|
| 117 |
-
{ gpa_edu_2: { equals: Number.parseFloat(search) } },
|
| 118 |
-
{ gpa_edu_3: { equals: Number.parseFloat(search) } },
|
| 119 |
-
]),
|
| 120 |
-
...(Number.isNaN(Number.parseInt(search)) ? [] : [
|
| 121 |
-
{ yoe: { equals: Number.parseInt(search) } },
|
| 122 |
-
]),
|
| 123 |
-
],
|
| 124 |
-
} : {}),
|
| 125 |
-
|
| 126 |
-
...(domicile && { domicile }),
|
| 127 |
-
...(yoe && { yoe: { gte: Number.parseInt(yoe) } }),
|
| 128 |
-
...(softskills.length > 0 && { softskills: { hasSome: softskills } }),
|
| 129 |
-
...(hardskills.length > 0 && { hardskills: { hasSome: hardskills } }),
|
| 130 |
-
...(certifications.length > 0 && { certifications: { hasSome: certifications } }),
|
| 131 |
-
...(business_domain.length > 0 && { business_domain: { hasSome: business_domain } }),
|
| 132 |
-
...(univ_edu_1 && { univ_edu_1 }),
|
| 133 |
-
...(major_edu_1 && { major_edu_1 }),
|
| 134 |
-
...(gpa_1 && { gpa_edu_1: { gte: Number.parseFloat(gpa_1) } }),
|
| 135 |
-
...(univ_edu_2 && { univ_edu_2 }),
|
| 136 |
-
...(major_edu_2 && { major_edu_2 }),
|
| 137 |
-
...(gpa_2 && { gpa_edu_2: { gte: Number.parseFloat(gpa_2) } }),
|
| 138 |
-
...(univ_edu_3 && { univ_edu_3 }),
|
| 139 |
-
...(major_edu_3 && { major_edu_3 }),
|
| 140 |
-
...(gpa_3 && { gpa_edu_3: { gte: Number.parseFloat(gpa_3) } }),
|
| 141 |
-
}
|
| 142 |
|
| 143 |
try {
|
| 144 |
-
// ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
const scoreMap = new Map<string, number | null>()
|
| 146 |
|
| 147 |
if (criteria_id) {
|
|
@@ -169,7 +275,9 @@ export async function GET(request: NextRequest) {
|
|
| 169 |
)
|
| 170 |
|
| 171 |
for (const s of scores) {
|
| 172 |
-
const profileId =
|
|
|
|
|
|
|
| 173 |
if (profileId) {
|
| 174 |
scoreMap.set(profileId, s.score ?? null)
|
| 175 |
}
|
|
@@ -178,42 +286,44 @@ export async function GET(request: NextRequest) {
|
|
| 178 |
}
|
| 179 |
}
|
| 180 |
|
| 181 |
-
// ---
|
|
|
|
|
|
|
| 182 |
const profiles = await prisma.cv_profile.findMany({
|
| 183 |
where,
|
| 184 |
-
orderBy: isScoreSort ? { created_at: "desc" } : orderBy,
|
| 185 |
})
|
| 186 |
|
| 187 |
-
// -
|
| 188 |
if (isScoreSort) {
|
| 189 |
profiles.sort((a, b) => {
|
| 190 |
const aScore = scoreMap.get(a.profile_id) ?? -Infinity
|
| 191 |
const bScore = scoreMap.get(b.profile_id) ?? -Infinity
|
| 192 |
-
return sortOrder === "asc"
|
|
|
|
|
|
|
| 193 |
})
|
| 194 |
}
|
| 195 |
|
| 196 |
-
// --- ATTACH SCORES ---
|
| 197 |
const profilesWithScore = profiles.map((profile) => ({
|
| 198 |
...profile,
|
| 199 |
score: scoreMap.get(profile.profile_id) ?? null,
|
| 200 |
}))
|
| 201 |
|
| 202 |
-
|
| 203 |
-
const csvContent = generateCSV(profilesWithScore)
|
| 204 |
|
| 205 |
-
if (!
|
| 206 |
return NextResponse.json(
|
| 207 |
{ error: "No data to export" },
|
| 208 |
{ status: 400 }
|
| 209 |
)
|
| 210 |
}
|
| 211 |
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
|
| 216 |
-
return new NextResponse(
|
| 217 |
status: 200,
|
| 218 |
headers: {
|
| 219 |
"Content-Type": "text/csv;charset=utf-8",
|
|
@@ -221,7 +331,6 @@ export async function GET(request: NextRequest) {
|
|
| 221 |
"Cache-Control": "no-cache, no-store, must-revalidate",
|
| 222 |
},
|
| 223 |
})
|
| 224 |
-
|
| 225 |
} catch (error) {
|
| 226 |
console.error("Export error:", error)
|
| 227 |
return NextResponse.json(
|
|
@@ -231,9 +340,9 @@ export async function GET(request: NextRequest) {
|
|
| 231 |
}
|
| 232 |
}
|
| 233 |
|
| 234 |
-
/
|
| 235 |
-
|
| 236 |
-
|
| 237 |
function generateCSV(profiles: any[]): string {
|
| 238 |
if (profiles.length === 0) return ""
|
| 239 |
|
|
@@ -258,29 +367,29 @@ function generateCSV(profiles: any[]): string {
|
|
| 258 |
"score",
|
| 259 |
]
|
| 260 |
|
| 261 |
-
// CSV Header
|
| 262 |
const header = columns.join(",")
|
| 263 |
|
| 264 |
-
// CSV Rows
|
| 265 |
const rows = profiles.map((profile) =>
|
| 266 |
columns
|
| 267 |
.map((col) => {
|
| 268 |
const value = profile[col]
|
| 269 |
|
| 270 |
-
// Handle arrays
|
| 271 |
if (Array.isArray(value)) {
|
| 272 |
return `"${value.join("; ")}"`
|
| 273 |
}
|
| 274 |
|
| 275 |
-
// Handle null/undefined
|
| 276 |
if (value === null || value === undefined) {
|
| 277 |
return ""
|
| 278 |
}
|
| 279 |
|
| 280 |
-
// Handle strings with special characters
|
| 281 |
const stringValue = String(value)
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
}
|
| 285 |
|
| 286 |
return stringValue
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import { PrismaClient } from "@/generated/prisma"
|
| 2 |
import { prisma } from "@/lib/prisma"
|
| 3 |
import { NextRequest, NextResponse } from "next/server"
|
| 4 |
|
| 5 |
+
const UUID_REGEX =
|
| 6 |
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
| 7 |
+
|
| 8 |
+
// ---------------------------------------------
|
| 9 |
+
// SAFE ARRAY SEARCH (same as main endpoint)
|
| 10 |
+
// ---------------------------------------------
|
| 11 |
+
async function getMatchingArrayValues(
|
| 12 |
+
column: string,
|
| 13 |
+
search: string,
|
| 14 |
+
client: PrismaClient
|
| 15 |
+
): Promise<string[]> {
|
| 16 |
+
const allowedColumns = [
|
| 17 |
+
"hardskills",
|
| 18 |
+
"softskills",
|
| 19 |
+
"certifications",
|
| 20 |
+
"business_domain",
|
| 21 |
+
]
|
| 22 |
+
|
| 23 |
+
if (!allowedColumns.includes(column)) {
|
| 24 |
+
throw new Error(`Invalid column: ${column}`)
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
const result = await client.$queryRawUnsafe(
|
| 28 |
+
`SELECT DISTINCT unnest("${column}") as val
|
| 29 |
+
FROM cv_profile
|
| 30 |
+
WHERE EXISTS (
|
| 31 |
+
SELECT 1 FROM unnest("${column}") AS elem
|
| 32 |
+
WHERE elem ILIKE '%' || $1 || '%'
|
| 33 |
+
)`,
|
| 34 |
+
search
|
| 35 |
+
) as { val: string }[]
|
| 36 |
+
|
| 37 |
+
return result
|
| 38 |
+
.map((r) => r.val)
|
| 39 |
+
.filter((v) => v.toLowerCase().includes(search.toLowerCase()))
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
// ---------------------------------------------
|
| 43 |
+
// Resolve user_id → profile_ids
|
| 44 |
+
// ---------------------------------------------
|
| 45 |
+
// In getProfileIdsByUserId, return null if no profiles found
|
| 46 |
+
// so it doesn't filter to empty set
|
| 47 |
+
async function getProfileIdsByUserId(user_id: string): Promise<string[] | null> {
|
| 48 |
+
const rows = await prisma.$queryRaw<{ profile_id: string }[]>`
|
| 49 |
+
SELECT p.profile_id
|
| 50 |
+
FROM cv_profile p
|
| 51 |
+
INNER JOIN cv_file f ON f.file_id = p.file_id
|
| 52 |
+
WHERE f.user_id = ${user_id}::uuid
|
| 53 |
+
`
|
| 54 |
+
if (rows.length === 0) return null // <-- treat as "no filter" instead of "match nothing"
|
| 55 |
+
return rows.map((r) => r.profile_id)
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// ---------------------------------------------
|
| 59 |
+
// EXPORT ENDPOINT
|
| 60 |
+
// ---------------------------------------------
|
| 61 |
export async function GET(request: NextRequest) {
|
| 62 |
const { searchParams } = new URL(request.url)
|
| 63 |
|
| 64 |
+
// -------------------------
|
| 65 |
+
// VALIDATION
|
| 66 |
+
// -------------------------
|
| 67 |
const criteria_id = searchParams.get("criteria_id")
|
| 68 |
if (criteria_id && !UUID_REGEX.test(criteria_id)) {
|
| 69 |
+
return NextResponse.json(
|
| 70 |
+
{ error: "Invalid criteria_id format" },
|
| 71 |
+
{ status: 400 }
|
| 72 |
+
)
|
| 73 |
}
|
| 74 |
|
| 75 |
+
const user_id = searchParams.get("user_id")
|
| 76 |
+
if (user_id && !UUID_REGEX.test(user_id)) {
|
| 77 |
+
return NextResponse.json(
|
| 78 |
+
{ error: "Invalid user_id format" },
|
| 79 |
+
{ status: 400 }
|
| 80 |
+
)
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// -------------------------
|
| 84 |
+
// SEARCH
|
| 85 |
+
// -------------------------
|
| 86 |
const search = searchParams.get("search")
|
| 87 |
+
|
| 88 |
+
// -------------------------
|
| 89 |
+
// FILTERS
|
| 90 |
+
// -------------------------
|
| 91 |
const domicile = searchParams.get("domicile")
|
| 92 |
const yoe = searchParams.get("yoe")
|
| 93 |
+
|
| 94 |
const softskills = searchParams.getAll("softskills")
|
| 95 |
const hardskills = searchParams.getAll("hardskills")
|
| 96 |
const certifications = searchParams.getAll("certifications")
|
| 97 |
const business_domain = searchParams.getAll("business_domain")
|
| 98 |
|
| 99 |
+
const univ_edu_1 = searchParams.getAll("univ_edu_1")
|
| 100 |
+
const univ_edu_2 = searchParams.getAll("univ_edu_2")
|
| 101 |
+
const univ_edu_3 = searchParams.getAll("univ_edu_3")
|
| 102 |
+
|
| 103 |
+
const major_edu_1 = searchParams.getAll("major_edu_1")
|
| 104 |
+
const major_edu_2 = searchParams.getAll("major_edu_2")
|
| 105 |
+
const major_edu_3 = searchParams.getAll("major_edu_3")
|
| 106 |
+
|
| 107 |
const gpa_1 = searchParams.get("gpa_1")
|
| 108 |
const gpa_2 = searchParams.get("gpa_2")
|
| 109 |
const gpa_3 = searchParams.get("gpa_3")
|
| 110 |
|
| 111 |
+
// -------------------------
|
| 112 |
+
// SORT
|
| 113 |
+
// -------------------------
|
| 114 |
const sortBy = searchParams.get("sortBy") ?? "created_at"
|
| 115 |
+
const sortOrder =
|
| 116 |
+
searchParams.get("sortOrder") === "asc" ? "asc" : "desc"
|
| 117 |
|
| 118 |
const allowedSortFields = [
|
| 119 |
+
"fullname",
|
| 120 |
+
"domicile",
|
| 121 |
+
"yoe",
|
| 122 |
+
"gpa_edu_1",
|
| 123 |
+
"gpa_edu_2",
|
| 124 |
+
"gpa_edu_3",
|
| 125 |
+
"univ_edu_1",
|
| 126 |
+
"univ_edu_2",
|
| 127 |
+
"univ_edu_3",
|
| 128 |
+
"major_edu_1",
|
| 129 |
+
"major_edu_2",
|
| 130 |
+
"major_edu_3",
|
| 131 |
+
"created_at",
|
| 132 |
+
"score",
|
| 133 |
]
|
| 134 |
|
| 135 |
const isScoreSort = sortBy === "score" && !!criteria_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
+
const orderBy =
|
| 138 |
+
!isScoreSort && allowedSortFields.includes(sortBy)
|
| 139 |
+
? { [sortBy]: sortOrder }
|
| 140 |
+
: { created_at: "desc" as const }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
try {
|
| 143 |
+
// -------------------------
|
| 144 |
+
// PARALLEL RESOLUTION
|
| 145 |
+
// -------------------------
|
| 146 |
+
const [
|
| 147 |
+
matchingHardskills,
|
| 148 |
+
matchingSoftskills,
|
| 149 |
+
matchingCertifications,
|
| 150 |
+
matchingBusinessDomain,
|
| 151 |
+
userProfileIds,
|
| 152 |
+
] = await Promise.all([
|
| 153 |
+
search
|
| 154 |
+
? getMatchingArrayValues("hardskills", search, prisma)
|
| 155 |
+
: Promise.resolve([]),
|
| 156 |
+
search
|
| 157 |
+
? getMatchingArrayValues("softskills", search, prisma)
|
| 158 |
+
: Promise.resolve([]),
|
| 159 |
+
search
|
| 160 |
+
? getMatchingArrayValues("certifications", search, prisma)
|
| 161 |
+
: Promise.resolve([]),
|
| 162 |
+
search
|
| 163 |
+
? getMatchingArrayValues("business_domain", search, prisma)
|
| 164 |
+
: Promise.resolve([]),
|
| 165 |
+
user_id
|
| 166 |
+
? getProfileIdsByUserId(user_id)
|
| 167 |
+
: Promise.resolve(null),
|
| 168 |
+
])
|
| 169 |
+
|
| 170 |
+
// -------------------------
|
| 171 |
+
// BUILD WHERE
|
| 172 |
+
// -------------------------
|
| 173 |
+
const where: any = {
|
| 174 |
+
...(userProfileIds !== null && {
|
| 175 |
+
profile_id: { in: userProfileIds },
|
| 176 |
+
}),
|
| 177 |
+
|
| 178 |
+
...(search
|
| 179 |
+
? {
|
| 180 |
+
OR: [
|
| 181 |
+
{ fullname: { contains: search, mode: "insensitive" } },
|
| 182 |
+
{ domicile: { contains: search, mode: "insensitive" } },
|
| 183 |
+
{ univ_edu_1: { contains: search, mode: "insensitive" } },
|
| 184 |
+
{ univ_edu_2: { contains: search, mode: "insensitive" } },
|
| 185 |
+
{ univ_edu_3: { contains: search, mode: "insensitive" } },
|
| 186 |
+
{ major_edu_1: { contains: search, mode: "insensitive" } },
|
| 187 |
+
{ major_edu_2: { contains: search, mode: "insensitive" } },
|
| 188 |
+
{ major_edu_3: { contains: search, mode: "insensitive" } },
|
| 189 |
+
{ filename: { contains: search, mode: "insensitive" } },
|
| 190 |
+
|
| 191 |
+
...(matchingHardskills.length > 0
|
| 192 |
+
? [{ hardskills: { hasSome: matchingHardskills } }]
|
| 193 |
+
: []),
|
| 194 |
+
...(matchingSoftskills.length > 0
|
| 195 |
+
? [{ softskills: { hasSome: matchingSoftskills } }]
|
| 196 |
+
: []),
|
| 197 |
+
...(matchingCertifications.length > 0
|
| 198 |
+
? [{ certifications: { hasSome: matchingCertifications } }]
|
| 199 |
+
: []),
|
| 200 |
+
...(matchingBusinessDomain.length > 0
|
| 201 |
+
? [{ business_domain: { hasSome: matchingBusinessDomain } }]
|
| 202 |
+
: []),
|
| 203 |
+
|
| 204 |
+
...(Number.isNaN(Number.parseFloat(search))
|
| 205 |
+
? []
|
| 206 |
+
: [
|
| 207 |
+
{ gpa_edu_1: { equals: Number.parseFloat(search) } },
|
| 208 |
+
{ gpa_edu_2: { equals: Number.parseFloat(search) } },
|
| 209 |
+
{ gpa_edu_3: { equals: Number.parseFloat(search) } },
|
| 210 |
+
]),
|
| 211 |
+
|
| 212 |
+
...(Number.isNaN(Number.parseInt(search))
|
| 213 |
+
? []
|
| 214 |
+
: [{ yoe: { equals: Number.parseInt(search) } }]),
|
| 215 |
+
],
|
| 216 |
+
}
|
| 217 |
+
: {}),
|
| 218 |
+
|
| 219 |
+
...(domicile && { domicile }),
|
| 220 |
+
...(yoe && { yoe: { gte: Number.parseInt(yoe) } }),
|
| 221 |
+
|
| 222 |
+
...(softskills.length > 0 && {
|
| 223 |
+
softskills: { hasSome: softskills },
|
| 224 |
+
}),
|
| 225 |
+
...(hardskills.length > 0 && {
|
| 226 |
+
hardskills: { hasSome: hardskills },
|
| 227 |
+
}),
|
| 228 |
+
...(certifications.length > 0 && {
|
| 229 |
+
certifications: { hasSome: certifications },
|
| 230 |
+
}),
|
| 231 |
+
...(business_domain.length > 0 && {
|
| 232 |
+
business_domain: { hasSome: business_domain },
|
| 233 |
+
}),
|
| 234 |
+
|
| 235 |
+
...(univ_edu_1.length > 0 && { univ_edu_1: { in: univ_edu_1 } }),
|
| 236 |
+
...(major_edu_1.length > 0 && { major_edu_1: { in: major_edu_1 } }),
|
| 237 |
+
...(gpa_1 && { gpa_edu_1: { gte: Number.parseFloat(gpa_1) } }),
|
| 238 |
+
|
| 239 |
+
...(univ_edu_2.length > 0 && { univ_edu_2: { in: univ_edu_2 } }),
|
| 240 |
+
...(major_edu_2.length > 0 && { major_edu_2: { in: major_edu_2 } }),
|
| 241 |
+
...(gpa_2 && { gpa_edu_2: { gte: Number.parseFloat(gpa_2) } }),
|
| 242 |
+
|
| 243 |
+
...(univ_edu_3.length > 0 && { univ_edu_3: { in: univ_edu_3 } }),
|
| 244 |
+
...(major_edu_3.length > 0 && { major_edu_3: { in: major_edu_3 } }),
|
| 245 |
+
...(gpa_3 && { gpa_edu_3: { gte: Number.parseFloat(gpa_3) } }),
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
// -------------------------
|
| 249 |
+
// SCORE MAP
|
| 250 |
+
// -------------------------
|
| 251 |
const scoreMap = new Map<string, number | null>()
|
| 252 |
|
| 253 |
if (criteria_id) {
|
|
|
|
| 275 |
)
|
| 276 |
|
| 277 |
for (const s of scores) {
|
| 278 |
+
const profileId =
|
| 279 |
+
s.profile_id ??
|
| 280 |
+
matchingToProfile.get(s.matching_id ?? "")
|
| 281 |
if (profileId) {
|
| 282 |
scoreMap.set(profileId, s.score ?? null)
|
| 283 |
}
|
|
|
|
| 286 |
}
|
| 287 |
}
|
| 288 |
|
| 289 |
+
// -------------------------
|
| 290 |
+
// FETCH ALL (NO PAGINATION)
|
| 291 |
+
// -------------------------
|
| 292 |
const profiles = await prisma.cv_profile.findMany({
|
| 293 |
where,
|
| 294 |
+
orderBy: isScoreSort ? { created_at: "desc" } : orderBy,
|
| 295 |
})
|
| 296 |
|
| 297 |
+
// In-memory score sort
|
| 298 |
if (isScoreSort) {
|
| 299 |
profiles.sort((a, b) => {
|
| 300 |
const aScore = scoreMap.get(a.profile_id) ?? -Infinity
|
| 301 |
const bScore = scoreMap.get(b.profile_id) ?? -Infinity
|
| 302 |
+
return sortOrder === "asc"
|
| 303 |
+
? aScore - bScore
|
| 304 |
+
: bScore - aScore
|
| 305 |
})
|
| 306 |
}
|
| 307 |
|
|
|
|
| 308 |
const profilesWithScore = profiles.map((profile) => ({
|
| 309 |
...profile,
|
| 310 |
score: scoreMap.get(profile.profile_id) ?? null,
|
| 311 |
}))
|
| 312 |
|
| 313 |
+
const csv = generateCSV(profilesWithScore)
|
|
|
|
| 314 |
|
| 315 |
+
if (!csv) {
|
| 316 |
return NextResponse.json(
|
| 317 |
{ error: "No data to export" },
|
| 318 |
{ status: 400 }
|
| 319 |
)
|
| 320 |
}
|
| 321 |
|
| 322 |
+
const filename = `candidates-${new Date()
|
| 323 |
+
.toISOString()
|
| 324 |
+
.slice(0, 10)}.csv`
|
| 325 |
|
| 326 |
+
return new NextResponse(csv, {
|
| 327 |
status: 200,
|
| 328 |
headers: {
|
| 329 |
"Content-Type": "text/csv;charset=utf-8",
|
|
|
|
| 331 |
"Cache-Control": "no-cache, no-store, must-revalidate",
|
| 332 |
},
|
| 333 |
})
|
|
|
|
| 334 |
} catch (error) {
|
| 335 |
console.error("Export error:", error)
|
| 336 |
return NextResponse.json(
|
|
|
|
| 340 |
}
|
| 341 |
}
|
| 342 |
|
| 343 |
+
// ---------------------------------------------
|
| 344 |
+
// CSV GENERATOR
|
| 345 |
+
// ---------------------------------------------
|
| 346 |
function generateCSV(profiles: any[]): string {
|
| 347 |
if (profiles.length === 0) return ""
|
| 348 |
|
|
|
|
| 367 |
"score",
|
| 368 |
]
|
| 369 |
|
|
|
|
| 370 |
const header = columns.join(",")
|
| 371 |
|
|
|
|
| 372 |
const rows = profiles.map((profile) =>
|
| 373 |
columns
|
| 374 |
.map((col) => {
|
| 375 |
const value = profile[col]
|
| 376 |
|
|
|
|
| 377 |
if (Array.isArray(value)) {
|
| 378 |
return `"${value.join("; ")}"`
|
| 379 |
}
|
| 380 |
|
|
|
|
| 381 |
if (value === null || value === undefined) {
|
| 382 |
return ""
|
| 383 |
}
|
| 384 |
|
|
|
|
| 385 |
const stringValue = String(value)
|
| 386 |
+
|
| 387 |
+
if (
|
| 388 |
+
stringValue.includes(",") ||
|
| 389 |
+
stringValue.includes('"') ||
|
| 390 |
+
stringValue.includes("\n")
|
| 391 |
+
) {
|
| 392 |
+
return `"${stringValue.replace(/"/g, '""')}"`
|
| 393 |
}
|
| 394 |
|
| 395 |
return stringValue
|
src/app/login/page.tsx
CHANGED
|
@@ -1,21 +1,306 @@
|
|
| 1 |
-
|
| 2 |
-
import {
|
|
|
|
| 3 |
|
| 4 |
export default function LoginPage() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
return (
|
| 6 |
-
<
|
| 7 |
-
<
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>
|
| 19 |
-
</
|
| 20 |
);
|
| 21 |
}
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
import { useRouter } from 'next/navigation';
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
|
| 5 |
export default function LoginPage() {
|
| 6 |
+
const [phase, setPhase] = useState<'landing'|'transitioning'|'login'>('landing');
|
| 7 |
+
const [username, setUsername] = useState('');
|
| 8 |
+
const [password, setPassword] = useState('');
|
| 9 |
+
const [error, setError] = useState('');
|
| 10 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 11 |
+
const [focused, setFocused] = useState<string|null>(null);
|
| 12 |
+
const router = useRouter();
|
| 13 |
+
|
| 14 |
+
const handleGetStarted = () => {
|
| 15 |
+
setPhase('transitioning');
|
| 16 |
+
setTimeout(() => setPhase('login'), 700);
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const handleSubmit = async (e: React.FormEvent) => {
|
| 20 |
+
e.preventDefault();
|
| 21 |
+
setError('');
|
| 22 |
+
setIsLoading(true);
|
| 23 |
+
try {
|
| 24 |
+
const response = await fetch('/api/auth/login', {
|
| 25 |
+
method: 'POST',
|
| 26 |
+
headers: { 'Content-Type': 'application/json' },
|
| 27 |
+
body: JSON.stringify({ username, password }),
|
| 28 |
+
});
|
| 29 |
+
const data = await response.json();
|
| 30 |
+
if (!response.ok) {
|
| 31 |
+
setError(data.message || 'Login failed');
|
| 32 |
+
setIsLoading(false);
|
| 33 |
+
return;
|
| 34 |
+
}
|
| 35 |
+
router.push('/recruitment');
|
| 36 |
+
} catch {
|
| 37 |
+
setError('Something went wrong. Please try again.');
|
| 38 |
+
setIsLoading(false);
|
| 39 |
+
}
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
return (
|
| 43 |
+
<>
|
| 44 |
+
<style>{`
|
| 45 |
+
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
|
| 46 |
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
| 47 |
+
:root { --blue: #3b82f6; --blue-dark: #1d4ed8; --blue-light: #eff6ff; --blue-mid: #dbeafe; }
|
| 48 |
+
|
| 49 |
+
@keyframes fadeUp { from { opacity:0; transform:translateY(30px); } to { opacity:1; transform:translateY(0); } }
|
| 50 |
+
@keyframes fadeIn { from { opacity:0; } to { opacity:1; } }
|
| 51 |
+
@keyframes float { 0%,100%{transform:translateY(0) rotate(-2deg);} 50%{transform:translateY(-16px) rotate(2deg);} }
|
| 52 |
+
@keyframes float2 { 0%,100%{transform:translateY(0) rotate(3deg);} 50%{transform:translateY(-10px) rotate(-3deg);} }
|
| 53 |
+
@keyframes bobble { 0%,100%{transform:translateY(0);} 50%{transform:translateY(-8px);} }
|
| 54 |
+
@keyframes spin { to{transform:rotate(360deg);} }
|
| 55 |
+
@keyframes shake { 0%,100%{transform:translateX(0);} 20%{transform:translateX(-5px);} 40%{transform:translateX(5px);} 60%{transform:translateX(-3px);} 80%{transform:translateX(3px);} }
|
| 56 |
+
@keyframes slideLeft { from{opacity:1;transform:translateX(0);} to{opacity:0;transform:translateX(-120%);} }
|
| 57 |
+
@keyframes slideInRight { from{opacity:0;transform:translateX(60px);} to{opacity:1;transform:translateX(0);} }
|
| 58 |
+
@keyframes scaleIn { from{opacity:0;transform:scale(0.92);} to{opacity:1;transform:scale(1);} }
|
| 59 |
+
@keyframes blob { 0%,100%{border-radius:60% 40% 30% 70%/60% 30% 70% 40%;} 50%{border-radius:30% 60% 70% 40%/50% 60% 30% 60%;} }
|
| 60 |
+
@keyframes pulse { 0%,100%{opacity:0.15;transform:scale(1);} 50%{opacity:0.3;transform:scale(1.05);} }
|
| 61 |
+
@keyframes dotBounce { 0%,80%,100%{transform:translateY(0);} 40%{transform:translateY(-8px);} }
|
| 62 |
+
|
| 63 |
+
input::placeholder { color: #93c5fd; }
|
| 64 |
+
input:-webkit-autofill { -webkit-box-shadow: 0 0 0 30px #eff6ff inset !important; }
|
| 65 |
+
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(59,130,246,0.45) !important; }
|
| 66 |
+
.btn-primary:active { transform: translateY(0); }
|
| 67 |
+
.input-field:focus { border-color: #3b82f6 !important; box-shadow: 0 0 0 3px rgba(59,130,246,0.15) !important; }
|
| 68 |
+
`}</style>
|
| 69 |
+
|
| 70 |
+
<div style={{ minHeight:'100vh', fontFamily:'Plus Jakarta Sans, sans-serif', background:'#f0f7ff', overflow:'hidden', position:'relative' }}>
|
| 71 |
+
|
| 72 |
+
{/* === LANDING PHASE === */}
|
| 73 |
+
{(phase === 'landing' || phase === 'transitioning') && (
|
| 74 |
+
<div style={{
|
| 75 |
+
position:'absolute', inset:0, display:'flex', alignItems:'stretch',
|
| 76 |
+
animation: phase==='transitioning' ? 'slideLeft 0.65s cubic-bezier(0.4,0,0.2,1) forwards' : 'fadeIn 0.5s ease both',
|
| 77 |
+
}}>
|
| 78 |
+
{/* Left Panel */}
|
| 79 |
+
<div style={{ flex:'0 0 45%', background:'#fff', display:'flex', flexDirection:'column', justifyContent:'center', padding:'60px 56px', position:'relative', zIndex:2 }}>
|
| 80 |
+
{/* Logo */}
|
| 81 |
+
<div style={{ display:'flex', alignItems:'center', gap:'10px', marginBottom:'56px', animation:'fadeUp 0.6s ease 0.1s both' }}>
|
| 82 |
+
<div style={{ width:36, height:36, borderRadius:10, background:'linear-gradient(135deg,#3b82f6,#1d4ed8)', display:'flex', alignItems:'center', justifyContent:'center' }}>
|
| 83 |
+
<span style={{ color:'#fff', fontWeight:800, fontSize:16 }}>CE</span>
|
| 84 |
+
</div>
|
| 85 |
+
<span style={{ fontWeight:700, fontSize:18, color:'#1e3a8a', letterSpacing:'-0.02em' }}>Candidate Explorer</span>
|
| 86 |
+
</div>
|
| 87 |
+
|
| 88 |
+
<div style={{ animation:'fadeUp 0.6s ease 0.2s both' }}>
|
| 89 |
+
<p style={{ fontSize:12, fontWeight:700, letterSpacing:'0.12em', textTransform:'uppercase', color:'#3b82f6', marginBottom:12 }}>AI-Powered Recruiting</p>
|
| 90 |
+
<h1 style={{ fontSize:42, fontWeight:800, lineHeight:1.1, color:'#1e3a8a', letterSpacing:'-0.03em', marginBottom:20 }}>
|
| 91 |
+
Find the best<br/>
|
| 92 |
+
<span style={{ color:'#3b82f6' }}>candidates</span><br/>
|
| 93 |
+
faster.
|
| 94 |
+
</h1>
|
| 95 |
+
<p style={{ fontSize:15, color:'#64748b', lineHeight:1.7, maxWidth:340, marginBottom:40 }}>
|
| 96 |
+
Our intelligent platform analyzes thousands of profiles in seconds, so your team can focus on what matters — building great teams.
|
| 97 |
+
</p>
|
| 98 |
+
</div>
|
| 99 |
+
|
| 100 |
+
<div style={{ display:'flex', flexDirection:'column', gap:12, animation:'fadeUp 0.6s ease 0.35s both' }}>
|
| 101 |
+
<button onClick={handleGetStarted} className="btn-primary" style={{
|
| 102 |
+
padding:'16px 32px', background:'linear-gradient(135deg,#3b82f6,#2563eb)',
|
| 103 |
+
border:'none', borderRadius:14, color:'#fff', fontSize:15, fontWeight:700,
|
| 104 |
+
cursor:'pointer', transition:'all 0.25s ease',
|
| 105 |
+
boxShadow:'0 4px 16px rgba(59,130,246,0.35)',
|
| 106 |
+
letterSpacing:'-0.01em',
|
| 107 |
+
}}>
|
| 108 |
+
Get Started →
|
| 109 |
+
</button>
|
| 110 |
+
{/* <button style={{
|
| 111 |
+
padding:'14px 32px', background:'transparent',
|
| 112 |
+
border:'2px solid #dbeafe', borderRadius:14, color:'#3b82f6', fontSize:15, fontWeight:600,
|
| 113 |
+
cursor:'pointer', transition:'all 0.2s ease',
|
| 114 |
+
}}>
|
| 115 |
+
Learn More
|
| 116 |
+
</button> */}
|
| 117 |
+
</div>
|
| 118 |
+
|
| 119 |
+
{/* Stats */}
|
| 120 |
+
{/* <div style={{ display:'flex', gap:32, marginTop:48, animation:'fadeUp 0.6s ease 0.45s both' }}>
|
| 121 |
+
{[['10k+','Candidates'],['98%','Accuracy'],['3x','Faster Hiring']].map(([val,label]) => (
|
| 122 |
+
<div key={label}>
|
| 123 |
+
<div style={{ fontSize:20, fontWeight:800, color:'#1e3a8a' }}>{val}</div>
|
| 124 |
+
<div style={{ fontSize:11, color:'#94a3b8', fontWeight:500, marginTop:2 }}>{label}</div>
|
| 125 |
+
</div>
|
| 126 |
+
))}
|
| 127 |
+
</div> */}
|
| 128 |
+
</div>
|
| 129 |
+
|
| 130 |
+
{/* Right Panel */}
|
| 131 |
+
<div style={{ flex:1, background:'linear-gradient(135deg,#1d4ed8 0%,#2563eb 40%,#3b82f6 100%)', position:'relative', overflow:'hidden', display:'flex', alignItems:'center', justifyContent:'center' }}>
|
| 132 |
+
{/* Blob shapes */}
|
| 133 |
+
<div style={{ position:'absolute', top:'-10%', right:'-5%', width:400, height:400, background:'rgba(255,255,255,0.06)', borderRadius:'60% 40% 30% 70%/60% 30% 70% 40%', animation:'blob 8s ease-in-out infinite' }} />
|
| 134 |
+
<div style={{ position:'absolute', bottom:'-10%', left:'-5%', width:350, height:350, background:'rgba(255,255,255,0.04)', borderRadius:'40% 60% 70% 30%/40% 50% 60% 50%', animation:'blob 10s ease-in-out 2s infinite' }} />
|
| 135 |
+
{/* Dots grid */}
|
| 136 |
+
<div style={{ position:'absolute', inset:0, opacity:0.1, backgroundImage:'radial-gradient(circle, #fff 1px, transparent 1px)', backgroundSize:'28px 28px' }} />
|
| 137 |
+
|
| 138 |
+
{/* Robot images */}
|
| 139 |
+
<div style={{ position:'relative', display:'flex', alignItems:'flex-end', gap:24 }}>
|
| 140 |
+
<img src="/loading2.png" alt="robot" style={{ width:200, height:200, objectFit:'contain', animation:'float 4s ease-in-out infinite', filter:'drop-shadow(0 20px 40px rgba(0,0,0,0.25))' }} />
|
| 141 |
+
<img src="/loading1.png" alt="robot" style={{ width:180, height:180, objectFit:'contain', animation:'float2 5s ease-in-out 0.8s infinite', filter:'drop-shadow(0 20px 40px rgba(0,0,0,0.2))' }} />
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
{/* Feature chips */}
|
| 145 |
+
<div style={{ position:'absolute', top:60, left:40, animation:'fadeUp 0.8s ease 0.5s both' }}>
|
| 146 |
+
<div style={{ background:'rgba(255,255,255,0.15)', backdropFilter:'blur(12px)', borderRadius:12, padding:'10px 16px', color:'#fff', fontSize:13, fontWeight:600, border:'1px solid rgba(255,255,255,0.2)' }}>
|
| 147 |
+
🤖 AI Resume Analysis
|
| 148 |
+
</div>
|
| 149 |
+
</div>
|
| 150 |
+
<div style={{ position:'absolute', top:120, right:40, animation:'fadeUp 0.8s ease 0.65s both' }}>
|
| 151 |
+
<div style={{ background:'rgba(255,255,255,0.15)', backdropFilter:'blur(12px)', borderRadius:12, padding:'10px 16px', color:'#fff', fontSize:13, fontWeight:600, border:'1px solid rgba(255,255,255,0.2)' }}>
|
| 152 |
+
📊 Smart Rankings
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
<div style={{ position:'absolute', bottom:80, right:48, animation:'fadeUp 0.8s ease 0.8s both' }}>
|
| 156 |
+
<div style={{ background:'rgba(255,255,255,0.15)', backdropFilter:'blur(12px)', borderRadius:12, padding:'10px 16px', color:'#fff', fontSize:13, fontWeight:600, border:'1px solid rgba(255,255,255,0.2)' }}>
|
| 157 |
+
⚡ Real-time Matching
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
<div style={{ position:'absolute', top:200, right:60, animation:'fadeUp 0.8s ease 0.5s both' }}>
|
| 161 |
+
<div style={{ background:'rgba(255,255,255,0.15)', backdropFilter:'blur(12px)', borderRadius:12, padding:'10px 16px', color:'#fff', fontSize:13, fontWeight:600, border:'1px solid rgba(255,255,255,0.2)' }}>
|
| 162 |
+
🧠 Skill Gap Detection
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
<div style={{ position:'absolute', bottom:160, left:36, animation:'fadeUp 0.8s ease 0.5s both' }}>
|
| 166 |
+
<div style={{ background:'rgba(255,255,255,0.15)', backdropFilter:'blur(12px)', borderRadius:12, padding:'10px 16px', color:'#fff', fontSize:13, fontWeight:600, border:'1px solid rgba(255,255,255,0.2)' }}>
|
| 167 |
+
📋 Automated Shortlisting
|
| 168 |
+
</div>
|
| 169 |
+
</div>
|
| 170 |
+
<div style={{ position:'absolute', bottom:40, left:100, animation:'fadeUp 0.8s ease 0.5s both' }}>
|
| 171 |
+
<div style={{ background:'rgba(255,255,255,0.15)', backdropFilter:'blur(12px)', borderRadius:12, padding:'10px 16px', color:'#fff', fontSize:13, fontWeight:600, border:'1px solid rgba(255,255,255,0.2)' }}>
|
| 172 |
+
🎯 Culture Fit Scoring
|
| 173 |
+
</div>
|
| 174 |
+
</div>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
|
| 179 |
+
{/* === LOGIN PHASE === */}
|
| 180 |
+
{phase === 'login' && (
|
| 181 |
+
<div style={{
|
| 182 |
+
position:'absolute', inset:0, display:'flex', alignItems:'stretch',
|
| 183 |
+
animation:'scaleIn 0.5s cubic-bezier(0.16,1,0.3,1) both',
|
| 184 |
+
}}>
|
| 185 |
+
{/* Left: Form */}
|
| 186 |
+
<div style={{ flex:'0 0 50%', background:'#fff', display:'flex', flexDirection:'column', justifyContent:'center', padding:'60px 64px', position:'relative' }}>
|
| 187 |
+
{/* Back button */}
|
| 188 |
+
<button onClick={() => setPhase('landing')} style={{
|
| 189 |
+
position:'absolute', top:32, left:40,
|
| 190 |
+
background:'none', border:'none', cursor:'pointer',
|
| 191 |
+
color:'#64748b', fontSize:13, fontWeight:600,
|
| 192 |
+
display:'flex', alignItems:'center', gap:6,
|
| 193 |
+
padding:'6px 10px', borderRadius:8,
|
| 194 |
+
transition:'color 0.2s',
|
| 195 |
+
}}>
|
| 196 |
+
← Back
|
| 197 |
+
</button>
|
| 198 |
+
|
| 199 |
+
{/* Logo */}
|
| 200 |
+
<div style={{ display:'flex', alignItems:'center', gap:10, marginBottom:48, animation:'fadeUp 0.5s ease 0.05s both' }}>
|
| 201 |
+
<div style={{ width:36, height:36, borderRadius:10, background:'linear-gradient(135deg,#3b82f6,#1d4ed8)', display:'flex', alignItems:'center', justifyContent:'center' }}>
|
| 202 |
+
<span style={{ color:'#fff', fontWeight:800, fontSize:16 }}>CE</span>
|
| 203 |
+
</div>
|
| 204 |
+
<span style={{ fontWeight:700, fontSize:18, color:'#1e3a8a', letterSpacing:'-0.02em' }}>Candidate Explorer</span>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<div style={{ animation:'fadeUp 0.5s ease 0.1s both' }}>
|
| 208 |
+
<h2 style={{ fontSize:32, fontWeight:800, color:'#1e3a8a', letterSpacing:'-0.03em', marginBottom:8 }}>Welcome back 👋</h2>
|
| 209 |
+
<p style={{ color:'#64748b', fontSize:14, marginBottom:36 }}>Sign in to your account to continue</p>
|
| 210 |
+
</div>
|
| 211 |
+
|
| 212 |
+
{error && (
|
| 213 |
+
<div style={{ background:'#fef2f2', border:'1px solid #fecaca', color:'#dc2626', padding:'12px 16px', borderRadius:10, fontSize:13, marginBottom:20, animation:'shake 0.4s ease' }}>
|
| 214 |
+
{error}
|
| 215 |
+
</div>
|
| 216 |
+
)}
|
| 217 |
+
|
| 218 |
+
<form onSubmit={handleSubmit} style={{ display:'flex', flexDirection:'column', gap:18, animation:'fadeUp 0.5s ease 0.2s both' }}>
|
| 219 |
+
<div>
|
| 220 |
+
<label style={{ display:'block', fontSize:12, fontWeight:700, color:'#374151', marginBottom:8, letterSpacing:'0.04em' }}>USERNAME</label>
|
| 221 |
+
<input
|
| 222 |
+
type="text" value={username}
|
| 223 |
+
onChange={e => setUsername(e.target.value)}
|
| 224 |
+
onFocus={() => setFocused('user')} onBlur={() => setFocused(null)}
|
| 225 |
+
placeholder="Enter your username"
|
| 226 |
+
required disabled={isLoading}
|
| 227 |
+
className="input-field"
|
| 228 |
+
style={{ width:'100%', padding:'14px 16px', border:'2px solid #e2e8f0', borderRadius:12, fontSize:14, color:'#1e293b', outline:'none', transition:'all 0.25s ease', background:'#f8faff' }}
|
| 229 |
+
/>
|
| 230 |
+
</div>
|
| 231 |
+
<div>
|
| 232 |
+
<div style={{ display:'flex', justifyContent:'space-between', marginBottom:8 }}>
|
| 233 |
+
<label style={{ fontSize:12, fontWeight:700, color:'#374151', letterSpacing:'0.04em' }}>PASSWORD</label>
|
| 234 |
+
{/* <a href="#" style={{ fontSize:12, color:'#3b82f6', textDecoration:'none', fontWeight:600 }}>Forgot Password?</a> */}
|
| 235 |
+
</div>
|
| 236 |
+
<input
|
| 237 |
+
type="password" value={password}
|
| 238 |
+
onChange={e => setPassword(e.target.value)}
|
| 239 |
+
onFocus={() => setFocused('pass')} onBlur={() => setFocused(null)}
|
| 240 |
+
placeholder="••••••••"
|
| 241 |
+
required disabled={isLoading}
|
| 242 |
+
className="input-field"
|
| 243 |
+
style={{ width:'100%', padding:'14px 16px', border:'2px solid #e2e8f0', borderRadius:12, fontSize:14, color:'#1e293b', outline:'none', transition:'all 0.25s ease', background:'#f8faff' }}
|
| 244 |
+
/>
|
| 245 |
+
</div>
|
| 246 |
+
|
| 247 |
+
<button type="submit" disabled={isLoading} className="btn-primary" style={{
|
| 248 |
+
marginTop:8, padding:'16px',
|
| 249 |
+
background:isLoading ? '#93c5fd' : 'linear-gradient(135deg,#3b82f6,#2563eb)',
|
| 250 |
+
border:'none', borderRadius:14, color:'#fff', fontSize:15, fontWeight:700,
|
| 251 |
+
cursor: isLoading ? 'not-allowed' : 'pointer',
|
| 252 |
+
transition:'all 0.25s ease',
|
| 253 |
+
boxShadow:'0 4px 16px rgba(59,130,246,0.35)',
|
| 254 |
+
letterSpacing:'-0.01em',
|
| 255 |
+
}}>
|
| 256 |
+
{isLoading ? (
|
| 257 |
+
<span style={{ display:'flex', alignItems:'center', justifyContent:'center', gap:10 }}>
|
| 258 |
+
<span style={{ width:16,height:16,border:'2.5px solid rgba(255,255,255,0.4)',borderTop:'2.5px solid #fff',borderRadius:'50%',display:'inline-block',animation:'spin 0.7s linear infinite' }} />
|
| 259 |
+
Signing in...
|
| 260 |
+
</span>
|
| 261 |
+
) : 'Sign In'}
|
| 262 |
+
</button>
|
| 263 |
+
</form>
|
| 264 |
+
|
| 265 |
+
{/* <p style={{ marginTop:28, textAlign:'center', fontSize:13, color:'#94a3b8', animation:'fadeUp 0.5s ease 0.35s both' }}>
|
| 266 |
+
{"Don't have an account? "}
|
| 267 |
+
<a href="#" style={{ color:'#3b82f6', fontWeight:700, textDecoration:'none' }}>Sign up</a>
|
| 268 |
+
</p> */}
|
| 269 |
+
|
| 270 |
+
<div style={{ marginTop:'auto', paddingTop:48, display:'flex', justifyContent:'center', gap:24, animation:'fadeUp 0.5s ease 0.4s both' }}>
|
| 271 |
+
{['FAQ','Features','Support'].map(item => (
|
| 272 |
+
<a key={item} href="#" style={{ fontSize:12, color:'#94a3b8', textDecoration:'none', fontWeight:500 }}>{item}</a>
|
| 273 |
+
))}
|
| 274 |
+
</div>
|
| 275 |
+
</div>
|
| 276 |
+
|
| 277 |
+
{/* Right: Illustration */}
|
| 278 |
+
<div style={{ flex:1, background:'linear-gradient(160deg,#1e40af 0%,#2563eb 50%,#3b82f6 100%)', position:'relative', overflow:'hidden', display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:32 }}>
|
| 279 |
+
<div style={{ position:'absolute', inset:0, opacity:0.08, backgroundImage:'radial-gradient(circle, #fff 1px, transparent 1px)', backgroundSize:'24px 24px' }} />
|
| 280 |
+
<div style={{ position:'absolute', top:'-15%', right:'-10%', width:450, height:450, background:'rgba(255,255,255,0.05)', borderRadius:'60% 40% 30% 70%/60% 30% 70% 40%', animation:'blob 9s ease-in-out infinite' }} />
|
| 281 |
+
|
| 282 |
+
<div style={{ position:'relative', zIndex:1, textAlign:'center', padding:'0 40px', animation:'fadeUp 0.6s ease 0.2s both' }}>
|
| 283 |
+
<img src="robot-1.png" alt="robot" style={{ width:180, height:180, objectFit:'contain', animation:'bobble 3s ease-in-out infinite', filter:'drop-shadow(0 16px 32px rgba(0,0,0,0.3))' }} />
|
| 284 |
+
<h3 style={{ color:'#fff', fontSize:26, fontWeight:800, marginTop:24, letterSpacing:'-0.02em' }}>
|
| 285 |
+
AI-Powered Recruitment
|
| 286 |
+
</h3>
|
| 287 |
+
<p style={{ color:'rgba(255,255,255,0.7)', fontSize:14, lineHeight:1.7, marginTop:12, maxWidth:300 }}>
|
| 288 |
+
Let our intelligent assistant help you discover the perfect candidates from thousands of applications.
|
| 289 |
+
</p>
|
| 290 |
+
</div>
|
| 291 |
+
|
| 292 |
+
{/* Floating feature pills */}
|
| 293 |
+
<div style={{ position:'relative', zIndex:1, display:'flex', flexWrap:'wrap', gap:10, justifyContent:'center', padding:'0 48px', animation:'fadeUp 0.6s ease 0.4s both' }}>
|
| 294 |
+
{['✅ Smart Screening','📈 Analytics','🔍 Deep Search','⚡ Auto-Match'].map((feat, i) => (
|
| 295 |
+
<div key={feat} style={{ background:'rgba(255,255,255,0.12)', backdropFilter:'blur(12px)', border:'1px solid rgba(255,255,255,0.2)', borderRadius:10, padding:'8px 14px', color:'#fff', fontSize:13, fontWeight:600, animation:`fadeUp 0.5s ease ${0.5+i*0.08}s both` }}>
|
| 296 |
+
{feat}
|
| 297 |
+
</div>
|
| 298 |
+
))}
|
| 299 |
+
</div>
|
| 300 |
+
</div>
|
| 301 |
+
</div>
|
| 302 |
+
)}
|
| 303 |
</div>
|
| 304 |
+
</>
|
| 305 |
);
|
| 306 |
}
|
src/app/recruitment/upload/page.tsx
CHANGED
|
@@ -433,7 +433,7 @@ export default function CandidateExplorer() {
|
|
| 433 |
// Upload status
|
| 434 |
if (displayFile.uploadStatus === 'uploading') {
|
| 435 |
return (
|
| 436 |
-
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-
|
| 437 |
<Loader2 className="w-3 h-3 animate-spin" />
|
| 438 |
Uploading
|
| 439 |
</span>
|
|
@@ -470,7 +470,7 @@ export default function CandidateExplorer() {
|
|
| 470 |
|
| 471 |
if (displayFile.extractStatus === 'extracted') {
|
| 472 |
return (
|
| 473 |
-
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-
|
| 474 |
<CheckCircle2 className="w-3 h-3" />
|
| 475 |
Extracted
|
| 476 |
</span>
|
|
@@ -542,33 +542,33 @@ export default function CandidateExplorer() {
|
|
| 542 |
{/* ============================================================================ */}
|
| 543 |
<section className="mb-8">
|
| 544 |
<div className="flex items-center gap-2 mb-4">
|
| 545 |
-
<BarChart3 className="w-5 h-5 text-
|
| 546 |
-
<h2 className="text-lg font-semibold text-
|
| 547 |
</div>
|
| 548 |
|
| 549 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 550 |
{/* Total CV Card */}
|
| 551 |
<div className="bg-white rounded-lg border border-gray-200 p-6 shadow-sm">
|
| 552 |
-
<div className="flex items-center gap-2 text-
|
| 553 |
<Upload className="w-4 h-4" />
|
| 554 |
TOTAL CV
|
| 555 |
</div>
|
| 556 |
-
<div className="text-5xl font-bold text-
|
| 557 |
<div className="text-sm text-gray-500">
|
| 558 |
files in your workspace
|
| 559 |
{uploadingCount > 0 && (
|
| 560 |
-
<span className="ml-2 text-
|
| 561 |
)}
|
| 562 |
</div>
|
| 563 |
</div>
|
| 564 |
|
| 565 |
{/* Profiles Extracted Card */}
|
| 566 |
<div className="bg-white rounded-lg border border-gray-200 p-6 shadow-sm">
|
| 567 |
-
<div className="flex items-center gap-2 text-
|
| 568 |
<CheckSquare className="w-4 h-4" />
|
| 569 |
PROFILES EXTRACTED
|
| 570 |
</div>
|
| 571 |
-
<div className="text-5xl font-bold text-
|
| 572 |
<div className="text-sm text-gray-500">
|
| 573 |
structured profiles
|
| 574 |
{extractingCount > 0 && (
|
|
@@ -579,7 +579,7 @@ export default function CandidateExplorer() {
|
|
| 579 |
|
| 580 |
{/* Extraction Rate Card */}
|
| 581 |
<div className="bg-white rounded-lg border border-gray-200 p-6 shadow-sm">
|
| 582 |
-
<div className="flex items-center gap-2 text-
|
| 583 |
<TrendingUp className="w-4 h-4" />
|
| 584 |
EXTRACTION RATE
|
| 585 |
</div>
|
|
@@ -598,7 +598,7 @@ export default function CandidateExplorer() {
|
|
| 598 |
{/* File Upload Dropzone */}
|
| 599 |
<div
|
| 600 |
className={`relative border-2 border-dashed rounded-lg p-12 mb-6 transition-colors ${dragActive
|
| 601 |
-
? 'border-
|
| 602 |
: 'border-gray-300 bg-white'
|
| 603 |
} ${uploadingCount > 0 ? 'pointer-events-none opacity-60' : ''}`}
|
| 604 |
onDragEnter={handleDrag}
|
|
@@ -610,7 +610,7 @@ export default function CandidateExplorer() {
|
|
| 610 |
{uploadingCount > 0 && (
|
| 611 |
<div className="absolute inset-0 bg-white bg-opacity-90 flex items-center justify-center rounded-lg z-10">
|
| 612 |
<div className="text-center">
|
| 613 |
-
<Loader2 className="w-12 h-12 text-
|
| 614 |
<p className="text-gray-700 font-medium">Uploading files...</p>
|
| 615 |
<p className="text-sm text-gray-500 mt-1">
|
| 616 |
{uploadingCount} file(s) in progress
|
|
@@ -634,7 +634,7 @@ export default function CandidateExplorer() {
|
|
| 634 |
Limit 3MB per file • PDF
|
| 635 |
</p>
|
| 636 |
<label htmlFor="file-upload" className="cursor-pointer">
|
| 637 |
-
<span className="inline-block bg-
|
| 638 |
Browse files
|
| 639 |
</span>
|
| 640 |
<input
|
|
@@ -660,7 +660,7 @@ export default function CandidateExplorer() {
|
|
| 660 |
<button
|
| 661 |
onClick={() => refetch()}
|
| 662 |
disabled={isLoading || isFetching}
|
| 663 |
-
className="flex items-center gap-2 bg-
|
| 664 |
>
|
| 665 |
<RefreshCw className={`w-4 h-4 ${isLoading || isFetching ? 'animate-spin' : ''}`} />
|
| 666 |
Refresh List
|
|
@@ -686,7 +686,7 @@ export default function CandidateExplorer() {
|
|
| 686 |
<button
|
| 687 |
onClick={() => refetch()}
|
| 688 |
disabled={isLoading || isFetching}
|
| 689 |
-
className="flex items-center gap-2 bg-
|
| 690 |
>
|
| 691 |
<RefreshCw className={`w-4 h-4 ${isLoading || isFetching ? 'animate-spin' : ''}`} />
|
| 692 |
Refresh List
|
|
@@ -717,7 +717,7 @@ export default function CandidateExplorer() {
|
|
| 717 |
{isLoading || isFetching ? (
|
| 718 |
<tr>
|
| 719 |
<td colSpan={4} className="px-6 py-8 text-center">
|
| 720 |
-
<Loader2 className="w-6 h-6 animate-spin mx-auto text-
|
| 721 |
</td>
|
| 722 |
</tr>
|
| 723 |
) : error ? (
|
|
@@ -773,7 +773,7 @@ export default function CandidateExplorer() {
|
|
| 773 |
displayFile.extractStatus !== 'extracting' && (
|
| 774 |
<button
|
| 775 |
onClick={() => extractData(file.id, file.filename)}
|
| 776 |
-
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-
|
| 777 |
>
|
| 778 |
<FileText className="w-3 h-3" />
|
| 779 |
Extract
|
|
|
|
| 433 |
// Upload status
|
| 434 |
if (displayFile.uploadStatus === 'uploading') {
|
| 435 |
return (
|
| 436 |
+
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
| 437 |
<Loader2 className="w-3 h-3 animate-spin" />
|
| 438 |
Uploading
|
| 439 |
</span>
|
|
|
|
| 470 |
|
| 471 |
if (displayFile.extractStatus === 'extracted') {
|
| 472 |
return (
|
| 473 |
+
<span className="inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-700">
|
| 474 |
<CheckCircle2 className="w-3 h-3" />
|
| 475 |
Extracted
|
| 476 |
</span>
|
|
|
|
| 542 |
{/* ============================================================================ */}
|
| 543 |
<section className="mb-8">
|
| 544 |
<div className="flex items-center gap-2 mb-4">
|
| 545 |
+
<BarChart3 className="w-5 h-5 text-blue-600" />
|
| 546 |
+
<h2 className="text-lg font-semibold text-blue-600">Dashboard Overview</h2>
|
| 547 |
</div>
|
| 548 |
|
| 549 |
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
| 550 |
{/* Total CV Card */}
|
| 551 |
<div className="bg-white rounded-lg border border-gray-200 p-6 shadow-sm">
|
| 552 |
+
<div className="flex items-center gap-2 text-blue-600 text-sm font-medium mb-3">
|
| 553 |
<Upload className="w-4 h-4" />
|
| 554 |
TOTAL CV
|
| 555 |
</div>
|
| 556 |
+
<div className="text-5xl font-bold text-blue-600 mb-1">{totalCVs}</div>
|
| 557 |
<div className="text-sm text-gray-500">
|
| 558 |
files in your workspace
|
| 559 |
{uploadingCount > 0 && (
|
| 560 |
+
<span className="ml-2 text-blue-600">({uploadingCount} uploading)</span>
|
| 561 |
)}
|
| 562 |
</div>
|
| 563 |
</div>
|
| 564 |
|
| 565 |
{/* Profiles Extracted Card */}
|
| 566 |
<div className="bg-white rounded-lg border border-gray-200 p-6 shadow-sm">
|
| 567 |
+
<div className="flex items-center gap-2 text-blue-600 text-sm font-medium mb-3">
|
| 568 |
<CheckSquare className="w-4 h-4" />
|
| 569 |
PROFILES EXTRACTED
|
| 570 |
</div>
|
| 571 |
+
<div className="text-5xl font-bold text-blue-600 mb-1">{profilesExtracted}</div>
|
| 572 |
<div className="text-sm text-gray-500">
|
| 573 |
structured profiles
|
| 574 |
{extractingCount > 0 && (
|
|
|
|
| 579 |
|
| 580 |
{/* Extraction Rate Card */}
|
| 581 |
<div className="bg-white rounded-lg border border-gray-200 p-6 shadow-sm">
|
| 582 |
+
<div className="flex items-center gap-2 text-blue-600 text-sm font-medium mb-3">
|
| 583 |
<TrendingUp className="w-4 h-4" />
|
| 584 |
EXTRACTION RATE
|
| 585 |
</div>
|
|
|
|
| 598 |
{/* File Upload Dropzone */}
|
| 599 |
<div
|
| 600 |
className={`relative border-2 border-dashed rounded-lg p-12 mb-6 transition-colors ${dragActive
|
| 601 |
+
? 'border-blue-500 bg-blue-50'
|
| 602 |
: 'border-gray-300 bg-white'
|
| 603 |
} ${uploadingCount > 0 ? 'pointer-events-none opacity-60' : ''}`}
|
| 604 |
onDragEnter={handleDrag}
|
|
|
|
| 610 |
{uploadingCount > 0 && (
|
| 611 |
<div className="absolute inset-0 bg-white bg-opacity-90 flex items-center justify-center rounded-lg z-10">
|
| 612 |
<div className="text-center">
|
| 613 |
+
<Loader2 className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
|
| 614 |
<p className="text-gray-700 font-medium">Uploading files...</p>
|
| 615 |
<p className="text-sm text-gray-500 mt-1">
|
| 616 |
{uploadingCount} file(s) in progress
|
|
|
|
| 634 |
Limit 3MB per file • PDF
|
| 635 |
</p>
|
| 636 |
<label htmlFor="file-upload" className="cursor-pointer">
|
| 637 |
+
<span className="inline-block bg-blue-100 text-blue-600 px-6 py-2 rounded-md font-medium hover:bg-blue-200 transition-colors">
|
| 638 |
Browse files
|
| 639 |
</span>
|
| 640 |
<input
|
|
|
|
| 660 |
<button
|
| 661 |
onClick={() => refetch()}
|
| 662 |
disabled={isLoading || isFetching}
|
| 663 |
+
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-md font-medium hover:bg-blue-700 transition-colors disabled:opacity-50"
|
| 664 |
>
|
| 665 |
<RefreshCw className={`w-4 h-4 ${isLoading || isFetching ? 'animate-spin' : ''}`} />
|
| 666 |
Refresh List
|
|
|
|
| 686 |
<button
|
| 687 |
onClick={() => refetch()}
|
| 688 |
disabled={isLoading || isFetching}
|
| 689 |
+
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-md font-medium hover:bg-blue-700 transition-colors disabled:opacity-50"
|
| 690 |
>
|
| 691 |
<RefreshCw className={`w-4 h-4 ${isLoading || isFetching ? 'animate-spin' : ''}`} />
|
| 692 |
Refresh List
|
|
|
|
| 717 |
{isLoading || isFetching ? (
|
| 718 |
<tr>
|
| 719 |
<td colSpan={4} className="px-6 py-8 text-center">
|
| 720 |
+
<Loader2 className="w-6 h-6 animate-spin mx-auto text-blue-600" />
|
| 721 |
</td>
|
| 722 |
</tr>
|
| 723 |
) : error ? (
|
|
|
|
| 773 |
displayFile.extractStatus !== 'extracting' && (
|
| 774 |
<button
|
| 775 |
onClick={() => extractData(file.id, file.filename)}
|
| 776 |
+
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
|
| 777 |
>
|
| 778 |
<FileText className="w-3 h-3" />
|
| 779 |
Extract
|
src/components/dashboard/candidates-table.tsx
CHANGED
|
@@ -282,7 +282,7 @@ const ColumnSettingsDialog = memo(({
|
|
| 282 |
</div>
|
| 283 |
<DialogFooter>
|
| 284 |
<Button variant="outline" onClick={onReset}>Reset</Button>
|
| 285 |
-
<Button onClick={onApply} className="bg-
|
| 286 |
</DialogFooter>
|
| 287 |
</DialogContent>
|
| 288 |
</Dialog>
|
|
@@ -431,7 +431,7 @@ const FilterDialog = memo(({
|
|
| 431 |
<Button
|
| 432 |
type="button"
|
| 433 |
variant="link"
|
| 434 |
-
className="text-
|
| 435 |
onClick={() => append({ university: "", major: "", gpa: "" })}
|
| 436 |
>
|
| 437 |
+ Add Education
|
|
@@ -561,7 +561,7 @@ const FilterDialog = memo(({
|
|
| 561 |
|
| 562 |
<DialogFooter>
|
| 563 |
<Button variant="outline" onClick={handleReset}>Reset</Button>
|
| 564 |
-
<Button onClick={handleApply} className="bg-
|
| 565 |
Apply
|
| 566 |
</Button>
|
| 567 |
</DialogFooter>
|
|
@@ -590,7 +590,7 @@ const SliderRow = memo(({
|
|
| 590 |
value={value}
|
| 591 |
onChange={(e) => onChange(path, parseInt(e.target.value))}
|
| 592 |
disabled={disabled}
|
| 593 |
-
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-
|
| 594 |
/>
|
| 595 |
<input
|
| 596 |
type="number"
|
|
@@ -747,7 +747,7 @@ const CriteriaEditDialog = memo(({
|
|
| 747 |
<Button
|
| 748 |
type="button"
|
| 749 |
variant="link"
|
| 750 |
-
className="text-
|
| 751 |
onClick={() => append({ university: "", major: "", gpa: "" })}
|
| 752 |
>
|
| 753 |
+ Add Education
|
|
@@ -875,7 +875,7 @@ const CriteriaEditDialog = memo(({
|
|
| 875 |
|
| 876 |
<DialogFooter>
|
| 877 |
<Button variant="outline" onClick={handleReset}>Reset</Button>
|
| 878 |
-
<Button onClick={handleApply} className="bg-
|
| 879 |
Apply
|
| 880 |
</Button>
|
| 881 |
</DialogFooter>
|
|
@@ -1179,7 +1179,7 @@ const CalculateScoreDialog = memo(({
|
|
| 1179 |
{/* Total */}
|
| 1180 |
<div className="flex justify-between items-center py-4 border-t sticky bottom-0 bg-white">
|
| 1181 |
<span className="font-medium">Total Allocated</span>
|
| 1182 |
-
<span className={`text-lg font-bold ${total === 100 ? 'text-
|
| 1183 |
{total}/100
|
| 1184 |
</span>
|
| 1185 |
</div>
|
|
@@ -1191,7 +1191,7 @@ const CalculateScoreDialog = memo(({
|
|
| 1191 |
onClick={onCalculate}
|
| 1192 |
disabled={total !== 100}
|
| 1193 |
className={`flex-1 px-6 ${total === 100
|
| 1194 |
-
? 'bg-
|
| 1195 |
: 'bg-gray-300 cursor-not-allowed'
|
| 1196 |
}`}
|
| 1197 |
>
|
|
@@ -1209,7 +1209,7 @@ const LoadingDialog = memo(({ open }: { open: boolean }) => (
|
|
| 1209 |
<Dialog open={open}>
|
| 1210 |
<DialogContent className="max-w-sm">
|
| 1211 |
<div className="flex flex-col items-center justify-center py-8">
|
| 1212 |
-
<div className="size-16 border-4 border-
|
| 1213 |
<p className="text-sm text-muted-foreground">Calculating scores...</p>
|
| 1214 |
</div>
|
| 1215 |
</DialogContent>
|
|
@@ -1222,12 +1222,12 @@ const SuccessDialog = memo(({ open, onClose }: { open: boolean; onClose: () => v
|
|
| 1222 |
<Dialog open={open} onOpenChange={onClose}>
|
| 1223 |
<DialogContent className="max-w-sm">
|
| 1224 |
<div className="flex flex-col items-center justify-center py-6 text-center">
|
| 1225 |
-
<div className="size-16 rounded-full bg-
|
| 1226 |
-
<Check className="size-8 text-
|
| 1227 |
</div>
|
| 1228 |
<h3 className="text-lg font-semibold mb-2">Scoring Complete</h3>
|
| 1229 |
<p className="text-sm text-muted-foreground mb-6">All candidate scores have been calculated.</p>
|
| 1230 |
-
<Button onClick={onClose} className="bg-
|
| 1231 |
</div>
|
| 1232 |
</DialogContent>
|
| 1233 |
</Dialog>
|
|
@@ -1391,7 +1391,8 @@ export default function CandidateTable() {
|
|
| 1391 |
debouncedSearch,
|
| 1392 |
sortConfig,
|
| 1393 |
appliedFilters,
|
| 1394 |
-
criteriaId
|
|
|
|
| 1395 |
)
|
| 1396 |
} catch (error) {
|
| 1397 |
console.error("Export failed:", error)
|
|
@@ -1415,7 +1416,7 @@ export default function CandidateTable() {
|
|
| 1415 |
<div className="overflow-x-auto">
|
| 1416 |
{isLoading || isFetching ? (
|
| 1417 |
<div className="flex items-center justify-center py-16">
|
| 1418 |
-
<div className="size-8 border-4 border-
|
| 1419 |
</div>
|
| 1420 |
) : candidates.length === 0 ? (
|
| 1421 |
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
|
|
|
|
| 282 |
</div>
|
| 283 |
<DialogFooter>
|
| 284 |
<Button variant="outline" onClick={onReset}>Reset</Button>
|
| 285 |
+
<Button onClick={onApply} className="bg-blue-600 hover:bg-blue-700 text-white">Apply</Button>
|
| 286 |
</DialogFooter>
|
| 287 |
</DialogContent>
|
| 288 |
</Dialog>
|
|
|
|
| 431 |
<Button
|
| 432 |
type="button"
|
| 433 |
variant="link"
|
| 434 |
+
className="text-blue-600 mt-2 p-0 h-auto"
|
| 435 |
onClick={() => append({ university: "", major: "", gpa: "" })}
|
| 436 |
>
|
| 437 |
+ Add Education
|
|
|
|
| 561 |
|
| 562 |
<DialogFooter>
|
| 563 |
<Button variant="outline" onClick={handleReset}>Reset</Button>
|
| 564 |
+
<Button onClick={handleApply} className="bg-blue-600 hover:bg-blue-700 text-white">
|
| 565 |
Apply
|
| 566 |
</Button>
|
| 567 |
</DialogFooter>
|
|
|
|
| 590 |
value={value}
|
| 591 |
onChange={(e) => onChange(path, parseInt(e.target.value))}
|
| 592 |
disabled={disabled}
|
| 593 |
+
className="flex-1 h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-blue-500"
|
| 594 |
/>
|
| 595 |
<input
|
| 596 |
type="number"
|
|
|
|
| 747 |
<Button
|
| 748 |
type="button"
|
| 749 |
variant="link"
|
| 750 |
+
className="text-blue-600 mt-2 p-0 h-auto"
|
| 751 |
onClick={() => append({ university: "", major: "", gpa: "" })}
|
| 752 |
>
|
| 753 |
+ Add Education
|
|
|
|
| 875 |
|
| 876 |
<DialogFooter>
|
| 877 |
<Button variant="outline" onClick={handleReset}>Reset</Button>
|
| 878 |
+
<Button onClick={handleApply} className="bg-blue-600 hover:bg-blue-700 text-white">
|
| 879 |
Apply
|
| 880 |
</Button>
|
| 881 |
</DialogFooter>
|
|
|
|
| 1179 |
{/* Total */}
|
| 1180 |
<div className="flex justify-between items-center py-4 border-t sticky bottom-0 bg-white">
|
| 1181 |
<span className="font-medium">Total Allocated</span>
|
| 1182 |
+
<span className={`text-lg font-bold ${total === 100 ? 'text-blue-600' : 'text-red-600'}`}>
|
| 1183 |
{total}/100
|
| 1184 |
</span>
|
| 1185 |
</div>
|
|
|
|
| 1191 |
onClick={onCalculate}
|
| 1192 |
disabled={total !== 100}
|
| 1193 |
className={`flex-1 px-6 ${total === 100
|
| 1194 |
+
? 'bg-blue-600 hover:bg-blue-700'
|
| 1195 |
: 'bg-gray-300 cursor-not-allowed'
|
| 1196 |
}`}
|
| 1197 |
>
|
|
|
|
| 1209 |
<Dialog open={open}>
|
| 1210 |
<DialogContent className="max-w-sm">
|
| 1211 |
<div className="flex flex-col items-center justify-center py-8">
|
| 1212 |
+
<div className="size-16 border-4 border-blue-600 border-t-transparent rounded-full animate-spin mb-4" />
|
| 1213 |
<p className="text-sm text-muted-foreground">Calculating scores...</p>
|
| 1214 |
</div>
|
| 1215 |
</DialogContent>
|
|
|
|
| 1222 |
<Dialog open={open} onOpenChange={onClose}>
|
| 1223 |
<DialogContent className="max-w-sm">
|
| 1224 |
<div className="flex flex-col items-center justify-center py-6 text-center">
|
| 1225 |
+
<div className="size-16 rounded-full bg-blue-100 flex items-center justify-center mb-4">
|
| 1226 |
+
<Check className="size-8 text-blue-600" />
|
| 1227 |
</div>
|
| 1228 |
<h3 className="text-lg font-semibold mb-2">Scoring Complete</h3>
|
| 1229 |
<p className="text-sm text-muted-foreground mb-6">All candidate scores have been calculated.</p>
|
| 1230 |
+
<Button onClick={onClose} className="bg-blue-600 hover:bg-blue-700 text-white w-full">Done</Button>
|
| 1231 |
</div>
|
| 1232 |
</DialogContent>
|
| 1233 |
</Dialog>
|
|
|
|
| 1391 |
debouncedSearch,
|
| 1392 |
sortConfig,
|
| 1393 |
appliedFilters,
|
| 1394 |
+
criteriaId,
|
| 1395 |
+
user?.user_id
|
| 1396 |
)
|
| 1397 |
} catch (error) {
|
| 1398 |
console.error("Export failed:", error)
|
|
|
|
| 1416 |
<div className="overflow-x-auto">
|
| 1417 |
{isLoading || isFetching ? (
|
| 1418 |
<div className="flex items-center justify-center py-16">
|
| 1419 |
+
<div className="size-8 border-4 border-blue-600 border-t-transparent rounded-full animate-spin" />
|
| 1420 |
</div>
|
| 1421 |
) : candidates.length === 0 ? (
|
| 1422 |
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
|
src/components/dashboard/charts-section.tsx
CHANGED
|
@@ -8,13 +8,13 @@ export function ChartsSection() {
|
|
| 8 |
return (
|
| 9 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 my-2 mt-4">
|
| 10 |
<Card className="p-6 border-2">
|
| 11 |
-
<h3 className="text-base font-semibold text-
|
| 12 |
All Major
|
| 13 |
</h3>
|
| 14 |
<MajorChart />
|
| 15 |
</Card>
|
| 16 |
<Card className="p-6 border-2">
|
| 17 |
-
<h3 className="text-base font-semibold text-
|
| 18 |
All University
|
| 19 |
</h3>
|
| 20 |
<UniversityBar />
|
|
|
|
| 8 |
return (
|
| 9 |
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 my-2 mt-4">
|
| 10 |
<Card className="p-6 border-2">
|
| 11 |
+
<h3 className="text-base font-semibold text-center text-blue-500">
|
| 12 |
All Major
|
| 13 |
</h3>
|
| 14 |
<MajorChart />
|
| 15 |
</Card>
|
| 16 |
<Card className="p-6 border-2">
|
| 17 |
+
<h3 className="text-base font-semibold text-center text-blue-500">
|
| 18 |
All University
|
| 19 |
</h3>
|
| 20 |
<UniversityBar />
|
src/components/dashboard/detail-dialog.tsx
CHANGED
|
@@ -161,7 +161,7 @@ export function DetailDialog({ open, onOpenChange, candidate }: DetailDialogProp
|
|
| 161 |
<div>
|
| 162 |
<h3 className="text-sm text-gray-500">CV</h3>
|
| 163 |
<button
|
| 164 |
-
className="mt-1 flex items-center gap-1 text-sm text-
|
| 165 |
onClick={handleDownload}
|
| 166 |
>
|
| 167 |
<Download className="size-4" />
|
|
|
|
| 161 |
<div>
|
| 162 |
<h3 className="text-sm text-gray-500">CV</h3>
|
| 163 |
<button
|
| 164 |
+
className="mt-1 flex items-center gap-1 text-sm text-blue-600 hover:underline"
|
| 165 |
onClick={handleDownload}
|
| 166 |
>
|
| 167 |
<Download className="size-4" />
|
src/components/dashboard/header-menu.tsx
CHANGED
|
@@ -195,7 +195,7 @@ export function HeaderMenu() {
|
|
| 195 |
<DynamicBreadcrumb />
|
| 196 |
<div className="text-sm text-gray-500">{dateStr}</div>
|
| 197 |
</header>
|
| 198 |
-
<header className="h-16 bg-white border-b border-gray-200 px-8 flex items-center justify-between
|
| 199 |
<h1 className="text-base font-bold text-foreground relative z-10">
|
| 200 |
Recruitment AI Dashboard – MT Intake
|
| 201 |
</h1>
|
|
|
|
| 195 |
<DynamicBreadcrumb />
|
| 196 |
<div className="text-sm text-gray-500">{dateStr}</div>
|
| 197 |
</header>
|
| 198 |
+
<header className="h-16 bg-white border-b border-gray-200 px-8 flex items-center justify-between">
|
| 199 |
<h1 className="text-base font-bold text-foreground relative z-10">
|
| 200 |
Recruitment AI Dashboard – MT Intake
|
| 201 |
</h1>
|
src/components/dashboard/metrics-row.tsx
CHANGED
|
@@ -23,43 +23,6 @@ const fallbackScore: ScoreData = {
|
|
| 23 |
}
|
| 24 |
}
|
| 25 |
|
| 26 |
-
function CircularProgress({ percentage }: { percentage: number }) {
|
| 27 |
-
// Format to max 2 decimal places
|
| 28 |
-
const formattedPercentage = Number(percentage.toFixed(2));
|
| 29 |
-
|
| 30 |
-
return (
|
| 31 |
-
<div className="relative w-24 h-24 flex-shrink-0">
|
| 32 |
-
<svg className="w-full h-full transform -rotate-90">
|
| 33 |
-
<circle
|
| 34 |
-
cx="48"
|
| 35 |
-
cy="48"
|
| 36 |
-
r="40"
|
| 37 |
-
stroke="#E5E7EB"
|
| 38 |
-
strokeWidth="8"
|
| 39 |
-
fill="none"
|
| 40 |
-
/>
|
| 41 |
-
<circle
|
| 42 |
-
cx="48"
|
| 43 |
-
cy="48"
|
| 44 |
-
r="40"
|
| 45 |
-
stroke="#10B981"
|
| 46 |
-
strokeWidth="8"
|
| 47 |
-
fill="none"
|
| 48 |
-
strokeDasharray={`${2 * Math.PI * 40}`}
|
| 49 |
-
strokeDashoffset={`${2 * Math.PI * 40 * (1 - percentage / 100)}`}
|
| 50 |
-
strokeLinecap="round"
|
| 51 |
-
className="transition-all duration-500"
|
| 52 |
-
/>
|
| 53 |
-
</svg>
|
| 54 |
-
<div className="absolute inset-0 flex items-center justify-center">
|
| 55 |
-
<span className="text-xl font-bold text-gray-700">
|
| 56 |
-
{formattedPercentage}%
|
| 57 |
-
</span>
|
| 58 |
-
</div>
|
| 59 |
-
</div>
|
| 60 |
-
);
|
| 61 |
-
}
|
| 62 |
-
|
| 63 |
export function MetricsRow() {
|
| 64 |
const router = useRouter()
|
| 65 |
const fetchScore = async (): Promise<ScoreData> => {
|
|
@@ -68,39 +31,24 @@ export function MetricsRow() {
|
|
| 68 |
return res.json()
|
| 69 |
}
|
| 70 |
|
| 71 |
-
const { data, isLoading
|
| 72 |
queryKey: ["score-data"],
|
| 73 |
-
queryFn:
|
| 74 |
staleTime: 0,
|
| 75 |
-
placeholderData: (prev) => prev,
|
| 76 |
refetchOnWindowFocus: false,
|
| 77 |
-
refetchOnMount:
|
| 78 |
})
|
| 79 |
|
| 80 |
const scoreData = isError ? fallbackScore : data ?? fallbackScore
|
| 81 |
|
| 82 |
-
// Format percentage to max 2 decimal places
|
| 83 |
-
const formattedPercentage = Number(scoreData.data.percent_extracted.toFixed(2));
|
| 84 |
-
|
| 85 |
-
const lastProcessed = new Date().toLocaleString('en-US', {
|
| 86 |
-
weekday: 'short',
|
| 87 |
-
month: 'short',
|
| 88 |
-
day: '2-digit',
|
| 89 |
-
year: 'numeric',
|
| 90 |
-
hour: '2-digit',
|
| 91 |
-
minute: '2-digit',
|
| 92 |
-
second: '2-digit',
|
| 93 |
-
hour12: true
|
| 94 |
-
});
|
| 95 |
-
|
| 96 |
const handleAddData = () => {
|
| 97 |
router.push("/recruitment/upload")
|
| 98 |
}
|
| 99 |
|
| 100 |
-
if (
|
| 101 |
return (
|
| 102 |
-
<div className="grid grid-cols-1
|
| 103 |
-
{[1
|
| 104 |
<Card key={i} className="p-6 border bg-white animate-pulse">
|
| 105 |
<div className="flex items-start gap-4">
|
| 106 |
<div className="w-24 h-24 rounded-full bg-gray-200" />
|
|
@@ -124,7 +72,7 @@ export function MetricsRow() {
|
|
| 124 |
<div className="flex items-start gap-4">
|
| 125 |
<div className="flex-1">
|
| 126 |
<div className="flex items-center gap-3 mb-3">
|
| 127 |
-
<span className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold bg-
|
| 128 |
Profile Extracted
|
| 129 |
</span>
|
| 130 |
<span className="text-3xl font-bold text-gray-900">
|
|
@@ -136,12 +84,12 @@ export function MetricsRow() {
|
|
| 136 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
| 137 |
</svg>
|
| 138 |
<span>
|
| 139 |
-
<strong>{scoreData.data.total_extracted}</strong> / <strong>{scoreData.data.total_file}</strong>Total Profiles
|
| 140 |
</span>
|
| 141 |
</div>
|
| 142 |
</div>
|
| 143 |
</div>
|
| 144 |
-
<button onClick={handleAddData} className="cursor-pointer w-10 h-10 rounded-full bg-[#
|
| 145 |
<Plus className="w-5 h-5" />
|
| 146 |
</button>
|
| 147 |
</div>
|
|
@@ -154,7 +102,7 @@ export function MetricsRow() {
|
|
| 154 |
|
| 155 |
<div className="flex-1">
|
| 156 |
<div className="mb-4">
|
| 157 |
-
<div className="text-3xl font-bold text-
|
| 158 |
{formattedPercentage}%
|
| 159 |
</div>
|
| 160 |
<div className="text-lg font-semibold text-gray-900">
|
|
|
|
| 23 |
}
|
| 24 |
}
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
export function MetricsRow() {
|
| 27 |
const router = useRouter()
|
| 28 |
const fetchScore = async (): Promise<ScoreData> => {
|
|
|
|
| 31 |
return res.json()
|
| 32 |
}
|
| 33 |
|
| 34 |
+
const { data, isLoading, isFetching, isError } = useQuery({
|
| 35 |
queryKey: ["score-data"],
|
| 36 |
+
queryFn: fetchScore,
|
| 37 |
staleTime: 0,
|
|
|
|
| 38 |
refetchOnWindowFocus: false,
|
| 39 |
+
refetchOnMount: "always", // 👈 force refetch
|
| 40 |
})
|
| 41 |
|
| 42 |
const scoreData = isError ? fallbackScore : data ?? fallbackScore
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
const handleAddData = () => {
|
| 45 |
router.push("/recruitment/upload")
|
| 46 |
}
|
| 47 |
|
| 48 |
+
if (isLoading || isFetching) {
|
| 49 |
return (
|
| 50 |
+
<div className="grid grid-cols-1 gap-4">
|
| 51 |
+
{[1].map((i) => (
|
| 52 |
<Card key={i} className="p-6 border bg-white animate-pulse">
|
| 53 |
<div className="flex items-start gap-4">
|
| 54 |
<div className="w-24 h-24 rounded-full bg-gray-200" />
|
|
|
|
| 72 |
<div className="flex items-start gap-4">
|
| 73 |
<div className="flex-1">
|
| 74 |
<div className="flex items-center gap-3 mb-3">
|
| 75 |
+
<span className="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-semibold bg-blue-100 text-blue-800">
|
| 76 |
Profile Extracted
|
| 77 |
</span>
|
| 78 |
<span className="text-3xl font-bold text-gray-900">
|
|
|
|
| 84 |
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
| 85 |
</svg>
|
| 86 |
<span>
|
| 87 |
+
<strong>{scoreData.data.total_extracted}</strong> / <strong>{scoreData.data.total_file}</strong> Total Profiles
|
| 88 |
</span>
|
| 89 |
</div>
|
| 90 |
</div>
|
| 91 |
</div>
|
| 92 |
+
<button onClick={handleAddData} className="cursor-pointer w-10 h-10 rounded-full bg-[#3b82f6] text-white flex items-center justify-center hover:bg-[#1d4ed8] transition-colors">
|
| 93 |
<Plus className="w-5 h-5" />
|
| 94 |
</button>
|
| 95 |
</div>
|
|
|
|
| 102 |
|
| 103 |
<div className="flex-1">
|
| 104 |
<div className="mb-4">
|
| 105 |
+
<div className="text-3xl font-bold text-blue-600 mb-1">
|
| 106 |
{formattedPercentage}%
|
| 107 |
</div>
|
| 108 |
<div className="text-lg font-semibold text-gray-900">
|
src/components/ui/ChartBar.tsx
CHANGED
|
@@ -69,6 +69,7 @@ export function ChartBar({
|
|
| 69 |
<ResponsiveContainer
|
| 70 |
width="100%"
|
| 71 |
height={Math.max(250, data.length * 40)}
|
|
|
|
| 72 |
>
|
| 73 |
<BarChart
|
| 74 |
data={data}
|
|
|
|
| 69 |
<ResponsiveContainer
|
| 70 |
width="100%"
|
| 71 |
height={Math.max(250, data.length * 40)}
|
| 72 |
+
// height={300}
|
| 73 |
>
|
| 74 |
<BarChart
|
| 75 |
data={data}
|
src/components/ui/ChartPie.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
'use client';
|
| 2 |
|
| 3 |
-
import { ChartData } from '@/types/chart';
|
| 4 |
import {
|
| 5 |
Cell,
|
| 6 |
Legend,
|
|
@@ -10,85 +10,88 @@ import {
|
|
| 10 |
Tooltip,
|
| 11 |
} from 'recharts';
|
| 12 |
|
| 13 |
-
type ChartPieProps = {
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
}
|
| 17 |
|
| 18 |
-
export function ChartPie({ loading, data }: ChartPieProps) {
|
| 19 |
-
|
| 20 |
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
|
| 30 |
-
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
| 46 |
</div>
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
}
|
| 50 |
|
| 51 |
-
if (!data || data.length === 0) {
|
| 52 |
return (
|
| 53 |
-
<div className="
|
| 54 |
-
|
| 55 |
-
<
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
</div>
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
<PieChart>
|
| 70 |
-
<Pie
|
| 71 |
-
data={dataWithPercentages}
|
| 72 |
-
dataKey="value"
|
| 73 |
-
label={({ percentage }) => `${percentage}%`}
|
| 74 |
-
>
|
| 75 |
-
{dataWithPercentages.map((entry, index) => (
|
| 76 |
-
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 77 |
-
))}
|
| 78 |
-
</Pie>
|
| 79 |
-
<Tooltip
|
| 80 |
-
formatter={(value: number, name: string, props: any) => [
|
| 81 |
-
`${value} (${props.payload.percentage}%)`,
|
| 82 |
-
name
|
| 83 |
-
]}
|
| 84 |
-
/>
|
| 85 |
-
<Legend
|
| 86 |
-
formatter={(value: string, entry: any) =>
|
| 87 |
-
`${value}: ${entry.payload.value} (${entry.payload.percentage}%)`
|
| 88 |
-
}
|
| 89 |
-
/>
|
| 90 |
-
</PieChart>
|
| 91 |
-
</ResponsiveContainer>
|
| 92 |
-
</div>
|
| 93 |
-
);
|
| 94 |
-
}
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
|
| 3 |
+
import { ChartData } from '@/types/chart';
|
| 4 |
import {
|
| 5 |
Cell,
|
| 6 |
Legend,
|
|
|
|
| 10 |
Tooltip,
|
| 11 |
} from 'recharts';
|
| 12 |
|
| 13 |
+
type ChartPieProps = {
|
| 14 |
+
loading: boolean
|
| 15 |
+
data: ChartData[]
|
| 16 |
+
}
|
| 17 |
|
| 18 |
+
export function ChartPie({ loading, data }: ChartPieProps) {
|
| 19 |
+
const items = 5;
|
| 20 |
|
| 21 |
+
// Calculate percentages
|
| 22 |
+
const dataWithPercentages = data ? (() => {
|
| 23 |
+
const total = data.reduce((sum, item) => sum + item.value, 0);
|
| 24 |
+
return data.map(item => ({
|
| 25 |
+
...item,
|
| 26 |
+
percentage: total > 0 ? Number(((item.value / total) * 100).toFixed(1)) : 0
|
| 27 |
+
}));
|
| 28 |
+
})() : [];
|
| 29 |
|
| 30 |
+
const total = dataWithPercentages.reduce((sum, item) => sum + item.value, 0);
|
| 31 |
|
| 32 |
+
if (loading) {
|
| 33 |
+
return (
|
| 34 |
+
<div className="w-full flex items-center justify-center gap-10 animate-pulse">
|
| 35 |
+
<div className="relative">
|
| 36 |
+
<div className="w-48 h-48 bg-gray-200 rounded-full" />
|
| 37 |
+
<div className="absolute inset-6 bg-white rounded-full" />
|
| 38 |
+
</div>
|
| 39 |
+
<div className="flex flex-col gap-4">
|
| 40 |
+
{Array.from({ length: items }).map((_, index) => (
|
| 41 |
+
<div key={index} className="flex items-center gap-3">
|
| 42 |
+
<div className="w-4 h-4 bg-gray-200 rounded-sm" />
|
| 43 |
+
<div className="h-4 bg-gray-200 rounded w-32" />
|
| 44 |
+
</div>
|
| 45 |
+
))}
|
| 46 |
+
</div>
|
| 47 |
</div>
|
| 48 |
+
);
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
if (!data || data.length === 0) {
|
| 52 |
+
return (
|
| 53 |
+
<div className="w-full h-60 flex flex-col items-center justify-center text-gray-400">
|
| 54 |
+
<div className="w-40 h-40 border-4 border-dashed border-gray-200 rounded-full mb-4" />
|
| 55 |
+
<p className="text-sm">No data available</p>
|
| 56 |
</div>
|
| 57 |
+
);
|
| 58 |
+
}
|
|
|
|
| 59 |
|
|
|
|
| 60 |
return (
|
| 61 |
+
<div className="space-y-4">
|
| 62 |
+
{/* Total Count */}
|
| 63 |
+
<div className="text-center">
|
| 64 |
+
<p className="text-sm text-gray-600">Total</p>
|
| 65 |
+
<p className="text-2xl font-bold text-gray-800">{total}</p>
|
| 66 |
+
</div>
|
| 67 |
|
| 68 |
+
<ResponsiveContainer width="100%"
|
| 69 |
+
height={Math.max(250, dataWithPercentages.length * 40)}
|
| 70 |
+
// height={300}
|
| 71 |
+
>
|
| 72 |
+
<PieChart>
|
| 73 |
+
<Pie
|
| 74 |
+
data={dataWithPercentages}
|
| 75 |
+
dataKey="value"
|
| 76 |
+
label={({ percentage }) => `${percentage}%`}
|
| 77 |
+
>
|
| 78 |
+
{dataWithPercentages.map((entry, index) => (
|
| 79 |
+
<Cell key={`cell-${index}`} fill={entry.color} />
|
| 80 |
+
))}
|
| 81 |
+
</Pie>
|
| 82 |
+
<Tooltip
|
| 83 |
+
formatter={(value: number, name: string, props: any) => [
|
| 84 |
+
`${value} (${props.payload.percentage}%)`,
|
| 85 |
+
name
|
| 86 |
+
]}
|
| 87 |
+
/>
|
| 88 |
+
<Legend
|
| 89 |
+
formatter={(value: string, entry: any) =>
|
| 90 |
+
`${value}: ${entry.payload.value} (${entry.payload.percentage}%)`
|
| 91 |
+
}
|
| 92 |
+
/>
|
| 93 |
+
</PieChart>
|
| 94 |
+
</ResponsiveContainer>
|
| 95 |
</div>
|
| 96 |
+
);
|
| 97 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
src/components/ui/breadcrumb.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
-
import * as React from 'react'
|
| 2 |
import { Slot } from '@radix-ui/react-slot'
|
| 3 |
import { ChevronRight, Home, MoreHorizontal } from 'lucide-react'
|
|
|
|
| 4 |
|
| 5 |
import { cn } from '@/lib/utils'
|
| 6 |
|
|
@@ -72,7 +72,7 @@ function BreadcrumbSeparator({
|
|
| 72 |
data-slot="breadcrumb-separator"
|
| 73 |
role="presentation"
|
| 74 |
aria-hidden="true"
|
| 75 |
-
className={cn('[&>svg]:size-3.5 text-
|
| 76 |
{...props}
|
| 77 |
>
|
| 78 |
{children ?? <ChevronRight />}
|
|
@@ -89,7 +89,7 @@ function BreadcrumbHome({
|
|
| 89 |
data-slot="breadcrumb-home"
|
| 90 |
role="presentation"
|
| 91 |
aria-hidden="true"
|
| 92 |
-
className={cn('[&>svg]:size-4 text-
|
| 93 |
{...props}
|
| 94 |
>
|
| 95 |
<Home />
|
|
@@ -116,12 +116,8 @@ function BreadcrumbEllipsis({
|
|
| 116 |
}
|
| 117 |
|
| 118 |
export {
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
BreadcrumbItem,
|
| 123 |
-
BreadcrumbLink,
|
| 124 |
-
BreadcrumbPage,
|
| 125 |
-
BreadcrumbSeparator,
|
| 126 |
-
BreadcrumbEllipsis,
|
| 127 |
}
|
|
|
|
|
|
|
|
|
| 1 |
import { Slot } from '@radix-ui/react-slot'
|
| 2 |
import { ChevronRight, Home, MoreHorizontal } from 'lucide-react'
|
| 3 |
+
import * as React from 'react'
|
| 4 |
|
| 5 |
import { cn } from '@/lib/utils'
|
| 6 |
|
|
|
|
| 72 |
data-slot="breadcrumb-separator"
|
| 73 |
role="presentation"
|
| 74 |
aria-hidden="true"
|
| 75 |
+
className={cn('[&>svg]:size-3.5 text-blue-500', className)}
|
| 76 |
{...props}
|
| 77 |
>
|
| 78 |
{children ?? <ChevronRight />}
|
|
|
|
| 89 |
data-slot="breadcrumb-home"
|
| 90 |
role="presentation"
|
| 91 |
aria-hidden="true"
|
| 92 |
+
className={cn('[&>svg]:size-4 text-blue-500', className)}
|
| 93 |
{...props}
|
| 94 |
>
|
| 95 |
<Home />
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
export {
|
| 119 |
+
Breadcrumb, BreadcrumbEllipsis, BreadcrumbHome, BreadcrumbItem,
|
| 120 |
+
BreadcrumbLink, BreadcrumbList, BreadcrumbPage,
|
| 121 |
+
BreadcrumbSeparator
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
}
|
| 123 |
+
|
src/components/ui/button.tsx
CHANGED
|
@@ -1,24 +1,31 @@
|
|
| 1 |
-
import * as React from 'react'
|
| 2 |
import { Slot } from '@radix-ui/react-slot'
|
| 3 |
import { cva, type VariantProps } from 'class-variance-authority'
|
|
|
|
| 4 |
|
| 5 |
import { cn } from '@/lib/utils'
|
| 6 |
|
| 7 |
const buttonVariants = cva(
|
| 8 |
-
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:
|
| 9 |
{
|
| 10 |
variants: {
|
| 11 |
variant: {
|
| 12 |
-
default:
|
|
|
|
|
|
|
| 13 |
destructive:
|
| 14 |
-
'bg-
|
|
|
|
| 15 |
outline:
|
| 16 |
-
'border
|
|
|
|
| 17 |
secondary:
|
| 18 |
-
'bg-
|
|
|
|
| 19 |
ghost:
|
| 20 |
-
'
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
},
|
| 23 |
size: {
|
| 24 |
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
@@ -33,7 +40,7 @@ const buttonVariants = cva(
|
|
| 33 |
variant: 'default',
|
| 34 |
size: 'default',
|
| 35 |
},
|
| 36 |
-
}
|
| 37 |
)
|
| 38 |
|
| 39 |
function Button({
|
|
@@ -51,7 +58,7 @@ function Button({
|
|
| 51 |
return (
|
| 52 |
<Comp
|
| 53 |
data-slot="button"
|
| 54 |
-
className={cn(buttonVariants({ variant, size, className
|
| 55 |
{...props}
|
| 56 |
/>
|
| 57 |
)
|
|
|
|
|
|
|
| 1 |
import { Slot } from '@radix-ui/react-slot'
|
| 2 |
import { cva, type VariantProps } from 'class-variance-authority'
|
| 3 |
+
import * as React from 'react'
|
| 4 |
|
| 5 |
import { cn } from '@/lib/utils'
|
| 6 |
|
| 7 |
const buttonVariants = cva(
|
| 8 |
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-[3px]",
|
| 9 |
{
|
| 10 |
variants: {
|
| 11 |
variant: {
|
| 12 |
+
default:
|
| 13 |
+
'bg-blue-600 text-white hover:bg-blue-700 focus-visible:ring-blue-300',
|
| 14 |
+
|
| 15 |
destructive:
|
| 16 |
+
'bg-red-600 text-white hover:bg-red-700 focus-visible:ring-red-300',
|
| 17 |
+
|
| 18 |
outline:
|
| 19 |
+
'border border-blue-200 text-blue-600 bg-white hover:bg-blue-50',
|
| 20 |
+
|
| 21 |
secondary:
|
| 22 |
+
'bg-blue-100 text-blue-700 hover:bg-blue-200',
|
| 23 |
+
|
| 24 |
ghost:
|
| 25 |
+
'text-blue-600 hover:bg-blue-50',
|
| 26 |
+
|
| 27 |
+
link:
|
| 28 |
+
'text-blue-600 underline-offset-4 hover:underline',
|
| 29 |
},
|
| 30 |
size: {
|
| 31 |
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
|
|
|
| 40 |
variant: 'default',
|
| 41 |
size: 'default',
|
| 42 |
},
|
| 43 |
+
}
|
| 44 |
)
|
| 45 |
|
| 46 |
function Button({
|
|
|
|
| 58 |
return (
|
| 59 |
<Comp
|
| 60 |
data-slot="button"
|
| 61 |
+
className={cn(buttonVariants({ variant, size }), className)}
|
| 62 |
{...props}
|
| 63 |
/>
|
| 64 |
)
|
src/components/ui/metrics-card.tsx
CHANGED
|
@@ -13,7 +13,7 @@ export function MetricsCard({ children, label, value, loading }: MetricsCardProp
|
|
| 13 |
<Card className="px-6 border-2 bg-white hover:shadow-md transition-shadow gap-3">
|
| 14 |
<div className="flex flex-row items-center justify-between">
|
| 15 |
<div>
|
| 16 |
-
<div className="text-base text-gray-600 font-medium text-
|
| 17 |
{
|
| 18 |
loading ? "Loading..." : <div className="text-3xl font-bold text-gray-900">{value}</div>
|
| 19 |
}
|
|
|
|
| 13 |
<Card className="px-6 border-2 bg-white hover:shadow-md transition-shadow gap-3">
|
| 14 |
<div className="flex flex-row items-center justify-between">
|
| 15 |
<div>
|
| 16 |
+
<div className="text-base text-gray-600 font-medium text-blue-500">{label}</div>
|
| 17 |
{
|
| 18 |
loading ? "Loading..." : <div className="text-3xl font-bold text-gray-900">{value}</div>
|
| 19 |
}
|
src/components/ui/pagination.tsx
CHANGED
|
@@ -1,12 +1,12 @@
|
|
| 1 |
-
import * as React from 'react'
|
| 2 |
import {
|
| 3 |
ChevronLeftIcon,
|
| 4 |
ChevronRightIcon,
|
| 5 |
MoreHorizontalIcon,
|
| 6 |
} from 'lucide-react'
|
|
|
|
| 7 |
|
|
|
|
| 8 |
import { cn } from '@/lib/utils'
|
| 9 |
-
import { Button, buttonVariants } from '@/components/ui/button'
|
| 10 |
|
| 11 |
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
| 12 |
return (
|
|
@@ -54,11 +54,14 @@ function PaginationLink({
|
|
| 54 |
data-slot="pagination-link"
|
| 55 |
data-active={isActive}
|
| 56 |
className={cn(
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
| 62 |
)}
|
| 63 |
{...props}
|
| 64 |
/>
|
|
@@ -118,10 +121,6 @@ function PaginationEllipsis({
|
|
| 118 |
|
| 119 |
export {
|
| 120 |
Pagination,
|
| 121 |
-
PaginationContent,
|
| 122 |
-
PaginationLink,
|
| 123 |
-
PaginationItem,
|
| 124 |
-
PaginationPrevious,
|
| 125 |
-
PaginationNext,
|
| 126 |
-
PaginationEllipsis,
|
| 127 |
}
|
|
|
|
|
|
|
|
|
| 1 |
import {
|
| 2 |
ChevronLeftIcon,
|
| 3 |
ChevronRightIcon,
|
| 4 |
MoreHorizontalIcon,
|
| 5 |
} from 'lucide-react'
|
| 6 |
+
import * as React from 'react'
|
| 7 |
|
| 8 |
+
import { Button } from '@/components/ui/button'
|
| 9 |
import { cn } from '@/lib/utils'
|
|
|
|
| 10 |
|
| 11 |
function Pagination({ className, ...props }: React.ComponentProps<'nav'>) {
|
| 12 |
return (
|
|
|
|
| 54 |
data-slot="pagination-link"
|
| 55 |
data-active={isActive}
|
| 56 |
className={cn(
|
| 57 |
+
'transition-colors',
|
| 58 |
+
isActive
|
| 59 |
+
? 'bg-blue-500 text-white hover:bg-blue-700'
|
| 60 |
+
: 'text-blue-600 hover:bg-blue-50',
|
| 61 |
+
'rounded-md',
|
| 62 |
+
size === 'icon' && 'h-9 w-9 flex items-center justify-center',
|
| 63 |
+
size === 'default' && 'h-9 px-3 flex items-center justify-center',
|
| 64 |
+
className
|
| 65 |
)}
|
| 66 |
{...props}
|
| 67 |
/>
|
|
|
|
| 121 |
|
| 122 |
export {
|
| 123 |
Pagination,
|
| 124 |
+
PaginationContent, PaginationEllipsis, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
}
|
| 126 |
+
|
src/lib/export-service.ts
CHANGED
|
@@ -131,7 +131,8 @@ export const exportCandidatesToCSV = async (
|
|
| 131 |
search: string,
|
| 132 |
sortConfig: { key: string | null; direction: "asc" | "desc" },
|
| 133 |
filters: FilterFormValues,
|
| 134 |
-
criteriaId: string | null
|
|
|
|
| 135 |
): Promise<void> => {
|
| 136 |
try {
|
| 137 |
const params = new URLSearchParams()
|
|
@@ -143,20 +144,22 @@ export const exportCandidatesToCSV = async (
|
|
| 143 |
}
|
| 144 |
|
| 145 |
if (filters.domicile) params.set("domicile", filters.domicile)
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
|
|
|
|
|
|
| 160 |
|
| 161 |
// Call backend export endpoint
|
| 162 |
const response = await authFetch(`/api/cv-profile/export?${params}`, {
|
|
|
|
| 131 |
search: string,
|
| 132 |
sortConfig: { key: string | null; direction: "asc" | "desc" },
|
| 133 |
filters: FilterFormValues,
|
| 134 |
+
criteriaId: string | null,
|
| 135 |
+
userId: string
|
| 136 |
): Promise<void> => {
|
| 137 |
try {
|
| 138 |
const params = new URLSearchParams()
|
|
|
|
| 144 |
}
|
| 145 |
|
| 146 |
if (filters.domicile) params.set("domicile", filters.domicile)
|
| 147 |
+
if (filters.yoe) params.set("yoe", filters.yoe)
|
| 148 |
+
filters.educations.forEach((edu, i) => {
|
| 149 |
+
const n = i + 1
|
| 150 |
+
// Handle arrays for university and major
|
| 151 |
+
edu.university.forEach((univ) => params.append(`univ_edu_${n}`, univ))
|
| 152 |
+
edu.major.forEach((maj) => params.append(`major_edu_${n}`, maj))
|
| 153 |
+
if (edu.gpa) params.set(`gpa_${n}`, edu.gpa)
|
| 154 |
+
})
|
| 155 |
+
// Updated to handle arrays
|
| 156 |
+
filters.softskills.forEach((skill) => params.append("softskills", skill))
|
| 157 |
+
filters.hardskills.forEach((skill) => params.append("hardskills", skill))
|
| 158 |
+
filters.certifications.forEach((cert) => params.append("certifications", cert))
|
| 159 |
+
filters.businessDomain.forEach((domain) => params.append("business_domain", domain))
|
| 160 |
+
|
| 161 |
+
if (criteriaId) params.append("criteria_id", criteriaId)
|
| 162 |
+
params.append("user_id", userId)
|
| 163 |
|
| 164 |
// Call backend export endpoint
|
| 165 |
const response = await authFetch(`/api/cv-profile/export?${params}`, {
|