Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
H
Hire-Guru
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Analytics
Analytics
CI / CD
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vigneswaran Shanmugam
Hire-Guru
Commits
954049ba
Commit
954049ba
authored
May 12, 2026
by
Aravind RK
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Group interview bug have corrected
parent
78c91f3e
Changes
18
Hide whitespace changes
Inline
Side-by-side
Showing
18 changed files
with
5630 additions
and
748 deletions
+5630
-748
Backend/models/User.js
Backend/models/User.js
+2
-2
Backend/routes/interview.js
Backend/routes/interview.js
+4
-4
Backend/uploads/1778479566478-635889545.jpeg
Backend/uploads/1778479566478-635889545.jpeg
+0
-0
Backend/uploads/1778488067587-769251812.pdf
Backend/uploads/1778488067587-769251812.pdf
+0
-0
Backend/uploads/1778490006053-347095609.zip
Backend/uploads/1778490006053-347095609.zip
+0
-0
Backend/uploads/1778495154498-888467455.pdf
Backend/uploads/1778495154498-888467455.pdf
+0
-0
Frontend/package-lock.json
Frontend/package-lock.json
+2432
-668
Frontend/src/app/pages/admin/group-interview/group-interview.css
...d/src/app/pages/admin/group-interview/group-interview.css
+117
-8
Frontend/src/app/pages/admin/group-interview/group-interview.html
.../src/app/pages/admin/group-interview/group-interview.html
+317
-47
Frontend/src/app/pages/admin/group-interview/group-interview.ts
...nd/src/app/pages/admin/group-interview/group-interview.ts
+111
-4
Frontend/src/app/pages/admin/individual-interview/individual-interview.html
...ages/admin/individual-interview/individual-interview.html
+3
-3
Frontend/src/app/pages/admin/users/users.html
Frontend/src/app/pages/admin/users/users.html
+2
-2
Frontend/src/app/pages/hr/group-interview/group-interview.css
...tend/src/app/pages/hr/group-interview/group-interview.css
+476
-0
Frontend/src/app/pages/hr/group-interview/group-interview.html
...end/src/app/pages/hr/group-interview/group-interview.html
+748
-1
Frontend/src/app/pages/hr/group-interview/group-interview.ts
Frontend/src/app/pages/hr/group-interview/group-interview.ts
+409
-4
Frontend/src/app/pages/hr/individual-interview/individual-interview.css
...pp/pages/hr/individual-interview/individual-interview.css
+243
-0
Frontend/src/app/pages/hr/individual-interview/individual-interview.html
...p/pages/hr/individual-interview/individual-interview.html
+494
-1
Frontend/src/app/pages/hr/individual-interview/individual-interview.ts
...app/pages/hr/individual-interview/individual-interview.ts
+272
-4
No files found.
Backend/models/User.js
View file @
954049ba
...
...
@@ -48,8 +48,8 @@ const userSchema = new mongoose.Schema({
},
level
:
{
type
:
String
,
enum
:
[
'
beginner
'
,
'
intermediate
'
,
'
advanced
'
,
'
expert
'
],
default
:
'
beginner
'
enum
:
[
'
Fresher
'
,
'
Intern
'
,
'
Pre final year
'
,
'
Final year
'
],
default
:
'
Fresher
'
},
topicsOfInterest
:
[{
topic
:
{
type
:
String
,
required
:
true
},
...
...
Backend/routes/interview.js
View file @
954049ba
...
...
@@ -428,9 +428,9 @@ router.put('/:id/evaluate', authorize('admin', 'hr', 'pm', 'interviewer'), async
// ============================================================
// @route PUT /api/interview/:id/decision
// @desc Set final decision (accepted/rejected/on_hold/2nd_round)
// @access Admin
only
// @access Admin
, HR
// ============================================================
router
.
put
(
'
/:id/decision
'
,
authorize
(
'
admin
'
),
async
(
req
,
res
)
=>
{
router
.
put
(
'
/:id/decision
'
,
authorize
(
'
admin
'
,
'
hr
'
),
async
(
req
,
res
)
=>
{
try
{
const
{
decision
}
=
req
.
body
;
if
(
!
decision
)
return
res
.
status
(
400
).
json
({
message
:
'
Decision is required
'
});
...
...
@@ -515,9 +515,9 @@ router.put('/:id/validate-coding', authorize('admin', 'hr', 'pm', 'interviewer')
// ============================================================
// @route DELETE /api/interview/:id
// @desc Delete an interview
// @access Admin
only
// @access Admin
, HR
// ============================================================
router
.
delete
(
'
/:id
'
,
authorize
(
'
admin
'
),
async
(
req
,
res
)
=>
{
router
.
delete
(
'
/:id
'
,
authorize
(
'
admin
'
,
'
hr
'
),
async
(
req
,
res
)
=>
{
try
{
const
interview
=
await
Interview
.
findById
(
req
.
params
.
id
);
if
(
!
interview
)
return
res
.
status
(
404
).
json
({
message
:
'
Interview not found
'
});
...
...
Backend/uploads/1778479566478-635889545.jpeg
0 → 100644
View file @
954049ba
69.9 KB
Backend/uploads/1778488067587-769251812.pdf
0 → 100644
View file @
954049ba
File added
Backend/uploads/1778490006053-347095609.zip
0 → 100644
View file @
954049ba
File added
Backend/uploads/1778495154498-888467455.pdf
0 → 100644
View file @
954049ba
File added
Frontend/package-lock.json
View file @
954049ba
This source diff could not be displayed because it is too large. You can
view the blob
instead.
Frontend/src/app/pages/admin/group-interview/group-interview.css
View file @
954049ba
...
...
@@ -59,12 +59,15 @@
/* Candidate chips row */
.candidate-chips
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
6px
;
margin-bottom
:
12px
;
}
.candidate-chip
{
width
:
30px
;
height
:
30px
;
border-radius
:
50%
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
font-size
:
12px
;
font-weight
:
700
;
color
:
#fff
;
padding
:
4px
12px
;
border-radius
:
16px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
font-size
:
12px
;
font-weight
:
600
;
color
:
#fff
!important
;
border
:
2px
solid
var
(
--bg-card
);
background
:
#667eea
;
transition
:
transform
0.15s
;
white-space
:
nowrap
;
}
.candidate-chip
:hover
{
transform
:
scale
(
1.15
);
z-index
:
1
;
}
.candidate-chip.clickable-chip
{
cursor
:
pointer
;
}
.candidate-chip.clickable-chip
:hover
{
transform
:
scale
(
1.12
);
box-shadow
:
0
4px
12px
rgba
(
0
,
0
,
0
,
0.18
);
}
.candidate-chip.badge-warning
{
background
:
#f59e0b
;
}
.candidate-chip.badge-info
{
background
:
#3b82f6
;
}
.candidate-chip.badge-success
{
background
:
#22c55e
;
}
...
...
@@ -73,6 +76,29 @@
.iv-card-bottom
{
display
:
flex
;
justify-content
:
flex-end
;
}
.status-summary
{
font-size
:
12px
;
color
:
var
(
--text-muted
);
font-style
:
italic
;
}
/* ⚠ badge on chip */
.chip-alert
{
font-size
:
11px
;
margin-right
:
3px
;
}
/* Evaluation pending badge inside candidate row name */
.eval-pending-badge
{
display
:
inline-flex
;
align-items
:
center
;
font-size
:
11px
;
font-weight
:
600
;
padding
:
2px
7px
;
border-radius
:
10px
;
margin-left
:
8px
;
background
:
rgba
(
245
,
158
,
11
,
0.15
);
color
:
#f59e0b
;
}
.eval-pending-badge.warn
{
background
:
rgba
(
102
,
126
,
234
,
0.1
);
color
:
#667eea
;
}
/* Clickable candidate left section (button reset) */
.cr-clickable
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
background
:
none
;
border
:
none
;
cursor
:
pointer
;
text-align
:
left
;
padding
:
6px
10px
;
border-radius
:
10px
;
transition
:
background
0.15s
;
min-width
:
160px
;
flex-shrink
:
0
;
}
.cr-clickable
:hover
{
background
:
rgba
(
102
,
126
,
234
,
0.08
);
}
/* Hint text at end of bottom row */
.cr-open-hint
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
margin-left
:
auto
;
font-style
:
italic
;
}
/* ═══════════════════════════════════════════════════════
BADGES
═══════════════════════════════════════════════════════ */
...
...
@@ -293,20 +319,26 @@
/* Candidate detail list */
.candidate-detail-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
}
.candidate-row
{
display
:
flex
;
align-items
:
flex-start
;
gap
:
16
px
;
padding
:
16px
;
display
:
flex
;
flex-direction
:
column
;
gap
:
12
px
;
padding
:
16px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
12px
;
background
:
var
(
--bg-hover
);
flex-wrap
:
wrap
;
transition
:
border-color
0.2s
;
transition
:
border-color
0.2s
;
}
.candidate-row
:hover
{
border-color
:
rgba
(
102
,
126
,
234
,
0.35
);
}
.candidate-row-left
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
min-width
:
180px
;
}
/* Top row: avatar+name on left, progress stepper fills the rest */
.candidate-row-top
{
display
:
flex
;
align-items
:
center
;
gap
:
16px
;
}
.candidate-row-left
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
min-width
:
160px
;
flex-shrink
:
0
;
}
.iv-avatar
{
width
:
40px
;
height
:
40px
;
border-radius
:
10px
;
background
:
linear-gradient
(
135deg
,
#667eea
,
#764ba2
);
color
:
#fff
;
font-weight
:
700
;
font-size
:
16px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-shrink
:
0
;
}
.iv-avatar.small
{
width
:
3
2px
;
height
:
32px
;
font-size
:
14px
;
border-radius
:
8
px
;
}
.iv-avatar.small
{
width
:
3
6px
;
height
:
36px
;
font-size
:
15px
;
border-radius
:
9
px
;
}
.cr-name
{
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
}
.cr-email
{
font-size
:
12px
;
color
:
var
(
--text-muted
);
}
.candidate-row-mid
{
flex
:
1
;
}
.candidate-row-mid
{
flex
:
1
;
min-width
:
280px
;
}
.candidate-row-right
{
display
:
flex
;
flex-direction
:
column
;
gap
:
8px
;
align-items
:
flex-end
;
}
/* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */
.candidate-row-bottom
{
display
:
flex
;
flex-wrap
:
wrap
;
align-items
:
center
;
gap
:
8px
;
padding-top
:
10px
;
border-top
:
1px
solid
var
(
--border-color
);
}
/* Progress steps (mini) */
.progress-steps
{
display
:
flex
;
align-items
:
center
;
}
...
...
@@ -352,6 +384,45 @@
@keyframes
fadeIn
{
from
{
opacity
:
0
;
}
to
{
opacity
:
1
;
}
}
@keyframes
slideUp
{
from
{
transform
:
translateY
(
24px
);
opacity
:
0
;
}
to
{
transform
:
translateY
(
0
);
opacity
:
1
;
}
}
/* ═══════════════════════════════════════════════════════
EVALUATION PANEL (member detail modal)
Mirrors individual-interview styles
═══════════════════════════════════════════════════════ */
.quiz-results-grid
{
display
:
grid
;
grid-template-columns
:
repeat
(
auto-fill
,
minmax
(
180px
,
1
fr
));
gap
:
12px
;
}
.quiz-result-card
{
padding
:
14px
16px
;
border-radius
:
12px
;
border
:
1px
solid
var
(
--border-color
);
background
:
var
(
--bg-input
);
}
.qr-title
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
margin-bottom
:
6px
;
}
.qr-score
{
font-size
:
22px
;
font-weight
:
700
;
color
:
#22c55e
;
}
.qr-pending
{
font-size
:
13px
;
color
:
var
(
--text-muted
);
font-style
:
italic
;
}
.eval-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
margin-bottom
:
16px
;
}
.eval-card
{
padding
:
14px
16px
;
border-radius
:
12px
;
border
:
1px
solid
var
(
--border-color
);
background
:
var
(
--bg-input
);
}
.eval-header
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
margin-bottom
:
8px
;
}
.eval-evaluator
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-size
:
14px
;
}
.eval-role
{
font-size
:
10px
!important
;
padding
:
2px
6px
!important
;
}
.eval-comments
{
font-size
:
13px
;
color
:
var
(
--text-secondary
);
font-style
:
italic
;
margin
:
6px
0
8px
;
}
.eval-date
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
}
.eval-form
{
margin-top
:
16px
;
padding
:
18px
20px
;
border-radius
:
14px
;
border
:
1px
dashed
rgba
(
102
,
126
,
234
,
0.35
);
background
:
rgba
(
102
,
126
,
234
,
0.04
);
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
}
.eval-form
h4
{
font-size
:
14px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
margin
:
0
;
}
.form-textarea
{
resize
:
vertical
;
min-height
:
80px
;
}
.decision-section
{
border-bottom
:
none
;
}
.decision-buttons
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
10px
;
}
.btn-success
{
background
:
#22c55e
;
color
:
#fff
;
border
:
none
;
}
.btn-warning
{
background
:
#f59e0b
;
color
:
#fff
;
border
:
none
;
}
/* ═══════════════════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════════════════ */
...
...
@@ -365,3 +436,41 @@
.candidate-row-right
{
align-items
:
flex-start
;
}
.da-header
,
.da-row
{
grid-template-columns
:
1
fr
;
}
}
/* ═══════════════════════════════════════════════════════
PRINT STYLES FOR EVALUATION PDF
═══════════════════════════════════════════════════════ */
.print-container
{
display
:
none
;
}
@media
print
{
body
*
{
visibility
:
hidden
;
}
.print-container
,
.print-container
*
{
visibility
:
visible
;
}
.print-container
{
display
:
block
;
position
:
absolute
;
left
:
0
;
top
:
0
;
width
:
100%
;
padding
:
20px
;
background
:
white
;
color
:
black
;
font-family
:
Arial
,
sans-serif
;
}
.print-header
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
border-bottom
:
2px
solid
#0078d4
;
padding-bottom
:
10px
;
margin-bottom
:
20px
;
}
}
Frontend/src/app/pages/admin/group-interview/group-interview.html
View file @
954049ba
...
...
@@ -93,15 +93,22 @@
}
</div>
<!-- Candidate progress chips -->
<!-- Candidate progress chips
— click any chip to open the evaluation panel directly
-->
<div
class=
"candidate-chips"
>
@for (m of g.members; track m._id) {
<span
class=
"candidate-chip"
[ngClass]=
"getStatusClass(m.status)"
[title]=
"m.candidateId?.name + ' — ' + formatStatus(m.status)"
>
{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}
<span
class=
"candidate-chip clickable-chip"
[ngClass]=
"getStatusClass(m.status)"
[title]=
"m.candidateId?.name + ' — ' + formatStatus(m.status) + (hasPendingEvaluations(m) ? ' (Evaluations pending)' : '')"
(click)=
"openMemberDetail(m._id, $event)"
>
@if (hasPendingEvaluations(m)) {
<span
class=
"chip-alert"
title=
"Evaluations still pending"
>
⚠️
</span>
}
{{ m.candidateId?.name }}
</span>
}
</div>
<div
class=
"iv-card-bottom"
>
<span
class=
"status-summary"
>
{{ groupStatusSummary(g.members) }}
</span>
</div>
...
...
@@ -403,60 +410,65 @@
<div
class=
"candidate-detail-list"
>
@for (m of selectedGroup().members; track m._id) {
<div
class=
"candidate-row"
>
<div
class=
"candidate-row-left"
>
<div
class=
"iv-avatar small"
>
{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}
</div>
<div>
<div
class=
"cr-name"
>
{{ m.candidateId?.name }}
</div>
<div
class=
"cr-email"
>
{{ m.candidateId?.email }}
</div>
</div>
</div>
<div
class=
"candidate-row-mid"
>
<!-- Progress steps mini -->
<div
class=
"progress-steps mini"
>
<div
class=
"step"
[class.done]=
"['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Created
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='quiz_phase'"
[class.done]=
"['coding_phase','evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Quiz
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['coding_phase','evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='coding_phase'"
[class.done]=
"['evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Coding
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='evaluation'"
[class.done]=
"m.status==='completed'"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Evaluate
</span>
<!-- Top row: clickable avatar+name on the left, progress bar on the right -->
<div
class=
"candidate-row-top"
>
<button
class=
"cr-clickable"
(click)=
"openMemberDetail(m._id, $event)"
>
<div
class=
"iv-avatar small"
>
{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}
</div>
<div>
<div
class=
"cr-name"
>
{{ m.candidateId?.name }}
@if (needsEvaluation(m)) {
<span
class=
"eval-pending-badge"
title=
"Your evaluation is pending"
>
⚠️ Evaluate
</span>
}
@if (hasPendingEvaluations(m)) {
<span
class=
"eval-pending-badge warn"
title=
"Some evaluations are still pending"
>
⏳
</span>
}
</div>
<div
class=
"cr-email"
>
{{ m.candidateId?.email }}
</div>
</div>
<div
class=
"step-line"
[class.done]=
"m.status==='completed'"
></div>
<div
class=
"step"
[class.active]=
"m.status==='completed'"
[class.done]=
"m.status==='completed'"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Done
</span>
</button>
<div
class=
"candidate-row-mid"
>
<div
class=
"progress-steps mini"
>
<div
class=
"step"
[class.done]=
"['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Created
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='quiz_phase'"
[class.done]=
"['coding_phase','evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Quiz
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['coding_phase','evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='coding_phase'"
[class.done]=
"['evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Coding
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='evaluation'"
[class.done]=
"m.status==='completed'"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Evaluate
</span>
</div>
<div
class=
"step-line"
[class.done]=
"m.status==='completed'"
></div>
<div
class=
"step"
[class.active]=
"m.status==='completed'"
[class.done]=
"m.status==='completed'"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Done
</span>
</div>
</div>
</div>
</div>
<div
class=
"candidate-row-right"
>
<!-- Bottom row: status badge + quiz scores (no accept/reject here) -->
<div
class=
"candidate-row-bottom"
>
<span
class=
"badge"
[ngClass]=
"getStatusClass(m.status)"
>
{{ formatStatus(m.status) }}
</span>
@if (m.finalDecision !== 'pending') {
<span
class=
"badge"
[ngClass]=
"getDecisionClass(m.finalDecision)"
>
{{ formatDecision(m.finalDecision) }}
</span>
}
<!-- Quiz scores -->
@if (m.quizzes?.length > 0) {
<div
class=
"quiz-scores-inline"
>
@for (q of m.quizzes; track q.quizId) {
<span
class=
"quiz-score-chip"
[class.completed]=
"q.completed"
>
{{ q.title }}: {{ q.completed ? q.score + '/' + q.totalMarks : 'Pending' }}
</span>
}
</div>
}
<!-- Decision buttons (admin) -->
@if (authService.getUserRole() === 'admin'
&&
m.status !== 'completed') {
<div
class=
"mini-decision-btns"
>
<button
class=
"mini-btn success"
(click)=
"setDecision(m._id, 'accepted'); $event.stopPropagation()"
>
Accept
</button>
<button
class=
"mini-btn danger"
(click)=
"setDecision(m._id, 'rejected'); $event.stopPropagation()"
>
Reject
</button>
</div>
@for (q of m.quizzes; track q.quizId) {
<span
class=
"quiz-score-chip"
[class.completed]=
"q.completed"
>
{{ q.title }}: {{ q.completed ? q.score + '/' + q.totalMarks : 'Pending' }}
</span>
}
}
<span
class=
"cr-open-hint"
>
Click name to evaluate →
</span>
</div>
</div>
}
</div>
...
...
@@ -465,7 +477,7 @@
</div>
<div
class=
"modal-footer"
>
@if (authService.getUserRole() === 'admin') {
@if (authService.getUserRole() === 'admin'
|| authService.getUserRole() === 'hr'
) {
<button
class=
"btn btn-danger btn-sm"
(click)=
"deleteGroupInterview(selectedGroup().groupId)"
>
Delete All Interviews
</button>
...
...
@@ -476,3 +488,261 @@
</div>
</div>
}
<!-- ═══════════════════════════════════════════════════════════
MEMBER DETAIL MODAL — full evaluation panel for one candidate
═══════════════════════════════════════════════════════════ -->
@if (showMemberDetailModal()
&&
selectedMember()) {
<div
class=
"modal-overlay"
(click)=
"closeMemberDetail()"
>
<div
class=
"modal-container modal-xl"
(click)=
"$event.stopPropagation()"
>
<!-- Header -->
<div
class=
"modal-header"
>
<div>
<h2>
{{ selectedMember().candidateId?.name }}
</h2>
<span
class=
"badge"
[ngClass]=
"getStatusClass(selectedMember().status)"
>
{{ formatStatus(selectedMember().status) }}
</span>
@if (selectedMember().finalDecision !== 'pending') {
<span
class=
"badge"
[ngClass]=
"getDecisionClass(selectedMember().finalDecision)"
style=
"margin-left:6px"
>
{{ formatDecision(selectedMember().finalDecision) }}
</span>
}
</div>
<button
class=
"icon-btn"
(click)=
"closeMemberDetail()"
><span
class=
"material-symbols-rounded"
>
close
</span></button>
</div>
<div
class=
"modal-body detail-body"
>
<!-- Candidate Info -->
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Candidate Information
</h3>
<div
class=
"detail-grid"
>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Name
</span>
<span
class=
"detail-value"
>
{{ selectedMember().candidateId?.name }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Email
</span>
<span
class=
"detail-value"
>
{{ selectedMember().candidateId?.email }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Position
</span>
<span
class=
"detail-value"
>
{{ selectedMember().position }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Tech Stack
</span>
<span
class=
"detail-value"
>
{{ selectedMember().techStack || '—' }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Date of Interview
</span>
<span
class=
"detail-value"
>
{{ selectedMember().dateOfInterview | date:'mediumDate' }}
</span>
</div>
</div>
</div>
<!-- Quiz Results -->
@if (selectedMember().quizzes?.length > 0) {
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Quiz Results
</h3>
<div
class=
"quiz-results-grid"
>
@for (q of selectedMember().quizzes; track q.quizId) {
<div
class=
"quiz-result-card"
>
<div
class=
"qr-title"
>
{{ q.title }}
</div>
@if (q.completed) {
<div
class=
"qr-score"
>
{{ q.score }}/{{ q.totalMarks }} ({{ q.percentage }}%)
</div>
} @else {
<div
class=
"qr-pending"
>
Not Taken
</div>
}
</div>
}
</div>
</div>
}
<!-- Evaluations -->
<div
class=
"detail-section"
>
<div
style=
"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;"
>
<h3
class=
"detail-section-title"
style=
"margin:0;"
>
Evaluations
</h3>
<div
style=
"display: flex; gap: 8px; align-items: center;"
>
@if (allMemberEvaluationsDone()) {
<span
class=
"badge badge-success"
>
All evaluations done
</span>
<button
class=
"btn btn-primary btn-sm"
(click)=
"downloadEvaluationPdf()"
>
@if (isPdfGenerating()) {
<span
class=
"spinner spinner-sm"
></span>
Generating... } @else {
<span
class=
"material-symbols-rounded"
style=
"font-size: 16px; margin-right: 4px;"
>
download
</span>
Download PDF }
</button>
} @else {
<span
class=
"badge badge-warning"
>
Evaluations pending
</span>
}
</div>
</div>
<!-- Evaluation list -->
@if (selectedMember().evaluations?.length > 0) {
<div
class=
"eval-list"
>
@for (ev of selectedMember().evaluations; track ev._id) {
<div
class=
"eval-card"
>
<div
class=
"eval-header"
>
<div
class=
"eval-evaluator"
>
<strong>
{{ ev.evaluatorId?.name }}
</strong>
<span
class=
"eval-role badge badge-muted"
>
{{ ev.evaluatorRole | uppercase }}
</span>
</div>
<span
class=
"badge"
[ngClass]=
"getDecisionClass(ev.recommendation)"
>
{{ formatDecision(ev.recommendation) }}
</span>
</div>
@if (ev.comments) {
<p
class=
"eval-comments"
>
"{{ ev.comments }}"
</p>
}
<span
class=
"eval-date"
>
{{ ev.date | date:'medium' }}
</span>
</div>
}
</div>
} @else {
<p
class=
"text-muted"
>
No evaluations yet
</p>
}
<!-- Add evaluation form (HR / PM / Interviewer, not if already submitted or completed) -->
@if (!hasMemberEvaluated()
&&
selectedMember().status !== 'completed') {
<div
class=
"eval-form"
>
<h4>
Add Your Evaluation
</h4>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Comments
</label>
<textarea
class=
"form-input form-textarea"
[(ngModel)]=
"memberEvalComment"
placeholder=
"Enter your comments..."
rows=
"3"
></textarea>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Recommendation *
</label>
<select
class=
"form-input"
[(ngModel)]=
"memberEvalRecommendation"
>
<option
value=
""
>
Select recommendation
</option>
<option
value=
"offer"
>
Offer / Hire as Intern
</option>
<option
value=
"on_hold"
>
On Hold
</option>
<option
value=
"rejected"
>
Rejected
</option>
<option
value=
"2nd_round"
>
2nd Round
</option>
</select>
</div>
<button
class=
"btn btn-primary"
(click)=
"submitMemberEvaluation()"
[disabled]=
"!memberEvalRecommendation || isMemberSubmitting()"
>
@if (isMemberSubmitting()) {
<span
class=
"spinner spinner-sm"
></span>
Submitting... }
@else { Submit Evaluation }
</button>
</div>
}
</div>
<!-- Final Decision — Admin and HR, only when all evaluations done -->
@if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr')
&&
allMemberEvaluationsDone()
&&
selectedMember().status !== 'completed') {
<div
class=
"detail-section decision-section"
>
<h3
class=
"detail-section-title"
>
Final Decision
</h3>
<div
class=
"decision-buttons"
>
<button
class=
"btn btn-success"
(click)=
"setMemberDecision('accepted')"
>
<span
class=
"material-symbols-rounded"
>
check_circle
</span>
Accept
</button>
<button
class=
"btn btn-warning"
(click)=
"setMemberDecision('on_hold')"
>
<span
class=
"material-symbols-rounded"
>
pause_circle
</span>
On Hold
</button>
<button
class=
"btn btn-danger"
(click)=
"setMemberDecision('rejected')"
>
<span
class=
"material-symbols-rounded"
>
cancel
</span>
Reject
</button>
<button
class=
"btn btn-outline"
(click)=
"setMemberDecision('2nd_round')"
>
<span
class=
"material-symbols-rounded"
>
replay
</span>
2nd Round
</button>
</div>
</div>
}
</div>
<div
class=
"modal-footer"
>
<button
class=
"btn btn-outline"
(click)=
"closeMemberDetail()"
>
Close
</button>
</div>
</div>
</div>
}
<!-- Print Template for Evaluation PDF -->
@if (selectedMember()) {
<div
class=
"print-container"
>
<div
class=
"print-header"
>
<div
class=
"print-header-left"
>
<h2
style=
"margin: 0; font-size: 20px;"
>
Intern Interview Evaluation Form
</h2>
</div>
<div
class=
"print-header-right"
style=
"text-align: right;"
>
<span
style=
"color: #0078d4; font-weight: bold; font-size: 24px;"
>
IDEAL
</span><br>
<span
style=
"font-size: 10px; color: #555;"
>
TECH LABS
</span>
</div>
</div>
<table
class=
"print-table"
style=
"width: 100%; border-collapse: collapse; margin-bottom: 20px;"
>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;"
>
Candidate Name:
</td>
<td
style=
"border: 1px solid #000; padding: 8px; width: 25%;"
>
{{ selectedMember().candidateId?.name }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;"
>
Date of Interview:
</td>
<td
style=
"border: 1px solid #000; padding: 8px; width: 25%;"
>
{{ selectedMember().dateOfInterview | date:'mediumDate' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Position:
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().position }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Source
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().source || '—' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Tech Stack:
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().techStack || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Interviewer(s):
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
@if (selectedMember().assignedInterviewers?.length) {
@for (i of selectedMember().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedMember().interviewerId?.name || '—' }}
}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
General Aptitude Test QP Set
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().quizzes?.[0]?.title || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
General Aptitude Test Score
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().quizzes?.[0]?.completed ? selectedMember().quizzes[0].score + '/' + selectedMember().quizzes[0].totalMarks : 'Not Taken' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Technical MCQ Test QP Set
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().quizzes?.[1]?.title || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Technical MCQ Test Score
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().quizzes?.[1]?.completed ? selectedMember().quizzes[1].score + '/' + selectedMember().quizzes[1].totalMarks : 'Not Taken' }}
</td>
</tr>
</table>
<!-- Render evaluations -->
@for (ev of selectedMember().evaluations; track ev._id) {
<div
class=
"print-eval-block"
style=
"margin-bottom: 30px; page-break-inside: avoid;"
>
<div
class=
"print-eval-title"
style=
"font-weight: bold; margin-bottom: 10px;"
>
{{ ev.evaluatorRole === 'hr' ? 'HR' : ev.evaluatorRole === 'pm' ? 'Project Manager' : 'Interviewer' }}'s Comments ({{ ev.evaluatorId?.name }}):
</div>
<div
class=
"print-comments"
style=
"min-height: 80px; margin-bottom: 15px;"
>
{{ ev.comments || 'No comments provided.' }}
</div>
<div
class=
"print-recommendation"
style=
"margin-bottom: 20px;"
>
<strong
style=
"margin-right: 15px;"
>
Recommendation:
</strong>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'offer' ? '☑' : '☐' }}
</span>
Offer/Hire as Intern
</span>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'on_hold' ? '☑' : '☐' }}
</span>
On Hold
</span>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'rejected' ? '☑' : '☐' }}
</span>
Rejected
</span>
<span><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === '2nd_round' ? '☑' : '☐' }}
</span>
2nd Round
</span>
</div>
<div
class=
"print-signature-row"
style=
"display: flex; justify-content: space-between; align-items: flex-end;"
>
<div
class=
"print-signature"
style=
"display: flex; align-items: flex-end;"
>
<strong>
Evaluator's Signature:
</strong>
@if (ev.evaluatorId?.signature) {
<img
[src]=
"'http://localhost:5000' + ev.evaluatorId.signature"
style=
"max-height: 40px; margin-left: 10px;"
>
} @else {
<span
style=
"border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"
></span>
}
</div>
<div
class=
"print-date"
style=
"display: flex; align-items: flex-end;"
>
<strong>
Date:
</strong>
<span
style=
"border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;"
>
{{ ev.date | date:'shortDate' }}
</span>
</div>
</div>
</div>
}
</div>
}
Frontend/src/app/pages/admin/group-interview/group-interview.ts
View file @
954049ba
...
...
@@ -74,11 +74,16 @@ export class GroupInterviewComponent implements OnInit {
pendingSetsCount
=
2
;
quizSets
:
QuizSet
[]
=
[];
// ──
Detail modal state ───────
─────────────────────────────
// ──
Group detail modal state
─────────────────────────────
showDetailModal
=
signal
(
false
);
selectedGroup
=
signal
<
any
>
(
null
);
evalComment
=
signal
(
''
);
evalRecommendation
=
signal
(
''
);
// ── Per-candidate (member) detail modal ──────────────────
showMemberDetailModal
=
signal
(
false
);
selectedMember
=
signal
<
any
>
(
null
);
// full interview doc
memberEvalComment
=
''
;
memberEvalRecommendation
=
''
;
isMemberSubmitting
=
signal
(
false
);
constructor
(
private
quizService
:
QuizService
,
public
authService
:
AuthService
)
{}
...
...
@@ -244,7 +249,7 @@ export class GroupInterviewComponent implements OnInit {
});
}
// ──
Detail modal ──────
────────────────────────────────────
// ──
Group detail modal
────────────────────────────────────
openDetail
(
group
:
any
):
void
{
this
.
selectedGroup
.
set
(
group
);
this
.
showDetailModal
.
set
(
true
);
...
...
@@ -255,6 +260,108 @@ export class GroupInterviewComponent implements OnInit {
this
.
selectedGroup
.
set
(
null
);
}
// ── Member (candidate) detail modal ──────────────────────
openMemberDetail
(
interviewId
:
string
,
event
:
Event
):
void
{
event
.
stopPropagation
();
this
.
quizService
.
getInterviewById
(
interviewId
).
subscribe
({
next
:
res
=>
{
this
.
selectedMember
.
set
(
res
.
interview
);
this
.
memberEvalComment
=
''
;
this
.
memberEvalRecommendation
=
''
;
this
.
showMemberDetailModal
.
set
(
true
);
}
});
}
closeMemberDetail
():
void
{
this
.
showMemberDetailModal
.
set
(
false
);
this
.
selectedMember
.
set
(
null
);
}
submitMemberEvaluation
():
void
{
const
m
=
this
.
selectedMember
();
if
(
!
m
||
!
this
.
memberEvalRecommendation
)
return
;
this
.
isMemberSubmitting
.
set
(
true
);
this
.
quizService
.
submitEvaluation
(
m
.
_id
,
{
comments
:
this
.
memberEvalComment
,
recommendation
:
this
.
memberEvalRecommendation
}).
subscribe
({
next
:
res
=>
{
this
.
selectedMember
.
set
(
res
.
interview
);
this
.
memberEvalComment
=
''
;
this
.
memberEvalRecommendation
=
''
;
this
.
isMemberSubmitting
.
set
(
false
);
// refresh the group list so chips update
this
.
loadInterviews
();
},
error
:
()
=>
this
.
isMemberSubmitting
.
set
(
false
)
});
}
setMemberDecision
(
decision
:
string
):
void
{
const
m
=
this
.
selectedMember
();
if
(
!
m
)
return
;
this
.
quizService
.
setInterviewDecision
(
m
.
_id
,
decision
).
subscribe
({
next
:
res
=>
{
this
.
selectedMember
.
set
(
res
.
interview
);
this
.
loadInterviews
();
this
.
loadStats
();
}
});
}
hasMemberEvaluated
():
boolean
{
const
m
=
this
.
selectedMember
();
if
(
!
m
)
return
false
;
const
userId
=
this
.
authService
.
currentUser
()?.
id
;
return
m
.
evaluations
?.
some
((
e
:
any
)
=>
e
.
evaluatorId
?.
_id
===
userId
||
e
.
evaluatorId
===
userId
);
}
allMemberEvaluationsDone
():
boolean
{
const
m
=
this
.
selectedMember
();
if
(
!
m
)
return
false
;
const
total
=
(
m
.
assignedInterviewers
?.
length
||
0
)
+
(
m
.
assignedHRs
?.
length
||
0
)
+
(
m
.
assignedPMs
?.
length
||
0
);
if
(
total
===
0
)
return
false
;
return
(
m
.
evaluations
?.
length
||
0
)
>=
total
;
}
/** True when THIS user still needs to evaluate this member */
needsEvaluation
(
m
:
any
):
boolean
{
if
(
m
.
status
===
'
completed
'
)
return
false
;
const
userId
=
this
.
authService
.
currentUser
()?.
id
;
const
role
=
this
.
authService
.
getUserRole
();
if
(
role
===
'
admin
'
)
return
false
;
// admin gives final decision, not evaluation
const
evaluated
=
m
.
evaluations
?.
some
(
(
e
:
any
)
=>
e
.
evaluatorId
?.
_id
===
userId
||
e
.
evaluatorId
===
userId
);
return
!
evaluated
;
}
/** Whether ANY assigned staff member hasn't evaluated a given candidate interview yet */
hasPendingEvaluations
(
m
:
any
):
boolean
{
if
(
m
.
status
===
'
completed
'
)
return
false
;
const
total
=
(
m
.
assignedInterviewers
?.
length
||
0
)
+
(
m
.
assignedHRs
?.
length
||
0
)
+
(
m
.
assignedPMs
?.
length
||
0
);
return
(
m
.
evaluations
?.
length
||
0
)
<
total
;
}
isPdfGenerating
=
signal
(
false
);
downloadEvaluationPdf
():
void
{
const
m
=
this
.
selectedMember
();
if
(
!
m
)
return
;
this
.
isPdfGenerating
.
set
(
true
);
setTimeout
(()
=>
{
window
.
print
();
this
.
isPdfGenerating
.
set
(
false
);
},
500
);
}
// ── Helpers ───────────────────────────────────────────────
getStatusClass
(
status
:
string
):
string
{
const
map
:
any
=
{
...
...
Frontend/src/app/pages/admin/individual-interview/individual-interview.html
View file @
954049ba
...
...
@@ -370,8 +370,8 @@
}
</div>
<!-- Final Decision (admin only) -->
@if (
authService.getUserRole() === 'admin'
&&
selectedInterview().status !== 'completed') {
<!-- Final Decision (admin
and hr
only) -->
@if (
(authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr')
&&
selectedInterview().status !== 'completed') {
<div
class=
"detail-section decision-section"
>
<h3
class=
"detail-section-title"
>
Final Decision
</h3>
<div
class=
"decision-buttons"
>
...
...
@@ -393,7 +393,7 @@
</div>
<div
class=
"modal-footer"
>
@if (authService.getUserRole() === 'admin') {
@if (authService.getUserRole() === 'admin'
|| authService.getUserRole() === 'hr'
) {
<button
class=
"btn btn-danger btn-sm"
(click)=
"deleteInterview(selectedInterview()._id)"
>
Delete Interview
</button>
}
<button
class=
"btn btn-outline"
(click)=
"closeDetail()"
>
Close
</button>
...
...
Frontend/src/app/pages/admin/users/users.html
View file @
954049ba
...
...
@@ -33,8 +33,8 @@
<div
class=
"user-card-info"
>
<div
class=
"name-row"
>
<h4>
{{ user.name }}
</h4>
<span
class=
"level-badge"
[attr.data-level]=
"user.level || '
beginn
er'"
>
{{ (user.level || '
beginn
er') | titlecase }}
<span
class=
"level-badge"
[attr.data-level]=
"user.level || '
Fresh
er'"
>
{{ (user.level || '
Fresh
er') | titlecase }}
</span>
</div>
<p>
{{ user.email }}
</p>
...
...
Frontend/src/app/pages/hr/group-interview/group-interview.css
View file @
954049ba
/* ═══════════════════════════════════════════════════════
PAGE LAYOUT
═══════════════════════════════════════════════════════ */
.page-container
{
padding
:
32px
40px
;
}
.content-wrapper
{
max-width
:
1200px
;
margin
:
0
;
}
.page-header
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
margin-bottom
:
28px
;
}
.page-header
h1
{
font-size
:
24px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
margin
:
0
0
4px
;
}
.page-subtitle
{
font-size
:
14px
;
color
:
var
(
--text-muted
);
margin
:
0
;
}
/* Stats */
.stats-row
{
display
:
flex
;
gap
:
16px
;
margin-bottom
:
24px
;
}
.mini-stat
{
flex
:
1
;
background
:
var
(
--bg-card
);
border
:
1px
solid
var
(
--border-color
);
border-radius
:
12px
;
padding
:
16px
20px
;
text-align
:
center
;
}
.mini-stat-value
{
display
:
block
;
font-size
:
28px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
}
.mini-stat-value.orange
{
color
:
#f59e0b
;
}
.mini-stat-value.blue
{
color
:
#3b82f6
;
}
.mini-stat-value.green
{
color
:
#22c55e
;
}
.mini-stat-value.red
{
color
:
#ef4444
;
}
.mini-stat-label
{
font-size
:
12px
;
color
:
var
(
--text-muted
);
font-weight
:
500
;
text-transform
:
uppercase
;
letter-spacing
:
0.5px
;
}
/* Filter */
.filter-bar
{
margin-bottom
:
20px
;
}
.filter-select
{
padding
:
8px
14px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
background
:
var
(
--bg-input
);
color
:
var
(
--text-primary
);
font-size
:
14px
;
font-family
:
inherit
;
}
/* ═══════════════════════════════════════════════════════
INTERVIEW CARDS
═══════════════════════════════════════════════════════ */
.interview-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
16px
;
}
.interview-card
{
cursor
:
pointer
;
transition
:
all
0.2s
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
16px
;
background
:
var
(
--bg-card
);
}
.interview-card
:hover
{
border-color
:
var
(
--accent-primary
);
box-shadow
:
0
4px
20px
rgba
(
102
,
126
,
234
,
0.12
);
transform
:
translateY
(
-1px
);
}
.iv-card-top
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
margin-bottom
:
12px
;
}
.iv-group-info
{
display
:
flex
;
align-items
:
center
;
gap
:
14px
;
}
.iv-group-avatar
{
width
:
48px
;
height
:
48px
;
border-radius
:
14px
;
background
:
linear-gradient
(
135deg
,
#667eea
0%
,
#764ba2
100%
);
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-shrink
:
0
;
}
.iv-group-avatar
.material-symbols-rounded
{
color
:
#fff
;
font-size
:
26px
;
}
.iv-name
{
font-size
:
16px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
margin
:
0
;
}
.iv-email
{
font-size
:
13px
;
color
:
var
(
--text-muted
);
margin
:
0
;
}
.iv-badges
{
display
:
flex
;
gap
:
8px
;
flex-wrap
:
wrap
;
}
.iv-card-meta
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
16px
;
margin-bottom
:
14px
;
padding-bottom
:
12px
;
border-bottom
:
1px
solid
var
(
--border-color
);
}
.meta-item
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
13px
;
color
:
var
(
--text-secondary
);
}
.meta-item
.material-symbols-rounded
{
font-size
:
16px
;
color
:
var
(
--text-muted
);
}
/* Candidate chips row */
.candidate-chips
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
6px
;
margin-bottom
:
12px
;
}
.candidate-chip
{
padding
:
4px
12px
;
border-radius
:
16px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
font-size
:
12px
;
font-weight
:
600
;
color
:
#fff
!important
;
border
:
2px
solid
var
(
--bg-card
);
background
:
#667eea
;
transition
:
transform
0.15s
;
white-space
:
nowrap
;
}
.candidate-chip
:hover
{
transform
:
scale
(
1.15
);
z-index
:
1
;
}
.candidate-chip.clickable-chip
{
cursor
:
pointer
;
}
.candidate-chip.clickable-chip
:hover
{
transform
:
scale
(
1.12
);
box-shadow
:
0
4px
12px
rgba
(
0
,
0
,
0
,
0.18
);
}
.candidate-chip.badge-warning
{
background
:
#f59e0b
;
}
.candidate-chip.badge-info
{
background
:
#3b82f6
;
}
.candidate-chip.badge-success
{
background
:
#22c55e
;
}
.candidate-chip.badge-purple
{
background
:
#a855f7
;
}
.iv-card-bottom
{
display
:
flex
;
justify-content
:
flex-end
;
}
.status-summary
{
font-size
:
12px
;
color
:
var
(
--text-muted
);
font-style
:
italic
;
}
/* ⚠ badge on chip */
.chip-alert
{
font-size
:
11px
;
margin-right
:
3px
;
}
/* Evaluation pending badge inside candidate row name */
.eval-pending-badge
{
display
:
inline-flex
;
align-items
:
center
;
font-size
:
11px
;
font-weight
:
600
;
padding
:
2px
7px
;
border-radius
:
10px
;
margin-left
:
8px
;
background
:
rgba
(
245
,
158
,
11
,
0.15
);
color
:
#f59e0b
;
}
.eval-pending-badge.warn
{
background
:
rgba
(
102
,
126
,
234
,
0.1
);
color
:
#667eea
;
}
/* Clickable candidate left section (button reset) */
.cr-clickable
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
background
:
none
;
border
:
none
;
cursor
:
pointer
;
text-align
:
left
;
padding
:
6px
10px
;
border-radius
:
10px
;
transition
:
background
0.15s
;
min-width
:
160px
;
flex-shrink
:
0
;
}
.cr-clickable
:hover
{
background
:
rgba
(
102
,
126
,
234
,
0.08
);
}
/* Hint text at end of bottom row */
.cr-open-hint
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
margin-left
:
auto
;
font-style
:
italic
;
}
/* ═══════════════════════════════════════════════════════
BADGES
═══════════════════════════════════════════════════════ */
.badge
{
padding
:
4px
10px
;
border-radius
:
6px
;
font-size
:
11px
;
font-weight
:
600
;
text-transform
:
uppercase
;
letter-spacing
:
0.5px
;
}
.badge-warning
{
background
:
rgba
(
245
,
158
,
11
,
0.1
);
color
:
#f59e0b
;
}
.badge-info
{
background
:
rgba
(
59
,
130
,
246
,
0.1
);
color
:
#3b82f6
;
}
.badge-purple
{
background
:
rgba
(
168
,
85
,
247
,
0.1
);
color
:
#a855f7
;
}
.badge-success
{
background
:
rgba
(
34
,
197
,
94
,
0.1
);
color
:
#22c55e
;
}
.badge-danger
{
background
:
rgba
(
239
,
68
,
68
,
0.1
);
color
:
#ef4444
;
}
.badge-muted
{
background
:
var
(
--bg-hover
);
color
:
var
(
--text-muted
);
}
.badge-group
{
background
:
linear-gradient
(
135deg
,
rgba
(
102
,
126
,
234
,
.15
),
rgba
(
118
,
75
,
162
,
.15
));
color
:
#667eea
;
}
/* ═══════════════════════════════════════════════════════
EMPTY / LOADING
═══════════════════════════════════════════════════════ */
.loading-center
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
padding
:
80px
;
gap
:
16px
;
color
:
var
(
--text-muted
);
}
.empty-state
{
text-align
:
center
;
padding
:
80px
;
color
:
var
(
--text-muted
);
}
.empty-state
.material-symbols-rounded
{
font-size
:
64px
;
display
:
block
;
margin-bottom
:
16px
;
opacity
:
0.35
;
}
.empty-state
h3
{
color
:
var
(
--text-primary
);
font-size
:
18px
;
margin
:
0
0
8px
;
}
.empty-state
p
{
font-size
:
14px
;
margin
:
0
;
}
/* ═══════════════════════════════════════════════════════
MODAL
═══════════════════════════════════════════════════════ */
.modal-overlay
{
position
:
fixed
;
inset
:
0
;
background
:
rgba
(
0
,
0
,
0
,
0.5
);
backdrop-filter
:
blur
(
5px
);
z-index
:
1000
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
animation
:
fadeIn
0.2s
ease-out
;
}
.modal-container
{
background
:
var
(
--bg-card
);
border-radius
:
18px
;
box-shadow
:
0
20px
60px
rgba
(
0
,
0
,
0
,
0.3
);
border
:
1px
solid
var
(
--border-color
);
width
:
92%
;
max-height
:
88vh
;
overflow-y
:
auto
;
animation
:
slideUp
0.3s
cubic-bezier
(
0.16
,
1
,
0.3
,
1
);
}
.modal-xl
{
max-width
:
860px
;
}
.modal-header
{
padding
:
22px
26px
;
border-bottom
:
1px
solid
var
(
--border-color
);
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
position
:
sticky
;
top
:
0
;
background
:
var
(
--bg-card
);
z-index
:
2
;
border-radius
:
18px
18px
0
0
;
}
.modal-header
h2
{
font-size
:
18px
;
font-weight
:
700
;
margin
:
0
;
}
.modal-body
{
padding
:
26px
;
display
:
flex
;
flex-direction
:
column
;
gap
:
8px
;
}
.modal-footer
{
padding
:
16px
26px
;
border-top
:
1px
solid
var
(
--border-color
);
display
:
flex
;
justify-content
:
flex-end
;
gap
:
12px
;
background
:
var
(
--bg-hover
);
position
:
sticky
;
bottom
:
0
;
border-radius
:
0
0
18px
18px
;
}
/* Success banner */
.success-banner
{
margin
:
24px
;
padding
:
20px
24px
;
border-radius
:
14px
;
background
:
linear-gradient
(
135deg
,
rgba
(
34
,
197
,
94
,
.12
),
rgba
(
16
,
185
,
129
,
.08
));
border
:
1px
solid
rgba
(
34
,
197
,
94
,
.3
);
color
:
#22c55e
;
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
font-size
:
15px
;
font-weight
:
500
;
}
.success-banner
.material-symbols-rounded
{
font-size
:
28px
;
}
.success-banner
.btn
{
margin-left
:
auto
;
}
/* ═══════════════════════════════════════════════════════
FORM ELEMENTS
═══════════════════════════════════════════════════════ */
.section-title
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-size
:
13px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
text-transform
:
uppercase
;
letter-spacing
:
0.8px
;
padding
:
10px
0
6px
;
border-bottom
:
1px
solid
var
(
--border-color
);
margin-bottom
:
14px
;
margin-top
:
10px
;
}
.section-title
.material-symbols-rounded
{
font-size
:
18px
;
color
:
var
(
--accent-primary
);
}
.form-row
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
16px
;
margin-bottom
:
14px
;
}
.form-row.three-col
{
grid-template-columns
:
1
fr
1
fr
1
fr
;
}
.form-group
{
display
:
flex
;
flex-direction
:
column
;
gap
:
6px
;
}
.form-label
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
}
.form-input
{
padding
:
10px
14px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
background
:
var
(
--bg-input
);
color
:
var
(
--text-primary
);
font-size
:
14px
;
font-family
:
inherit
;
transition
:
all
0.2s
;
width
:
100%
;
box-sizing
:
border-box
;
}
.form-input
:focus
{
outline
:
none
;
border-color
:
var
(
--accent-primary
);
box-shadow
:
0
0
0
3px
rgba
(
102
,
126
,
234
,
0.12
);
}
.field-hint
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
4px
;
font-size
:
12px
;
color
:
#22c55e
;
margin-top
:
2px
;
}
.field-hint
.material-symbols-rounded
{
font-size
:
14px
;
}
/* Checklist box */
.checklist-box
{
border
:
1px
solid
var
(
--border-color
);
border-radius
:
10px
;
padding
:
10px
12px
;
max-height
:
130px
;
overflow-y
:
auto
;
background
:
var
(
--bg-input
);
display
:
flex
;
flex-direction
:
column
;
gap
:
6px
;
}
.check-item
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-size
:
13px
;
color
:
var
(
--text-secondary
);
cursor
:
pointer
;
padding
:
2px
0
;
}
.check-item
input
[
type
=
"checkbox"
]
{
accent-color
:
var
(
--accent-primary
);
}
.check-item
:hover
{
color
:
var
(
--text-primary
);
}
.text-muted
{
color
:
var
(
--text-muted
);
font-size
:
13px
;
}
/* ═══════════════════════════════════════════════════════
QUIZ CONFIGURATION
═══════════════════════════════════════════════════════ */
.quiz-empty-hint
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
gap
:
10px
;
padding
:
32px
;
border
:
2px
dashed
var
(
--border-color
);
border-radius
:
14px
;
background
:
var
(
--bg-hover
);
color
:
var
(
--text-muted
);
margin-bottom
:
8px
;
}
.quiz-empty-hint
.material-symbols-rounded
{
font-size
:
40px
;
opacity
:
0.4
;
}
.quiz-empty-hint
p
{
margin
:
0
;
font-size
:
14px
;
}
.quiz-setup-prompt
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
flex-wrap
:
wrap
;
padding
:
14px
18px
;
border-radius
:
10px
;
background
:
rgba
(
102
,
126
,
234
,
0.06
);
border
:
1px
solid
rgba
(
102
,
126
,
234
,
0.2
);
margin-bottom
:
16px
;
}
.quiz-setup-prompt
.material-symbols-rounded
{
font-size
:
20px
;
color
:
var
(
--accent-primary
);
}
.quiz-setup-prompt
span
{
font-size
:
14px
;
font-weight
:
500
;
color
:
var
(
--text-primary
);
}
.sets-count-input
{
width
:
72px
;
padding
:
8px
10px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
background
:
var
(
--bg-input
);
color
:
var
(
--text-primary
);
font-size
:
14px
;
text-align
:
center
;
font-family
:
inherit
;
}
.sets-count-input
:focus
{
outline
:
none
;
border-color
:
var
(
--accent-primary
);
}
/* Quiz Set Block */
.quiz-set-block
{
border
:
1px
solid
var
(
--border-color
);
border-radius
:
14px
;
padding
:
18px
20px
;
margin-bottom
:
14px
;
background
:
var
(
--bg-hover
);
display
:
flex
;
flex-direction
:
column
;
gap
:
10px
;
transition
:
border-color
0.2s
;
}
.quiz-set-block
:hover
{
border-color
:
rgba
(
102
,
126
,
234
,
0.4
);
}
.quiz-set-header
{
display
:
flex
;
align-items
:
center
;
justify-content
:
space-between
;
}
.quiz-set-title
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
}
.set-badge
{
background
:
linear-gradient
(
135deg
,
#667eea
,
#764ba2
);
color
:
#fff
;
font-size
:
12px
;
font-weight
:
700
;
padding
:
4px
12px
;
border-radius
:
20px
;
}
.set-note
{
font-size
:
12px
;
color
:
var
(
--text-muted
);
}
/* Quiz entry row */
.quiz-entry-row
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
}
.quiz-entry-row
.form-input
{
flex
:
1
;
}
/* Assignment mode */
.assignment-mode-row
{
display
:
flex
;
align-items
:
center
;
gap
:
14px
;
flex-wrap
:
wrap
;
padding-top
:
8px
;
border-top
:
1px
solid
var
(
--border-color
);
}
.mode-label
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
}
.mode-toggle
{
display
:
flex
;
gap
:
8px
;
}
.mode-btn
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
7px
16px
;
border-radius
:
8px
;
font-size
:
13px
;
font-weight
:
500
;
border
:
1px
solid
var
(
--border-color
);
background
:
var
(
--bg-input
);
color
:
var
(
--text-secondary
);
cursor
:
pointer
;
transition
:
all
0.2s
;
}
.mode-btn
.material-symbols-rounded
{
font-size
:
16px
;
}
.mode-btn
:hover
{
border-color
:
var
(
--accent-primary
);
color
:
var
(
--text-primary
);
}
.mode-btn.active
{
border-color
:
#667eea
;
background
:
rgba
(
102
,
126
,
234
,
0.12
);
color
:
#667eea
;
font-weight
:
700
;
}
/* Direct assignment table */
.direct-assignment-table
{
border
:
1px
solid
var
(
--border-color
);
border-radius
:
10px
;
overflow
:
hidden
;
margin-top
:
4px
;
}
.da-header
{
display
:
grid
;
grid-template-columns
:
1
fr
1.5
fr
;
padding
:
10px
14px
;
background
:
rgba
(
102
,
126
,
234
,
0.08
);
font-size
:
12px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
text-transform
:
uppercase
;
letter-spacing
:
0.5px
;
}
.da-row
{
display
:
grid
;
grid-template-columns
:
1
fr
1.5
fr
;
align-items
:
center
;
padding
:
10px
14px
;
border-top
:
1px
solid
var
(
--border-color
);
gap
:
12px
;
background
:
var
(
--bg-card
);
transition
:
background
0.15s
;
}
.da-row
:hover
{
background
:
var
(
--bg-hover
);
}
.da-candidate
{
display
:
flex
;
align-items
:
center
;
gap
:
10px
;
}
.da-avatar
{
width
:
30px
;
height
:
30px
;
border-radius
:
8px
;
font-size
:
13px
;
font-weight
:
700
;
background
:
linear-gradient
(
135deg
,
#667eea
,
#764ba2
);
color
:
#fff
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-shrink
:
0
;
}
.da-row
.form-input
{
padding
:
7px
10px
;
font-size
:
13px
;
}
/* Ghost btn */
.btn-ghost
{
background
:
none
;
border
:
1px
dashed
var
(
--border-color
);
color
:
var
(
--text-muted
);
padding
:
7px
14px
;
border-radius
:
8px
;
font-size
:
13px
;
cursor
:
pointer
;
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
transition
:
all
0.2s
;
font-family
:
inherit
;
}
.btn-ghost
:hover
{
border-color
:
var
(
--accent-primary
);
color
:
var
(
--accent-primary
);
}
.btn-ghost
.material-symbols-rounded
{
font-size
:
16px
;
}
.icon-btn.danger
{
color
:
#ef4444
;
}
.icon-btn.danger
:hover
{
background
:
rgba
(
239
,
68
,
68
,
0.08
);
}
/* ═══════════════════════════════════════════════════════
DETAIL MODAL
═══════════════════════════════════════════════════════ */
.detail-body
{
display
:
flex
;
flex-direction
:
column
;
gap
:
24px
;
}
.detail-section
{
padding-bottom
:
16px
;
border-bottom
:
1px
solid
var
(
--border-color
);
}
.detail-section
:last-child
{
border-bottom
:
none
;
}
.detail-section-title
{
font-size
:
12px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
margin
:
0
0
16px
;
text-transform
:
uppercase
;
letter-spacing
:
0.8px
;
}
.detail-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
12px
;
}
.detail-item
{
display
:
flex
;
flex-direction
:
column
;
gap
:
3px
;
}
.detail-label
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
text-transform
:
uppercase
;
letter-spacing
:
0.5px
;
}
.detail-value
{
font-size
:
14px
;
color
:
var
(
--text-primary
);
font-weight
:
500
;
}
/* Candidate detail list */
.candidate-detail-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
}
.candidate-row
{
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
padding
:
16px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
12px
;
background
:
var
(
--bg-hover
);
transition
:
border-color
0.2s
;
}
.candidate-row
:hover
{
border-color
:
rgba
(
102
,
126
,
234
,
0.35
);
}
/* Top row: avatar+name on left, progress stepper fills the rest */
.candidate-row-top
{
display
:
flex
;
align-items
:
center
;
gap
:
16px
;
}
.candidate-row-left
{
display
:
flex
;
align-items
:
center
;
gap
:
12px
;
min-width
:
160px
;
flex-shrink
:
0
;
}
.iv-avatar
{
width
:
40px
;
height
:
40px
;
border-radius
:
10px
;
background
:
linear-gradient
(
135deg
,
#667eea
,
#764ba2
);
color
:
#fff
;
font-weight
:
700
;
font-size
:
16px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
flex-shrink
:
0
;
}
.iv-avatar.small
{
width
:
36px
;
height
:
36px
;
font-size
:
15px
;
border-radius
:
9px
;
}
.cr-name
{
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
}
.cr-email
{
font-size
:
12px
;
color
:
var
(
--text-muted
);
}
.candidate-row-mid
{
flex
:
1
;
}
/* Bottom row: status badge + quiz chips + accept/reject — all left-aligned */
.candidate-row-bottom
{
display
:
flex
;
flex-wrap
:
wrap
;
align-items
:
center
;
gap
:
8px
;
padding-top
:
10px
;
border-top
:
1px
solid
var
(
--border-color
);
}
/* Progress steps (mini) */
.progress-steps
{
display
:
flex
;
align-items
:
center
;
}
.progress-steps.mini
.step-label
{
font-size
:
10px
;
}
.progress-steps.mini
.step-dot
{
width
:
8px
;
height
:
8px
;
}
.step
{
display
:
flex
;
align-items
:
center
;
gap
:
4px
;
}
.step-dot
{
width
:
10px
;
height
:
10px
;
border-radius
:
50%
;
background
:
var
(
--border-color
);
transition
:
all
0.3s
;
}
.step.done
.step-dot
{
background
:
#22c55e
;
}
.step.active
.step-dot
{
background
:
#667eea
;
box-shadow
:
0
0
0
3px
rgba
(
102
,
126
,
234
,
0.2
);
}
.step-label
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
font-weight
:
500
;
}
.step.done
.step-label
{
color
:
#22c55e
;
}
.step.active
.step-label
{
color
:
#667eea
;
font-weight
:
600
;
}
.step-line
{
flex
:
1
;
height
:
2px
;
background
:
var
(
--border-color
);
margin
:
0
6px
;
transition
:
background
0.3s
;
min-width
:
16px
;
}
.step-line.done
{
background
:
#22c55e
;
}
/* Quiz score chips */
.quiz-scores-inline
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
6px
;
}
.quiz-score-chip
{
font-size
:
11px
;
padding
:
3px
10px
;
border-radius
:
20px
;
background
:
var
(
--bg-card
);
border
:
1px
solid
var
(
--border-color
);
color
:
var
(
--text-muted
);
white-space
:
nowrap
;
}
.quiz-score-chip.completed
{
color
:
#22c55e
;
border-color
:
rgba
(
34
,
197
,
94
,
0.3
);
background
:
rgba
(
34
,
197
,
94
,
0.07
);
}
/* Mini decision buttons */
.mini-decision-btns
{
display
:
flex
;
gap
:
6px
;
}
.mini-btn
{
padding
:
4px
12px
;
border-radius
:
6px
;
font-size
:
12px
;
font-weight
:
600
;
border
:
none
;
cursor
:
pointer
;
transition
:
opacity
0.2s
;
font-family
:
inherit
;
}
.mini-btn.success
{
background
:
rgba
(
34
,
197
,
94
,
0.15
);
color
:
#22c55e
;
}
.mini-btn.success
:hover
{
background
:
#22c55e
;
color
:
#fff
;
}
.mini-btn.danger
{
background
:
rgba
(
239
,
68
,
68
,
0.12
);
color
:
#ef4444
;
}
.mini-btn.danger
:hover
{
background
:
#ef4444
;
color
:
#fff
;
}
/* ═══════════════════════════════════════════════════════
BUTTONS (shared)
═══════════════════════════════════════════════════════ */
.btn-success
{
background
:
#22c55e
;
color
:
#fff
;
}
.btn-danger
{
background
:
#ef4444
;
color
:
#fff
;
}
.btn-sm
{
padding
:
6px
14px
;
font-size
:
13px
;
}
@keyframes
fadeIn
{
from
{
opacity
:
0
;
}
to
{
opacity
:
1
;
}
}
@keyframes
slideUp
{
from
{
transform
:
translateY
(
24px
);
opacity
:
0
;
}
to
{
transform
:
translateY
(
0
);
opacity
:
1
;
}
}
/* ═══════════════════════════════════════════════════════
EVALUATION PANEL (member detail modal)
Mirrors individual-interview styles
═══════════════════════════════════════════════════════ */
.quiz-results-grid
{
display
:
grid
;
grid-template-columns
:
repeat
(
auto-fill
,
minmax
(
180px
,
1
fr
));
gap
:
12px
;
}
.quiz-result-card
{
padding
:
14px
16px
;
border-radius
:
12px
;
border
:
1px
solid
var
(
--border-color
);
background
:
var
(
--bg-input
);
}
.qr-title
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
margin-bottom
:
6px
;
}
.qr-score
{
font-size
:
22px
;
font-weight
:
700
;
color
:
#22c55e
;
}
.qr-pending
{
font-size
:
13px
;
color
:
var
(
--text-muted
);
font-style
:
italic
;
}
.eval-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
margin-bottom
:
16px
;
}
.eval-card
{
padding
:
14px
16px
;
border-radius
:
12px
;
border
:
1px
solid
var
(
--border-color
);
background
:
var
(
--bg-input
);
}
.eval-header
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
margin-bottom
:
8px
;
}
.eval-evaluator
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
font-size
:
14px
;
}
.eval-role
{
font-size
:
10px
!important
;
padding
:
2px
6px
!important
;
}
.eval-comments
{
font-size
:
13px
;
color
:
var
(
--text-secondary
);
font-style
:
italic
;
margin
:
6px
0
8px
;
}
.eval-date
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
}
.eval-form
{
margin-top
:
16px
;
padding
:
18px
20px
;
border-radius
:
14px
;
border
:
1px
dashed
rgba
(
102
,
126
,
234
,
0.35
);
background
:
rgba
(
102
,
126
,
234
,
0.04
);
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
}
.eval-form
h4
{
font-size
:
14px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
margin
:
0
;
}
.form-textarea
{
resize
:
vertical
;
min-height
:
80px
;
}
.decision-section
{
border-bottom
:
none
;
}
.decision-buttons
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
10px
;
}
.btn-success
{
background
:
#22c55e
;
color
:
#fff
;
border
:
none
;
}
.btn-warning
{
background
:
#f59e0b
;
color
:
#fff
;
border
:
none
;
}
/* ═══════════════════════════════════════════════════════
RESPONSIVE
═══════════════════════════════════════════════════════ */
@media
(
max-width
:
768px
)
{
.page-container
{
padding
:
20px
;
}
.stats-row
{
flex-wrap
:
wrap
;
}
.mini-stat
{
min-width
:
100px
;
}
.form-row
,
.form-row.three-col
{
grid-template-columns
:
1
fr
;
}
.detail-grid
{
grid-template-columns
:
1
fr
;
}
.candidate-row
{
flex-direction
:
column
;
}
.candidate-row-right
{
align-items
:
flex-start
;
}
.da-header
,
.da-row
{
grid-template-columns
:
1
fr
;
}
}
/* ═══════════════════════════════════════════════════════
PRINT STYLES FOR EVALUATION PDF
═══════════════════════════════════════════════════════ */
.print-container
{
display
:
none
;
}
@media
print
{
body
*
{
visibility
:
hidden
;
}
.print-container
,
.print-container
*
{
visibility
:
visible
;
}
.print-container
{
display
:
block
;
position
:
absolute
;
left
:
0
;
top
:
0
;
width
:
100%
;
padding
:
20px
;
background
:
white
;
color
:
black
;
font-family
:
Arial
,
sans-serif
;
}
.print-header
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
border-bottom
:
2px
solid
#0078d4
;
padding-bottom
:
10px
;
margin-bottom
:
20px
;
}
}
Frontend/src/app/pages/hr/group-interview/group-interview.html
View file @
954049ba
<p>
group-interview works!
</p>
<div
class=
"page-container animate-fade-in"
>
<div
class=
"content-wrapper"
>
<!-- Page Header -->
<div
class=
"page-header"
>
<div>
<h1>
Group Interviews
</h1>
<p
class=
"page-subtitle"
>
Create and manage batch interview sessions for candidate groups
</p>
</div>
<button
class=
"btn btn-primary"
(click)=
"openCreateModal()"
>
<span
class=
"material-symbols-rounded"
>
group_add
</span>
Create Group Interview
</button>
</div>
<!-- Stats -->
<div
class=
"stats-row"
>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value"
>
{{ stats().total }}
</span>
<span
class=
"mini-stat-label"
>
Total
</span>
</div>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value orange"
>
{{ stats().pending }}
</span>
<span
class=
"mini-stat-label"
>
In Progress
</span>
</div>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value blue"
>
{{ stats().completed }}
</span>
<span
class=
"mini-stat-label"
>
Completed
</span>
</div>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value green"
>
{{ stats().accepted }}
</span>
<span
class=
"mini-stat-label"
>
Accepted
</span>
</div>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value red"
>
{{ stats().rejected }}
</span>
<span
class=
"mini-stat-label"
>
Rejected
</span>
</div>
</div>
<!-- Filter -->
<div
class=
"filter-bar"
>
<select
class=
"filter-select"
[(ngModel)]=
"filterStatus"
(ngModelChange)=
"onFilterChange()"
>
<option
value=
""
>
All Statuses
</option>
<option
value=
"pending"
>
Pending
</option>
<option
value=
"quiz_phase"
>
Quiz Phase
</option>
<option
value=
"coding_phase"
>
Coding Phase
</option>
<option
value=
"evaluation"
>
Evaluation
</option>
<option
value=
"completed"
>
Completed
</option>
</select>
</div>
<!-- Interview Group List -->
@if (loading()) {
<div
class=
"loading-center"
><div
class=
"spinner spinner-lg"
></div><p>
Loading interviews...
</p></div>
} @else if (groupedList().length === 0) {
<div
class=
"empty-state"
>
<span
class=
"material-symbols-rounded"
>
groups
</span>
<h3>
No group interviews found
</h3>
<p>
Create your first group interview session to get started
</p>
</div>
} @else {
<div
class=
"interview-list"
>
@for (g of groupedList(); track g.groupId) {
<div
class=
"interview-card card card-padding"
(click)=
"openDetail(g)"
>
<div
class=
"iv-card-top"
>
<div
class=
"iv-group-info"
>
<div
class=
"iv-group-avatar"
>
<span
class=
"material-symbols-rounded"
>
groups
</span>
</div>
<div>
<h3
class=
"iv-name"
>
{{ g.groupId }}
</h3>
<p
class=
"iv-email"
>
{{ g.members.length }} candidate{{ g.members.length !== 1 ? 's' : '' }} · {{ g.position }}
</p>
</div>
</div>
<div
class=
"iv-badges"
>
<span
class=
"badge badge-group"
>
Group
</span>
@if (completedCount(g.members) === g.members.length
&&
g.members.length > 0) {
<span
class=
"badge badge-success"
>
All Done
</span>
} @else {
<span
class=
"badge badge-warning"
>
In Progress
</span>
}
</div>
</div>
<div
class=
"iv-card-meta"
>
<span
class=
"meta-item"
><span
class=
"material-symbols-rounded"
>
work
</span>
{{ g.position }}
</span>
@if (g.techStack) {
<span
class=
"meta-item"
><span
class=
"material-symbols-rounded"
>
code
</span>
{{ g.techStack }}
</span>
}
<span
class=
"meta-item"
><span
class=
"material-symbols-rounded"
>
calendar_today
</span>
{{ g.dateOfInterview | date:'mediumDate' }}
</span>
@if (g.assignedInterviewers?.length > 0) {
<span
class=
"meta-item"
><span
class=
"material-symbols-rounded"
>
person
</span>
{{ g.assignedInterviewers[0]?.name }}
</span>
}
</div>
<!-- Candidate progress chips — click any chip to open the evaluation panel directly -->
<div
class=
"candidate-chips"
>
@for (m of g.members; track m._id) {
<span
class=
"candidate-chip clickable-chip"
[ngClass]=
"getStatusClass(m.status)"
[title]=
"m.candidateId?.name + ' — ' + formatStatus(m.status) + (hasPendingEvaluations(m) ? ' (Evaluations pending)' : '')"
(click)=
"openMemberDetail(m._id, $event)"
>
@if (hasPendingEvaluations(m)) {
<span
class=
"chip-alert"
title=
"Evaluations still pending"
>
⚠️
</span>
}
{{ m.candidateId?.name }}
</span>
}
</div>
<div
class=
"iv-card-bottom"
>
<span
class=
"status-summary"
>
{{ groupStatusSummary(g.members) }}
</span>
</div>
</div>
}
</div>
}
</div>
</div>
<!-- ═══════════════════════════════════════════════════════════
CREATE GROUP INTERVIEW MODAL
═══════════════════════════════════════════════════════════ -->
@if (showCreateModal()) {
<div
class=
"modal-overlay"
(click)=
"closeCreateModal()"
>
<div
class=
"modal-container modal-xl"
(click)=
"$event.stopPropagation()"
>
<div
class=
"modal-header"
>
<h2>
Create Group Interview
</h2>
<button
class=
"icon-btn"
(click)=
"closeCreateModal()"
><span
class=
"material-symbols-rounded"
>
close
</span></button>
</div>
@if (successMsg()) {
<div
class=
"success-banner"
>
<span
class=
"material-symbols-rounded"
>
check_circle
</span>
{{ successMsg() }}
<button
class=
"btn btn-outline btn-sm"
(click)=
"closeCreateModal()"
>
Close
</button>
</div>
} @else {
<div
class=
"modal-body"
>
<!-- Staff assignment -->
<div
class=
"section-title"
>
<span
class=
"material-symbols-rounded"
>
badge
</span>
Staff Assignment
</div>
<div
class=
"form-row three-col"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Interviewers
</label>
<div
class=
"checklist-box"
>
@for (i of interviewers(); track i._id) {
<label
class=
"check-item"
>
<input
type=
"checkbox"
[value]=
"i._id"
(change)=
"toggleSelection($event, newGroupInterview.assignedInterviewers)"
>
<span>
{{ i.name }}
</span>
</label>
}
@if (interviewers().length === 0) {
<p
class=
"text-muted"
>
No interviewers found
</p>
}
</div>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
HR Personnel
</label>
<div
class=
"checklist-box"
>
@for (h of hrs(); track h._id) {
<label
class=
"check-item"
>
<input
type=
"checkbox"
[value]=
"h._id"
(change)=
"toggleSelection($event, newGroupInterview.assignedHRs)"
>
<span>
{{ h.name }}
</span>
</label>
}
@if (hrs().length === 0) {
<p
class=
"text-muted"
>
No HR found
</p>
}
</div>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Project Managers
</label>
<div
class=
"checklist-box"
>
@for (p of pms(); track p._id) {
<label
class=
"check-item"
>
<input
type=
"checkbox"
[value]=
"p._id"
(change)=
"toggleSelection($event, newGroupInterview.assignedPMs)"
>
<span>
{{ p.name }}
</span>
</label>
}
@if (pms().length === 0) {
<p
class=
"text-muted"
>
No PMs found
</p>
}
</div>
</div>
</div>
<!-- Group + Position -->
<div
class=
"section-title"
>
<span
class=
"material-symbols-rounded"
>
groups
</span>
Group
&
Position
</div>
<div
class=
"form-row"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Group *
</label>
<select
class=
"form-input"
[(ngModel)]=
"newGroupInterview.groupName"
(ngModelChange)=
"onGroupChange()"
>
<option
value=
""
>
Select group
</option>
@for (g of groups(); track g) {
<option
[value]=
"g"
>
{{ g }}
</option>
}
</select>
@if (groupMembers().length > 0) {
<span
class=
"field-hint"
>
<span
class=
"material-symbols-rounded"
>
person
</span>
{{ groupMembers().length }} candidate{{ groupMembers().length !== 1 ? 's' : '' }} in this group
</span>
}
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Position *
</label>
<input
class=
"form-input"
[(ngModel)]=
"newGroupInterview.position"
placeholder=
"e.g., MEAN Stack Developer"
>
</div>
</div>
<div
class=
"form-row"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Source
</label>
<input
class=
"form-input"
[(ngModel)]=
"newGroupInterview.source"
placeholder=
"e.g., LinkedIn, Campus"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Date of Interview
</label>
<input
class=
"form-input"
type=
"date"
[(ngModel)]=
"newGroupInterview.dateOfInterview"
>
</div>
</div>
<div
class=
"form-row"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Tech Stack
</label>
<input
class=
"form-input"
[(ngModel)]=
"newGroupInterview.techStack"
placeholder=
"e.g., MERN, Java, Python"
>
</div>
</div>
<!-- Quiz Configuration -->
<div
class=
"section-title"
>
<span
class=
"material-symbols-rounded"
>
quiz
</span>
Quiz Configuration
@if (quizSets.length > 0) {
<button
class=
"btn btn-outline btn-sm"
style=
"margin-left: auto;"
(click)=
"addMoreSet()"
>
<span
class=
"material-symbols-rounded"
>
add
</span>
Add Set
</button>
}
</div>
@if (quizSets.length === 0
&&
!showQuizSetup) {
<div
class=
"quiz-empty-hint"
>
<span
class=
"material-symbols-rounded"
>
quiz
</span>
<p>
No quiz sets configured yet.
</p>
<button
class=
"btn btn-primary"
(click)=
"showQuizSetup = true"
>
<span
class=
"material-symbols-rounded"
>
add
</span>
Add Quiz
</button>
</div>
}
@if (showQuizSetup) {
<div
class=
"quiz-setup-prompt"
>
<span
class=
"material-symbols-rounded"
>
help_outline
</span>
<span>
How many quiz sets?
</span>
<input
type=
"number"
min=
"1"
max=
"10"
class=
"sets-count-input"
[(ngModel)]=
"pendingSetsCount"
placeholder=
"e.g. 2"
>
<button
class=
"btn btn-primary btn-sm"
(click)=
"confirmSetsCount()"
>
Confirm
</button>
<button
class=
"btn btn-outline btn-sm"
(click)=
"showQuizSetup = false"
>
Cancel
</button>
</div>
}
<!-- Quiz Sets -->
@for (set of quizSets; track $index; let si = $index) {
<div
class=
"quiz-set-block"
>
<div
class=
"quiz-set-header"
>
<div
class=
"quiz-set-title"
>
<span
class=
"set-badge"
>
Set {{ si + 1 }}
</span>
@if (isSingleQuizSet(set)) {
<span
class=
"set-note"
>
📌 Assigned to all candidates
</span>
} @else if (isMultiQuizSet(set)) {
<span
class=
"set-note"
>
🎯 {{ validEntries(set).length }} quizzes · {{ set.mode === 'random' ? '🎲 Random' : 'Direct' }} assignment
</span>
}
</div>
<button
class=
"icon-btn danger"
(click)=
"removeQuizSet(si)"
>
<span
class=
"material-symbols-rounded"
>
delete
</span>
</button>
</div>
<!-- Quiz entries in this set -->
@for (entry of set.quizEntries; track $index; let ei = $index) {
<div
class=
"quiz-entry-row"
>
<select
class=
"form-input"
[(ngModel)]=
"entry.quizId"
>
<option
value=
""
>
— Select quiz —
</option>
@for (q of getAvailableQuizzes(si, ei); track q._id) {
<option
[value]=
"q._id"
>
{{ q.title }}
</option>
}
</select>
<button
class=
"icon-btn"
(click)=
"removeQuizEntry(si, ei)"
>
<span
class=
"material-symbols-rounded"
>
remove_circle_outline
</span>
</button>
</div>
}
<button
class=
"btn btn-ghost btn-sm"
(click)=
"addQuizToSet(si)"
>
<span
class=
"material-symbols-rounded"
>
add
</span>
Add quiz to Set {{ si + 1 }}
</button>
<!-- Assignment mode — only shown when multiple quizzes -->
@if (isMultiQuizSet(set)) {
<div
class=
"assignment-mode-row"
>
<span
class=
"mode-label"
>
Assignment Mode:
</span>
<div
class=
"mode-toggle"
>
<button
class=
"mode-btn"
[class.active]=
"set.mode === 'random'"
(click)=
"set.mode = 'random'"
>
<span
class=
"material-symbols-rounded"
>
shuffle
</span>
Random
</button>
<button
class=
"mode-btn"
[class.active]=
"set.mode === 'direct'"
(click)=
"set.mode = 'direct'"
>
<span
class=
"material-symbols-rounded"
>
assignment_ind
</span>
Direct
</button>
</div>
</div>
<!-- Direct assignment table -->
@if (set.mode === 'direct') {
@if (groupMembers().length === 0) {
<p
class=
"text-muted"
style=
"font-size:13px; margin-top:8px;"
>
⚠️ Select a group above to assign quizzes to individual candidates.
</p>
} @else {
<div
class=
"direct-assignment-table"
>
<div
class=
"da-header"
>
<span>
Candidate
</span>
<span>
Assigned Quiz (Set {{ si + 1 }})
</span>
</div>
@for (member of groupMembers(); track member._id) {
<div
class=
"da-row"
>
<div
class=
"da-candidate"
>
<div
class=
"da-avatar"
>
{{ member.name.charAt(0).toUpperCase() }}
</div>
<span>
{{ member.name }}
</span>
</div>
<select
class=
"form-input"
[(ngModel)]=
"set.directAssignments[member._id]"
>
<option
value=
""
>
— Select —
</option>
@for (entry of validEntries(set); track entry.quizId) {
<option
[value]=
"entry.quizId"
>
{{ getQuizTitle(entry.quizId) }}
</option>
}
</select>
</div>
}
</div>
}
}
}
</div>
}
</div>
<!-- /modal-body -->
<div
class=
"modal-footer"
>
<button
class=
"btn btn-outline"
(click)=
"closeCreateModal()"
>
Cancel
</button>
<button
class=
"btn btn-primary"
(click)=
"createGroupInterview()"
[disabled]=
"isSubmitting() || !newGroupInterview.groupName || !newGroupInterview.position"
>
@if (isSubmitting()) {
<span
class=
"spinner spinner-sm"
></span>
Creating...
} @else {
<span
class=
"material-symbols-rounded"
>
rocket_launch
</span>
Create Group Interview
}
</button>
</div>
}
</div>
</div>
}
<!-- ═══════════════════════════════════════════════════════════
DETAIL MODAL — All candidates in a group session
═══════════════════════════════════════════════════════════ -->
@if (showDetailModal()
&&
selectedGroup()) {
<div
class=
"modal-overlay"
(click)=
"closeDetail()"
>
<div
class=
"modal-container modal-xl"
(click)=
"$event.stopPropagation()"
>
<div
class=
"modal-header"
>
<div>
<h2>
{{ selectedGroup().groupId }}
</h2>
<span
style=
"font-size:13px; color:var(--text-muted)"
>
{{ selectedGroup().position }} · {{ selectedGroup().members.length }} candidates
</span>
</div>
<button
class=
"icon-btn"
(click)=
"closeDetail()"
><span
class=
"material-symbols-rounded"
>
close
</span></button>
</div>
<div
class=
"modal-body detail-body"
>
<!-- Session meta -->
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Session Info
</h3>
<div
class=
"detail-grid"
>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Position
</span>
<span
class=
"detail-value"
>
{{ selectedGroup().position }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Tech Stack
</span>
<span
class=
"detail-value"
>
{{ selectedGroup().techStack || '—' }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Source
</span>
<span
class=
"detail-value"
>
{{ selectedGroup().source || '—' }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Interview Date
</span>
<span
class=
"detail-value"
>
{{ selectedGroup().dateOfInterview | date:'mediumDate' }}
</span>
</div>
</div>
</div>
<!-- Candidate rows -->
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Candidates
</h3>
<div
class=
"candidate-detail-list"
>
@for (m of selectedGroup().members; track m._id) {
<div
class=
"candidate-row"
>
<!-- Top row: clickable avatar+name on the left, progress bar on the right -->
<div
class=
"candidate-row-top"
>
<button
class=
"cr-clickable"
(click)=
"openMemberDetail(m._id, $event)"
>
<div
class=
"iv-avatar small"
>
{{ m.candidateId?.name?.charAt(0)?.toUpperCase() }}
</div>
<div>
<div
class=
"cr-name"
>
{{ m.candidateId?.name }}
@if (needsEvaluation(m)) {
<span
class=
"eval-pending-badge"
title=
"Your evaluation is pending"
>
⚠️ Evaluate
</span>
}
@if (hasPendingEvaluations(m)) {
<span
class=
"eval-pending-badge warn"
title=
"Some evaluations are still pending"
>
⏳
</span>
}
</div>
<div
class=
"cr-email"
>
{{ m.candidateId?.email }}
</div>
</div>
</button>
<div
class=
"candidate-row-mid"
>
<div
class=
"progress-steps mini"
>
<div
class=
"step"
[class.done]=
"['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Created
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['quiz_phase','coding_phase','evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='quiz_phase'"
[class.done]=
"['coding_phase','evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Quiz
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['coding_phase','evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='coding_phase'"
[class.done]=
"['evaluation','completed'].includes(m.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Coding
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['evaluation','completed'].includes(m.status)"
></div>
<div
class=
"step"
[class.active]=
"m.status==='evaluation'"
[class.done]=
"m.status==='completed'"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Evaluate
</span>
</div>
<div
class=
"step-line"
[class.done]=
"m.status==='completed'"
></div>
<div
class=
"step"
[class.active]=
"m.status==='completed'"
[class.done]=
"m.status==='completed'"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Done
</span>
</div>
</div>
</div>
</div>
<!-- Bottom row: status badge + quiz scores (no accept/reject here) -->
<div
class=
"candidate-row-bottom"
>
<span
class=
"badge"
[ngClass]=
"getStatusClass(m.status)"
>
{{ formatStatus(m.status) }}
</span>
@if (m.finalDecision !== 'pending') {
<span
class=
"badge"
[ngClass]=
"getDecisionClass(m.finalDecision)"
>
{{ formatDecision(m.finalDecision) }}
</span>
}
@if (m.quizzes?.length > 0) {
@for (q of m.quizzes; track q.quizId) {
<span
class=
"quiz-score-chip"
[class.completed]=
"q.completed"
>
{{ q.title }}: {{ q.completed ? q.score + '/' + q.totalMarks : 'Pending' }}
</span>
}
}
<span
class=
"cr-open-hint"
>
Click name to evaluate →
</span>
</div>
</div>
}
</div>
</div>
</div>
<div
class=
"modal-footer"
>
@if (authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') {
<button
class=
"btn btn-danger btn-sm"
(click)=
"deleteGroupInterview(selectedGroup().groupId)"
>
Delete All Interviews
</button>
}
<button
class=
"btn btn-outline"
(click)=
"closeDetail()"
>
Close
</button>
</div>
</div>
</div>
}
<!-- ═══════════════════════════════════════════════════════════
MEMBER DETAIL MODAL — full evaluation panel for one candidate
═══════════════════════════════════════════════════════════ -->
@if (showMemberDetailModal()
&&
selectedMember()) {
<div
class=
"modal-overlay"
(click)=
"closeMemberDetail()"
>
<div
class=
"modal-container modal-xl"
(click)=
"$event.stopPropagation()"
>
<!-- Header -->
<div
class=
"modal-header"
>
<div>
<h2>
{{ selectedMember().candidateId?.name }}
</h2>
<span
class=
"badge"
[ngClass]=
"getStatusClass(selectedMember().status)"
>
{{ formatStatus(selectedMember().status) }}
</span>
@if (selectedMember().finalDecision !== 'pending') {
<span
class=
"badge"
[ngClass]=
"getDecisionClass(selectedMember().finalDecision)"
style=
"margin-left:6px"
>
{{ formatDecision(selectedMember().finalDecision) }}
</span>
}
</div>
<button
class=
"icon-btn"
(click)=
"closeMemberDetail()"
><span
class=
"material-symbols-rounded"
>
close
</span></button>
</div>
<div
class=
"modal-body detail-body"
>
<!-- Candidate Info -->
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Candidate Information
</h3>
<div
class=
"detail-grid"
>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Name
</span>
<span
class=
"detail-value"
>
{{ selectedMember().candidateId?.name }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Email
</span>
<span
class=
"detail-value"
>
{{ selectedMember().candidateId?.email }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Position
</span>
<span
class=
"detail-value"
>
{{ selectedMember().position }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Tech Stack
</span>
<span
class=
"detail-value"
>
{{ selectedMember().techStack || '—' }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Date of Interview
</span>
<span
class=
"detail-value"
>
{{ selectedMember().dateOfInterview | date:'mediumDate' }}
</span>
</div>
</div>
</div>
<!-- Quiz Results -->
@if (selectedMember().quizzes?.length > 0) {
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Quiz Results
</h3>
<div
class=
"quiz-results-grid"
>
@for (q of selectedMember().quizzes; track q.quizId) {
<div
class=
"quiz-result-card"
>
<div
class=
"qr-title"
>
{{ q.title }}
</div>
@if (q.completed) {
<div
class=
"qr-score"
>
{{ q.score }}/{{ q.totalMarks }} ({{ q.percentage }}%)
</div>
} @else {
<div
class=
"qr-pending"
>
Not Taken
</div>
}
</div>
}
</div>
</div>
}
<!-- Evaluations -->
<div
class=
"detail-section"
>
<div
style=
"display:flex; justify-content:space-between; align-items:center; margin-bottom:16px;"
>
<h3
class=
"detail-section-title"
style=
"margin:0;"
>
Evaluations
</h3>
<div
style=
"display: flex; gap: 8px; align-items: center;"
>
@if (allMemberEvaluationsDone()) {
<span
class=
"badge badge-success"
>
All evaluations done
</span>
<button
class=
"btn btn-primary btn-sm"
(click)=
"downloadEvaluationPdf()"
>
@if (isPdfGenerating()) {
<span
class=
"spinner spinner-sm"
></span>
Generating... } @else {
<span
class=
"material-symbols-rounded"
style=
"font-size: 16px; margin-right: 4px;"
>
download
</span>
Download PDF }
</button>
} @else {
<span
class=
"badge badge-warning"
>
Evaluations pending
</span>
}
</div>
</div>
<!-- Evaluation list -->
@if (selectedMember().evaluations?.length > 0) {
<div
class=
"eval-list"
>
@for (ev of selectedMember().evaluations; track ev._id) {
<div
class=
"eval-card"
>
<div
class=
"eval-header"
>
<div
class=
"eval-evaluator"
>
<strong>
{{ ev.evaluatorId?.name }}
</strong>
<span
class=
"eval-role badge badge-muted"
>
{{ ev.evaluatorRole | uppercase }}
</span>
</div>
<span
class=
"badge"
[ngClass]=
"getDecisionClass(ev.recommendation)"
>
{{ formatDecision(ev.recommendation) }}
</span>
</div>
@if (ev.comments) {
<p
class=
"eval-comments"
>
"{{ ev.comments }}"
</p>
}
<span
class=
"eval-date"
>
{{ ev.date | date:'medium' }}
</span>
</div>
}
</div>
} @else {
<p
class=
"text-muted"
>
No evaluations yet
</p>
}
<!-- Add evaluation form (HR / PM / Interviewer, not if already submitted or completed) -->
@if (!hasMemberEvaluated()
&&
selectedMember().status !== 'completed') {
<div
class=
"eval-form"
>
<h4>
Add Your Evaluation
</h4>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Comments
</label>
<textarea
class=
"form-input form-textarea"
[(ngModel)]=
"memberEvalComment"
placeholder=
"Enter your comments..."
rows=
"3"
></textarea>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Recommendation *
</label>
<select
class=
"form-input"
[(ngModel)]=
"memberEvalRecommendation"
>
<option
value=
""
>
Select recommendation
</option>
<option
value=
"offer"
>
Offer / Hire as Intern
</option>
<option
value=
"on_hold"
>
On Hold
</option>
<option
value=
"rejected"
>
Rejected
</option>
<option
value=
"2nd_round"
>
2nd Round
</option>
</select>
</div>
<button
class=
"btn btn-primary"
(click)=
"submitMemberEvaluation()"
[disabled]=
"!memberEvalRecommendation || isMemberSubmitting()"
>
@if (isMemberSubmitting()) {
<span
class=
"spinner spinner-sm"
></span>
Submitting... }
@else { Submit Evaluation }
</button>
</div>
}
</div>
<!-- Final Decision — Admin and HR, only when all evaluations done -->
@if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr')
&&
allMemberEvaluationsDone()
&&
selectedMember().status !== 'completed') {
<div
class=
"detail-section decision-section"
>
<h3
class=
"detail-section-title"
>
Final Decision
</h3>
<div
class=
"decision-buttons"
>
<button
class=
"btn btn-success"
(click)=
"setMemberDecision('accepted')"
>
<span
class=
"material-symbols-rounded"
>
check_circle
</span>
Accept
</button>
<button
class=
"btn btn-warning"
(click)=
"setMemberDecision('on_hold')"
>
<span
class=
"material-symbols-rounded"
>
pause_circle
</span>
On Hold
</button>
<button
class=
"btn btn-danger"
(click)=
"setMemberDecision('rejected')"
>
<span
class=
"material-symbols-rounded"
>
cancel
</span>
Reject
</button>
<button
class=
"btn btn-outline"
(click)=
"setMemberDecision('2nd_round')"
>
<span
class=
"material-symbols-rounded"
>
replay
</span>
2nd Round
</button>
</div>
</div>
}
</div>
<div
class=
"modal-footer"
>
<button
class=
"btn btn-outline"
(click)=
"closeMemberDetail()"
>
Close
</button>
</div>
</div>
</div>
}
<!-- Print Template for Evaluation PDF -->
@if (selectedMember()) {
<div
class=
"print-container"
>
<div
class=
"print-header"
>
<div
class=
"print-header-left"
>
<h2
style=
"margin: 0; font-size: 20px;"
>
Intern Interview Evaluation Form
</h2>
</div>
<div
class=
"print-header-right"
style=
"text-align: right;"
>
<span
style=
"color: #0078d4; font-weight: bold; font-size: 24px;"
>
IDEAL
</span><br>
<span
style=
"font-size: 10px; color: #555;"
>
TECH LABS
</span>
</div>
</div>
<table
class=
"print-table"
style=
"width: 100%; border-collapse: collapse; margin-bottom: 20px;"
>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;"
>
Candidate Name:
</td>
<td
style=
"border: 1px solid #000; padding: 8px; width: 25%;"
>
{{ selectedMember().candidateId?.name }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;"
>
Date of Interview:
</td>
<td
style=
"border: 1px solid #000; padding: 8px; width: 25%;"
>
{{ selectedMember().dateOfInterview | date:'mediumDate' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Position:
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().position }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Source
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().source || '—' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Tech Stack:
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().techStack || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Interviewer(s):
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
@if (selectedMember().assignedInterviewers?.length) {
@for (i of selectedMember().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedMember().interviewerId?.name || '—' }}
}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
General Aptitude Test QP Set
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().quizzes?.[0]?.title || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
General Aptitude Test Score
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().quizzes?.[0]?.completed ? selectedMember().quizzes[0].score + '/' + selectedMember().quizzes[0].totalMarks : 'Not Taken' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Technical MCQ Test QP Set
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().quizzes?.[1]?.title || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Technical MCQ Test Score
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedMember().quizzes?.[1]?.completed ? selectedMember().quizzes[1].score + '/' + selectedMember().quizzes[1].totalMarks : 'Not Taken' }}
</td>
</tr>
</table>
<!-- Render evaluations -->
@for (ev of selectedMember().evaluations; track ev._id) {
<div
class=
"print-eval-block"
style=
"margin-bottom: 30px; page-break-inside: avoid;"
>
<div
class=
"print-eval-title"
style=
"font-weight: bold; margin-bottom: 10px;"
>
{{ ev.evaluatorRole === 'hr' ? 'HR' : ev.evaluatorRole === 'pm' ? 'Project Manager' : 'Interviewer' }}'s Comments ({{ ev.evaluatorId?.name }}):
</div>
<div
class=
"print-comments"
style=
"min-height: 80px; margin-bottom: 15px;"
>
{{ ev.comments || 'No comments provided.' }}
</div>
<div
class=
"print-recommendation"
style=
"margin-bottom: 20px;"
>
<strong
style=
"margin-right: 15px;"
>
Recommendation:
</strong>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'offer' ? '☑' : '☐' }}
</span>
Offer/Hire as Intern
</span>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'on_hold' ? '☑' : '☐' }}
</span>
On Hold
</span>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'rejected' ? '☑' : '☐' }}
</span>
Rejected
</span>
<span><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === '2nd_round' ? '☑' : '☐' }}
</span>
2nd Round
</span>
</div>
<div
class=
"print-signature-row"
style=
"display: flex; justify-content: space-between; align-items: flex-end;"
>
<div
class=
"print-signature"
style=
"display: flex; align-items: flex-end;"
>
<strong>
Evaluator's Signature:
</strong>
@if (ev.evaluatorId?.signature) {
<img
[src]=
"'http://localhost:5000' + ev.evaluatorId.signature"
style=
"max-height: 40px; margin-left: 10px;"
>
} @else {
<span
style=
"border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"
></span>
}
</div>
<div
class=
"print-date"
style=
"display: flex; align-items: flex-end;"
>
<strong>
Date:
</strong>
<span
style=
"border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;"
>
{{ ev.date | date:'shortDate' }}
</span>
</div>
</div>
</div>
}
</div>
}
Frontend/src/app/pages/hr/group-interview/group-interview.ts
View file @
954049ba
import
{
Component
}
from
'
@angular/core
'
;
import
{
Component
,
OnInit
,
signal
,
computed
}
from
'
@angular/core
'
;
import
{
CommonModule
}
from
'
@angular/common
'
;
import
{
FormsModule
}
from
'
@angular/forms
'
;
import
{
QuizService
}
from
'
../../../services/quiz.service
'
;
import
{
AuthService
}
from
'
../../../services/auth.service
'
;
export
interface
QuizEntry
{
quizId
:
string
;
}
export
interface
QuizSet
{
quizEntries
:
QuizEntry
[];
mode
:
'
random
'
|
'
direct
'
;
directAssignments
:
{
[
candidateId
:
string
]:
string
};
}
@
Component
({
selector
:
'
app-group-interview
'
,
imports
:
[],
standalone
:
true
,
imports
:
[
CommonModule
,
FormsModule
],
templateUrl
:
'
./group-interview.html
'
,
styleUrl
:
'
./group-interview.css
'
,
styleUrl
:
'
./group-interview.css
'
})
export
class
GroupInterviewComponent
{}
export
class
GroupInterviewComponent
implements
OnInit
{
// ── List state ────────────────────────────────────────────
interviews
=
signal
<
any
[]
>
([]);
loading
=
signal
(
true
);
filterStatus
=
''
;
// ── Grouped view (one card per groupId) ──────────────────
groupedList
=
computed
<
any
[]
>
(()
=>
{
const
map
=
new
Map
<
string
,
any
>
();
for
(
const
iv
of
this
.
interviews
())
{
const
key
=
iv
.
groupId
||
'
ungrouped
'
;
if
(
!
map
.
has
(
key
))
{
map
.
set
(
key
,
{
groupId
:
key
,
position
:
iv
.
position
,
techStack
:
iv
.
techStack
,
source
:
iv
.
source
,
dateOfInterview
:
iv
.
dateOfInterview
,
assignedInterviewers
:
iv
.
assignedInterviewers
,
assignedHRs
:
iv
.
assignedHRs
,
assignedPMs
:
iv
.
assignedPMs
,
members
:
[]
});
}
map
.
get
(
key
)
!
.
members
.
push
(
iv
);
}
return
Array
.
from
(
map
.
values
());
});
stats
=
signal
<
any
>
({
total
:
0
,
pending
:
0
,
completed
:
0
,
accepted
:
0
,
rejected
:
0
});
// ── Create modal state ────────────────────────────────────
showCreateModal
=
signal
(
false
);
isSubmitting
=
signal
(
false
);
successMsg
=
signal
(
''
);
// dropdown data
groups
=
signal
<
any
[]
>
([]);
interviewers
=
signal
<
any
[]
>
([]);
hrs
=
signal
<
any
[]
>
([]);
pms
=
signal
<
any
[]
>
([]);
quizzes
=
signal
<
any
[]
>
([]);
groupMembers
=
signal
<
any
[]
>
([]);
// form
newGroupInterview
=
this
.
blankForm
();
// quiz-set builder
showQuizSetup
=
false
;
pendingSetsCount
=
2
;
quizSets
:
QuizSet
[]
=
[];
// ── Group detail modal state ─────────────────────────────
showDetailModal
=
signal
(
false
);
selectedGroup
=
signal
<
any
>
(
null
);
// ── Per-candidate (member) detail modal ──────────────────
showMemberDetailModal
=
signal
(
false
);
selectedMember
=
signal
<
any
>
(
null
);
// full interview doc
memberEvalComment
=
''
;
memberEvalRecommendation
=
''
;
isMemberSubmitting
=
signal
(
false
);
constructor
(
private
quizService
:
QuizService
,
public
authService
:
AuthService
)
{}
ngOnInit
():
void
{
this
.
loadInterviews
();
this
.
loadStats
();
}
// ── Data loaders ──────────────────────────────────────────
loadInterviews
():
void
{
this
.
loading
.
set
(
true
);
const
params
:
any
=
{
type
:
'
group
'
};
if
(
this
.
filterStatus
)
params
.
status
=
this
.
filterStatus
;
this
.
quizService
.
getInterviews
(
params
).
subscribe
({
next
:
res
=>
{
this
.
interviews
.
set
(
res
.
interviews
||
[]);
this
.
loading
.
set
(
false
);
},
error
:
()
=>
this
.
loading
.
set
(
false
)
});
}
loadStats
():
void
{
this
.
quizService
.
getInterviewStats
().
subscribe
({
next
:
res
=>
this
.
stats
.
set
(
res
)
});
}
onFilterChange
():
void
{
this
.
loadInterviews
();
}
// ── Create modal ──────────────────────────────────────────
openCreateModal
():
void
{
this
.
newGroupInterview
=
this
.
blankForm
();
this
.
quizSets
=
[];
this
.
showQuizSetup
=
false
;
this
.
pendingSetsCount
=
2
;
this
.
groupMembers
.
set
([]);
this
.
successMsg
.
set
(
''
);
this
.
quizService
.
getGroups
().
subscribe
({
next
:
r
=>
this
.
groups
.
set
(
r
.
groups
||
[])
});
this
.
quizService
.
getInterviewers
().
subscribe
({
next
:
r
=>
{
const
staff
=
r
.
interviewers
||
[];
this
.
interviewers
.
set
(
staff
.
filter
((
s
:
any
)
=>
s
.
role
===
'
interviewer
'
));
this
.
pms
.
set
(
staff
.
filter
((
s
:
any
)
=>
s
.
role
===
'
pm
'
));
this
.
hrs
.
set
(
staff
.
filter
((
s
:
any
)
=>
s
.
role
===
'
hr
'
));
}
});
this
.
quizService
.
getAdminQuizzes
().
subscribe
({
next
:
r
=>
this
.
quizzes
.
set
(
r
.
quizzes
||
[])
});
this
.
showCreateModal
.
set
(
true
);
}
closeCreateModal
():
void
{
this
.
showCreateModal
.
set
(
false
);
this
.
successMsg
.
set
(
''
);
}
blankForm
()
{
return
{
groupName
:
''
,
assignedInterviewers
:
[]
as
string
[],
assignedHRs
:
[]
as
string
[],
assignedPMs
:
[]
as
string
[],
position
:
''
,
techStack
:
''
,
source
:
''
,
dateOfInterview
:
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
]
};
}
toggleSelection
(
event
:
any
,
array
:
string
[]):
void
{
const
val
=
event
.
target
.
value
;
if
(
event
.
target
.
checked
)
{
array
.
push
(
val
);
}
else
{
const
i
=
array
.
indexOf
(
val
);
if
(
i
>=
0
)
array
.
splice
(
i
,
1
);
}
}
onGroupChange
():
void
{
if
(
!
this
.
newGroupInterview
.
groupName
)
{
this
.
groupMembers
.
set
([]);
return
;
}
this
.
quizService
.
getGroupMembers
(
this
.
newGroupInterview
.
groupName
).
subscribe
({
next
:
r
=>
{
this
.
groupMembers
.
set
(
r
.
candidates
||
[]);
// reset direct assignments when group changes
for
(
const
set
of
this
.
quizSets
)
set
.
directAssignments
=
{};
}
});
}
// ── Quiz Sets builder ─────────────────────────────────────
confirmSetsCount
():
void
{
const
n
=
Math
.
max
(
1
,
Math
.
min
(
10
,
this
.
pendingSetsCount
||
1
));
this
.
quizSets
=
Array
.
from
({
length
:
n
},
(
_
,
i
)
=>
({
quizEntries
:
[],
mode
:
'
random
'
,
directAssignments
:
{}
}));
this
.
showQuizSetup
=
false
;
}
addQuizToSet
(
setIdx
:
number
):
void
{
this
.
quizSets
[
setIdx
].
quizEntries
.
push
({
quizId
:
''
});
}
removeQuizEntry
(
setIdx
:
number
,
entryIdx
:
number
):
void
{
this
.
quizSets
[
setIdx
].
quizEntries
.
splice
(
entryIdx
,
1
);
}
removeQuizSet
(
setIdx
:
number
):
void
{
this
.
quizSets
.
splice
(
setIdx
,
1
);
}
addMoreSet
():
void
{
this
.
quizSets
.
push
({
quizEntries
:
[],
mode
:
'
random
'
,
directAssignments
:
{}
});
}
/** Quizzes available for a specific dropdown (setIdx, entryIdx).
* Excludes any quizId already chosen in ANY other (set, entry) pair. */
getAvailableQuizzes
(
setIdx
:
number
,
entryIdx
:
number
):
any
[]
{
const
usedIds
=
new
Set
<
string
>
();
this
.
quizSets
.
forEach
((
set
,
si
)
=>
{
set
.
quizEntries
.
forEach
((
entry
,
ei
)
=>
{
if
(
entry
.
quizId
&&
!
(
si
===
setIdx
&&
ei
===
entryIdx
))
usedIds
.
add
(
entry
.
quizId
);
});
});
return
this
.
quizzes
().
filter
(
q
=>
!
usedIds
.
has
(
q
.
_id
));
}
getQuizTitle
(
quizId
:
string
):
string
{
return
this
.
quizzes
().
find
(
q
=>
q
.
_id
===
quizId
)?.
title
||
quizId
;
}
validEntries
(
set
:
QuizSet
):
QuizEntry
[]
{
return
set
.
quizEntries
.
filter
(
e
=>
e
.
quizId
);
}
isSingleQuizSet
(
set
:
QuizSet
):
boolean
{
return
this
.
validEntries
(
set
).
length
===
1
;
}
isMultiQuizSet
(
set
:
QuizSet
):
boolean
{
return
this
.
validEntries
(
set
).
length
>
1
;
}
// ── Create interview ──────────────────────────────────────
createGroupInterview
():
void
{
if
(
!
this
.
newGroupInterview
.
groupName
||
!
this
.
newGroupInterview
.
position
)
return
;
this
.
isSubmitting
.
set
(
true
);
const
payload
=
{
...
this
.
newGroupInterview
,
quizSets
:
this
.
quizSets
.
map
(
set
=>
({
quizEntries
:
set
.
quizEntries
.
filter
(
e
=>
e
.
quizId
),
mode
:
set
.
mode
,
directAssignments
:
set
.
directAssignments
}))
};
this
.
quizService
.
createGroupInterview
(
payload
).
subscribe
({
next
:
res
=>
{
this
.
isSubmitting
.
set
(
false
);
this
.
successMsg
.
set
(
res
.
message
||
`Interview created for
${
res
.
count
}
candidates!`
);
this
.
loadInterviews
();
this
.
loadStats
();
},
error
:
err
=>
{
this
.
isSubmitting
.
set
(
false
);
alert
(
err
.
error
?.
message
||
'
Failed to create group interview
'
);
}
});
}
// ── Group detail modal ────────────────────────────────────
openDetail
(
group
:
any
):
void
{
this
.
selectedGroup
.
set
(
group
);
this
.
showDetailModal
.
set
(
true
);
}
closeDetail
():
void
{
this
.
showDetailModal
.
set
(
false
);
this
.
selectedGroup
.
set
(
null
);
}
// ── Member (candidate) detail modal ──────────────────────
openMemberDetail
(
interviewId
:
string
,
event
:
Event
):
void
{
event
.
stopPropagation
();
this
.
quizService
.
getInterviewById
(
interviewId
).
subscribe
({
next
:
res
=>
{
this
.
selectedMember
.
set
(
res
.
interview
);
this
.
memberEvalComment
=
''
;
this
.
memberEvalRecommendation
=
''
;
this
.
showMemberDetailModal
.
set
(
true
);
}
});
}
closeMemberDetail
():
void
{
this
.
showMemberDetailModal
.
set
(
false
);
this
.
selectedMember
.
set
(
null
);
}
submitMemberEvaluation
():
void
{
const
m
=
this
.
selectedMember
();
if
(
!
m
||
!
this
.
memberEvalRecommendation
)
return
;
this
.
isMemberSubmitting
.
set
(
true
);
this
.
quizService
.
submitEvaluation
(
m
.
_id
,
{
comments
:
this
.
memberEvalComment
,
recommendation
:
this
.
memberEvalRecommendation
}).
subscribe
({
next
:
res
=>
{
this
.
selectedMember
.
set
(
res
.
interview
);
this
.
memberEvalComment
=
''
;
this
.
memberEvalRecommendation
=
''
;
this
.
isMemberSubmitting
.
set
(
false
);
// refresh the group list so chips update
this
.
loadInterviews
();
},
error
:
()
=>
this
.
isMemberSubmitting
.
set
(
false
)
});
}
setMemberDecision
(
decision
:
string
):
void
{
const
m
=
this
.
selectedMember
();
if
(
!
m
)
return
;
this
.
quizService
.
setInterviewDecision
(
m
.
_id
,
decision
).
subscribe
({
next
:
res
=>
{
this
.
selectedMember
.
set
(
res
.
interview
);
this
.
loadInterviews
();
this
.
loadStats
();
}
});
}
hasMemberEvaluated
():
boolean
{
const
m
=
this
.
selectedMember
();
if
(
!
m
)
return
false
;
const
userId
=
this
.
authService
.
currentUser
()?.
id
;
return
m
.
evaluations
?.
some
((
e
:
any
)
=>
e
.
evaluatorId
?.
_id
===
userId
||
e
.
evaluatorId
===
userId
);
}
allMemberEvaluationsDone
():
boolean
{
const
m
=
this
.
selectedMember
();
if
(
!
m
)
return
false
;
const
total
=
(
m
.
assignedInterviewers
?.
length
||
0
)
+
(
m
.
assignedHRs
?.
length
||
0
)
+
(
m
.
assignedPMs
?.
length
||
0
);
if
(
total
===
0
)
return
false
;
return
(
m
.
evaluations
?.
length
||
0
)
>=
total
;
}
/** True when THIS user still needs to evaluate this member */
needsEvaluation
(
m
:
any
):
boolean
{
if
(
m
.
status
===
'
completed
'
)
return
false
;
const
userId
=
this
.
authService
.
currentUser
()?.
id
;
const
role
=
this
.
authService
.
getUserRole
();
if
(
role
===
'
admin
'
)
return
false
;
// admin gives final decision, not evaluation
const
evaluated
=
m
.
evaluations
?.
some
(
(
e
:
any
)
=>
e
.
evaluatorId
?.
_id
===
userId
||
e
.
evaluatorId
===
userId
);
return
!
evaluated
;
}
/** Whether ANY assigned staff member hasn't evaluated a given candidate interview yet */
hasPendingEvaluations
(
m
:
any
):
boolean
{
if
(
m
.
status
===
'
completed
'
)
return
false
;
const
total
=
(
m
.
assignedInterviewers
?.
length
||
0
)
+
(
m
.
assignedHRs
?.
length
||
0
)
+
(
m
.
assignedPMs
?.
length
||
0
);
return
(
m
.
evaluations
?.
length
||
0
)
<
total
;
}
isPdfGenerating
=
signal
(
false
);
downloadEvaluationPdf
():
void
{
const
m
=
this
.
selectedMember
();
if
(
!
m
)
return
;
this
.
isPdfGenerating
.
set
(
true
);
setTimeout
(()
=>
{
window
.
print
();
this
.
isPdfGenerating
.
set
(
false
);
},
500
);
}
// ── Helpers ───────────────────────────────────────────────
getStatusClass
(
status
:
string
):
string
{
const
map
:
any
=
{
pending
:
'
badge-warning
'
,
quiz_phase
:
'
badge-info
'
,
coding_phase
:
'
badge-info
'
,
evaluation
:
'
badge-purple
'
,
completed
:
'
badge-success
'
};
return
map
[
status
]
||
''
;
}
getDecisionClass
(
decision
:
string
):
string
{
const
map
:
any
=
{
accepted
:
'
badge-success
'
,
rejected
:
'
badge-danger
'
,
on_hold
:
'
badge-warning
'
,
'
2nd_round
'
:
'
badge-info
'
};
return
map
[
decision
]
||
'
badge-muted
'
;
}
formatStatus
(
status
:
string
):
string
{
return
(
status
||
''
).
replace
(
/_/g
,
'
'
).
replace
(
/
\b\w
/g
,
l
=>
l
.
toUpperCase
());
}
formatDecision
(
d
:
string
):
string
{
const
m
:
any
=
{
pending
:
'
Pending
'
,
accepted
:
'
Accepted
'
,
rejected
:
'
Rejected
'
,
on_hold
:
'
On Hold
'
,
'
2nd_round
'
:
'
2nd Round
'
};
return
m
[
d
]
||
d
;
}
groupStatusSummary
(
members
:
any
[]):
string
{
const
counts
:
any
=
{};
for
(
const
m
of
members
)
counts
[
m
.
status
]
=
(
counts
[
m
.
status
]
||
0
)
+
1
;
return
Object
.
entries
(
counts
).
map
(([
s
,
c
])
=>
`
${
this
.
formatStatus
(
s
)}
:
${
c
}
`
).
join
(
'
·
'
);
}
pendingCount
(
members
:
any
[]):
number
{
return
members
.
filter
(
m
=>
m
.
status
!==
'
completed
'
).
length
;
}
completedCount
(
members
:
any
[]):
number
{
return
members
.
filter
(
m
=>
m
.
status
===
'
completed
'
).
length
;
}
deleteGroupInterview
(
groupId
:
string
):
void
{
if
(
!
confirm
(
`Delete all interviews in group "
${
groupId
}
"?`
))
return
;
const
ids
=
(
this
.
groupedList
().
find
(
g
=>
g
.
groupId
===
groupId
)?.
members
||
[]).
map
((
m
:
any
)
=>
m
.
_id
);
let
done
=
0
;
for
(
const
id
of
ids
)
{
this
.
quizService
.
deleteInterview
(
id
).
subscribe
({
next
:
()
=>
{
done
++
;
if
(
done
===
ids
.
length
)
{
this
.
loadInterviews
();
this
.
closeDetail
();
}
}
});
}
}
setDecision
(
interviewId
:
string
,
decision
:
string
):
void
{
this
.
quizService
.
setInterviewDecision
(
interviewId
,
decision
).
subscribe
({
next
:
()
=>
this
.
loadInterviews
()
});
}
}
Frontend/src/app/pages/hr/individual-interview/individual-interview.css
View file @
954049ba
.page-container
{
padding
:
32px
40px
;
}
.content-wrapper
{
max-width
:
1200px
;
margin
:
0
;
}
.page-header
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
margin-bottom
:
28px
;
}
.page-header
h1
{
font-size
:
24px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
margin
:
0
0
4px
;
}
.page-subtitle
{
font-size
:
14px
;
color
:
var
(
--text-muted
);
margin
:
0
;
}
/* Stats Row */
.stats-row
{
display
:
flex
;
gap
:
16px
;
margin-bottom
:
24px
;
}
.mini-stat
{
flex
:
1
;
background
:
var
(
--bg-card
);
border
:
1px
solid
var
(
--border-color
);
border-radius
:
12px
;
padding
:
16px
20px
;
text-align
:
center
;
}
.mini-stat-value
{
display
:
block
;
font-size
:
28px
;
font-weight
:
700
;
color
:
var
(
--text-primary
);
}
.mini-stat-value.orange
{
color
:
#f59e0b
;
}
.mini-stat-value.blue
{
color
:
#3b82f6
;
}
.mini-stat-value.green
{
color
:
#22c55e
;
}
.mini-stat-value.red
{
color
:
#ef4444
;
}
.mini-stat-label
{
font-size
:
12px
;
color
:
var
(
--text-muted
);
font-weight
:
500
;
text-transform
:
uppercase
;
letter-spacing
:
0.5px
;
}
/* Filter */
.filter-bar
{
margin-bottom
:
20px
;
}
.filter-select
{
padding
:
8px
14px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
background
:
var
(
--bg-input
);
color
:
var
(
--text-primary
);
font-size
:
14px
;
font-family
:
inherit
;
}
/* Interview Cards */
.interview-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
16px
;
}
.interview-card
{
cursor
:
pointer
;
transition
:
all
0.2s
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
16px
;
background
:
var
(
--bg-card
);
}
.interview-card
:hover
{
border-color
:
var
(
--accent-primary
);
box-shadow
:
0
4px
16px
rgba
(
102
,
126
,
234
,
0.1
);
transform
:
translateY
(
-1px
);
}
.iv-card-top
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
margin-bottom
:
14px
;
}
.iv-candidate
{
display
:
flex
;
align-items
:
center
;
gap
:
14px
;
}
.iv-avatar
{
width
:
44px
;
height
:
44px
;
border-radius
:
12px
;
background
:
linear-gradient
(
135deg
,
#667eea
,
#764ba2
);
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
color
:
#fff
;
font-weight
:
700
;
font-size
:
18px
;
flex-shrink
:
0
;
}
.iv-name
{
font-size
:
16px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
margin
:
0
;
}
.iv-email
{
font-size
:
13px
;
color
:
var
(
--text-muted
);
margin
:
0
;
}
.iv-badges
{
display
:
flex
;
gap
:
8px
;
}
.iv-card-meta
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
16px
;
margin-bottom
:
14px
;
padding-bottom
:
14px
;
border-bottom
:
1px
solid
var
(
--border-color
);
}
.meta-item
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
font-size
:
13px
;
color
:
var
(
--text-secondary
);
}
.meta-item
.material-symbols-rounded
{
font-size
:
16px
;
color
:
var
(
--text-muted
);
}
/* Progress Steps */
.progress-steps
{
display
:
flex
;
align-items
:
center
;
}
.step
{
display
:
flex
;
align-items
:
center
;
gap
:
6px
;
}
.step-dot
{
width
:
10px
;
height
:
10px
;
border-radius
:
50%
;
background
:
var
(
--border-color
);
transition
:
all
0.3s
;
}
.step.done
.step-dot
{
background
:
#22c55e
;
}
.step.active
.step-dot
{
background
:
#667eea
;
box-shadow
:
0
0
0
3px
rgba
(
102
,
126
,
234
,
0.2
);
}
.step-label
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
font-weight
:
500
;
}
.step.done
.step-label
{
color
:
#22c55e
;
}
.step.active
.step-label
{
color
:
#667eea
;
font-weight
:
600
;
}
.step-line
{
flex
:
1
;
height
:
2px
;
background
:
var
(
--border-color
);
margin
:
0
8px
;
transition
:
background
0.3s
;
}
.step-line.done
{
background
:
#22c55e
;
}
/* Badges */
.badge
{
padding
:
4px
10px
;
border-radius
:
6px
;
font-size
:
11px
;
font-weight
:
600
;
text-transform
:
uppercase
;
letter-spacing
:
0.5px
;
}
.badge-warning
{
background
:
rgba
(
245
,
158
,
11
,
0.1
);
color
:
#f59e0b
;
}
.badge-info
{
background
:
rgba
(
59
,
130
,
246
,
0.1
);
color
:
#3b82f6
;
}
.badge-purple
{
background
:
rgba
(
168
,
85
,
247
,
0.1
);
color
:
#a855f7
;
}
.badge-success
{
background
:
rgba
(
34
,
197
,
94
,
0.1
);
color
:
#22c55e
;
}
.badge-danger
{
background
:
rgba
(
239
,
68
,
68
,
0.1
);
color
:
#ef4444
;
}
.badge-muted
{
background
:
var
(
--bg-hover
);
color
:
var
(
--text-muted
);
}
/* Empty / Loading */
.loading-center
{
display
:
flex
;
flex-direction
:
column
;
align-items
:
center
;
padding
:
80px
;
gap
:
16px
;
color
:
var
(
--text-muted
);
}
.empty-state
{
text-align
:
center
;
padding
:
80px
;
color
:
var
(
--text-muted
);
}
.empty-state
.material-symbols-rounded
{
font-size
:
56px
;
display
:
block
;
margin-bottom
:
16px
;
opacity
:
0.4
;
}
.empty-state
h3
{
color
:
var
(
--text-primary
);
font-size
:
18px
;
margin
:
0
0
8px
;
}
.empty-state
p
{
font-size
:
14px
;
margin
:
0
;
}
/* Modal */
.modal-overlay
{
position
:
fixed
;
top
:
0
;
left
:
0
;
right
:
0
;
bottom
:
0
;
background
:
rgba
(
0
,
0
,
0
,
0.45
);
backdrop-filter
:
blur
(
4px
);
z-index
:
1000
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
animation
:
fadeIn
0.2s
ease-out
;
}
.modal-container
{
background
:
var
(
--bg-card
);
border-radius
:
16px
;
box-shadow
:
0
10px
50px
rgba
(
0
,
0
,
0
,
0.25
);
border
:
1px
solid
var
(
--border-color
);
width
:
90%
;
max-height
:
85vh
;
overflow-y
:
auto
;
animation
:
slideUp
0.3s
cubic-bezier
(
0.16
,
1
,
0.3
,
1
);
}
.modal-lg
{
max-width
:
640px
;
}
.modal-xl
{
max-width
:
800px
;
}
.modal-header
{
padding
:
20px
24px
;
border-bottom
:
1px
solid
var
(
--border-color
);
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
position
:
sticky
;
top
:
0
;
background
:
var
(
--bg-card
);
z-index
:
2
;
border-radius
:
16px
16px
0
0
;
}
.modal-header
h2
{
font-size
:
18px
;
font-weight
:
600
;
margin
:
0
12px
0
0
;
display
:
inline
;
}
.modal-body
{
padding
:
24px
;
}
.modal-footer
{
padding
:
16px
24px
;
border-top
:
1px
solid
var
(
--border-color
);
display
:
flex
;
justify-content
:
flex-end
;
gap
:
12px
;
background
:
var
(
--bg-hover
);
position
:
sticky
;
bottom
:
0
;
border-radius
:
0
0
16px
16px
;
}
/* Form */
.form-row
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
16px
;
margin-bottom
:
16px
;
}
.form-group
{
display
:
flex
;
flex-direction
:
column
;
gap
:
6px
;
}
.form-label
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
}
.form-input
{
padding
:
10px
14px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
background
:
var
(
--bg-input
);
color
:
var
(
--text-primary
);
font-size
:
14px
;
font-family
:
inherit
;
transition
:
all
0.2s
;
width
:
100%
;
box-sizing
:
border-box
;
}
.form-input
:focus
{
outline
:
none
;
border-color
:
var
(
--accent-primary
);
box-shadow
:
0
0
0
3px
rgba
(
102
,
126
,
234
,
0.1
);
}
.form-textarea
{
resize
:
vertical
;
min-height
:
80px
;
}
/* Quiz Selector */
.quiz-select-grid
{
display
:
flex
;
flex-wrap
:
wrap
;
gap
:
8px
;
margin-top
:
4px
;
}
.quiz-select-item
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
6px
;
padding
:
8px
14px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
8px
;
cursor
:
pointer
;
font-size
:
13px
;
color
:
var
(
--text-secondary
);
transition
:
all
0.2s
;
}
.quiz-select-item
.material-symbols-rounded
{
font-size
:
18px
;
}
.quiz-select-item.selected
{
border-color
:
#667eea
;
background
:
rgba
(
102
,
126
,
234
,
0.08
);
color
:
#667eea
;
}
.quiz-select-item
:hover
{
border-color
:
var
(
--accent-primary
);
}
/* Detail */
.detail-body
{
display
:
flex
;
flex-direction
:
column
;
gap
:
24px
;
}
.detail-section
{
padding-bottom
:
16px
;
border-bottom
:
1px
solid
var
(
--border-color
);
}
.detail-section
:last-child
{
border-bottom
:
none
;
}
.detail-section-title
{
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
margin
:
0
0
16px
;
text-transform
:
uppercase
;
letter-spacing
:
0.5px
;
}
.detail-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
12px
;
}
.detail-item
{
display
:
flex
;
flex-direction
:
column
;
gap
:
2px
;
}
.detail-label
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
text-transform
:
uppercase
;
letter-spacing
:
0.5px
;
}
.detail-value
{
font-size
:
14px
;
color
:
var
(
--text-primary
);
font-weight
:
500
;
}
/* Quiz Results */
.quiz-results-grid
{
display
:
grid
;
grid-template-columns
:
1
fr
1
fr
;
gap
:
12px
;
}
.quiz-result-card
{
padding
:
14px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
10px
;
background
:
var
(
--bg-hover
);
}
.qr-title
{
font-size
:
13px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
margin-bottom
:
6px
;
}
.qr-score
{
font-size
:
14px
;
color
:
#22c55e
;
font-weight
:
700
;
}
.qr-pending
{
font-size
:
13px
;
color
:
var
(
--text-muted
);
font-style
:
italic
;
}
/* Evaluations */
.eval-list
{
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
margin-bottom
:
20px
;
}
.eval-card
{
padding
:
16px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
10px
;
background
:
var
(
--bg-hover
);
}
.eval-header
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
margin-bottom
:
8px
;
}
.eval-evaluator
{
display
:
flex
;
align-items
:
center
;
gap
:
8px
;
}
.eval-evaluator
strong
{
font-size
:
14px
;
color
:
var
(
--text-primary
);
}
.eval-role
{
font-size
:
10px
;
}
.eval-comments
{
font-size
:
14px
;
color
:
var
(
--text-secondary
);
margin
:
8px
0
;
font-style
:
italic
;
line-height
:
1.5
;
}
.eval-date
{
font-size
:
11px
;
color
:
var
(
--text-muted
);
}
.eval-form
{
margin-top
:
16px
;
padding
:
20px
;
border
:
1px
dashed
rgba
(
102
,
126
,
234
,
0.3
);
border-radius
:
12px
;
background
:
rgba
(
102
,
126
,
234
,
0.03
);
}
.eval-form
h4
{
font-size
:
14px
;
font-weight
:
600
;
color
:
var
(
--text-primary
);
margin
:
0
0
16px
;
}
/* Decision */
.decision-section
{
padding
:
16px
;
background
:
rgba
(
102
,
126
,
234
,
0.03
);
border-radius
:
12px
;
border
:
1px
dashed
rgba
(
102
,
126
,
234
,
0.2
);
}
.decision-buttons
{
display
:
flex
;
gap
:
12px
;
flex-wrap
:
wrap
;
}
.btn-success
{
background
:
#22c55e
;
color
:
#fff
;
}
.btn-success
:hover
{
background
:
#16a34a
;
}
.btn-warning
{
background
:
#f59e0b
;
color
:
#fff
;
}
.btn-warning
:hover
{
background
:
#d97706
;
}
.btn-danger
{
background
:
#ef4444
;
color
:
#fff
;
}
.btn-danger
:hover
{
background
:
#dc2626
;
}
.text-muted
{
color
:
var
(
--text-muted
);
font-size
:
13px
;
}
@keyframes
fadeIn
{
from
{
opacity
:
0
;
}
to
{
opacity
:
1
;
}
}
@keyframes
slideUp
{
from
{
transform
:
translateY
(
20px
);
opacity
:
0
;
}
to
{
transform
:
translateY
(
0
);
opacity
:
1
;
}
}
@media
(
max-width
:
768px
)
{
.page-container
{
padding
:
20px
;
}
.stats-row
{
flex-wrap
:
wrap
;
}
.mini-stat
{
min-width
:
120px
;
}
.form-row
{
grid-template-columns
:
1
fr
;
}
.detail-grid
{
grid-template-columns
:
1
fr
;
}
}
/* Print Styles */
.print-container
{
display
:
none
;
}
@media
print
{
body
*
{
visibility
:
hidden
;
}
.print-container
,
.print-container
*
{
visibility
:
visible
;
}
.print-container
{
display
:
block
;
position
:
absolute
;
left
:
0
;
top
:
0
;
width
:
100%
;
padding
:
20px
;
background
:
white
;
color
:
black
;
font-family
:
Arial
,
sans-serif
;
}
.print-header
{
display
:
flex
;
justify-content
:
space-between
;
align-items
:
center
;
border-bottom
:
2px
solid
#0078d4
;
padding-bottom
:
10px
;
margin-bottom
:
20px
;
}
}
Frontend/src/app/pages/hr/individual-interview/individual-interview.html
View file @
954049ba
<p>
individual-interview works!
</p>
<div
class=
"page-container animate-fade-in"
>
<div
class=
"content-wrapper"
>
<div
class=
"page-header"
>
<div>
<h1>
Individual Interviews
</h1>
<p
class=
"page-subtitle"
>
Manage one-on-one candidate interview evaluations
</p>
</div>
<button
class=
"btn btn-primary"
(click)=
"openCreateModal()"
>
<span
class=
"material-symbols-rounded"
>
add
</span>
New Interview
</button>
</div>
<!-- Stats -->
<div
class=
"stats-row"
>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value"
>
{{ stats().total }}
</span>
<span
class=
"mini-stat-label"
>
Total
</span>
</div>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value orange"
>
{{ stats().pending }}
</span>
<span
class=
"mini-stat-label"
>
In Progress
</span>
</div>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value blue"
>
{{ stats().completed }}
</span>
<span
class=
"mini-stat-label"
>
Completed
</span>
</div>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value green"
>
{{ stats().accepted }}
</span>
<span
class=
"mini-stat-label"
>
Accepted
</span>
</div>
<div
class=
"mini-stat"
>
<span
class=
"mini-stat-value red"
>
{{ stats().rejected }}
</span>
<span
class=
"mini-stat-label"
>
Rejected
</span>
</div>
</div>
<!-- Filter -->
<div
class=
"filter-bar"
>
<select
class=
"filter-select"
[(ngModel)]=
"filterStatus"
(ngModelChange)=
"onFilterChange()"
>
<option
value=
""
>
All Statuses
</option>
<option
value=
"pending"
>
Pending
</option>
<option
value=
"quiz_phase"
>
Quiz Phase
</option>
<option
value=
"coding_phase"
>
Coding Phase
</option>
<option
value=
"evaluation"
>
Evaluation
</option>
<option
value=
"completed"
>
Completed
</option>
</select>
</div>
<!-- Interview List -->
@if (loading()) {
<div
class=
"loading-center"
><div
class=
"spinner spinner-lg"
></div><p>
Loading interviews...
</p></div>
} @else if (interviews().length === 0) {
<div
class=
"empty-state"
>
<span
class=
"material-symbols-rounded"
>
work_off
</span>
<h3>
No interviews found
</h3>
<p>
Create your first interview to get started
</p>
</div>
} @else {
<div
class=
"interview-list"
>
@for (iv of interviews(); track iv._id) {
<div
class=
"interview-card card card-padding"
(click)=
"openDetail(iv)"
>
<div
class=
"iv-card-top"
>
<div
class=
"iv-candidate"
>
<div
class=
"iv-avatar"
>
{{ iv.candidateId?.name?.charAt(0)?.toUpperCase() }}
</div>
<div>
<h3
class=
"iv-name"
>
{{ iv.candidateId?.name }}
</h3>
<p
class=
"iv-email"
>
{{ iv.candidateId?.email }}
</p>
</div>
</div>
<div
class=
"iv-badges"
>
<span
class=
"badge"
[ngClass]=
"getStatusClass(iv.status)"
>
{{ formatStatus(iv.status) }}
</span>
@if (iv.finalDecision !== 'pending') {
<span
class=
"badge"
[ngClass]=
"getDecisionClass(iv.finalDecision)"
>
{{ formatDecision(iv.finalDecision) }}
</span>
}
</div>
</div>
<div
class=
"iv-card-meta"
>
<span
class=
"meta-item"
><span
class=
"material-symbols-rounded"
>
work
</span>
{{ iv.position }}
</span>
@if (iv.techStack) {
<span
class=
"meta-item"
><span
class=
"material-symbols-rounded"
>
code
</span>
{{ iv.techStack }}
</span>
}
<span
class=
"meta-item"
><span
class=
"material-symbols-rounded"
>
person
</span>
{{ iv.interviewerId?.name }}
</span>
<span
class=
"meta-item"
><span
class=
"material-symbols-rounded"
>
calendar_today
</span>
{{ iv.dateOfInterview | date:'mediumDate' }}
</span>
</div>
<div
class=
"iv-card-progress"
>
<div
class=
"progress-steps"
>
<div
class=
"step"
[class.active]=
"iv.status === 'pending'"
[class.done]=
"['quiz_phase','coding_phase','evaluation','completed'].includes(iv.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Created
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['quiz_phase','coding_phase','evaluation','completed'].includes(iv.status)"
></div>
<div
class=
"step"
[class.active]=
"iv.status === 'quiz_phase'"
[class.done]=
"['coding_phase','evaluation','completed'].includes(iv.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Quiz
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['coding_phase','evaluation','completed'].includes(iv.status)"
></div>
<div
class=
"step"
[class.active]=
"iv.status === 'coding_phase'"
[class.done]=
"['evaluation','completed'].includes(iv.status)"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Coding
</span>
</div>
<div
class=
"step-line"
[class.done]=
"['evaluation','completed'].includes(iv.status)"
></div>
<div
class=
"step"
[class.active]=
"iv.status === 'evaluation'"
[class.done]=
"iv.status === 'completed'"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Evaluate
</span>
</div>
<div
class=
"step-line"
[class.done]=
"iv.status === 'completed'"
></div>
<div
class=
"step"
[class.active]=
"iv.status === 'completed'"
[class.done]=
"iv.status === 'completed'"
>
<span
class=
"step-dot"
></span><span
class=
"step-label"
>
Done
</span>
</div>
</div>
</div>
</div>
}
</div>
}
</div>
</div>
<!-- Create Interview Modal -->
@if (showCreateModal()) {
<div
class=
"modal-overlay"
(click)=
"closeCreateModal()"
>
<div
class=
"modal-container modal-lg"
(click)=
"$event.stopPropagation()"
>
<div
class=
"modal-header"
>
<h2>
Create Individual Interview
</h2>
<button
class=
"icon-btn"
(click)=
"closeCreateModal()"
><span
class=
"material-symbols-rounded"
>
close
</span></button>
</div>
<div
class=
"modal-body"
>
<div
class=
"form-row"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Candidate *
</label>
<select
class=
"form-input"
[(ngModel)]=
"newInterview.candidateId"
>
<option
value=
""
>
Select candidate
</option>
@for (c of candidates(); track c._id) {
<option
[value]=
"c._id"
>
{{ c.name }} ({{ c.email }})
</option>
}
</select>
</div>
</div>
<div
class=
"form-row"
>
<div
class=
"form-group"
style=
"flex: 1;"
>
<label
class=
"form-label"
>
Interviewers
</label>
<div
class=
"quiz-select-grid"
style=
"max-height: 120px; overflow-y: auto;"
>
@for (i of interviewers(); track i._id) {
<label
class=
"quiz-select-item"
style=
"cursor: pointer;"
>
<input
type=
"checkbox"
[value]=
"i._id"
(change)=
"toggleSelection($event, newInterview.assignedInterviewers)"
style=
"margin-right: 8px;"
>
{{ i.name }}
</label>
}
@if (interviewers().length === 0) {
<p
class=
"text-muted"
style=
"font-size: 12px;"
>
No interviewers found
</p>
}
</div>
</div>
<div
class=
"form-group"
style=
"flex: 1;"
>
<label
class=
"form-label"
>
Project Managers
</label>
<div
class=
"quiz-select-grid"
style=
"max-height: 120px; overflow-y: auto;"
>
@for (p of pms(); track p._id) {
<label
class=
"quiz-select-item"
style=
"cursor: pointer;"
>
<input
type=
"checkbox"
[value]=
"p._id"
(change)=
"toggleSelection($event, newInterview.assignedPMs)"
style=
"margin-right: 8px;"
>
{{ p.name }}
</label>
}
@if (pms().length === 0) {
<p
class=
"text-muted"
style=
"font-size: 12px;"
>
No PMs found
</p>
}
</div>
</div>
<div
class=
"form-group"
style=
"flex: 1;"
>
<label
class=
"form-label"
>
HRs
</label>
<div
class=
"quiz-select-grid"
style=
"max-height: 120px; overflow-y: auto;"
>
@for (h of hrs(); track h._id) {
<label
class=
"quiz-select-item"
style=
"cursor: pointer;"
>
<input
type=
"checkbox"
[value]=
"h._id"
(change)=
"toggleSelection($event, newInterview.assignedHRs)"
style=
"margin-right: 8px;"
>
{{ h.name }}
</label>
}
@if (hrs().length === 0) {
<p
class=
"text-muted"
style=
"font-size: 12px;"
>
No HRs found
</p>
}
</div>
</div>
</div>
<div
class=
"form-row"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Position *
</label>
<input
class=
"form-input"
[(ngModel)]=
"newInterview.position"
placeholder=
"e.g., Software Intern"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Tech Stack
</label>
<input
class=
"form-input"
[(ngModel)]=
"newInterview.techStack"
placeholder=
"e.g., React, Node.js"
>
</div>
</div>
<div
class=
"form-row"
>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Source
</label>
<input
class=
"form-input"
[(ngModel)]=
"newInterview.source"
placeholder=
"e.g., LinkedIn, Campus"
>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Date of Interview
</label>
<input
class=
"form-input"
type=
"date"
[(ngModel)]=
"newInterview.dateOfInterview"
>
</div>
</div>
<!-- Quiz Selection -->
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Assign Quizzes (optional)
</label>
<div
class=
"quiz-select-grid"
>
@for (q of quizzes(); track q._id) {
<div
class=
"quiz-select-item"
[class.selected]=
"isQuizSelected(q._id)"
(click)=
"toggleQuizSelection(q._id)"
>
<span
class=
"material-symbols-rounded"
>
{{ isQuizSelected(q._id) ? 'check_box' : 'check_box_outline_blank' }}
</span>
<span>
{{ q.title }}
</span>
</div>
}
@if (quizzes().length === 0) {
<p
class=
"text-muted"
>
No quizzes available
</p>
}
</div>
</div>
</div>
<div
class=
"modal-footer"
>
<button
class=
"btn btn-outline"
(click)=
"closeCreateModal()"
>
Cancel
</button>
<button
class=
"btn btn-primary"
(click)=
"createInterview()"
[disabled]=
"isSubmitting() || !newInterview.candidateId || !newInterview.position"
>
@if (isSubmitting()) {
<span
class=
"spinner spinner-sm"
></span>
Creating... } @else { Create Interview }
</button>
</div>
</div>
</div>
}
<!-- Interview Detail Modal -->
@if (showDetailModal()
&&
selectedInterview()) {
<div
class=
"modal-overlay"
(click)=
"closeDetail()"
>
<div
class=
"modal-container modal-xl"
(click)=
"$event.stopPropagation()"
>
<div
class=
"modal-header"
>
<div>
<h2>
Interview Detail
</h2>
<span
class=
"badge"
[ngClass]=
"getStatusClass(selectedInterview().status)"
>
{{ formatStatus(selectedInterview().status) }}
</span>
@if (selectedInterview().finalDecision !== 'pending') {
<span
class=
"badge"
[ngClass]=
"getDecisionClass(selectedInterview().finalDecision)"
style=
"margin-left:8px"
>
{{ formatDecision(selectedInterview().finalDecision) }}
</span>
}
</div>
<button
class=
"icon-btn"
(click)=
"closeDetail()"
><span
class=
"material-symbols-rounded"
>
close
</span></button>
</div>
<div
class=
"modal-body detail-body"
>
<!-- Candidate Info -->
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Candidate Information
</h3>
<div
class=
"detail-grid"
>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Name
</span>
<span
class=
"detail-value"
>
{{ selectedInterview().candidateId?.name }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Email
</span>
<span
class=
"detail-value"
>
{{ selectedInterview().candidateId?.email }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Position
</span>
<span
class=
"detail-value"
>
{{ selectedInterview().position }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Tech Stack
</span>
<span
class=
"detail-value"
>
{{ selectedInterview().techStack || '—' }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Source
</span>
<span
class=
"detail-value"
>
{{ selectedInterview().source || '—' }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Interviewer
</span>
<span
class=
"detail-value"
>
{{ selectedInterview().interviewerId?.name }}
</span>
</div>
<div
class=
"detail-item"
>
<span
class=
"detail-label"
>
Date of Interview
</span>
<span
class=
"detail-value"
>
{{ selectedInterview().dateOfInterview | date:'mediumDate' }}
</span>
</div>
</div>
</div>
<!-- Quiz Results -->
@if (selectedInterview().quizzes?.length > 0) {
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Quiz Results
</h3>
<div
class=
"quiz-results-grid"
>
@for (q of selectedInterview().quizzes; track q.quizId) {
<div
class=
"quiz-result-card"
>
<div
class=
"qr-title"
>
{{ q.title }}
</div>
@if (q.completed) {
<div
class=
"qr-score"
>
{{ q.score }}/{{ q.totalMarks }} ({{ q.percentage }}%)
</div>
} @else {
<div
class=
"qr-pending"
>
Not Taken
</div>
}
</div>
}
</div>
</div>
}
<!-- Coding Round Submission -->
<div
class=
"detail-section"
>
<h3
class=
"detail-section-title"
>
Coding Challenge Submission
</h3>
@if (selectedInterview().codingRound?.zipFile) {
<div
class=
"eval-card"
style=
"display: flex; justify-content: space-between; align-items: center;"
>
<div>
<span
class=
"material-symbols-rounded"
style=
"color: var(--accent-primary); vertical-align: middle; margin-right: 8px; font-size: 24px;"
>
folder_zip
</span>
<strong>
Submitted on:
</strong>
{{ selectedInterview().codingRound.submittedAt | date:'medium' }}
</div>
<div
style=
"display: flex; gap: 12px; align-items: center;"
>
@if (selectedInterview().codingRound.validated) {
<span
class=
"badge badge-success"
>
Validated by {{ selectedInterview().codingRound.validatedBy?.name || 'Reviewer' }}
</span>
}
<a
[href]=
"'http://localhost:5000' + selectedInterview().codingRound.zipFile"
target=
"_blank"
class=
"btn btn-primary"
style=
"padding: 6px 12px; font-size: 14px; border-radius: 6px;"
>
<span
class=
"material-symbols-rounded"
style=
"font-size: 18px; margin-right: 4px;"
>
download
</span>
Download
</a>
</div>
</div>
} @else {
<div
class=
"quiz-results-grid"
>
<div
class=
"quiz-result-card"
style=
"border: 1px dashed var(--border-color); background: var(--bg-body);"
>
<div
class=
"qr-title"
>
Coding Challenge
</div>
<div
class=
"qr-pending"
>
Not Submitted
</div>
</div>
</div>
}
</div>
<!-- Evaluations -->
<div
class=
"detail-section"
>
<div
style=
"display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"
>
<h3
class=
"detail-section-title"
style=
"margin: 0;"
>
Evaluations
</h3>
@if (allEvaluationsDone()) {
<button
class=
"btn btn-primary"
(click)=
"downloadEvaluationPdf()"
>
@if (isPdfGenerating()) {
<span
class=
"spinner spinner-sm"
></span>
Generating... } @else {
<span
class=
"material-symbols-rounded"
>
download
</span>
Download PDF }
</button>
}
</div>
@if (selectedInterview().evaluations?.length > 0) {
<div
class=
"eval-list"
>
@for (ev of selectedInterview().evaluations; track ev._id) {
<div
class=
"eval-card"
>
<div
class=
"eval-header"
>
<div
class=
"eval-evaluator"
>
<strong>
{{ ev.evaluatorId?.name }}
</strong>
<span
class=
"eval-role badge badge-muted"
>
{{ ev.evaluatorRole | uppercase }}
</span>
</div>
<span
class=
"badge"
[ngClass]=
"getDecisionClass(ev.recommendation)"
>
{{ formatDecision(ev.recommendation) }}
</span>
</div>
@if (ev.comments) {
<p
class=
"eval-comments"
>
"{{ ev.comments }}"
</p>
}
<span
class=
"eval-date"
>
{{ ev.date | date:'medium' }}
</span>
</div>
}
</div>
} @else {
<p
class=
"text-muted"
>
No evaluations yet
</p>
}
<!-- Add evaluation form -->
@if (!hasUserEvaluated()
&&
selectedInterview().status !== 'completed') {
<div
class=
"eval-form"
>
<h4>
Add Your Evaluation
</h4>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Comments
</label>
<textarea
class=
"form-input form-textarea"
[(ngModel)]=
"evalComment"
placeholder=
"Enter your comments..."
rows=
"3"
></textarea>
</div>
<div
class=
"form-group"
>
<label
class=
"form-label"
>
Recommendation *
</label>
<select
class=
"form-input"
[(ngModel)]=
"evalRecommendation"
>
<option
value=
""
>
Select recommendation
</option>
<option
value=
"offer"
>
Offer / Hire as Intern
</option>
<option
value=
"on_hold"
>
On Hold
</option>
<option
value=
"rejected"
>
Rejected
</option>
<option
value=
"2nd_round"
>
2nd Round
</option>
</select>
</div>
<button
class=
"btn btn-primary"
(click)=
"submitEvaluation()"
[disabled]=
"!evalRecommendation() || isSubmitting()"
>
Submit Evaluation
</button>
</div>
}
</div>
<!-- Final Decision (admin and hr only) -->
@if ((authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr')
&&
selectedInterview().status !== 'completed') {
<div
class=
"detail-section decision-section"
>
<h3
class=
"detail-section-title"
>
Final Decision
</h3>
<div
class=
"decision-buttons"
>
<button
class=
"btn btn-success"
(click)=
"setDecision('accepted')"
>
<span
class=
"material-symbols-rounded"
>
check_circle
</span>
Accept
</button>
<button
class=
"btn btn-warning"
(click)=
"setDecision('on_hold')"
>
<span
class=
"material-symbols-rounded"
>
pause_circle
</span>
On Hold
</button>
<button
class=
"btn btn-danger"
(click)=
"setDecision('rejected')"
>
<span
class=
"material-symbols-rounded"
>
cancel
</span>
Reject
</button>
<button
class=
"btn btn-outline"
(click)=
"setDecision('2nd_round')"
>
<span
class=
"material-symbols-rounded"
>
replay
</span>
2nd Round
</button>
</div>
</div>
}
</div>
<div
class=
"modal-footer"
>
@if (authService.getUserRole() === 'admin' || authService.getUserRole() === 'hr') {
<button
class=
"btn btn-danger btn-sm"
(click)=
"deleteInterview(selectedInterview()._id)"
>
Delete Interview
</button>
}
<button
class=
"btn btn-outline"
(click)=
"closeDetail()"
>
Close
</button>
</div>
</div>
</div>
}
<!-- Print Template for Evaluation PDF -->
@if (selectedInterview()) {
<div
class=
"print-container"
>
<div
class=
"print-header"
>
<div
class=
"print-header-left"
>
<h2
style=
"margin: 0; font-size: 20px;"
>
Intern Interview Evaluation Form
</h2>
</div>
<div
class=
"print-header-right"
style=
"text-align: right;"
>
<span
style=
"color: #0078d4; font-weight: bold; font-size: 24px;"
>
IDEAL
</span><br>
<span
style=
"font-size: 10px; color: #555;"
>
TECH LABS
</span>
</div>
</div>
<table
class=
"print-table"
style=
"width: 100%; border-collapse: collapse; margin-bottom: 20px;"
>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;"
>
Candidate Name:
</td>
<td
style=
"border: 1px solid #000; padding: 8px; width: 25%;"
>
{{ selectedInterview().candidateId?.name }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold; width: 25%;"
>
Date of Interview:
</td>
<td
style=
"border: 1px solid #000; padding: 8px; width: 25%;"
>
{{ selectedInterview().dateOfInterview | date:'mediumDate' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Position:
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedInterview().position }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Source
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedInterview().source || '—' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Tech Stack:
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedInterview().techStack || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Interviewer(s):
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
@if (selectedInterview().assignedInterviewers?.length) {
@for (i of selectedInterview().assignedInterviewers; track i._id; let last = $last) {
{{ i.name }}{{ !last ? ', ' : '' }}
}
} @else {
{{ selectedInterview().interviewerId?.name || '—' }}
}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
General Aptitude Test QP Set
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedInterview().quizzes?.[0]?.title || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
General Aptitude Test Score
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedInterview().quizzes?.[0]?.completed ? selectedInterview().quizzes[0].score + '/' + selectedInterview().quizzes[0].totalMarks : 'Not Taken' }}
</td>
</tr>
<tr>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Technical MCQ Test QP Set
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedInterview().quizzes?.[1]?.title || '—' }}
</td>
<td
style=
"border: 1px solid #000; padding: 8px; font-weight: bold;"
>
Technical MCQ Test Score
</td>
<td
style=
"border: 1px solid #000; padding: 8px;"
>
{{ selectedInterview().quizzes?.[1]?.completed ? selectedInterview().quizzes[1].score + '/' + selectedInterview().quizzes[1].totalMarks : 'Not Taken' }}
</td>
</tr>
</table>
<!-- Render evaluations -->
@for (ev of selectedInterview().evaluations; track ev._id) {
<div
class=
"print-eval-block"
style=
"margin-bottom: 30px; page-break-inside: avoid;"
>
<div
class=
"print-eval-title"
style=
"font-weight: bold; margin-bottom: 10px;"
>
{{ ev.evaluatorRole === 'hr' ? 'HR' : ev.evaluatorRole === 'pm' ? 'Project Manager' : 'Interviewer' }}'s Comments ({{ ev.evaluatorId?.name }}):
</div>
<div
class=
"print-comments"
style=
"min-height: 80px; margin-bottom: 15px;"
>
{{ ev.comments || 'No comments provided.' }}
</div>
<div
class=
"print-recommendation"
style=
"margin-bottom: 20px;"
>
<strong
style=
"margin-right: 15px;"
>
Recommendation:
</strong>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'offer' ? '☑' : '☐' }}
</span>
Offer/Hire as Intern
</span>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'on_hold' ? '☑' : '☐' }}
</span>
On Hold
</span>
<span
style=
"margin-right: 15px;"
><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === 'rejected' ? '☑' : '☐' }}
</span>
Rejected
</span>
<span><span
style=
"font-size: 16px;"
>
{{ ev.recommendation === '2nd_round' ? '☑' : '☐' }}
</span>
2nd Round
</span>
</div>
<div
class=
"print-signature-row"
style=
"display: flex; justify-content: space-between; align-items: flex-end;"
>
<div
class=
"print-signature"
style=
"display: flex; align-items: flex-end;"
>
<strong>
Evaluator's Signature:
</strong>
@if (ev.evaluatorId?.signature) {
<img
[src]=
"'http://localhost:5000' + ev.evaluatorId.signature"
style=
"max-height: 40px; margin-left: 10px;"
>
} @else {
<span
style=
"border-bottom: 1px solid #000; display: inline-block; width: 150px; margin-left: 10px;"
></span>
}
</div>
<div
class=
"print-date"
style=
"display: flex; align-items: flex-end;"
>
<strong>
Date:
</strong>
<span
style=
"border-bottom: 1px solid #000; display: inline-block; width: 120px; margin-left: 10px; text-align: center;"
>
{{ ev.date | date:'shortDate' }}
</span>
</div>
</div>
</div>
}
</div>
}
Frontend/src/app/pages/hr/individual-interview/individual-interview.ts
View file @
954049ba
import
{
Component
}
from
'
@angular/core
'
;
import
{
Component
,
OnInit
,
signal
}
from
'
@angular/core
'
;
import
{
CommonModule
}
from
'
@angular/common
'
;
import
{
FormsModule
}
from
'
@angular/forms
'
;
import
{
QuizService
}
from
'
../../../services/quiz.service
'
;
import
{
AuthService
}
from
'
../../../services/auth.service
'
;
@
Component
({
selector
:
'
app-individual-interview
'
,
imports
:
[],
standalone
:
true
,
imports
:
[
CommonModule
,
FormsModule
],
templateUrl
:
'
./individual-interview.html
'
,
styleUrl
:
'
./individual-interview.css
'
,
styleUrl
:
'
./individual-interview.css
'
})
export
class
IndividualInterview
{}
export
class
IndividualInterviewComponent
implements
OnInit
{
interviews
=
signal
<
any
[]
>
([]);
loading
=
signal
(
true
);
showCreateModal
=
signal
(
false
);
showDetailModal
=
signal
(
false
);
selectedInterview
=
signal
<
any
>
(
null
);
// Create form data
candidates
=
signal
<
any
[]
>
([]);
interviewers
=
signal
<
any
[]
>
([]);
hrs
=
signal
<
any
[]
>
([]);
pms
=
signal
<
any
[]
>
([]);
quizzes
=
signal
<
any
[]
>
([]);
newInterview
=
{
candidateId
:
''
,
assignedInterviewers
:
[]
as
string
[],
assignedHRs
:
[]
as
string
[],
assignedPMs
:
[]
as
string
[],
position
:
''
,
techStack
:
''
,
source
:
''
,
dateOfInterview
:
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
],
quizIds
:
[]
as
string
[]
};
// Evaluation form
evalComment
=
signal
(
''
);
evalRecommendation
=
signal
(
''
);
// Stats
stats
=
signal
<
any
>
({
total
:
0
,
pending
:
0
,
completed
:
0
,
accepted
:
0
,
rejected
:
0
});
// Filter
filterStatus
=
signal
(
''
);
isSubmitting
=
signal
(
false
);
constructor
(
private
quizService
:
QuizService
,
public
authService
:
AuthService
)
{}
ngOnInit
():
void
{
this
.
loadInterviews
();
this
.
loadStats
();
}
loadInterviews
():
void
{
this
.
loading
.
set
(
true
);
const
params
:
any
=
{};
if
(
this
.
filterStatus
())
params
.
status
=
this
.
filterStatus
();
params
.
type
=
'
individual
'
;
this
.
quizService
.
getInterviews
(
params
).
subscribe
({
next
:
(
res
)
=>
{
this
.
interviews
.
set
(
res
.
interviews
||
[]);
this
.
loading
.
set
(
false
);
},
error
:
()
=>
this
.
loading
.
set
(
false
)
});
}
loadStats
():
void
{
this
.
quizService
.
getInterviewStats
().
subscribe
({
next
:
(
res
)
=>
this
.
stats
.
set
(
res
)
});
}
openCreateModal
():
void
{
// Load dropdown data
this
.
quizService
.
getInterviewCandidates
().
subscribe
({
next
:
(
res
)
=>
this
.
candidates
.
set
(
res
.
candidates
||
[])
});
this
.
quizService
.
getInterviewers
().
subscribe
({
next
:
(
res
)
=>
{
const
staff
=
res
.
interviewers
||
[];
this
.
interviewers
.
set
(
staff
.
filter
((
s
:
any
)
=>
s
.
role
===
'
interviewer
'
));
this
.
pms
.
set
(
staff
.
filter
((
s
:
any
)
=>
s
.
role
===
'
pm
'
));
this
.
hrs
.
set
(
staff
.
filter
((
s
:
any
)
=>
s
.
role
===
'
hr
'
));
}
});
this
.
quizService
.
getAdminQuizzes
().
subscribe
({
next
:
(
res
)
=>
this
.
quizzes
.
set
(
res
.
quizzes
||
[])
});
this
.
newInterview
=
{
candidateId
:
''
,
assignedInterviewers
:
[],
assignedHRs
:
[],
assignedPMs
:
[],
position
:
''
,
techStack
:
''
,
source
:
''
,
dateOfInterview
:
new
Date
().
toISOString
().
split
(
'
T
'
)[
0
],
quizIds
:
[]
};
this
.
showCreateModal
.
set
(
true
);
}
closeCreateModal
():
void
{
this
.
showCreateModal
.
set
(
false
);
}
toggleQuizSelection
(
quizId
:
string
):
void
{
const
idx
=
this
.
newInterview
.
quizIds
.
indexOf
(
quizId
);
if
(
idx
>=
0
)
{
this
.
newInterview
.
quizIds
.
splice
(
idx
,
1
);
}
else
{
this
.
newInterview
.
quizIds
.
push
(
quizId
);
}
}
toggleSelection
(
event
:
any
,
array
:
string
[]):
void
{
const
val
=
event
.
target
.
value
;
if
(
event
.
target
.
checked
)
{
array
.
push
(
val
);
}
else
{
const
idx
=
array
.
indexOf
(
val
);
if
(
idx
>=
0
)
array
.
splice
(
idx
,
1
);
}
}
isQuizSelected
(
quizId
:
string
):
boolean
{
return
this
.
newInterview
.
quizIds
.
includes
(
quizId
);
}
createInterview
():
void
{
if
(
!
this
.
newInterview
.
candidateId
||
!
this
.
newInterview
.
position
)
return
;
this
.
isSubmitting
.
set
(
true
);
this
.
quizService
.
createInterview
(
this
.
newInterview
).
subscribe
({
next
:
()
=>
{
this
.
isSubmitting
.
set
(
false
);
this
.
closeCreateModal
();
this
.
loadInterviews
();
this
.
loadStats
();
},
error
:
(
err
)
=>
{
this
.
isSubmitting
.
set
(
false
);
alert
(
err
.
error
?.
message
||
'
Failed to create interview
'
);
}
});
}
openDetail
(
interview
:
any
):
void
{
this
.
quizService
.
getInterviewById
(
interview
.
_id
).
subscribe
({
next
:
(
res
)
=>
{
this
.
selectedInterview
.
set
(
res
.
interview
);
this
.
evalComment
.
set
(
''
);
this
.
evalRecommendation
.
set
(
''
);
this
.
showDetailModal
.
set
(
true
);
}
});
}
closeDetail
():
void
{
this
.
showDetailModal
.
set
(
false
);
this
.
selectedInterview
.
set
(
null
);
}
submitEvaluation
():
void
{
const
interview
=
this
.
selectedInterview
();
if
(
!
interview
||
!
this
.
evalRecommendation
())
return
;
this
.
isSubmitting
.
set
(
true
);
this
.
quizService
.
submitEvaluation
(
interview
.
_id
,
{
comments
:
this
.
evalComment
(),
recommendation
:
this
.
evalRecommendation
()
}).
subscribe
({
next
:
(
res
)
=>
{
this
.
selectedInterview
.
set
(
res
.
interview
);
this
.
evalComment
.
set
(
''
);
this
.
evalRecommendation
.
set
(
''
);
this
.
isSubmitting
.
set
(
false
);
},
error
:
()
=>
this
.
isSubmitting
.
set
(
false
)
});
}
setDecision
(
decision
:
string
):
void
{
const
interview
=
this
.
selectedInterview
();
if
(
!
interview
)
return
;
this
.
quizService
.
setInterviewDecision
(
interview
.
_id
,
decision
).
subscribe
({
next
:
(
res
)
=>
{
this
.
selectedInterview
.
set
(
res
.
interview
);
this
.
loadInterviews
();
this
.
loadStats
();
}
});
}
deleteInterview
(
id
:
string
):
void
{
if
(
confirm
(
'
Are you sure you want to delete this interview?
'
))
{
this
.
quizService
.
deleteInterview
(
id
).
subscribe
({
next
:
()
=>
{
this
.
loadInterviews
();
this
.
loadStats
();
this
.
closeDetail
();
}
});
}
}
onFilterChange
():
void
{
this
.
loadInterviews
();
}
getStatusClass
(
status
:
string
):
string
{
switch
(
status
)
{
case
'
pending
'
:
return
'
badge-warning
'
;
case
'
quiz_phase
'
:
return
'
badge-info
'
;
case
'
coding_phase
'
:
return
'
badge-info
'
;
case
'
evaluation
'
:
return
'
badge-purple
'
;
case
'
completed
'
:
return
'
badge-success
'
;
default
:
return
''
;
}
}
getDecisionClass
(
decision
:
string
):
string
{
switch
(
decision
)
{
case
'
accepted
'
:
return
'
badge-success
'
;
case
'
rejected
'
:
return
'
badge-danger
'
;
case
'
on_hold
'
:
return
'
badge-warning
'
;
case
'
2nd_round
'
:
return
'
badge-info
'
;
default
:
return
'
badge-muted
'
;
}
}
formatStatus
(
status
:
string
):
string
{
return
status
.
replace
(
/_/g
,
'
'
).
replace
(
/
\b\w
/g
,
l
=>
l
.
toUpperCase
());
}
formatDecision
(
decision
:
string
):
string
{
const
map
:
any
=
{
pending
:
'
Pending
'
,
accepted
:
'
Accepted
'
,
rejected
:
'
Rejected
'
,
on_hold
:
'
On Hold
'
,
'
2nd_round
'
:
'
2nd Round
'
};
return
map
[
decision
]
||
decision
;
}
hasUserEvaluated
():
boolean
{
const
interview
=
this
.
selectedInterview
();
if
(
!
interview
)
return
false
;
const
userId
=
this
.
authService
.
currentUser
()?.
id
;
return
interview
.
evaluations
?.
some
((
e
:
any
)
=>
e
.
evaluatorId
?.
_id
===
userId
);
}
isPdfGenerating
=
signal
(
false
);
allEvaluationsDone
():
boolean
{
const
iv
=
this
.
selectedInterview
();
if
(
!
iv
)
return
false
;
const
numInterviewers
=
iv
.
assignedInterviewers
?.
length
||
0
;
const
numHrs
=
iv
.
assignedHRs
?.
length
||
0
;
const
numPms
=
iv
.
assignedPMs
?.
length
||
0
;
const
totalExpected
=
numInterviewers
+
numHrs
+
numPms
;
if
(
totalExpected
===
0
)
return
false
;
const
numEvaluations
=
iv
.
evaluations
?.
length
||
0
;
return
numEvaluations
>=
totalExpected
;
}
downloadEvaluationPdf
():
void
{
const
iv
=
this
.
selectedInterview
();
if
(
!
iv
)
return
;
this
.
isPdfGenerating
.
set
(
true
);
setTimeout
(()
=>
{
window
.
print
();
this
.
isPdfGenerating
.
set
(
false
);
},
500
);
}
}
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment