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
78c91f3e
Commit
78c91f3e
authored
May 03, 2026
by
AravindR-K
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat : group interview added
parent
485cd218
Changes
5
Show whitespace changes
Inline
Side-by-side
Showing
5 changed files
with
1268 additions
and
5 deletions
+1268
-5
Backend/routes/interview.js
Backend/routes/interview.js
+113
-0
Frontend/src/app/pages/admin/group-interview/group-interview.css
...d/src/app/pages/admin/group-interview/group-interview.css
+367
-0
Frontend/src/app/pages/admin/group-interview/group-interview.html
.../src/app/pages/admin/group-interview/group-interview.html
+478
-1
Frontend/src/app/pages/admin/group-interview/group-interview.ts
...nd/src/app/pages/admin/group-interview/group-interview.ts
+302
-4
Frontend/src/app/services/quiz.service.ts
Frontend/src/app/services/quiz.service.ts
+8
-0
No files found.
Backend/routes/interview.js
View file @
78c91f3e
...
...
@@ -178,6 +178,119 @@ router.get('/candidates', authorize('admin', 'hr', 'pm'), async (req, res) => {
}
});
// ============================================================
// @route GET /api/interview/group-members
// @desc Get candidates belonging to a specific group
// @access Admin, HR, PM
// ============================================================
router
.
get
(
'
/group-members
'
,
authorize
(
'
admin
'
,
'
hr
'
,
'
pm
'
),
async
(
req
,
res
)
=>
{
try
{
const
{
group
}
=
req
.
query
;
if
(
!
group
)
return
res
.
status
(
400
).
json
({
message
:
'
Group name is required
'
});
const
candidates
=
await
User
.
find
({
role
:
'
candidate
'
,
group
})
.
select
(
'
name email phoneNumber group
'
)
.
sort
({
name
:
1
});
res
.
json
({
candidates
});
}
catch
(
error
)
{
res
.
status
(
500
).
json
({
message
:
'
Server error
'
,
error
:
error
.
message
});
}
});
// ============================================================
// @route POST /api/interview/group
// @desc Create group interviews for all candidates in a group
// quizSets: [{ quizEntries: [{ quizId }], mode: 'random'|'direct',
// directAssignments: { candidateId: quizId } }]
// @access Admin, HR, PM
// ============================================================
router
.
post
(
'
/group
'
,
authorize
(
'
admin
'
,
'
hr
'
,
'
pm
'
),
async
(
req
,
res
)
=>
{
try
{
const
{
groupName
,
assignedInterviewers
,
assignedHRs
,
assignedPMs
,
position
,
techStack
,
source
,
dateOfInterview
,
quizSets
}
=
req
.
body
;
if
(
!
groupName
||
!
position
)
{
return
res
.
status
(
400
).
json
({
message
:
'
Group and position are required
'
});
}
const
candidates
=
await
User
.
find
({
role
:
'
candidate
'
,
group
:
groupName
}).
sort
({
name
:
1
});
if
(
candidates
.
length
===
0
)
{
return
res
.
status
(
400
).
json
({
message
:
'
No candidates found in this group
'
});
}
const
mainInterviewerId
=
assignedInterviewers
&&
assignedInterviewers
.
length
>
0
?
assignedInterviewers
[
0
]
:
null
;
const
createdInterviews
=
[];
for
(
const
candidate
of
candidates
)
{
let
quizzes
=
[];
if
(
quizSets
&&
quizSets
.
length
>
0
)
{
for
(
const
set
of
quizSets
)
{
const
validEntries
=
(
set
.
quizEntries
||
[]).
filter
(
e
=>
e
.
quizId
);
if
(
validEntries
.
length
===
0
)
continue
;
let
assignedQuizId
=
null
;
if
(
validEntries
.
length
===
1
)
{
// Only one quiz in this set — assign to everyone
assignedQuizId
=
validEntries
[
0
].
quizId
;
}
else
if
(
set
.
mode
===
'
direct
'
&&
set
.
directAssignments
)
{
assignedQuizId
=
set
.
directAssignments
[
candidate
.
_id
.
toString
()]
||
null
;
}
else
{
// Random: pick a random quiz from the set
const
pick
=
validEntries
[
Math
.
floor
(
Math
.
random
()
*
validEntries
.
length
)];
assignedQuizId
=
pick
.
quizId
;
}
if
(
assignedQuizId
)
{
const
quizDoc
=
await
Quiz
.
findById
(
assignedQuizId
).
select
(
'
title totalQuestions
'
);
if
(
quizDoc
)
{
quizzes
.
push
({
quizId
:
quizDoc
.
_id
,
title
:
quizDoc
.
title
,
score
:
null
,
totalMarks
:
quizDoc
.
totalQuestions
,
percentage
:
null
,
completed
:
false
});
}
}
}
}
const
interview
=
await
Interview
.
create
({
candidateId
:
candidate
.
_id
,
interviewerId
:
mainInterviewerId
,
assignedInterviewers
:
assignedInterviewers
||
[],
assignedHRs
:
assignedHRs
||
[],
assignedPMs
:
assignedPMs
||
[],
position
,
techStack
:
techStack
||
''
,
source
:
source
||
''
,
dateOfInterview
:
dateOfInterview
||
new
Date
(),
quizzes
,
status
:
quizzes
.
length
>
0
?
'
quiz_phase
'
:
'
pending
'
,
type
:
'
group
'
,
groupId
:
groupName
,
createdBy
:
req
.
user
.
_id
});
createdInterviews
.
push
(
interview
.
_id
);
}
res
.
status
(
201
).
json
({
message
:
`Group interview created for
${
candidates
.
length
}
candidate(s)`
,
count
:
candidates
.
length
,
interviewIds
:
createdInterviews
});
}
catch
(
error
)
{
res
.
status
(
500
).
json
({
message
:
'
Server error
'
,
error
:
error
.
message
});
}
});
// ============================================================
// @route GET /api/interview/:id
// @desc Get interview detail
...
...
Frontend/src/app/pages/admin/group-interview/group-interview.css
View file @
78c91f3e
/* ═══════════════════════════════════════════════════════
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
{
width
:
30px
;
height
:
30px
;
border-radius
:
50%
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
font-size
:
12px
;
font-weight
:
700
;
color
:
#fff
;
border
:
2px
solid
var
(
--bg-card
);
background
:
#667eea
;
transition
:
transform
0.15s
;
}
.candidate-chip
:hover
{
transform
:
scale
(
1.15
);
z-index
:
1
;
}
.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
;
}
/* ═══════════════════════════════════════════════════════
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
;
align-items
:
flex-start
;
gap
:
16px
;
padding
:
16px
;
border
:
1px
solid
var
(
--border-color
);
border-radius
:
12px
;
background
:
var
(
--bg-hover
);
flex-wrap
:
wrap
;
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
;
}
.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
:
32px
;
height
:
32px
;
font-size
:
14px
;
border-radius
:
8px
;
}
.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
;
min-width
:
280px
;
}
.candidate-row-right
{
display
:
flex
;
flex-direction
:
column
;
gap
:
8px
;
align-items
:
flex-end
;
}
/* 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
;
}
}
/* ═══════════════════════════════════════════════════════
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
;
}
}
Frontend/src/app/pages/admin/group-interview/group-interview.html
View file @
78c91f3e
<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 -->
<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>
}
</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"
>
<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>
</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
class=
"candidate-row-right"
>
<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>
}
</div>
</div>
}
</div>
</div>
</div>
<div
class=
"modal-footer"
>
@if (authService.getUserRole() === 'admin') {
<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>
}
Frontend/src/app/pages/admin/group-interview/group-interview.ts
View file @
78c91f3e
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
[]
=
[];
// ── Detail modal state ────────────────────────────────────
showDetailModal
=
signal
(
false
);
selectedGroup
=
signal
<
any
>
(
null
);
evalComment
=
signal
(
''
);
evalRecommendation
=
signal
(
''
);
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
'
);
}
});
}
// ── Detail modal ──────────────────────────────────────────
openDetail
(
group
:
any
):
void
{
this
.
selectedGroup
.
set
(
group
);
this
.
showDetailModal
.
set
(
true
);
}
closeDetail
():
void
{
this
.
showDetailModal
.
set
(
false
);
this
.
selectedGroup
.
set
(
null
);
}
// ── 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/services/quiz.service.ts
View file @
78c91f3e
...
...
@@ -291,4 +291,12 @@
getInterviewCandidates
():
Observable
<
any
>
{
return
this
.
http
.
get
(
`
${
this
.
interviewUrl
}
/candidates`
);
}
getGroupMembers
(
groupName
:
string
):
Observable
<
any
>
{
return
this
.
http
.
get
(
`
${
this
.
interviewUrl
}
/group-members?group=
${
encodeURIComponent
(
groupName
)}
`
);
}
createGroupInterview
(
data
:
any
):
Observable
<
any
>
{
return
this
.
http
.
post
(
`
${
this
.
interviewUrl
}
/group`
,
data
);
}
}
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