ishaq101 commited on
Commit
7ca5efc
Β·
1 Parent(s): 239d85f

[KM-385] [CEX] [FE] Admin Page for upload and extract CV

Browse files
Files changed (2) hide show
  1. requirements.txt +2 -1
  2. src/streamlit_app.py +721 -38
requirements.txt CHANGED
@@ -1,3 +1,4 @@
1
  altair
2
  pandas
3
- streamlit
 
 
1
  altair
2
  pandas
3
+ streamlit
4
+ requests
src/streamlit_app.py CHANGED
@@ -1,40 +1,723 @@
1
- import altair as alt
2
- import numpy as np
3
- import pandas as pd
4
  import streamlit as st
5
 
6
- """
7
- # Welcome to Streamlit!
8
-
9
- Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
- If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
- forums](https://discuss.streamlit.io).
12
-
13
- In the meantime, below is an example of what you can do with just a few lines of code:
14
- """
15
-
16
- num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
- num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
-
19
- indices = np.linspace(0, 1, num_points)
20
- theta = 2 * np.pi * num_turns * indices
21
- radius = indices
22
-
23
- x = radius * np.cos(theta)
24
- y = radius * np.sin(theta)
25
-
26
- df = pd.DataFrame({
27
- "x": x,
28
- "y": y,
29
- "idx": indices,
30
- "rand": np.random.randn(num_points),
31
- })
32
-
33
- st.altair_chart(alt.Chart(df, height=700, width=700)
34
- .mark_point(filled=True)
35
- .encode(
36
- x=alt.X("x", axis=None),
37
- y=alt.Y("y", axis=None),
38
- color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
- size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
- ))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import requests
4
  import streamlit as st
5
 
