Yvonne Priscilla commited on
Commit
bb909a5
·
1 Parent(s): 84f95f9

update theme login etc

Browse files
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 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
9
-
10
- /**
11
- * GET /api/cv-profile/export
12
- *
13
- * Exports ALL matching candidates as CSV file
14
- * No pagination - returns complete dataset
15
- *
16
- * Query params: Same as /api/cv-profile
17
- * - search
18
- * - criteria_id
19
- * - domicile, yoe
20
- * - univ_edu_1/2/3, major_edu_1/2/3, gpa_1/2/3
21
- * - softskills, hardskills, certifications, business_domain
22
- * - sortBy, sortOrder
23
- */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  export async function GET(request: NextRequest) {
25
  const { searchParams } = new URL(request.url)
26
 
27
- // --- VALIDATION ---
 
 
28
  const criteria_id = searchParams.get("criteria_id")
29
  if (criteria_id && !UUID_REGEX.test(criteria_id)) {
30
- return NextResponse.json({ error: "Invalid criteria_id format" }, { status: 400 })
 
 
 
31
  }
32
 
33
- // --- FILTERS (same as main endpoint) ---
 
 
 
 
 
 
 
 
 
 
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.get("univ_edu_1")
43
- const univ_edu_2 = searchParams.get("univ_edu_2")
44
- const univ_edu_3 = searchParams.get("univ_edu_3")
45
- const major_edu_1 = searchParams.get("major_edu_1")
46
- const major_edu_2 = searchParams.get("major_edu_2")
47
- const major_edu_3 = searchParams.get("major_edu_3")
 
 
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
- // --- SORT ---
 
 
53
  const sortBy = searchParams.get("sortBy") ?? "created_at"
54
- const sortOrder = searchParams.get("sortOrder") === "asc" ? "asc" : "desc"
 
55
 
56
  const allowedSortFields = [
57
- "fullname", "domicile", "yoe", "gpa_edu_1", "gpa_edu_2", "gpa_edu_3",
58
- "univ_edu_1", "univ_edu_2", "univ_edu_3", "major_edu_1", "major_edu_2",
59
- "major_edu_3", "created_at", "score",
 
 
 
 
 
 
 
 
 
 
 
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 [matchingHardskills, matchingSoftskills, matchingCertifications, matchingBusinessDomain] =
89
- search
90
- ? await Promise.all([
91
- getMatchingArrayValues("hardskills", search, prisma),
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
- // --- RESOLVE SCORE MAP (if criteria_id provided) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = s.profile_id ?? matchingToProfile.get(s.matching_id ?? "")
 
 
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
- // --- FETCH ALL PROFILES (NO PAGINATION) ---
 
 
182
  const profiles = await prisma.cv_profile.findMany({
183
  where,
184
- orderBy: isScoreSort ? { created_at: "desc" } : orderBy, // Don't sort by score in DB
185
  })
186
 
187
- // --- SORT BY SCORE IN-MEMORY IF NEEDED ---
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" ? aScore - bScore : bScore - aScore
 
 
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
- // --- GENERATE CSV ---
203
- const csvContent = generateCSV(profilesWithScore)
204
 
205
- if (!csvContent) {
206
  return NextResponse.json(
207
  { error: "No data to export" },
208
  { status: 400 }
209
  )
210
  }
211
 
212
- // --- RETURN CSV FILE ---
213
- const timestamp = new Date().toISOString().slice(0, 10)
214
- const filename = `candidates-${timestamp}.csv`
215
 
216
- return new NextResponse(csvContent, {
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
- * Generate CSV content from profiles
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
- if (stringValue.includes(",") || stringValue.includes('"') || stringValue.includes("\n")) {
283
- return `"${stringValue.replace(/"/g, '""')}"` // Escape quotes
 
 
 
 
 
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
- // pages/login.tsx
2
- import { LoginForm } from '@/components/LoginForm';
 
3
 
4
  export default function LoginPage() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  return (
6
- <div className="min-h-screen flex items-center justify-center bg-gray-50">
7
- <div className="w-full max-w-md space-y-8">
8
- <div className="text-center">
9
- <h2 className="text-3xl font-bold">Candidate Explorer</h2>
10
- <p className="mt-2 text-sm text-gray-600">
11
- Sign in to your account
12
- </p>
13
- </div>
14
-
15
- <div className="bg-white py-8 px-6 shadow rounded-lg">
16
- <LoginForm />
17
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  </div>
19
- </div>
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-green-100 text-green-700">
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-green-100 text-green-700">
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-green-600" />
546
- <h2 className="text-lg font-semibold text-green-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-green-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-green-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-green-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-green-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-green-600 mb-1">{profilesExtracted}</div>
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-green-600 text-sm font-medium mb-3">
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-green-500 bg-green-50'
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-green-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,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-green-100 text-green-600 px-6 py-2 rounded-md font-medium hover:bg-green-200 transition-colors">
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-green-600 text-white px-4 py-2 rounded-md font-medium hover:bg-green-700 transition-colors disabled:opacity-50"
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-green-600 text-white px-4 py-2 rounded-md font-medium hover:bg-green-700 transition-colors disabled:opacity-50"
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-green-600" />
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-green-600 hover:bg-green-700 rounded-md transition-colors"
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-green-600 hover:bg-green-700 text-white">Apply</Button>
286
  </DialogFooter>
287
  </DialogContent>
288
  </Dialog>
@@ -431,7 +431,7 @@ const FilterDialog = memo(({
431
  <Button
432
  type="button"
433
  variant="link"
434
- className="text-green-600 mt-2 p-0 h-auto"
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-green-600 hover:bg-green-700 text-white">
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-green-500"
594
  />
595
  <input
596
  type="number"
@@ -747,7 +747,7 @@ const CriteriaEditDialog = memo(({
747
  <Button
748
  type="button"
749
  variant="link"
750
- className="text-green-600 mt-2 p-0 h-auto"
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-green-600 hover:bg-green-700 text-white">
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-green-600' : 'text-red-600'}`}>
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-green-600 hover:bg-green-700'
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-green-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,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-green-100 flex items-center justify-center mb-4">
1226
- <Check className="size-8 text-green-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-green-600 hover:bg-green-700 text-white w-full">Done</Button>
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-green-600 border-t-transparent rounded-full animate-spin" />
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-gray-900 text-center text-green-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-gray-900 text-center text-green-500">
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-green-600 hover:underline"
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 bg-[url(/dashboard/background.png)] bg-cover">
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: loadingScore, isError } = useQuery({
72
  queryKey: ["score-data"],
73
- queryFn: () => fetchScore(),
74
  staleTime: 0,
75
- placeholderData: (prev) => prev,
76
  refetchOnWindowFocus: false,
77
- refetchOnMount: false,
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 (loadingScore) {
101
  return (
102
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
103
- {[1, 2].map((i) => (
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-green-100 text-green-800">
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-[#22c55e] text-white flex items-center justify-center hover:bg-[#16a34a] transition-colors">
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-green-600 mb-1">
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
- 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%" height={Math.max(250, dataWithPercentages.length * 40)}>
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-green-500', className)}
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-green-500', className)}
93
  {...props}
94
  >
95
  <Home />
@@ -116,12 +116,8 @@ function BreadcrumbEllipsis({
116
  }
117
 
118
  export {
119
- Breadcrumb,
120
- BreadcrumbHome,
121
- BreadcrumbList,
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:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
9
  {
10
  variants: {
11
  variant: {
12
- default: 'bg-primary text-primary-foreground hover:bg-primary/90',
 
 
13
  destructive:
14
- 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
 
15
  outline:
16
- 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
 
17
  secondary:
18
- 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
 
19
  ghost:
20
- 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
21
- link: 'text-primary underline-offset-4 hover:underline',
 
 
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-green-500">{label}</div>
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
- buttonVariants({
58
- variant: isActive ? 'outline' : 'ghost',
59
- size,
60
- }),
61
- className,
 
 
 
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
- if (filters.yoe) params.set("yoe", filters.yoe)
147
-
148
- filters.educations.forEach((edu, i) => {
149
- const n = i + 1
150
- if (edu.university) params.set(`univ_edu_${n}`, edu.university)
151
- if (edu.major) params.set(`major_edu_${n}`, edu.major)
152
- if (edu.gpa) params.set(`gpa_${n}`, edu.gpa)
153
- })
154
-
155
- if (filters.softskills) params.append("softskills", filters.softskills)
156
- if (filters.hardskills) params.append("hardskills", filters.hardskills)
157
- if (filters.certifications) params.append("certifications", filters.certifications)
158
- if (filters.businessDomain) params.append("business_domain", filters.businessDomain)
159
- if (criteriaId) params.append("criteria_id", criteriaId)
 
 
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}`, {