Yvonne Priscilla commited on
Commit
fb2a8ad
·
1 Parent(s): 7ad84c4

update hardskills, export, calculate e2e

Browse files
src/app/api/cv-profile/export/route.ts ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) {
148
+ const weight = await prisma.cv_weight.findFirst({
149
+ where: { criteria_id },
150
+ select: { weight_id: true },
151
+ })
152
+
153
+ if (weight) {
154
+ const matchings = await prisma.cv_matching.findMany({
155
+ where: { weight_id: weight.weight_id },
156
+ select: { matching_id: true, profile_id: true },
157
+ })
158
+
159
+ if (matchings.length > 0) {
160
+ const matchingIds = matchings.map((m) => m.matching_id)
161
+
162
+ const scores = await prisma.cv_score.findMany({
163
+ where: { matching_id: { in: matchingIds } },
164
+ select: { matching_id: true, profile_id: true, score: true },
165
+ })
166
+
167
+ const matchingToProfile = new Map(
168
+ matchings.map((m) => [m.matching_id, m.profile_id])
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
+ }
176
+ }
177
+ }
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",
220
+ "Content-Disposition": `attachment; filename="${filename}"`,
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(
228
+ { error: "Failed to export candidates" },
229
+ { status: 500 }
230
+ )
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Generate CSV content from profiles
236
+ */
237
+ function generateCSV(profiles: any[]): string {
238
+ if (profiles.length === 0) return ""
239
+
240
+ const columns = [
241
+ "profile_id",
242
+ "fullname",
243
+ "domicile",
244
+ "yoe",
245
+ "univ_edu_1",
246
+ "major_edu_1",
247
+ "gpa_edu_1",
248
+ "univ_edu_2",
249
+ "major_edu_2",
250
+ "gpa_edu_2",
251
+ "univ_edu_3",
252
+ "major_edu_3",
253
+ "gpa_edu_3",
254
+ "hardskills",
255
+ "softskills",
256
+ "certifications",
257
+ "business_domain",
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
287
+ })
288
+ .join(",")
289
+ )
290
+
291
+ return [header, ...rows].join("\n")
292
+ }
src/app/api/cv-profile/options/route.ts CHANGED
@@ -6,6 +6,7 @@ export async function GET() {
6
  const profiles = await prisma.cv_profile.findMany({
7
  select: {
8
  domicile: true,
 
9
  softskills: true,
10
  certifications: true,
11
  business_domain: true,
@@ -28,6 +29,7 @@ export async function GET() {
28
  const options = {
29
  domicile: unique(profiles.map((p) => p.domicile)),
30
  softskills: flatArray("softskills"),
 
31
  certifications: flatArray("certifications"),
32
  business_domain: flatArray("business_domain"),
33
  univ_edu: unique([
 
6
  const profiles = await prisma.cv_profile.findMany({
7
  select: {
8
  domicile: true,
9
+ hardskills: true,
10
  softskills: true,
11
  certifications: true,
12
  business_domain: true,
 
29
  const options = {
30
  domicile: unique(profiles.map((p) => p.domicile)),
31
  softskills: flatArray("softskills"),
32
+ hardskills: flatArray("hardskills"),
33
  certifications: flatArray("certifications"),
34
  business_domain: flatArray("business_domain"),
35
  univ_edu: unique([
src/app/api/cv-profile/route.ts CHANGED
@@ -2,6 +2,8 @@ import { PrismaClient } from "@/generated/prisma";
2
  import { prisma } from "@/lib/prisma";
3
  import { NextRequest, NextResponse } from "next/server";
4
 
 
 
5
  async function getMatchingArrayValues(
6
  column: string,
7
  search: string,
@@ -17,7 +19,6 @@ async function getMatchingArrayValues(
17
  search
18
  ) as { val: string }[];
19
 
20
- // Filter in JS for partial match
21
  return result
22
  .map((r) => r.val)
23
  .filter((v) => v.toLowerCase().includes(search.toLowerCase()));
@@ -27,18 +28,25 @@ export async function GET(request: NextRequest) {
27
  const { searchParams } = new URL(request.url);
28
 
29
  // --- PAGINATION ---
30
- const page = Number.parseInt(searchParams.get("page") ?? "1");
31
- const limit = Number.parseInt(searchParams.get("limit") ?? "10");
32
  const skip = (page - 1) * limit;
33
 
34
  // --- SEARCH ---
35
  const search = searchParams.get("search");
36
 
 
 
 
 
 
 
 
37
  // --- FILTERS ---
38
  const domicile = searchParams.get("domicile");
39
- const yoe_min = searchParams.get("yoe_min");
40
- const yoe_max = searchParams.get("yoe_max");
41
  const softskills = searchParams.getAll("softskills");
 
42
  const certifications = searchParams.getAll("certifications");
43
  const business_domain = searchParams.getAll("business_domain");
44
 
@@ -48,12 +56,9 @@ export async function GET(request: NextRequest) {
48
  const major_edu_1 = searchParams.get("major_edu_1");
49
  const major_edu_2 = searchParams.get("major_edu_2");
50
  const major_edu_3 = searchParams.get("major_edu_3");
51
- const gpa_min_1 = searchParams.get("gpa_min_1");
52
- const gpa_max_1 = searchParams.get("gpa_max_1");
53
- const gpa_min_2 = searchParams.get("gpa_min_2");
54
- const gpa_max_2 = searchParams.get("gpa_max_2");
55
- const gpa_min_3 = searchParams.get("gpa_min_3");
56
- const gpa_max_3 = searchParams.get("gpa_max_3");
57
 
58
  // --- SORT ---
59
  const sortBy = searchParams.get("sortBy") ?? "created_at";
@@ -62,128 +67,165 @@ export async function GET(request: NextRequest) {
62
  const allowedSortFields = [
63
  "fullname", "domicile", "yoe", "gpa_edu_1", "gpa_edu_2", "gpa_edu_3",
64
  "univ_edu_1", "univ_edu_2", "univ_edu_3", "major_edu_1", "major_edu_2",
65
- "major_edu_3", "created_at",
66
  ];
67
 
68
- const orderBy = allowedSortFields.includes(sortBy)
 
 
69
  ? { [sortBy]: sortOrder }
70
  : { created_at: "desc" as const };
71
 
72
- // --- BUILD WHERE ---
73
- const where: any = {
74
- // Search across all text columns
75
- ...(search ? {
76
- OR: [
77
- { fullname: { contains: search, mode: "insensitive" } },
78
- { domicile: { contains: search, mode: "insensitive" } },
79
- { univ_edu_1: { contains: search, mode: "insensitive" } },
80
- { univ_edu_2: { contains: search, mode: "insensitive" } },
81
- { univ_edu_3: { contains: search, mode: "insensitive" } },
82
- { major_edu_1: { contains: search, mode: "insensitive" } },
83
- { major_edu_2: { contains: search, mode: "insensitive" } },
84
- { major_edu_3: { contains: search, mode: "insensitive" } },
85
- { filename: { contains: search, mode: "insensitive" } },
86
-
87
- // Array partial + case-insensitive search using raw filter
88
- {
89
- hardskills: {
90
- hasSome: await getMatchingArrayValues("hardskills", search, prisma),
91
- },
92
- },
93
- {
94
- softskills: {
95
- hasSome: await getMatchingArrayValues("softskills", search, prisma),
96
- },
97
- },
98
- {
99
- certifications: {
100
- hasSome: await getMatchingArrayValues("certifications", search, prisma),
101
- },
102
- },
103
- {
104
- business_domain: {
105
- hasSome: await getMatchingArrayValues("business_domain", search, prisma),
106
- },
107
- },
108
-
109
- // GPA — only search if input is a valid number
110
- ...(Number.isNaN(Number.parseFloat(search)) ? [] : [
111
- { gpa_edu_1: { equals: Number.parseFloat(search) } },
112
- { gpa_edu_2: { equals: Number.parseFloat(search) } },
113
- { gpa_edu_3: { equals: Number.parseFloat(search) } },
114
- ]),
115
-
116
- // YOE — only search if input is a valid integer
117
- ...(Number.isNaN(Number.parseInt(search)) ? [] : [
118
- { yoe: { equals: Number.parseInt(search) } },
119
- ]),
120
- ],
121
- } : {}),
122
 
123
  ...(domicile && { domicile }),
124
-
125
- ...(yoe_min || yoe_max
126
- ? {
127
- yoe: {
128
- ...(yoe_min && { gte: Number.parseInt(yoe_min) }),
129
- ...(yoe_max && { lte: Number.parseInt(yoe_max) }),
130
- },
131
- }
132
- : {}),
133
-
134
- ...(softskills.length > 0 && {
135
- softskills: { hasSome: softskills },
136
- }),
137
-
138
- ...(certifications.length > 0 && {
139
- certifications: { hasSome: certifications },
140
- }),
141
-
142
- ...(business_domain.length > 0 && {
143
- business_domain: { hasSome: business_domain },
144
- }),
145
-
146
  ...(univ_edu_1 && { univ_edu_1 }),
147
  ...(major_edu_1 && { major_edu_1 }),
148
- ...((gpa_min_1 || gpa_max_1) && {
149
- gpa_edu_1: {
150
- ...(gpa_min_1 && { gte: Number.parseFloat(gpa_min_1) }),
151
- ...(gpa_max_1 && { lte: Number.parseFloat(gpa_max_1) }),
152
- },
153
- }),
154
-
155
  ...(univ_edu_2 && { univ_edu_2 }),
156
  ...(major_edu_2 && { major_edu_2 }),
157
- ...((gpa_min_2 || gpa_max_2) && {
158
- gpa_edu_2: {
159
- ...(gpa_min_2 && { gte: Number.parseFloat(gpa_min_2) }),
160
- ...(gpa_max_2 && { lte: Number.parseFloat(gpa_max_2) }),
161
- },
162
- }),
163
-
164
  ...(univ_edu_3 && { univ_edu_3 }),
165
  ...(major_edu_3 && { major_edu_3 }),
166
- ...((gpa_min_3 || gpa_max_3) && {
167
- gpa_edu_3: {
168
- ...(gpa_min_3 && { gte: Number.parseFloat(gpa_min_3) }),
169
- ...(gpa_max_3 && { lte: Number.parseFloat(gpa_max_3) }),
170
- },
171
- }),
172
  };
173
 
174
  try {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  const [profiles, total] = await Promise.all([
176
  prisma.cv_profile.findMany({
177
  where,
178
  orderBy,
179
- skip,
180
- take: limit,
181
  }),
182
  prisma.cv_profile.count({ where }),
183
  ]);
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  return NextResponse.json({
186
- data: profiles,
187
  pagination: {
188
  total,
189
  page,
@@ -193,6 +235,7 @@ const where: any = {
193
  hasPrev: page > 1,
194
  },
195
  });
 
196
  } catch (error) {
197
  console.error(error);
198
  return NextResponse.json({ error: "Failed to fetch profiles" }, { status: 500 });
 
2
  import { prisma } from "@/lib/prisma";
3
  import { NextRequest, NextResponse } from "next/server";
4
 
5
+ const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
6
+
7
  async function getMatchingArrayValues(
8
  column: string,
9
  search: string,
 
19
  search
20
  ) as { val: string }[];
21
 
 
22
  return result
23
  .map((r) => r.val)
24
  .filter((v) => v.toLowerCase().includes(search.toLowerCase()));
 
28
  const { searchParams } = new URL(request.url);
29
 
30
  // --- PAGINATION ---
31
+ const page = Math.max(1, Number.parseInt(searchParams.get("page") ?? "1") || 1);
32
+ const limit = Math.min(100, Math.max(1, Number.parseInt(searchParams.get("limit") ?? "10") || 10));
33
  const skip = (page - 1) * limit;
34
 
35
  // --- SEARCH ---
36
  const search = searchParams.get("search");
37
 
38
+ // --- CRITERIA ---
39
+ const criteria_id = searchParams.get("criteria_id");
40
+
41
+ if (criteria_id && !UUID_REGEX.test(criteria_id)) {
42
+ return NextResponse.json({ error: "Invalid criteria_id format" }, { status: 400 });
43
+ }
44
+
45
  // --- FILTERS ---
46
  const domicile = searchParams.get("domicile");
47
+ const yoe = searchParams.get("yoe");
 
48
  const softskills = searchParams.getAll("softskills");
49
+ const hardskills = searchParams.getAll("hardskills");
50
  const certifications = searchParams.getAll("certifications");
51
  const business_domain = searchParams.getAll("business_domain");
52
 
 
56
  const major_edu_1 = searchParams.get("major_edu_1");
57
  const major_edu_2 = searchParams.get("major_edu_2");
58
  const major_edu_3 = searchParams.get("major_edu_3");
59
+ const gpa_1 = searchParams.get("gpa_1");
60
+ const gpa_2 = searchParams.get("gpa_2");
61
+ const gpa_3 = searchParams.get("gpa_3");
 
 
 
62
 
63
  // --- SORT ---
64
  const sortBy = searchParams.get("sortBy") ?? "created_at";
 
67
  const allowedSortFields = [
68
  "fullname", "domicile", "yoe", "gpa_edu_1", "gpa_edu_2", "gpa_edu_3",
69
  "univ_edu_1", "univ_edu_2", "univ_edu_3", "major_edu_1", "major_edu_2",
70
+ "major_edu_3", "created_at", "score",
71
  ];
72
 
73
+ const isScoreSort = sortBy === "score" && !!criteria_id;
74
+
75
+ const orderBy = !isScoreSort && allowedSortFields.includes(sortBy)
76
  ? { [sortBy]: sortOrder }
77
  : { created_at: "desc" as const };
78
 
79
+ // --- PARALLEL ARRAY SEARCH (only when search is provided) ---
80
+ const [matchingHardskills, matchingSoftskills, matchingCertifications, matchingBusinessDomain] =
81
+ search
82
+ ? await Promise.all([
83
+ getMatchingArrayValues("hardskills", search, prisma),
84
+ getMatchingArrayValues("softskills", search, prisma),
85
+ getMatchingArrayValues("certifications", search, prisma),
86
+ getMatchingArrayValues("business_domain", search, prisma),
87
+ ])
88
+ : [[], [], [], []];
89
+
90
+ // --- BUILD WHERE ---
91
+ const where: any = {
92
+ ...(search ? {
93
+ OR: [
94
+ { fullname: { contains: search, mode: "insensitive" } },
95
+ { domicile: { contains: search, mode: "insensitive" } },
96
+ { univ_edu_1: { contains: search, mode: "insensitive" } },
97
+ { univ_edu_2: { contains: search, mode: "insensitive" } },
98
+ { univ_edu_3: { contains: search, mode: "insensitive" } },
99
+ { major_edu_1: { contains: search, mode: "insensitive" } },
100
+ { major_edu_2: { contains: search, mode: "insensitive" } },
101
+ { major_edu_3: { contains: search, mode: "insensitive" } },
102
+ { filename: { contains: search, mode: "insensitive" } },
103
+ // ✅ Guard against empty arrays which cause Prisma errors
104
+ ...(matchingHardskills.length > 0 ? [{ hardskills: { hasSome: matchingHardskills } }] : []),
105
+ ...(matchingSoftskills.length > 0 ? [{ softskills: { hasSome: matchingSoftskills } }] : []),
106
+ ...(matchingCertifications.length > 0 ? [{ certifications: { hasSome: matchingCertifications } }] : []),
107
+ ...(matchingBusinessDomain.length > 0 ? [{ business_domain: { hasSome: matchingBusinessDomain } }] : []),
108
+ ...(Number.isNaN(Number.parseFloat(search)) ? [] : [
109
+ { gpa_edu_1: { equals: Number.parseFloat(search) } },
110
+ { gpa_edu_2: { equals: Number.parseFloat(search) } },
111
+ { gpa_edu_3: { equals: Number.parseFloat(search) } },
112
+ ]),
113
+ ...(Number.isNaN(Number.parseInt(search)) ? [] : [
114
+ { yoe: { equals: Number.parseInt(search) } },
115
+ ]),
116
+ ],
117
+ } : {}),
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  ...(domicile && { domicile }),
120
+ ...(yoe && { yoe: { gte: Number.parseInt(yoe) } }),
121
+ ...(softskills.length > 0 && { softskills: { hasSome: softskills } }),
122
+ ...(hardskills.length > 0 && { hardskills: { hasSome: hardskills } }),
123
+ ...(certifications.length > 0 && { certifications: { hasSome: certifications } }),
124
+ ...(business_domain.length > 0 && { business_domain: { hasSome: business_domain } }),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  ...(univ_edu_1 && { univ_edu_1 }),
126
  ...(major_edu_1 && { major_edu_1 }),
127
+ ...(gpa_1 && { gpa_edu_1: { gte: Number.parseFloat(gpa_1) } }),
 
 
 
 
 
 
128
  ...(univ_edu_2 && { univ_edu_2 }),
129
  ...(major_edu_2 && { major_edu_2 }),
130
+ ...(gpa_2 && { gpa_edu_2: { gte: Number.parseFloat(gpa_2) } }),
 
 
 
 
 
 
131
  ...(univ_edu_3 && { univ_edu_3 }),
132
  ...(major_edu_3 && { major_edu_3 }),
133
+ ...(gpa_3 && { gpa_edu_3: { gte: Number.parseFloat(gpa_3) } }),
 
 
 
 
 
134
  };
135
 
136
  try {
137
+ // --- RESOLVE SCORE MAP ---
138
+ const scoreMap = new Map<string, number | null>();
139
+
140
+ if (criteria_id) {
141
+ const weight = await prisma.cv_weight.findFirst({
142
+ where: { criteria_id },
143
+ select: { weight_id: true },
144
+ });
145
+ if (weight) {
146
+ const matchings = await prisma.cv_matching.findMany({
147
+ where: { weight_id: weight.weight_id },
148
+ select: { matching_id: true, profile_id: true },
149
+ });
150
+
151
+ console.log(matchings)
152
+
153
+ if (matchings.length > 0) {
154
+ const matchingIds = matchings.map((m) => m.matching_id);
155
+
156
+ const scores = await prisma.cv_score.findMany({
157
+ where: { matching_id: { in: matchingIds } },
158
+ select: { matching_id: true, profile_id: true, score: true },
159
+ });
160
+
161
+ const matchingToProfile = new Map(
162
+ matchings.map((m) => [m.matching_id, m.profile_id])
163
+ );
164
+
165
+ for (const s of scores) {
166
+ const profileId = s.profile_id ?? matchingToProfile.get(s.matching_id ?? "");
167
+ if (profileId) {
168
+ scoreMap.set(profileId, s.score ?? null);
169
+ }
170
+ }
171
+ }
172
+ }
173
+ }
174
+
175
+ // --- FETCH PROFILES + TOTAL COUNT ---
176
  const [profiles, total] = await Promise.all([
177
  prisma.cv_profile.findMany({
178
  where,
179
  orderBy,
180
+ ...(isScoreSort ? {} : { skip, take: limit }),
 
181
  }),
182
  prisma.cv_profile.count({ where }),
183
  ]);
184
 
185
+ // --- EARLY RETURN IF NO criteria_id (no score enrichment needed) ---
186
+ if (!criteria_id) {
187
+ return NextResponse.json({
188
+ data: profiles,
189
+ pagination: {
190
+ total,
191
+ page,
192
+ limit,
193
+ totalPages: Math.ceil(total / limit),
194
+ hasNext: page < Math.ceil(total / limit),
195
+ hasPrev: page > 1,
196
+ },
197
+ });
198
+ }
199
+
200
+ // --- ATTACH SCORE ---
201
+ const profilesWithScore = profiles.map((profile) => ({
202
+ ...profile,
203
+ score: scoreMap.get(profile.profile_id) ?? null,
204
+ }));
205
+
206
+ // --- IN-MEMORY SORT + PAGINATE FOR SCORE SORT ---
207
+ if (isScoreSort) {
208
+ profilesWithScore.sort((a, b) => {
209
+ const aScore = a.score ?? -Infinity;
210
+ const bScore = b.score ?? -Infinity;
211
+ return sortOrder === "asc" ? aScore - bScore : bScore - aScore;
212
+ });
213
+
214
+ return NextResponse.json({
215
+ data: profilesWithScore.slice(skip, skip + limit),
216
+ pagination: {
217
+ total,
218
+ page,
219
+ limit,
220
+ totalPages: Math.ceil(total / limit),
221
+ hasNext: page < Math.ceil(total / limit),
222
+ hasPrev: page > 1,
223
+ },
224
+ });
225
+ }
226
+
227
  return NextResponse.json({
228
+ data: profilesWithScore,
229
  pagination: {
230
  total,
231
  page,
 
235
  hasPrev: page > 1,
236
  },
237
  });
238
+
239
  } catch (error) {
240
  console.error(error);
241
  return NextResponse.json({ error: "Failed to fetch profiles" }, { status: 500 });
src/components/dashboard/candidates-table.tsx CHANGED
@@ -7,7 +7,9 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
7
  import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"
8
  import { Input } from "@/components/ui/input"
9
  import { authFetch } from "@/lib/api"
10
- import { CalculateWeightPayload, WeightBody } from "@/types/calculate"
 
 
11
  import { Candidate, Column, FilterFormValues, OptionsData, PaginationProps } from "@/types/candidate-table"
12
  import { useMutation, useQuery } from "@tanstack/react-query"
13
  import { Check, ChevronDown, ChevronUp, Columns, Eye, Filter, Plus, Search, Trash2 } from "lucide-react"
@@ -15,22 +17,6 @@ import { memo, useCallback, useEffect, useMemo, useState } from "react"
15
  import { useFieldArray, useForm } from "react-hook-form"
16
  import { Combobox, ComboboxOption } from "../ui/combobox"
17
 
18
- // Transform form values → API body
19
- function toWeightBody(value: CalculateWeightPayload): WeightBody {
20
- const edu = (i: number) => value.education[i] ?? { university: 0, major: 0, gpa: 0 }
21
- return {
22
- univ_edu_1: edu(0).university, major_edu_1: edu(0).major, gpa_edu_1: edu(0).gpa,
23
- univ_edu_2: edu(1).university, major_edu_2: edu(1).major, gpa_edu_2: edu(1).gpa,
24
- univ_edu_3: edu(2).university, major_edu_3: edu(2).major, gpa_edu_3: edu(2).gpa,
25
- domicile: value.others.domicile,
26
- yoe: value.others.yearOfExperiences,
27
- hardskills: value.others.hardskills,
28
- softskills: value.others.softskills,
29
- certifications: value.others.certifications,
30
- business_domain: value.others.businessDomain,
31
- }
32
- }
33
-
34
  // ============= FETCHER FUNCTIONS (outside component) =============
35
  const fetchOptions = async (): Promise<OptionsData> => {
36
  const res = await authFetch("/api/cv-profile/options")
@@ -42,7 +28,8 @@ const fetchCandidates = async (
42
  page: number,
43
  search: string,
44
  sortConfig: { key: string | null; direction: "asc" | "desc" },
45
- filters: FilterFormValues
 
46
  ) => {
47
  const params = new URLSearchParams()
48
  params.set("page", String(page))
@@ -55,18 +42,18 @@ const fetchCandidates = async (
55
  }
56
 
57
  if (filters.domicile) params.set("domicile", filters.domicile)
58
- if (filters.yoe_min) params.set("yoe_min", filters.yoe_min)
59
- if (filters.yoe_max) params.set("yoe_max", filters.yoe_max)
60
  filters.educations.forEach((edu, i) => {
61
  const n = i + 1
62
  if (edu.university) params.set(`univ_edu_${n}`, edu.university)
63
  if (edu.major) params.set(`major_edu_${n}`, edu.major)
64
- if (edu.gpa_min) params.set(`gpa_min_${n}`, edu.gpa_min)
65
- if (edu.gpa_max) params.set(`gpa_max_${n}`, edu.gpa_max)
66
  })
67
  if (filters.softskills) params.append("softskills", filters.softskills)
 
68
  if (filters.certifications) params.append("certifications", filters.certifications)
69
  if (filters.businessDomain) params.append("business_domain", filters.businessDomain)
 
70
 
71
  const res = await authFetch(`/api/cv-profile?${params}`)
72
  if (!res.ok) throw new Error("Failed to fetch candidates")
@@ -91,6 +78,7 @@ const allColumns: Column[] = [
91
  { id: "softskills", label: "Softskills", category: "others", visible: false, disabledSort: true },
92
  { id: "certifications", label: "Certifications", category: "others", visible: false },
93
  { id: "business_domain", label: "Business Domain", category: "others", visible: false },
 
94
  ]
95
 
96
  // ============= TABLE HEADER =============
@@ -165,6 +153,7 @@ const TableRow = memo(({
165
  {visibleColumns.softskills && <td className="p-3 text-sm">{row.softskills?.join(", ") ?? "-"}</td>}
166
  {visibleColumns.certifications && <td className="p-3 text-sm">{row.certifications?.join(", ") ?? "-"}</td>}
167
  {visibleColumns.business_domain && <td className="p-3 text-sm">{row.business_domain?.join(", ") ?? "-"}</td>}
 
168
  <td className="p-3">
169
  <Button variant="ghost" size="sm" onClick={() => onView(row)}>
170
  <Eye className="size-4" />
@@ -176,13 +165,14 @@ TableRow.displayName = "TableRow"
176
 
177
  // ============= TOOLBAR =============
178
  const Toolbar = memo(({
179
- searchQuery, onSearchChange, onFilterOpen, onCalculateOpen, onColumnOpen
180
  }: {
181
  searchQuery: string
182
  onSearchChange: (v: string) => void
183
  onFilterOpen: () => void
184
  onCalculateOpen: () => void
185
  onColumnOpen: () => void
 
186
  }) => (
187
  <div className="flex items-center justify-between gap-4">
188
  <div className="relative flex-1 max-w-sm">
@@ -204,7 +194,7 @@ const Toolbar = memo(({
204
  <Button variant="outline" size="sm" onClick={onColumnOpen}>
205
  <Columns className="size-4 mr-2" /> Column
206
  </Button>
207
- {/* <Button variant="outline" size="sm">Export</Button> */}
208
  </div>
209
  </div>
210
  ))
@@ -304,20 +294,6 @@ const FilterDialog = memo(({
304
  const toOptions = (arr: string[]): ComboboxOption[] =>
305
  arr.map((v) => ({ value: v, label: v }))
306
 
307
- const gpaOptions: ComboboxOption[] = [
308
- { value: "2.0", label: "2.0" },
309
- { value: "2.5", label: "2.5" },
310
- { value: "3.0", label: "3.0" },
311
- { value: "3.25", label: "3.25" },
312
- { value: "3.5", label: "3.5" },
313
- { value: "3.75", label: "3.75" },
314
- { value: "4.0", label: "4.0" },
315
- ]
316
-
317
- const yoeOptions: ComboboxOption[] = Array.from({ length: 11 }, (_, i) => ({
318
- value: String(i), label: `${i} year${i !== 1 ? "s" : ""}`
319
- }))
320
-
321
  const filterForm = useForm<FilterFormValues>({
322
  defaultValues: filter,
323
  })
@@ -333,10 +309,10 @@ const FilterDialog = memo(({
333
 
334
  const handleReset = () => {
335
  filterForm.reset({
336
- educations: [{ university: "", major: "", gpa_min: "", gpa_max: "" }],
337
  domicile: "",
338
- yoe_min: "",
339
- yoe_max: "",
340
  softskills: "",
341
  certifications: "",
342
  businessDomain: "",
@@ -428,34 +404,11 @@ const FilterDialog = memo(({
428
  <div className="flex gap-2">
429
  <FormField
430
  control={filterForm.control}
431
- name={`educations.${index}.gpa_min`}
432
- render={({ field }) => (
433
- <FormItem className="flex-1">
434
- <FormControl>
435
- <Combobox
436
- options={gpaOptions}
437
- value={field.value}
438
- onValueChange={field.onChange}
439
- placeholder="Min"
440
- searchPlaceholder="Min GPA"
441
- />
442
- </FormControl>
443
- </FormItem>
444
- )}
445
- />
446
- <FormField
447
- control={filterForm.control}
448
- name={`educations.${index}.gpa_max`}
449
  render={({ field }) => (
450
  <FormItem className="flex-1">
451
  <FormControl>
452
- <Combobox
453
- options={gpaOptions}
454
- value={field.value}
455
- onValueChange={field.onChange}
456
- placeholder="Max"
457
- searchPlaceholder="Max GPA"
458
- />
459
  </FormControl>
460
  </FormItem>
461
  )}
@@ -473,7 +426,7 @@ const FilterDialog = memo(({
473
  type="button"
474
  variant="link"
475
  className="text-green-600 mt-2 p-0 h-auto"
476
- onClick={() => append({ university: "", major: "", gpa_min: "", gpa_max: "" })}
477
  >
478
  + Add Education
479
  </Button>
@@ -508,34 +461,13 @@ const FilterDialog = memo(({
508
  <div className="flex gap-2">
509
  <FormField
510
  control={filterForm.control}
511
- name="yoe_min"
512
  render={({ field }) => (
513
  <FormItem className="flex-1">
514
  <FormControl>
515
- <Combobox
516
- options={yoeOptions}
517
- value={field.value}
518
- onValueChange={field.onChange}
519
- placeholder="Min"
520
- searchPlaceholder="Min YoE"
521
- />
522
- </FormControl>
523
- </FormItem>
524
- )}
525
- />
526
- <FormField
527
- control={filterForm.control}
528
- name="yoe_max"
529
- render={({ field }) => (
530
- <FormItem className="flex-1">
531
- <FormControl>
532
- <Combobox
533
- options={yoeOptions}
534
- value={field.value}
535
- onValueChange={field.onChange}
536
- placeholder="Max"
537
- searchPlaceholder="Max YoE"
538
- />
539
  </FormControl>
540
  </FormItem>
541
  )}
@@ -562,6 +494,27 @@ const FilterDialog = memo(({
562
  )}
563
  />
564
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
565
  <FormField
566
  control={filterForm.control}
567
  name="certifications"
@@ -668,12 +621,12 @@ const CalculateDialog = memo(({
668
  education: filter.educations.map((e) => ({
669
  university: !!e.university,
670
  major: !!e.major,
671
- gpa: !!(e.gpa_min || e.gpa_max),
672
  })),
673
  others: {
674
  domicile: !!filter.domicile,
675
- yearOfExperiences: !!(filter.yoe_min || filter.yoe_max),
676
- hardskills: false, // hardskills not in filter, always disabled
677
  softskills: !!filter.softskills,
678
  certifications: !!filter.certifications,
679
  businessDomain: !!filter.businessDomain,
@@ -932,6 +885,7 @@ SuccessDialog.displayName = "SuccessDialog"
932
  // ============= MAIN COMPONENT =============
933
  export default function CandidateTable() {
934
  const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
 
935
  const [searchQuery, setSearchQuery] = useState("")
936
  const [debouncedSearch, setDebouncedSearch] = useState("")
937
  const [currentPage, setCurrentPage] = useState(1)
@@ -952,8 +906,8 @@ export default function CandidateTable() {
952
  const [tempVisibleColumns, setTempVisibleColumns] = useState(visibleColumns)
953
 
954
  const defaultFilters: FilterFormValues = {
955
- educations: [{ university: "", major: "", gpa_min: "", gpa_max: "" }],
956
- domicile: "", yoe_min: "", yoe_max: "",
957
  softskills: "", certifications: "", businessDomain: "",
958
  }
959
  const [appliedFilters, setAppliedFilters] = useState<FilterFormValues>(defaultFilters)
@@ -964,6 +918,7 @@ export default function CandidateTable() {
964
  const [showSuccess, setShowSuccess] = useState(false)
965
  const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null)
966
  const [showDetail, setShowDetail] = useState(false)
 
967
 
968
  const handleView = useCallback((candidate: Candidate) => {
969
  setSelectedCandidate(candidate)
@@ -971,7 +926,7 @@ export default function CandidateTable() {
971
  }, [])
972
 
973
  // ✅ Options — cached persistently, refetches only once per hour
974
- const { data: options = { domicile: [], softskills: [], certifications: [], business_domain: [], univ_edu: [], major_edu: [] } } = useQuery({
975
  queryKey: ["cv-profile-options"],
976
  queryFn: fetchOptions,
977
  staleTime: 1000 * 60 * 60, // 1 hour — options rarely change
@@ -979,11 +934,13 @@ export default function CandidateTable() {
979
  })
980
 
981
  // ✅ Candidates — always fresh, refetches on any param change
982
- const { data: candidatesData, isLoading } = useQuery({
983
  queryKey: ["cv-profiles", currentPage, debouncedSearch, sortConfig, appliedFilters],
984
- queryFn: () => fetchCandidates(currentPage, debouncedSearch, sortConfig, appliedFilters),
985
  staleTime: 0, // always fresh
986
  placeholderData: (prev) => prev, // keep previous data while loading (no flicker)
 
 
987
  })
988
 
989
  const candidates: Candidate[] = useMemo(
@@ -1031,16 +988,12 @@ export default function CandidateTable() {
1031
  })
1032
  }, [])
1033
 
1034
- const { mutate: createWeight, isPending: isWeightPending } = useMutation({
1035
- mutationFn: async ({ value, criteriaId }: { value: CalculateWeightPayload; criteriaId: string }) => {
1036
- const res = await authFetch(`/api/agentic/create_weight?criteria_id=${criteriaId}`, {
1037
- method: "POST",
1038
- body: JSON.stringify(toWeightBody(value)),
1039
- })
1040
- if (!res.ok) throw new Error("Failed to create weight")
1041
- return res.json()
1042
- },
1043
- onSuccess: () => {
1044
  setIsCalculating(false)
1045
  setShowSuccess(true)
1046
  },
@@ -1049,14 +1002,37 @@ export default function CandidateTable() {
1049
  console.error("Failed to calculate:", error)
1050
  },
1051
  })
 
1052
  const onApplyCalculation = (value: CalculateWeightPayload) => {
1053
  setShowCalculateScore(false)
1054
  setIsCalculating(true)
1055
- createWeight({ value, criteriaId: "" })
1056
  }
1057
 
1058
  const allSelected = candidates.length > 0 && candidates.every((c) => selectedRows.has(c.profile_id))
1059
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
  return (
1061
  <div className="w-full space-y-4 p-4">
1062
  <Toolbar
@@ -1065,6 +1041,7 @@ export default function CandidateTable() {
1065
  onFilterOpen={() => setShowFilter(true)}
1066
  onCalculateOpen={() => setShowCalculateScore(true)}
1067
  onColumnOpen={() => setShowColumnSettings(true)}
 
1068
  />
1069
 
1070
  <div className="border rounded-lg overflow-hidden">
@@ -1126,7 +1103,7 @@ export default function CandidateTable() {
1126
  onOpenChange={setShowFilter}
1127
  filter={appliedFilters}
1128
  options={options}
1129
- onApply={(values) => { setAppliedFilters(values); setShowFilter(false) }}
1130
  />
1131
 
1132
  <CalculateDialog
@@ -1142,7 +1119,7 @@ export default function CandidateTable() {
1142
  candidate={selectedCandidate}
1143
  />
1144
 
1145
- <LoadingDialog open={isCalculating} />
1146
  <SuccessDialog open={showSuccess} onClose={() => setShowSuccess(false)} />
1147
  </div>
1148
  )
 
7
  import { Form, FormControl, FormField, FormItem, FormLabel } from "@/components/ui/form"
8
  import { Input } from "@/components/ui/input"
9
  import { authFetch } from "@/lib/api"
10
+ import { exportCandidatesToCSV } from "@/lib/export-service"
11
+ import { createAndCalculateScore } from "@/lib/scoring-service"
12
+ import { CalculateWeightPayload } from "@/types/calculate"
13
  import { Candidate, Column, FilterFormValues, OptionsData, PaginationProps } from "@/types/candidate-table"
14
  import { useMutation, useQuery } from "@tanstack/react-query"
15
  import { Check, ChevronDown, ChevronUp, Columns, Eye, Filter, Plus, Search, Trash2 } from "lucide-react"
 
17
  import { useFieldArray, useForm } from "react-hook-form"
18
  import { Combobox, ComboboxOption } from "../ui/combobox"
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  // ============= FETCHER FUNCTIONS (outside component) =============
21
  const fetchOptions = async (): Promise<OptionsData> => {
22
  const res = await authFetch("/api/cv-profile/options")
 
28
  page: number,
29
  search: string,
30
  sortConfig: { key: string | null; direction: "asc" | "desc" },
31
+ filters: FilterFormValues,
32
+ criteriaId: string | null
33
  ) => {
34
  const params = new URLSearchParams()
35
  params.set("page", String(page))
 
42
  }
43
 
44
  if (filters.domicile) params.set("domicile", filters.domicile)
45
+ if (filters.yoe) params.set("yoe", filters.yoe)
 
46
  filters.educations.forEach((edu, i) => {
47
  const n = i + 1
48
  if (edu.university) params.set(`univ_edu_${n}`, edu.university)
49
  if (edu.major) params.set(`major_edu_${n}`, edu.major)
50
+ if (edu.gpa) params.set(`gpa_${n}`, edu.gpa)
 
51
  })
52
  if (filters.softskills) params.append("softskills", filters.softskills)
53
+ if (filters.hardskills) params.append("hardskills", filters.hardskills)
54
  if (filters.certifications) params.append("certifications", filters.certifications)
55
  if (filters.businessDomain) params.append("business_domain", filters.businessDomain)
56
+ if (criteriaId) params.append("criteria_id", criteriaId)
57
 
58
  const res = await authFetch(`/api/cv-profile?${params}`)
59
  if (!res.ok) throw new Error("Failed to fetch candidates")
 
78
  { id: "softskills", label: "Softskills", category: "others", visible: false, disabledSort: true },
79
  { id: "certifications", label: "Certifications", category: "others", visible: false },
80
  { id: "business_domain", label: "Business Domain", category: "others", visible: false },
81
+ { id: "score", label: "Score", category: "others", visible: true },
82
  ]
83
 
84
  // ============= TABLE HEADER =============
 
153
  {visibleColumns.softskills && <td className="p-3 text-sm">{row.softskills?.join(", ") ?? "-"}</td>}
154
  {visibleColumns.certifications && <td className="p-3 text-sm">{row.certifications?.join(", ") ?? "-"}</td>}
155
  {visibleColumns.business_domain && <td className="p-3 text-sm">{row.business_domain?.join(", ") ?? "-"}</td>}
156
+ {visibleColumns.score && <td className="p-3 text-sm">{row.score}</td>}
157
  <td className="p-3">
158
  <Button variant="ghost" size="sm" onClick={() => onView(row)}>
159
  <Eye className="size-4" />
 
165
 
166
  // ============= TOOLBAR =============
167
  const Toolbar = memo(({
168
+ searchQuery, onSearchChange, onFilterOpen, onCalculateOpen, onColumnOpen, onExport
169
  }: {
170
  searchQuery: string
171
  onSearchChange: (v: string) => void
172
  onFilterOpen: () => void
173
  onCalculateOpen: () => void
174
  onColumnOpen: () => void
175
+ onExport: () => void
176
  }) => (
177
  <div className="flex items-center justify-between gap-4">
178
  <div className="relative flex-1 max-w-sm">
 
194
  <Button variant="outline" size="sm" onClick={onColumnOpen}>
195
  <Columns className="size-4 mr-2" /> Column
196
  </Button>
197
+ <Button variant="outline" size="sm" onClick={onExport}>Export</Button>
198
  </div>
199
  </div>
200
  ))
 
294
  const toOptions = (arr: string[]): ComboboxOption[] =>
295
  arr.map((v) => ({ value: v, label: v }))
296
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  const filterForm = useForm<FilterFormValues>({
298
  defaultValues: filter,
299
  })
 
309
 
310
  const handleReset = () => {
311
  filterForm.reset({
312
+ educations: [{ university: "", major: "", gpa: "" }],
313
  domicile: "",
314
+ yoe: "",
315
+ hardskills: "",
316
  softskills: "",
317
  certifications: "",
318
  businessDomain: "",
 
404
  <div className="flex gap-2">
405
  <FormField
406
  control={filterForm.control}
407
+ name={`educations.${index}.gpa`}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
408
  render={({ field }) => (
409
  <FormItem className="flex-1">
410
  <FormControl>
411
+ <Input type="number" onChange={(e) => field.onChange(e.target.value)} value={field.value} placeholder="Type GPA" />
 
 
 
 
 
 
412
  </FormControl>
413
  </FormItem>
414
  )}
 
426
  type="button"
427
  variant="link"
428
  className="text-green-600 mt-2 p-0 h-auto"
429
+ onClick={() => append({ university: "", major: "", gpa: "" })}
430
  >
431
  + Add Education
432
  </Button>
 
461
  <div className="flex gap-2">
462
  <FormField
463
  control={filterForm.control}
464
+ name="yoe"
465
  render={({ field }) => (
466
  <FormItem className="flex-1">
467
  <FormControl>
468
+ <FormControl>
469
+ <Input type="number" onChange={(e) => field.onChange(e.target.value)} value={field.value} placeholder="Type YoE" />
470
+ </FormControl>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
471
  </FormControl>
472
  </FormItem>
473
  )}
 
494
  )}
495
  />
496
 
497
+
498
+
499
+ <FormField
500
+ control={filterForm.control}
501
+ name="hardskills"
502
+ render={({ field }) => (
503
+ <FormItem>
504
+ <FormLabel className="text-sm text-muted-foreground">Hardskills</FormLabel>
505
+ <FormControl>
506
+ <Combobox
507
+ options={toOptions(options.hardskills)}
508
+ value={field.value}
509
+ onValueChange={field.onChange}
510
+ placeholder="Select certification"
511
+ searchPlaceholder="Search..."
512
+ />
513
+ </FormControl>
514
+ </FormItem>
515
+ )}
516
+ />
517
+
518
  <FormField
519
  control={filterForm.control}
520
  name="certifications"
 
621
  education: filter.educations.map((e) => ({
622
  university: !!e.university,
623
  major: !!e.major,
624
+ gpa: !!(e.gpa),
625
  })),
626
  others: {
627
  domicile: !!filter.domicile,
628
+ yearOfExperiences: !!(filter.yoe),
629
+ hardskills: !!(filter.hardskills),
630
  softskills: !!filter.softskills,
631
  certifications: !!filter.certifications,
632
  businessDomain: !!filter.businessDomain,
 
885
  // ============= MAIN COMPONENT =============
886
  export default function CandidateTable() {
887
  const [selectedRows, setSelectedRows] = useState<Set<string>>(new Set())
888
+ const [criteriaId, setCriteriaId] = useState<string | null>(null)
889
  const [searchQuery, setSearchQuery] = useState("")
890
  const [debouncedSearch, setDebouncedSearch] = useState("")
891
  const [currentPage, setCurrentPage] = useState(1)
 
906
  const [tempVisibleColumns, setTempVisibleColumns] = useState(visibleColumns)
907
 
908
  const defaultFilters: FilterFormValues = {
909
+ educations: [{ university: "", major: "", gpa: "" }],
910
+ domicile: "", yoe: "", hardskills: "",
911
  softskills: "", certifications: "", businessDomain: "",
912
  }
913
  const [appliedFilters, setAppliedFilters] = useState<FilterFormValues>(defaultFilters)
 
918
  const [showSuccess, setShowSuccess] = useState(false)
919
  const [selectedCandidate, setSelectedCandidate] = useState<Candidate | null>(null)
920
  const [showDetail, setShowDetail] = useState(false)
921
+ const [isExporting, setIsExporting] = useState(false)
922
 
923
  const handleView = useCallback((candidate: Candidate) => {
924
  setSelectedCandidate(candidate)
 
926
  }, [])
927
 
928
  // ✅ Options — cached persistently, refetches only once per hour
929
+ const { data: options = { domicile: [], softskills: [], certifications: [], business_domain: [], univ_edu: [], major_edu: [], hardskills: [] } } = useQuery({
930
  queryKey: ["cv-profile-options"],
931
  queryFn: fetchOptions,
932
  staleTime: 1000 * 60 * 60, // 1 hour — options rarely change
 
934
  })
935
 
936
  // ✅ Candidates — always fresh, refetches on any param change
937
+ const { data: candidatesData, isLoading, refetch } = useQuery({
938
  queryKey: ["cv-profiles", currentPage, debouncedSearch, sortConfig, appliedFilters],
939
+ queryFn: () => fetchCandidates(currentPage, debouncedSearch, sortConfig, appliedFilters, criteriaId),
940
  staleTime: 0, // always fresh
941
  placeholderData: (prev) => prev, // keep previous data while loading (no flicker)
942
+ refetchOnWindowFocus: false,
943
+ refetchOnMount: false,
944
  })
945
 
946
  const candidates: Candidate[] = useMemo(
 
988
  })
989
  }, [])
990
 
991
+ const { mutate: createScore } = useMutation({
992
+ mutationFn: ({ value }: { value: CalculateWeightPayload }) =>
993
+ createAndCalculateScore(appliedFilters, value),
994
+ onSuccess: ({ criteriaId }) => {
995
+ setCriteriaId(criteriaId)
996
+ refetch()
 
 
 
 
997
  setIsCalculating(false)
998
  setShowSuccess(true)
999
  },
 
1002
  console.error("Failed to calculate:", error)
1003
  },
1004
  })
1005
+
1006
  const onApplyCalculation = (value: CalculateWeightPayload) => {
1007
  setShowCalculateScore(false)
1008
  setIsCalculating(true)
1009
+ createScore({ value })
1010
  }
1011
 
1012
  const allSelected = candidates.length > 0 && candidates.every((c) => selectedRows.has(c.profile_id))
1013
 
1014
+ const handleFilterSubmit = (values: FilterFormValues) => {
1015
+ setAppliedFilters(values)
1016
+ setShowFilter(false)
1017
+ setCriteriaId(null)
1018
+ }
1019
+
1020
+ const handleExport = async () => {
1021
+ try {
1022
+ setIsExporting(true)
1023
+ await exportCandidatesToCSV(
1024
+ debouncedSearch,
1025
+ sortConfig,
1026
+ appliedFilters,
1027
+ "7e6e4e8f-7ab9-4fda-8398-b42380b1b834"
1028
+ )
1029
+ } catch (error) {
1030
+ console.error("Export failed:", error)
1031
+ } finally {
1032
+ setIsExporting(false)
1033
+ }
1034
+ }
1035
+
1036
  return (
1037
  <div className="w-full space-y-4 p-4">
1038
  <Toolbar
 
1041
  onFilterOpen={() => setShowFilter(true)}
1042
  onCalculateOpen={() => setShowCalculateScore(true)}
1043
  onColumnOpen={() => setShowColumnSettings(true)}
1044
+ onExport={() => handleExport()}
1045
  />
1046
 
1047
  <div className="border rounded-lg overflow-hidden">
 
1103
  onOpenChange={setShowFilter}
1104
  filter={appliedFilters}
1105
  options={options}
1106
+ onApply={handleFilterSubmit}
1107
  />
1108
 
1109
  <CalculateDialog
 
1119
  candidate={selectedCandidate}
1120
  />
1121
 
1122
+ <LoadingDialog open={isCalculating || isExporting} />
1123
  <SuccessDialog open={showSuccess} onClose={() => setShowSuccess(false)} />
1124
  </div>
1125
  )
src/components/dashboard/header-menu.tsx CHANGED
@@ -1,6 +1,5 @@
1
  "use client";
2
 
3
- import Image from "next/image";
4
  import { useEffect, useState } from "react";
5
  import {
6
  Breadcrumb, BreadcrumbHome, BreadcrumbItem,
@@ -35,14 +34,7 @@ export function HeaderMenu() {
35
  </Breadcrumb>
36
  <div className="text-sm text-gray-500">{dateStr}</div>
37
  </header>
38
- <header className="h-16 bg-white border-b border-gray-200 px-8 flex items-center justify-between relative overflow-hidden">
39
- <Image
40
- src="/dashboard/background.png"
41
- alt=""
42
- fill
43
- className="object-cover"
44
- priority
45
- />
46
  <h1 className="text-base font-bold text-foreground relative z-10">
47
  Recruitment AI Dashboard – MT Intake
48
  </h1>
 
1
  "use client";
2
 
 
3
  import { useEffect, useState } from "react";
4
  import {
5
  Breadcrumb, BreadcrumbHome, BreadcrumbItem,
 
34
  </Breadcrumb>
35
  <div className="text-sm text-gray-500">{dateStr}</div>
36
  </header>
37
+ <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">
 
 
 
 
 
 
 
38
  <h1 className="text-base font-bold text-foreground relative z-10">
39
  Recruitment AI Dashboard – MT Intake
40
  </h1>
src/components/dashboard/metrics-row.tsx CHANGED
@@ -1,19 +1,53 @@
1
  "use client";
2
 
3
  import { MetricsCard } from "@/components/ui/metrics-card";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  export function MetricsRow() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  return (
7
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
8
- <MetricsCard label="Extracted Profile" value="36.000" />
9
- <MetricsCard label="Processed" value="35%">
10
  <div className="grid grid-cols-2 gap-5">
11
  <div className="text-gray-500">Processed</div>
12
- <div className="font-bold text-gray-900">12.000</div>
13
  </div>
14
  <div className="grid grid-cols-2 gap-5">
15
  <div className="text-gray-500">Total Profile</div>
16
- <div className="font-bold text-gray-900">32.000</div>
17
  </div>
18
  </MetricsCard>
19
  </div>
 
1
  "use client";
2
 
3
  import { MetricsCard } from "@/components/ui/metrics-card";
4
+ import { authFetch } from "@/lib/api";
5
+ import { useQuery } from "@tanstack/react-query";
6
+
7
+ const BASE_URL = "https://byteriot-candidateexplorer.hf.space/CandidateExplorer/agentic"
8
+
9
+ type ScoreData = {
10
+ total_file: number
11
+ total_extracted: number
12
+ percent_extracted: number
13
+ }
14
+
15
+ const fallbackScore: ScoreData = {
16
+ total_file: 0,
17
+ total_extracted: 0,
18
+ percent_extracted: 0,
19
+ }
20
 
21
  export function MetricsRow() {
22
+
23
+ const fetchScore = async (): Promise<ScoreData> => {
24
+ const res = await authFetch(`${BASE_URL}/file/score`)
25
+ if (!res.ok) throw new Error("Failed to fetch score")
26
+ return res.json()
27
+ }
28
+
29
+ const { data, isLoading: loadingScore, isError } = useQuery({
30
+ queryKey: ["score-data"],
31
+ queryFn: () => fetchScore(),
32
+ staleTime: 0, // always fresh
33
+ placeholderData: (prev) => prev, // keep previous data while loading (no flicker)
34
+ refetchOnWindowFocus: false,
35
+ refetchOnMount: false,
36
+ })
37
+
38
+ const scoreData = isError ? fallbackScore : data ?? fallbackScore
39
+
40
  return (
41
  <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
42
+ <MetricsCard loading={loadingScore} label="Extracted Profile" value={`${scoreData?.total_extracted}`} />
43
+ <MetricsCard loading={loadingScore} label="Processed" value={`${scoreData?.percent_extracted}%`}>
44
  <div className="grid grid-cols-2 gap-5">
45
  <div className="text-gray-500">Processed</div>
46
+ <div className="font-bold text-gray-900">{scoreData?.total_extracted}</div>
47
  </div>
48
  <div className="grid grid-cols-2 gap-5">
49
  <div className="text-gray-500">Total Profile</div>
50
+ <div className="font-bold text-gray-900">{scoreData?.total_file}</div>
51
  </div>
52
  </MetricsCard>
53
  </div>
src/components/ui/metrics-card.tsx CHANGED
@@ -5,17 +5,20 @@ interface MetricsCardProps {
5
  children?: React.ReactNode
6
  label: string;
7
  value: string;
 
8
  }
9
 
10
- export function MetricsCard({ children, label, value }: MetricsCardProps) {
11
  return (
12
  <Card className="px-6 border-2 bg-white hover:shadow-md transition-shadow gap-3">
13
  <div className="flex flex-row items-center justify-between">
14
  <div>
15
  <div className="text-base text-gray-600 font-medium text-green-500">{label}</div>
16
- <div className="text-3xl font-bold text-gray-900">{value}</div>
 
 
17
  </div>
18
- {children ? <div>{children}</div> : null}
19
  </div>
20
  </Card>
21
  );
 
5
  children?: React.ReactNode
6
  label: string;
7
  value: string;
8
+ loading: boolean
9
  }
10
 
11
+ export function MetricsCard({ children, label, value, loading }: MetricsCardProps) {
12
  return (
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
+ }
20
  </div>
21
+ {children && !loading ? <div>{children}</div> : null}
22
  </div>
23
  </Card>
24
  );
src/lib/export-service.ts ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { authFetch } from "@/lib/api";
2
+ import { FilterFormValues } from "@/types/candidate-table";
3
+
4
+ /**
5
+ * Fetch all candidates without pagination for export
6
+ */
7
+ export const fetchAllCandidatesForExport = async (
8
+ search: string,
9
+ sortConfig: { key: string | null; direction: "asc" | "desc" },
10
+ filters: FilterFormValues,
11
+ criteriaId: string | null
12
+ ): Promise<any[]> => {
13
+ const params = new URLSearchParams()
14
+
15
+ // Set very high limit to get all results in one request
16
+ // Adjust based on your backend's maximum limit
17
+ params.set("page", "1")
18
+ params.set("limit", "10000") // Fetch up to 10,000 records
19
+
20
+ if (search) params.set("search", search)
21
+ if (sortConfig.key) {
22
+ params.set("sortBy", sortConfig.key)
23
+ params.set("sortOrder", sortConfig.direction)
24
+ }
25
+
26
+ if (filters.domicile) params.set("domicile", filters.domicile)
27
+ if (filters.yoe) params.set("yoe", filters.yoe)
28
+ filters.educations.forEach((edu, i) => {
29
+ const n = i + 1
30
+ if (edu.university) params.set(`univ_edu_${n}`, edu.university)
31
+ if (edu.major) params.set(`major_edu_${n}`, edu.major)
32
+ if (edu.gpa) params.set(`gpa_${n}`, edu.gpa)
33
+ })
34
+ if (filters.softskills) params.append("softskills", filters.softskills)
35
+ if (filters.hardskills) params.append("hardskills", filters.hardskills)
36
+ if (filters.certifications) params.append("certifications", filters.certifications)
37
+ if (filters.businessDomain) params.append("business_domain", filters.businessDomain)
38
+ if (criteriaId) params.append("criteria_id", criteriaId)
39
+
40
+ const res = await authFetch(`/api/cv-profile?${params}`)
41
+ if (!res.ok) throw new Error("Failed to fetch candidates for export")
42
+
43
+ const data = await res.json()
44
+ return data.data ?? []
45
+ }
46
+
47
+ /**
48
+ * Convert candidates array to CSV string
49
+ */
50
+ export const generateCSVContent = (candidates: any[]): string => {
51
+ if (candidates.length === 0) return ""
52
+
53
+ // Define all possible columns
54
+ const columns = [
55
+ "profile_id",
56
+ "fullname",
57
+ "domicile",
58
+ "yoe",
59
+ "univ_edu_1",
60
+ "major_edu_1",
61
+ "gpa_edu_1",
62
+ "univ_edu_2",
63
+ "major_edu_2",
64
+ "gpa_edu_2",
65
+ "univ_edu_3",
66
+ "major_edu_3",
67
+ "gpa_edu_3",
68
+ "hardskills",
69
+ "softskills",
70
+ "certifications",
71
+ "business_domain",
72
+ "score",
73
+ ]
74
+
75
+ // CSV Header
76
+ const header = columns.join(",")
77
+
78
+ // CSV Rows
79
+ const rows = candidates.map((candidate) =>
80
+ columns
81
+ .map((col) => {
82
+ const value = candidate[col]
83
+
84
+ // Handle arrays (join with semicolon)
85
+ if (Array.isArray(value)) {
86
+ return `"${value.join("; ")}"`
87
+ }
88
+
89
+ // Handle null/undefined
90
+ if (value === null || value === undefined) {
91
+ return ""
92
+ }
93
+
94
+ // Handle strings with commas or quotes (escape them)
95
+ const stringValue = String(value)
96
+ if (stringValue.includes(",") || stringValue.includes('"') || stringValue.includes("\n")) {
97
+ return `"${stringValue.replace(/"/g, '""')}"` // Escape quotes
98
+ }
99
+
100
+ return stringValue
101
+ })
102
+ .join(",")
103
+ )
104
+
105
+ return [header, ...rows].join("\n")
106
+ }
107
+
108
+ /**
109
+ * Trigger CSV download in browser
110
+ */
111
+ export const downloadCSV = (content: string, filename: string = "candidates.csv"): void => {
112
+ const blob = new Blob([content], { type: "text/csv;charset=utf-8;" })
113
+ const link = document.createElement("a")
114
+ const url = URL.createObjectURL(blob)
115
+
116
+ link.setAttribute("href", url)
117
+ link.setAttribute("download", filename)
118
+ link.style.visibility = "hidden"
119
+
120
+ document.body.appendChild(link)
121
+ link.click()
122
+ document.body.removeChild(link)
123
+
124
+ URL.revokeObjectURL(url)
125
+ }
126
+
127
+ /**
128
+ * Main export function - orchestrates fetch, generate, and download
129
+ */
130
+ 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()
138
+
139
+ if (search) params.set("search", search)
140
+ if (sortConfig.key) {
141
+ params.set("sortBy", sortConfig.key)
142
+ params.set("sortOrder", sortConfig.direction)
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}`, {
163
+ method: "GET",
164
+ headers: {
165
+ "Accept": "text/csv",
166
+ },
167
+ })
168
+
169
+ if (!response.ok) {
170
+ const error = await response.json()
171
+ throw new Error(error.error || "Export failed")
172
+ }
173
+
174
+ // Get blob and trigger download
175
+ const blob = await response.blob()
176
+ const url = window.URL.createObjectURL(blob)
177
+ const link = document.createElement("a")
178
+ link.href = url
179
+
180
+ // Extract filename from Content-Disposition header
181
+ const contentDisposition = response.headers.get("Content-Disposition")
182
+ const filename = contentDisposition
183
+ ? contentDisposition.split("filename=")[1]?.replace(/"/g, "")
184
+ : `candidates-${new Date().toISOString().slice(0, 10)}.csv`
185
+
186
+ link.setAttribute("download", filename)
187
+ document.body.appendChild(link)
188
+ link.click()
189
+
190
+ // Cleanup
191
+ document.body.removeChild(link)
192
+ window.URL.revokeObjectURL(url)
193
+
194
+ } catch (error) {
195
+ console.error("Export failed:", error)
196
+ throw error
197
+ }
198
+ }
src/lib/scoring-service.ts ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // lib/scoring-service.ts
2
+ import { authFetch } from "@/lib/api"
3
+ import { CalculateWeightPayload, WeightBody } from "@/types/calculate"
4
+ import { FilterFormValues } from "@/types/candidate-table"
5
+
6
+ const BASE_URL = "https://byteriot-candidateexplorer.hf.space/CandidateExplorer/agentic"
7
+
8
+ function toFilterBody(filters: FilterFormValues) {
9
+ return {
10
+ gpa_edu_1: filters.educations[0]?.gpa || null,
11
+ univ_edu_1: filters.educations[0]?.university || null,
12
+ major_edu_1: filters.educations[0]?.major || null,
13
+ gpa_edu_2: filters.educations[1]?.gpa || null,
14
+ univ_edu_2: filters.educations[1]?.university || null,
15
+ major_edu_2: filters.educations[1]?.major || null,
16
+ gpa_edu_3: filters.educations[2]?.gpa || null,
17
+ univ_edu_3: filters.educations[2]?.university || null,
18
+ major_edu_3: filters.educations[2]?.major || null,
19
+ domicile: filters.domicile || null,
20
+ yoe: filters.yoe || null,
21
+ hardskills: filters.hardskills?.length ? [filters.hardskills] : null,
22
+ softskills: filters.softskills?.length ? [filters.softskills] : null,
23
+ certifications: filters.certifications?.length ? [filters.certifications] : null,
24
+ business_domain: filters.businessDomain?.length ? [filters.businessDomain] : null,
25
+ }
26
+ }
27
+
28
+ function toWeightBody(value: CalculateWeightPayload): WeightBody {
29
+ const edu = (i: number) => value.education[i] ?? { university: 0, major: 0, gpa: 0 }
30
+ return {
31
+ univ_edu_1: edu(0).university / 100, major_edu_1: edu(0).major / 100, gpa_edu_1: edu(0).gpa / 100,
32
+ univ_edu_2: edu(1).university / 100, major_edu_2: edu(1).major / 100, gpa_edu_2: edu(1).gpa / 100,
33
+ univ_edu_3: edu(2).university / 100, major_edu_3: edu(2).major / 100, gpa_edu_3: edu(2).gpa / 100,
34
+ domicile: value.others.domicile / 100,
35
+ yoe: value.others.yearOfExperiences / 100,
36
+ hardskills: value.others.hardskills / 100,
37
+ softskills: value.others.softskills / 100,
38
+ certifications: value.others.certifications / 100,
39
+ business_domain: value.others.businessDomain / 100,
40
+ }
41
+ }
42
+
43
+ async function postJSON(url: string, body?: unknown) {
44
+ const res = await authFetch(url, {
45
+ method: "POST",
46
+ ...(body ? { body: JSON.stringify(body) } : {}),
47
+ })
48
+ if (!res.ok) throw new Error(`Request failed: ${url} (${res.status})`)
49
+ return res.json()
50
+ }
51
+
52
+ export async function createAndCalculateScore(
53
+ filters: FilterFormValues,
54
+ weights: CalculateWeightPayload
55
+ ): Promise<{ criteriaId: string }> {
56
+ // Step 1 — create filter → get criteria_id
57
+ const filterData = await postJSON(`${BASE_URL}/create_filter`, toFilterBody(filters))
58
+
59
+ // Step 2 — create weight → get weight_id + criteria_id
60
+ const weightData = await postJSON(
61
+ `${BASE_URL}/create_weight?criteria_id=${filterData.criteria_id}`,
62
+ toWeightBody(weights)
63
+ )
64
+
65
+ // Step 3 — trigger score calculation
66
+ await postJSON(`${BASE_URL}/calculate_score?weight_id=${weightData.data.weight_id}`)
67
+
68
+ return { criteriaId: weightData.data.criteria_id }
69
+ }
src/types/candidate-table.ts CHANGED
@@ -26,6 +26,7 @@ export interface Candidate {
26
  business_domain: string[]
27
  filename: string
28
  created_at: string | null
 
29
  }
30
 
31
  export interface PaginationProps {
@@ -41,13 +42,12 @@ export interface FilterFormValues {
41
  educations: {
42
  university: string
43
  major: string
44
- gpa_min: string
45
- gpa_max: string
46
  }[]
47
  domicile: string
48
- yoe_min: string
49
- yoe_max: string
50
  softskills: string
 
51
  certifications: string
52
  businessDomain: string
53
  }
@@ -55,6 +55,7 @@ export interface FilterFormValues {
55
  export interface OptionsData {
56
  domicile: string[]
57
  softskills: string[]
 
58
  certifications: string[]
59
  business_domain: string[]
60
  univ_edu: string[]
 
26
  business_domain: string[]
27
  filename: string
28
  created_at: string | null
29
+ score: string | number | null
30
  }
31
 
32
  export interface PaginationProps {
 
42
  educations: {
43
  university: string
44
  major: string
45
+ gpa: string
 
46
  }[]
47
  domicile: string
48
+ yoe: string
 
49
  softskills: string
50
+ hardskills: string
51
  certifications: string
52
  businessDomain: string
53
  }
 
55
  export interface OptionsData {
56
  domicile: string[]
57
  softskills: string[]
58
+ hardskills: string[]
59
  certifications: string[]
60
  business_domain: string[]
61
  univ_edu: string[]