6
+ # ─────────────────────────────────────────────
7
+ # CONFIG
8
+ # ─────────────────────────────────────────────
9
+ BASE_URL = os.environ.get("BACKEND_BASE_URL", "http://localhost:8000")
10
+
11
+ st.set_page_config(
12
+ page_title="Candidate Explorer",
13
+ page_icon="πŸ”",
14
+ layout="wide",
15
+ initial_sidebar_state="expanded",
16
+ )
17
+
18
+ # ─────────────────────────────────────────────
19
+ # GLOBAL CSS
20
+ # ─────────────────────────────────────────────
21
+ st.markdown(
22
+ """
23
+ <style>
24
+ /* ── Global ─────────────────────────────── */
25
+ html, body, [class*="css"] {
26
+ font-family: 'Inter', 'Segoe UI', sans-serif;
27
+ color: #333e4a;
28
+ background-color: #ffffff;
29
+ }
30
+
31
+ /* ── Sidebar ─────────────────────────────── */
32
+ [data-testid="stSidebar"] {
33
+ background-color: #f4f6ff;
34
+ border-right: 1px solid #c7cef5;
35
+ }
36
+ [data-testid="stSidebar"] h1,
37
+ [data-testid="stSidebar"] h2,
38
+ [data-testid="stSidebar"] h3,
39
+ [data-testid="stSidebar"] label {
40
+ color: #435cdc !important;
41
+ }
42
+
43
+ /* ── Buttons ─────────────────────────────── */
44
+ .stButton > button {
45
+ background-color: #435cdc;
46
+ color: #ffffff;
47
+ border: none;
48
+ border-radius: 8px;
49
+ padding: 0.5rem 1.25rem;
50
+ font-weight: 600;
51
+ transition: background-color 0.2s ease;
52
+ }
53
+ .stButton > button:hover {
54
+ background-color: #7b8de7;
55
+ color: #ffffff;
56
+ }
57
+ .stButton > button:focus {
58
+ outline: 2px solid #c7cef5;
59
+ }
60
+
61
+ /* ── Tabs ─────────────────────────────────── */
62
+ [data-baseweb="tab-list"] {
63
+ gap: 8px;
64
+ border-bottom: 2px solid #c7cef5;
65
+ }
66
+ [data-baseweb="tab"] {
67
+ border-radius: 8px 8px 0 0;
68
+ padding: 0.5rem 1.25rem;
69
+ font-weight: 600;
70
+ color: #7b8de7;
71
+ background: transparent;
72
+ }
73
+ [aria-selected="true"][data-baseweb="tab"] {
74
+ color: #435cdc !important;
75
+ border-bottom: 3px solid #435cdc !important;
76
+ background: #f4f6ff;
77
+ }
78
+
79
+ /* ── Inputs ──────────────────────────────── */
80
+ [data-testid="stTextInput"] input,
81
+ [data-testid="stSelectbox"] select,
82
+ textarea {
83
+ border-radius: 8px !important;
84
+ border: 1.5px solid #c7cef5 !important;
85
+ color: #333e4a !important;
86
+ }
87
+ [data-testid="stTextInput"] input:focus,
88
+ textarea:focus {
89
+ border-color: #435cdc !important;
90
+ box-shadow: 0 0 0 2px #c7cef5;
91
+ }
92
+
93
+ /* ── File uploader ───────────────────────── */
94
+ [data-testid="stFileUploader"] {
95
+ border: 2px dashed #7b8de7;
96
+ border-radius: 10px;
97
+ background: #f4f6ff;
98
+ padding: 1rem;
99
+ }
100
+
101
+ /* ── Metric cards ────────────────────────── */
102
+ .scorecard-wrap {
103
+ display: flex;
104
+ gap: 1rem;
105
+ margin-bottom: 1.5rem;
106
+ }
107
+ .scorecard-card {
108
+ flex: 1;
109
+ background: #f4f6ff;
110
+ border: 1.5px solid #c7cef5;
111
+ border-radius: 12px;
112
+ padding: 1.25rem 1.5rem;
113
+ box-shadow: 0 2px 8px rgba(67,92,220,0.07);
114
+ text-align: center;
115
+ }
116
+ .scorecard-card .sc-label {
117
+ font-size: 0.82rem;
118
+ font-weight: 600;
119
+ color: #7b8de7;
120
+ text-transform: uppercase;
121
+ letter-spacing: 0.04em;
122
+ margin-bottom: 0.4rem;
123
+ }
124
+ .scorecard-card .sc-value {
125
+ font-size: 2rem;
126
+ font-weight: 800;
127
+ color: #435cdc;
128
+ line-height: 1.1;
129
+ }
130
+ .scorecard-card .sc-sub {
131
+ font-size: 0.78rem;
132
+ color: #7b8de7;
133
+ margin-top: 0.2rem;
134
+ }
135
+
136
+ /* ── Badge ───────────────────────────────── */
137
+ .badge-success {
138
+ background: #c7cef5; color: #435cdc;
139
+ border-radius: 99px; padding: 2px 10px;
140
+ font-size: 0.78rem; font-weight: 700;
141
+ }
142
+ .badge-warn {
143
+ background: #fff3c4; color: #a07c00;
144
+ border-radius: 99px; padding: 2px 10px;
145
+ font-size: 0.78rem; font-weight: 700;
146
+ }
147
+ .badge-error {
148
+ background: #fde8e8; color: #c0392b;
149
+ border-radius: 99px; padding: 2px 10px;
150
+ font-size: 0.78rem; font-weight: 700;
151
+ }
152
+
153
+ /* ── Section header ──────────────────────── */
154
+ .section-title {
155
+ font-size: 1.15rem;
156
+ font-weight: 700;
157
+ color: #435cdc;
158
+ margin-bottom: 0.75rem;
159
+ display: flex;
160
+ align-items: center;
161
+ gap: 0.4rem;
162
+ }
163
+ .section-title::after {
164
+ content: '';
165
+ flex: 1;
166
+ height: 2px;
167
+ background: linear-gradient(90deg, #c7cef5, transparent);
168
+ margin-left: 0.5rem;
169
+ }
170
+
171
+ /* ── Login card ──────────────────────────── */
172
+ .login-card {
173
+ max-width: 420px;
174
+ margin: 4rem auto;
175
+ padding: 2.5rem 2rem;
176
+ background: #ffffff;
177
+ border-radius: 16px;
178
+ box-shadow: 0 4px 32px rgba(67,92,220,0.13);
179
+ border: 1.5px solid #c7cef5;
180
+ }
181
+ .login-card h1 {
182
+ color: #435cdc;
183
+ font-size: 1.8rem;
184
+ font-weight: 800;
185
+ margin-bottom: 0.25rem;
186
+ }
187
+ .login-card p {
188
+ color: #7b8de7;
189
+ font-size: 0.95rem;
190
+ margin-bottom: 1.5rem;
191
+ }
192
+
193
+ /* ── Divider ─────────────────────────────── */
194
+ hr.styled { border: none; border-top: 1.5px solid #c7cef5; margin: 1.2rem 0; }
195
+
196
+ /* ── JSON output ─────────────────────────── */
197
+ .profile-json {
198
+ background: #f4f6ff;
199
+ border-radius: 10px;
200
+ border: 1.5px solid #c7cef5;
201
+ padding: 1rem 1.25rem;
202
+ font-size: 0.85rem;
203
+ color: #333e4a;
204
+ white-space: pre-wrap;
205
+ word-break: break-word;
206
+ max-height: 500px;
207
+ overflow-y: auto;
208
+ }
209
+
210
+ /* ── Table ───────────────────────────────── */
211
+ [data-testid="stDataFrame"] {
212
+ border-radius: 10px;
213
+ overflow: hidden;
214
+ border: 1.5px solid #c7cef5;
215
+ }
216
+ </style>
217
+ """,
218
+ unsafe_allow_html=True,
219
+ )
220
+
221
+
222
+ # ─────────────────────────────────────────────
223
+ # SESSION STATE INIT
224
+ # ─────────────────────────────────────────────
225
+ for key, default in [
226
+ ("token", None),
227
+ ("user", None),
228
+ ("upload_results", []),
229
+ ("extract_result", None),
230
+ ("user_files", []),
231
+ ]:
232
+ if key not in st.session_state:
233
+ st.session_state[key] = default
234
+
235
+
236
+ # ─────────────────────────────────────────────
237
+ # API HELPERS
238
+ # ─────────────────────────────────────────────
239
+ def _headers():
240
+ return {"Authorization": f"Bearer {st.session_state.token}"}
241
+
242
+
243
+ def api_login(email: str, password: str):
244
+ """POST /admin/login β€” returns (token, error_msg)"""
245
+ try:
246
+ resp = requests.post(
247
+ f"{BASE_URL}/admin/login",
248
+ data={"username": email, "password": password},
249
+ timeout=15,
250
+ )
251
+ if resp.status_code == 200:
252
+ return resp.json().get("access_token"), None
253
+ detail = resp.json().get("detail", resp.text)
254
+ return None, str(detail)
255
+ except requests.exceptions.ConnectionError:
256
+ return None, "Cannot connect to backend. Check BACKEND_BASE_URL."
257
+ except Exception as e:
258
+ return None, str(e)
259
+
260
+
261
+ def api_get_me():
262
+ """GET /admin/me β€” returns user dict"""
263
+ try:
264
+ resp = requests.get(f"{BASE_URL}/admin/me", headers=_headers(), timeout=10)
265
+ if resp.status_code == 200:
266
+ return resp.json(), None
267
+ return None, resp.json().get("detail", resp.text)
268
+ except Exception as e:
269
+ return None, str(e)
270
+
271
+
272
+ def api_get_scorecard():
273
+ """GET /file/score_card β€” returns data dict"""
274
+ try:
275
+ resp = requests.get(f"{BASE_URL}/file/score_card", headers=_headers(), timeout=10)
276
+ if resp.status_code == 200:
277
+ return resp.json().get("data", {}), None
278
+ return None, resp.json().get("detail", resp.text)
279
+ except Exception as e:
280
+ return None, str(e)
281
+
282
+
283
+ def api_upload_files(uploaded_files):
284
+ """POST /file/upload β€” returns list of results"""
285
+ try:
286
+ files = [
287
+ ("files", (f.name, f.read(), "application/pdf"))
288
+ for f in uploaded_files
289
+ ]
290
+ resp = requests.post(
291
+ f"{BASE_URL}/file/upload",
292
+ headers=_headers(),
293
+ files=files,
294
+ timeout=60,
295
+ )
296
+ if resp.status_code == 201:
297
+ return resp.json().get("files", []), None
298
+ return None, resp.json().get("detail", resp.text)
299
+ except Exception as e:
300
+ return None, str(e)
301
+
302
+
303
+ def api_get_user_files(user_id: str):
304
+ """GET /file/user/{user_id} β€” returns list of file dicts"""
305
+ try:
306
+ resp = requests.get(
307
+ f"{BASE_URL}/file/user/{user_id}",
308
+ headers=_headers(),
309
+ timeout=10,
310
+ )
311
+ if resp.status_code == 200:
312
+ return resp.json().get("files", []), None
313
+ return None, resp.json().get("detail", resp.text)
314
+ except Exception as e:
315
+ return None, str(e)
316
+
317
+
318
+ def api_extract_profile(filename: str):
319
+ """POST /profile/extract_profile?filename=... β€” returns profile dict"""
320
+ try:
321
+ resp = requests.post(
322
+ f"{BASE_URL}/profile/extract_profile",
323
+ headers=_headers(),
324
+ params={"filename": filename},
325
+ timeout=120,
326
+ )
327
+ if resp.status_code == 200:
328
+ return resp.json(), None
329
+ return None, resp.json().get("detail", resp.text)
330
+ except Exception as e:
331
+ return None, str(e)
332
+
333
+
334
+ def api_delete_file(filename: str):
335
+ """DELETE /file/{filename}"""
336
+ try:
337
+ resp = requests.delete(
338
+ f"{BASE_URL}/file/{filename}",
339
+ headers=_headers(),
340
+ timeout=15,
341
+ )
342
+ if resp.status_code == 200:
343
+ return True, None
344
+ return False, resp.json().get("detail", resp.text)
345
+ except Exception as e:
346
+ return False, str(e)
347
+
348
+
349
+ # ─────────────────────────────────────────────
350
+ # UI COMPONENTS
351
+ # ─────────────────────────────────────────────
352
+ def render_scorecard():
353
+ sc, err = api_get_scorecard()
354
+ if err:
355
+ st.warning(f"Could not load scorecard: {err}")
356
+ return
357
+
358
+ total_file = sc.get("total_file", 0)
359
+ total_extracted = sc.get("total_extracted", 0)
360
+ pct = sc.get("percent_extracted", 0)
361
+ # percent_extracted may be a float like 75.0 or string "75%"
362
+ if isinstance(pct, str):
363
+ pct_display = pct
364
+ else:
365
+ pct_display = f"{pct:.1f}%"
366
+
367
+ st.markdown(
368
+ f"""
369
+ <div class="scorecard-wrap">
370
+ <div class="scorecard-card">
371
+ <div class="sc-label">πŸ“ Total CVs Uploaded</div>
372
+ <div class="sc-value">{total_file}</div>
373
+ <div class="sc-sub">files in your workspace</div>
374
+ </div>
375
+ <div class="scorecard-card">
376
+ <div class="sc-label">βœ… Profiles Extracted</div>
377
+ <div class="sc-value">{total_extracted}</div>
378
+ <div class="sc-sub">structured profiles</div>
379
+ </div>
380
+ <div class="scorecard-card">
381
+ <div class="sc-label">πŸ“Š Extraction Rate</div>
382
+ <div class="sc-value" style="color:#dcc343">{pct_display}</div>
383
+ <div class="sc-sub">of uploaded CVs processed</div>
384
+ </div>
385
+ </div>
386
+ """,
387
+ unsafe_allow_html=True,
388
+ )
389
+
390
+
391
+ def render_sidebar():
392
+ user = st.session_state.user or {}
393
+ with st.sidebar:
394
+ st.markdown(
395
+ f"""
396
+ <div style="text-align:center;padding:1rem 0 0.5rem;">
397
+ <div style="font-size:2.5rem;">πŸ‘€</div>
398
+ <div style="font-weight:800;font-size:1.1rem;color:#435cdc;">
399
+ {user.get('full_name', 'User')}
400
+ </div>
401
+ <div style="font-size:0.82rem;color:#7b8de7;margin-top:2px;">
402
+ {user.get('email', '')}
403
+ </div>
404
+ <span class="badge-success" style="margin-top:6px;display:inline-block;">
405
+ {user.get('role', 'user').upper()}
406
+ </span>
407
+ </div>
408
+ <hr class="styled">
409
+ """,
410
+ unsafe_allow_html=True,
411
+ )
412
+
413
+ st.markdown(
414
+ "<div style='font-size:0.78rem;color:#7b8de7;margin-bottom:4px;'>BACKEND</div>",
415
+ unsafe_allow_html=True,
416
+ )
417
+ st.markdown(
418
+ f"<code style='font-size:0.75rem;color:#435cdc;'>{BASE_URL}</code>",
419
+ unsafe_allow_html=True,
420
+ )
421
+
422
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
423
+
424
+ if st.button("πŸšͺ Logout", use_container_width=True):
425
+ for key in ["token", "user", "upload_results", "extract_result", "user_files"]:
426
+ st.session_state[key] = None if key in ("token", "user", "extract_result") else []
427
+ st.rerun()
428
+
429
+
430
+ # ─────────────────────────────────────────────
431
+ # PAGES
432
+ # ─────────────────────────────────────────────
433
+ def page_login():
434
+ # Center the login card
435
+ _, col, _ = st.columns([1, 1.4, 1])
436
+ with col:
437
+ st.markdown(
438
+ """
439
+ <div class="login-card">
440
+ <h1>πŸ” Candidate Explorer</h1>
441
+ <p>Sign in to manage and analyze candidate CVs.</p>
442
+ </div>
443
+ """,
444
+ unsafe_allow_html=True,
445
+ )
446
+
447
+ with st.form("login_form", clear_on_submit=False):
448
+ st.markdown(
449
+ "<div class='section-title'>Sign In</div>", unsafe_allow_html=True
450
+ )
451
+ email = st.text_input("Email address", placeholder="you@company.com")
452
+ password = st.text_input("Password", type="password", placeholder="β€’β€’β€’β€’β€’β€’β€’β€’")
453
+ submitted = st.form_submit_button("Sign In β†’", use_container_width=True)
454
+
455
+ if submitted:
456
+ if not email or not password:
457
+ st.error("Please enter both email and password.")
458
+ else:
459
+ with st.spinner("Signing in…"):
460
+ token, err = api_login(email, password)
461
+ if err:
462
+ st.error(f"Login failed: {err}")
463
+ else:
464
+ st.session_state.token = token
465
+ user, err2 = api_get_me()
466
+ if err2:
467
+ st.session_state.user = {"email": email, "full_name": email, "role": "user"}
468
+ else:
469
+ st.session_state.user = user
470
+ st.rerun()
471
+
472
+
473
+ def page_main():
474
+ render_sidebar()
475
+
476
+ # ── Page header ──────────────────────────────
477
+ st.markdown(
478
+ "<h1 style='color:#435cdc;font-size:1.75rem;font-weight:800;margin-bottom:0.1rem;'>"
479
+ "πŸ” Candidate Explorer"
480
+ "</h1>"
481
+ "<p style='color:#7b8de7;margin-top:0;margin-bottom:1.25rem;font-size:0.95rem;'>"
482
+ "Upload CVs, extract candidate profiles, and track your workspace.</p>",
483
+ unsafe_allow_html=True,
484
+ )
485
+
486
+ # ── Scorecard ─────────────────────────────────
487
+ st.markdown("<div class='section-title'>πŸ“Š Dashboard Overview</div>", unsafe_allow_html=True)
488
+ render_scorecard()
489
+
490
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
491
+
492
+ # ── Tabs ──────────────────────────────────────
493
+ tab_upload, tab_extract = st.tabs(["πŸ“ Upload CV", "🧠 Extract Profile"])
494
+
495
+ # ══════════════════════════════════════════
496
+ # TAB 1 β€” UPLOAD
497
+ # ══════════════════════════════════════════
498
+ with tab_upload:
499
+ st.markdown("<br>", unsafe_allow_html=True)
500
+ st.markdown(
501
+ "<div class='section-title'>Upload Candidate CVs</div>",
502
+ unsafe_allow_html=True,
503
+ )
504
+
505
+ uploaded = st.file_uploader(
506
+ "Drop PDF files here or click to browse",
507
+ type=["pdf"],
508
+ accept_multiple_files=True,
509
+ help="Only PDF files are accepted.",
510
+ )
511
+
512
+ col_btn, col_info = st.columns([1, 3])
513
+ with col_btn:
514
+ do_upload = st.button("⬆️ Upload", use_container_width=True, disabled=not uploaded)
515
+
516
+ if do_upload and uploaded:
517
+ with st.spinner(f"Uploading {len(uploaded)} file(s)…"):
518
+ results, err = api_upload_files(uploaded)
519
+ if err:
520
+ st.error(f"Upload failed: {err}")
521
+ else:
522
+ st.session_state.upload_results = results
523
+ # Refresh user files list
524
+ user = st.session_state.user or {}
525
+ uid = str(user.get("user_id", ""))
526
+ if uid:
527
+ files, _ = api_get_user_files(uid)
528
+ st.session_state.user_files = files or []
529
+ st.rerun()
530
+
531
+ # ── Results table ──
532
+ if st.session_state.upload_results:
533
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
534
+ st.markdown(
535
+ "<div class='section-title'>Upload Results</div>",
536
+ unsafe_allow_html=True,
537
+ )
538
+ for r in st.session_state.upload_results:
539
+ fname = r.get("filename", r.get("name", ""))
540
+ status = r.get("status", "uploaded")
541
+ badge_cls = "badge-success" if "success" in status.lower() or status == "uploaded" else "badge-error"
542
+ st.markdown(
543
+ f"<span class='badge-success'>βœ“</span>&nbsp;"
544
+ f"<strong style='color:#333e4a;'>{fname}</strong>&nbsp;"
545
+ f"<span class='{badge_cls}'>{status}</span>",
546
+ unsafe_allow_html=True,
547
+ )
548
+
549
+ # ── Existing files ──
550
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
551
+ st.markdown(
552
+ "<div class='section-title'>Your Uploaded Files</div>",
553
+ unsafe_allow_html=True,
554
+ )
555
+
556
+ user = st.session_state.user or {}
557
+ uid = str(user.get("user_id", ""))
558
+
559
+ col_refresh, _ = st.columns([1, 5])
560
+ with col_refresh:
561
+ if st.button("πŸ”„ Refresh List", use_container_width=True):
562
+ if uid:
563
+ files, err = api_get_user_files(uid)
564
+ if err:
565
+ st.warning(f"Could not load files: {err}")
566
+ else:
567
+ st.session_state.user_files = files or []
568
+
569
+ if not st.session_state.user_files and uid:
570
+ # Auto-load on first visit
571
+ files, _ = api_get_user_files(uid)
572
+ st.session_state.user_files = files or []
573
+
574
+ if st.session_state.user_files:
575
+ rows = []
576
+ for f in st.session_state.user_files:
577
+ rows.append(
578
+ {
579
+ "Filename": f.get("filename", ""),
580
+ "Type": f.get("file_type", ""),
581
+ "Extracted": "βœ…" if f.get("is_extracted") else "⏳",
582
+ "Uploaded": str(f.get("uploaded_at", ""))[:19],
583
+ }
584
+ )
585
+ st.dataframe(rows, use_container_width=True, hide_index=True)
586
+ else:
587
+ st.info("No files uploaded yet.")
588
+
589
+ # ══════════════════════════════════════════
590
+ # TAB 2 β€” EXTRACT PROFILE
591
+ # ══════════════════════════════════════════
592
+ with tab_extract:
593
+ st.markdown("<br>", unsafe_allow_html=True)
594
+ st.markdown(
595
+ "<div class='section-title'>Extract Structured Profile from CV</div>",
596
+ unsafe_allow_html=True,
597
+ )
598
+
599
+ # Load files if needed
600
+ user = st.session_state.user or {}
601
+ uid = str(user.get("user_id", ""))
602
+ if not st.session_state.user_files and uid:
603
+ files, _ = api_get_user_files(uid)
604
+ st.session_state.user_files = files or []
605
+
606
+ file_options = [f.get("filename", "") for f in st.session_state.user_files if f.get("filename")]
607
+
608
+ if not file_options:
609
+ st.info("No CVs found. Upload files first in the **Upload CV** tab.")
610
+ else:
611
+ col_sel, col_ex = st.columns([3, 1])
612
+ with col_sel:
613
+ chosen = st.selectbox(
614
+ "Select a CV file to extract",
615
+ options=file_options,
616
+ help="Choose a PDF you have already uploaded.",
617
+ )
618
+ with col_ex:
619
+ st.markdown("<div style='margin-top:1.72rem;'></div>", unsafe_allow_html=True)
620
+ do_extract = st.button("🧠 Extract", use_container_width=True)
621
+
622
+ if do_extract and chosen:
623
+ with st.spinner(f"Extracting profile from **{chosen}**… this may take a moment."):
624
+ result, err = api_extract_profile(chosen)
625
+ if err:
626
+ st.error(f"Extraction failed: {err}")
627
+ st.session_state.extract_result = None
628
+ else:
629
+ st.session_state.extract_result = result
630
+ # Refresh files to update is_extracted flag
631
+ if uid:
632
+ files, _ = api_get_user_files(uid)
633
+ st.session_state.user_files = files or []
634
+ st.rerun()
635
+
636
+ # ── Display extracted profile ──
637
+ if st.session_state.extract_result:
638
+ st.markdown("<hr class='styled'>", unsafe_allow_html=True)
639
+ result = st.session_state.extract_result
640
+
641
+ # Try to highlight key fields
642
+ fullname = result.get("fullname") or result.get("full_name", "")
643
+ if fullname:
644
+ st.markdown(
645
+ f"<div style='background:#c7cef5;border-radius:10px;padding:0.75rem 1.2rem;"
646
+ f"margin-bottom:1rem;'>"
647
+ f"<span style='color:#435cdc;font-weight:800;font-size:1.1rem;'>πŸ‘€ {fullname}</span>"
648
+ f"</div>",
649
+ unsafe_allow_html=True,
650
+ )
651
+
652
+ col_l, col_r = st.columns(2)
653
+
654
+ with col_l:
655
+ st.markdown("<div class='section-title'>Education</div>", unsafe_allow_html=True)
656
+ for i in range(1, 4):
657
+ univ = result.get(f"univ_edu_{i}", "")
658
+ major = result.get(f"major_edu_{i}", "")
659
+ gpa = result.get(f"gpa_edu_{i}", "")
660
+ if univ or major:
661
+ gpa_str = f" Β· GPA {gpa}" if gpa else ""
662
+ st.markdown(
663
+ f"<div style='margin-bottom:0.5rem;padding:0.6rem 1rem;"
664
+ f"background:#f4f6ff;border-radius:8px;border-left:3px solid #435cdc;'>"
665
+ f"<strong style='color:#333e4a;'>{univ or 'β€”'}</strong><br>"
666
+ f"<span style='color:#7b8de7;font-size:0.85rem;'>{major or ''}{gpa_str}</span>"
667
+ f"</div>",
668
+ unsafe_allow_html=True,
669
+ )
670
+
671
+ st.markdown("<div class='section-title' style='margin-top:1rem;'>Experience</div>", unsafe_allow_html=True)
672
+ yoe = result.get("yoe")
673
+ domicile = result.get("domicile", "")
674
+ st.markdown(
675
+ f"<div style='padding:0.6rem 1rem;background:#f4f6ff;border-radius:8px;"
676
+ f"border-left:3px solid #dcc343;'>"
677
+ f"<span style='color:#333e4a;font-weight:600;'>Years of Experience:</span> "
678
+ f"<span style='color:#435cdc;font-weight:800;'>{yoe if yoe is not None else 'β€”'}</span><br>"
679
+ f"<span style='color:#333e4a;font-weight:600;'>Domicile:</span> "
680
+ f"<span style='color:#435cdc;'>{domicile or 'β€”'}</span>"
681
+ f"</div>",
682
+ unsafe_allow_html=True,
683
+ )
684
+
685
+ with col_r:
686
+ def _tag_list(label, items, color="#c7cef5", text_color="#435cdc"):
687
+ if not items:
688
+ return
689
+ st.markdown(
690
+ f"<div class='section-title'>{label}</div>",
691
+ unsafe_allow_html=True,
692
+ )
693
+ tags = "".join(
694
+ f"<span style='background:{color};color:{text_color};border-radius:99px;"
695
+ f"padding:3px 10px;font-size:0.78rem;font-weight:600;margin:2px;display:inline-block;'>"
696
+ f"{t}</span>"
697
+ for t in items
698
+ )
699
+ st.markdown(
700
+ f"<div style='margin-bottom:0.75rem;line-height:2;'>{tags}</div>",
701
+ unsafe_allow_html=True,
702
+ )
703
+
704
+ _tag_list("πŸ’» Hard Skills", result.get("hardskills", []))
705
+ _tag_list("🀝 Soft Skills", result.get("softskills", []), "#f4f6ff", "#333e4a")
706
+ _tag_list("πŸ† Certifications", result.get("certifications", []), "#fff3c4", "#a07c00")
707
+ _tag_list("🏒 Business Domains", result.get("business_domain", []), "#c7cef5", "#435cdc")
708
+
709
+ # Raw JSON toggle
710
+ with st.expander("πŸ“„ Raw JSON response"):
711
+ st.markdown(
712
+ f"<div class='profile-json'>{json.dumps(result, indent=2, default=str)}</div>",
713
+ unsafe_allow_html=True,
714
+ )
715
+
716
+
717
+ # ─────────────────────────────────────────────
718
+ # ROUTER
719
+ # ─────────────────────────────────────────────
720
+ if st.session_state.token:
721
+ page_main()
722
+ else:
723
+ page_login()