Commit fd448421 authored by AravindR-K's avatar AravindR-K

feat : Now you can Add, Delete and Edit Groups inorder to assign people the quiz

parent d2648061
const mongoose = require('mongoose');
const groupSchema = new mongoose.Schema({
name: {
type: String,
required: [true, 'Group name is required'],
unique: true,
trim: true
}
}, {
timestamps: true
});
module.exports = mongoose.model('Group', groupSchema);
......@@ -2,6 +2,7 @@
const xlsx = require('xlsx');
const fs = require('fs');
const User = require('../models/User');
const Group = require('../models/Group');
const Quiz = require('../models/Quiz');
const Question = require('../models/Question');
const Submission = require('../models/Submission');
......@@ -190,8 +191,7 @@
if (!req.file) {
return res.status(400).json({ message: 'Please upload an Excel file with questions' });
}
console.log("FILE:", req.file);
console.log("BODY:", req.body);
// Parse Excel file
const workbook = xlsx.readFile(req.file.path);
const sheetName = workbook.SheetNames[0];
......@@ -473,8 +473,13 @@
// @access Admin
router.get('/groups', async (req, res) => {
try {
const groups = await User.distinct('group');
res.json({ groups });
const groups = await Group.find().sort({ name: 1 });
const groupNames = groups.map(g => g.name);
// Fetch legacy groups from users just to be safe
const userGroups = await User.distinct('group');
const allGroups = [...new Set([...groupNames, ...userGroups])].filter(g => g && g !== 'General');
res.json({ groups: allGroups });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
......@@ -494,16 +499,56 @@
}
// Check if already exists
const existing = await User.findOne({ group: name.trim() });
const existing = await Group.findOne({ name: name.trim() });
if (existing) {
return res.status(400).json({ message: 'Group already exists' });
}
// ⚠️ Since you're using group as string,
// we don't store separately — just return success
res.status(201).json({ message: 'Group created successfully', group: name.trim() });
const group = await Group.create({ name: name.trim() });
res.status(201).json({ message: 'Group created successfully', group: group.name });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/admin/groups/:oldName
// @desc Edit a group name
// @access Admin
router.put('/groups/:oldName', async (req, res) => {
try {
const { oldName } = req.params;
const { newName } = req.body;
if (!newName || !newName.trim()) return res.status(400).json({ message: 'New group name is required' });
const existing = await Group.findOne({ name: newName.trim() });
if (existing && existing.name !== oldName) {
return res.status(400).json({ message: 'A group with this name already exists' });
}
await Group.findOneAndUpdate({ name: oldName }, { name: newName.trim() });
await User.updateMany({ group: oldName }, { group: newName.trim() });
await Quiz.updateMany({ assignedGroups: oldName }, { $set: { "assignedGroups.$": newName.trim() } });
res.json({ message: 'Group updated successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route DELETE /api/admin/groups/:name
// @desc Delete a group
// @access Admin
router.delete('/groups/:name', async (req, res) => {
try {
const { name } = req.params;
await Group.deleteOne({ name });
await User.updateMany({ group: name }, { group: 'General' });
await Quiz.updateMany({ assignedGroups: name }, { $pull: { assignedGroups: name } });
res.json({ message: 'Group deleted successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
......
const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/User');
const Group = require('../models/Group');
const { protect } = require('../middleware/auth');
const router = express.Router();
......@@ -16,7 +17,7 @@ const generateToken = (id) => {
// @access Public
router.post('/register', async (req, res) => {
try {
const { name, email, password } = req.body;
const { name, email, password, group } = req.body;
// Validate input
if (!name || !email || !password) {
......@@ -30,7 +31,10 @@ router.post('/register', async (req, res) => {
}
// Create user (role defaults to 'candidate')
const user = await User.create({ name, email, password, role: 'candidate' });
const userDbData = { name, email, password, role: 'candidate' };
if (group) userDbData.group = group;
const user = await User.create(userDbData);
res.status(201).json({
message: 'Registration successful',
......@@ -115,4 +119,20 @@ router.get('/me', protect, async (req, res) => {
}
});
// @route GET /api/auth/groups
// @desc Get all groups for registration
// @access Public
router.get('/groups', async (req, res) => {
try {
const groups = await Group.find().sort({ name: 1 });
const groupNames = groups.map(g => g.name);
// Backward compatibility: fetch from User model too
const userGroups = await User.distinct('group');
const allGroups = [...new Set([...groupNames, ...userGroups])].filter(g => g && g !== 'General');
res.json({ groups: allGroups });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router;
......@@ -2,6 +2,7 @@ const express = require('express');
const xlsx = require('xlsx');
const fs = require('fs');
const User = require('../models/User');
const Group = require('../models/Group');
const Quiz = require('../models/Quiz');
const Question = require('../models/Question');
const Submission = require('../models/Submission');
......@@ -292,8 +293,65 @@ router.get('/categories', async (req, res) => {
// @route GET /api/hr/groups
router.get('/groups', async (req, res) => {
try {
const groups = await User.distinct('group');
res.json({ groups });
const groups = await Group.find().sort({ name: 1 });
const groupNames = groups.map(g => g.name);
const userGroups = await User.distinct('group');
const allGroups = [...new Set([...groupNames, ...userGroups])].filter(g => g && g !== 'General');
res.json({ groups: allGroups });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route POST /api/hr/groups
router.post('/groups', async (req, res) => {
try {
const { name } = req.body;
if (!name || !name.trim()) return res.status(400).json({ message: 'Group name is required' });
const existing = await Group.findOne({ name: name.trim() });
if (existing) return res.status(400).json({ message: 'Group already exists' });
const group = await Group.create({ name: name.trim() });
res.status(201).json({ message: 'Group created successfully', group: group.name });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route PUT /api/hr/groups/:oldName
router.put('/groups/:oldName', async (req, res) => {
try {
const { oldName } = req.params;
const { newName } = req.body;
if (!newName || !newName.trim()) return res.status(400).json({ message: 'New group name is required' });
const existing = await Group.findOne({ name: newName.trim() });
if (existing && existing.name !== oldName) {
return res.status(400).json({ message: 'A group with this name already exists' });
}
await Group.findOneAndUpdate({ name: oldName }, { name: newName.trim() });
await User.updateMany({ group: oldName }, { group: newName.trim() });
await Quiz.updateMany({ assignedGroups: oldName }, { $set: { "assignedGroups.$": newName.trim() } });
res.json({ message: 'Group updated successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
// @route DELETE /api/hr/groups/:name
router.delete('/groups/:name', async (req, res) => {
try {
const { name } = req.params;
await Group.deleteOne({ name });
await User.updateMany({ group: name }, { group: 'General' });
await Quiz.updateMany({ assignedGroups: name }, { $pull: { assignedGroups: name } });
res.json({ message: 'Group deleted successfully' });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
......
......@@ -37,6 +37,10 @@ export const routes: Routes = [
path: 'users/:userId/history',
loadComponent: () => import('./pages/admin/user-history/user-history').then(m => m.UserHistoryComponent)
},
{
path: 'manage-groups',
loadComponent: () => import('./pages/admin/manage-groups/manage-groups').then(m => m.ManageGroupsComponent)
},
{
path: 'submissions/:submissionId',
loadComponent: () => import('./pages/admin/submission-detail/submission-detail').then(m => m.SubmissionDetailComponent)
......@@ -79,6 +83,10 @@ export const routes: Routes = [
path: 'candidates',
loadComponent: () => import('./pages/hr/candidates/candidates').then(m => m.HRCandidatesComponent)
},
{
path: 'manage-groups',
loadComponent: () => import('./pages/admin/manage-groups/manage-groups').then(m => m.ManageGroupsComponent)
},
{
path: 'candidates/:userId/history',
loadComponent: () => import('./pages/hr/candidate-history/candidate-history').then(m => m.HRCandidateHistoryComponent)
......
......@@ -352,3 +352,140 @@
margin-left: 0;
}
}
/* Glassy Modal Styles */
.glassy-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease-out forwards;
}
.glassy-modal {
background: var(--bg-card); /* Theme aware */
border: 1px solid var(--border-color);
border-radius: 20px;
width: 90%;
max-width: 550px;
padding: 32px;
box-shadow: 0 15px 35px rgba(0,0,0,0.2), inset 0 1px 0 rgba(255,255,255,0.1);
backdrop-filter: blur(20px);
position: relative;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.modal-header h2 {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.close-btn {
background: transparent;
border: none;
color: var(--text-muted);
cursor: pointer;
padding: 4px;
border-radius: 50%;
transition: all 0.2s;
display: flex;
}
.close-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.modal-options {
display: flex;
flex-direction: column;
gap: 16px;
}
.glassy-option {
display: flex;
align-items: center;
gap: 20px;
padding: 20px;
background: var(--bg-input);
border: 1px solid var(--border-color);
border-radius: 16px;
text-decoration: none;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
}
.glassy-option:hover {
transform: translateY(-4px);
background: var(--bg-hover);
border-color: var(--accent-primary);
box-shadow: 0 10px 20px rgba(0,0,0,0.1);
}
.option-icon-wrapper {
width: 56px;
height: 56px;
border-radius: 14px;
background: linear-gradient(135deg, var(--accent-primary) 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.block-icon {
font-size: 28px;
color: white;
}
.option-text {
flex: 1;
}
.option-text h3 {
margin: 0 0 4px 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.option-text p {
margin: 0;
font-size: 13px;
color: var(--text-muted);
line-height: 1.4;
}
.arrow-icon {
color: var(--text-muted);
transition: transform 0.2s;
}
.glassy-option:hover .arrow-icon {
color: var(--accent-primary);
transform: translateX(4px);
}
.animate-fade-in {
animation: modalScaleUp 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
@keyframes modalScaleUp {
0% { opacity: 0; transform: scale(0.95) translateY(10px); }
100% { opacity: 1; transform: scale(1) translateY(0); }
}
......@@ -37,14 +37,22 @@
<!-- Navigation -->
<nav class="sidebar-nav">
@for (item of navItems(); track item.route) {
<a [routerLink]="item.route"
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}"
class="nav-item"
(click)="mobileSidebarOpen = false">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
@if (item.route === '/admin/users' || item.route === '/hr/candidates') {
<a class="nav-item" (click)="handleNavClick(item, $event)" href="javascript:void(0)"
[class.active]="router.url.includes('/admin/users') || router.url.includes('/hr/candidates') || router.url.includes('manage-groups')">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
} @else {
<a [routerLink]="item.route"
routerLinkActive="active"
[routerLinkActiveOptions]="{exact: item.route.includes('dashboard')}"
class="nav-item"
(click)="mobileSidebarOpen = false">
<span class="material-symbols-rounded nav-icon">{{ item.icon }}</span>
<span class="nav-label">{{ item.label }}</span>
</a>
}
}
</nav>
......@@ -86,3 +94,41 @@
<router-outlet />
</main>
</div>
<!-- Manage Users Glassy Popup -->
@if (uiService.showManageUsersPopup()) {
<div class="glassy-overlay" (click)="closeManageUsersPopup()">
<div class="glassy-modal animate-fade-in" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Manage Users & Groups</h2>
<button class="close-btn" (click)="closeManageUsersPopup()">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-options">
<a [routerLink]="getUsersRoute()" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">people</span>
</div>
<div class="option-text">
<h3>Student Users</h3>
<p>Review and manage candidate profiles and test histories</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
<a [routerLink]="getManageGroupsRoute()" class="glassy-option" (click)="closeManageUsersPopup()">
<div class="option-icon-wrapper">
<span class="material-symbols-rounded block-icon">group_add</span>
</div>
<div class="option-text">
<h3>Manage Groups</h3>
<p>Create and structure student categorization effectively</p>
</div>
<span class="material-symbols-rounded arrow-icon">arrow_forward</span>
</a>
</div>
</div>
</div>
}
......@@ -3,6 +3,7 @@ import { CommonModule } from '@angular/common';
import { RouterLink, RouterLinkActive, RouterOutlet, Router } from '@angular/router';
import { AuthService } from '../../services/auth.service';
import { ThemeService, ThemeMode } from '../../services/theme.service';
import { UiService } from '../../services/ui.service';
interface NavItem {
icon: string;
......@@ -21,6 +22,7 @@ export class LayoutComponent {
authService = inject(AuthService);
themeService = inject(ThemeService);
router = inject(Router);
uiService = inject(UiService);
showThemeMenu = false;
mobileSidebarOpen = false;
......@@ -88,4 +90,25 @@ export class LayoutComponent {
logout(): void {
this.authService.logout();
}
handleNavClick(item: NavItem, event: Event): void {
if (item.route === '/admin/users' || item.route === '/hr/candidates') {
event.preventDefault();
this.uiService.showManageUsersPopup.set(true);
} else {
this.mobileSidebarOpen = false;
}
}
closeManageUsersPopup(): void {
this.uiService.showManageUsersPopup.set(false);
}
getUsersRoute(): string {
return this.authService.getUserRole() === 'hr' ? '/hr/candidates' : '/admin/users';
}
getManageGroupsRoute(): string {
return this.authService.getUserRole() === 'hr' ? '/hr/manage-groups' : '/admin/manage-groups';
}
}
......@@ -25,3 +25,72 @@
.page-container { padding: 20px 16px; }
.form-row { flex-direction: column; }
}
.assignment-box {
margin-top: 12px;
padding: 16px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
box-shadow: var(--shadow-sm);
}
.list-container {
max-height: 220px;
overflow-y: auto;
border: 1px solid var(--border-color);
border-radius: var(--radius-sm);
background: var(--bg-secondary);
display: flex;
flex-direction: column;
}
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid var(--border-color);
cursor: pointer;
transition: background 0.15s ease;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: var(--bg-hover);
}
.list-item input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--accent-primary);
cursor: pointer;
}
.item-details {
display: flex;
flex-direction: column;
}
.item-name {
font-size: 14px;
font-weight: 500;
color: var(--text-primary);
}
.item-sub {
font-size: 12px;
color: var(--text-muted);
}
.empty-state {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
......@@ -47,6 +47,59 @@
</div>
</div>
<div class="form-group">
<label class="form-label">Assign To</label>
<select class="form-select" [(ngModel)]="assignmentType" name="assignmentType">
<option value="all">All Candidates</option>
<option value="users">Individual People</option>
<option value="groups">Specific Groups</option>
</select>
</div>
@if (assignmentType === 'users') {
<div class="form-group assignment-box">
<label class="form-label">Search Candidates</label>
<input class="form-input" type="text" [(ngModel)]="userSearchQuery" name="userSearchQuery"
(input)="onUserSearch()" placeholder="Search by name or email...">
<div class="list-container mt-2">
@for (user of filteredUsers; track user._id) {
<div class="list-item" (click)="toggleUserSelection(user._id)">
<input type="checkbox" [checked]="selectedUsers.includes(user._id)"
(change)="toggleUserSelection(user._id)">
<div class="item-details">
<span class="item-name">{{ user.name }}</span>
<span class="item-sub">{{ user.email }}</span>
</div>
</div>
}
@if (filteredUsers.length === 0) {
<div class="empty-state">No candidates found</div>
}
</div>
</div>
}
@if (assignmentType === 'groups') {
<div class="form-group assignment-box">
<label class="form-label">Select Groups</label>
<div class="list-container">
@for (group of availableGroups; track group) {
<div class="list-item" (click)="toggleGroupSelection(group)">
<input type="checkbox" [checked]="selectedGroups.includes(group)"
(change)="toggleGroupSelection(group)">
<div class="item-details">
<span class="item-name">{{ group }}</span>
</div>
</div>
}
@if (availableGroups.length === 0) {
<div class="empty-state">No groups found</div>
}
</div>
</div>
}
<div class="form-group">
<label class="form-label">Questions File (Excel) *</label>
<div class="file-upload" (click)="fileInput.click()">
......
import { Component, signal } from '@angular/core';
import { Component, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
......@@ -11,7 +11,7 @@ import { QuizService } from '../../../services/quiz.service';
templateUrl: './create-quiz.html',
styleUrl: './create-quiz.css'
})
export class CreateQuizComponent {
export class CreateQuizComponent implements OnInit {
title = '';
timer = 30;
category = '';
......@@ -24,8 +24,69 @@ export class CreateQuizComponent {
success = signal('');
error = signal('');
assignmentType = 'all'; // 'all', 'users', 'groups'
availableUsers: any[] = [];
filteredUsers: any[] = [];
availableGroups: string[] = [];
selectedUsers: string[] = [];
selectedGroups: string[] = [];
userSearchQuery = '';
constructor(private quizService: QuizService, private router: Router) {}
ngOnInit(): void {
this.fetchUsersAndGroups();
}
fetchUsersAndGroups(): void {
// Fetch users (candidates)
this.quizService.getUsers('candidate').subscribe({
next: (res) => {
this.availableUsers = res.users || [];
this.filteredUsers = [...this.availableUsers];
},
error: (err) => console.error('Failed to fetch users', err)
});
// Fetch groups
this.quizService.getAdminGroups().subscribe({
next: (res) => {
this.availableGroups = res.groups || [];
},
error: (err) => console.error('Failed to fetch groups', err)
});
}
onUserSearch(): void {
if (!this.userSearchQuery.trim()) {
this.filteredUsers = [...this.availableUsers];
} else {
const q = this.userSearchQuery.toLowerCase();
this.filteredUsers = this.availableUsers.filter(u =>
u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
);
}
}
toggleUserSelection(userId: string): void {
const index = this.selectedUsers.indexOf(userId);
if (index > -1) {
this.selectedUsers.splice(index, 1);
} else {
this.selectedUsers.push(userId);
}
}
toggleGroupSelection(group: string): void {
const index = this.selectedGroups.indexOf(group);
if (index > -1) {
this.selectedGroups.splice(index, 1);
} else {
this.selectedGroups.push(group);
}
}
onFileSelected(event: Event): void {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
......@@ -51,6 +112,17 @@ export class CreateQuizComponent {
if (this.difficulty) formData.append('difficulty', this.difficulty);
if (this.topic) formData.append('topic', this.topic);
// Assignment handling
if (this.assignmentType === 'all') {
formData.append('assignToAll', 'true');
} else if (this.assignmentType === 'users') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify(this.selectedUsers));
} else if (this.assignmentType === 'groups') {
formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups));
}
this.quizService.createQuiz(formData).subscribe({
next: (res) => {
this.loading.set(false);
......
......@@ -70,7 +70,7 @@
<div class="section">
<h2 class="section-title">Quick Actions</h2>
<div class="actions-grid">
<a routerLink="/admin/users" class="action-card card card-hover card-padding">
<a (click)="openUsersPopup()" class="action-card card card-hover card-padding" style="cursor: pointer;">
<span class="material-symbols-rounded action-icon">group</span>
<div class="action-info">
<h3>Manage Users</h3>
......
......@@ -2,6 +2,7 @@ import { Component, OnInit, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
import { UiService } from '../../../services/ui.service';
@Component({
selector: 'app-admin-dashboard',
......@@ -15,7 +16,7 @@ export class AdminDashboardComponent implements OnInit {
recentSubmissions = signal<any[]>([]);
loading = signal(true);
constructor(private quizService: QuizService) {}
constructor(private quizService: QuizService, private uiService: UiService) {}
ngOnInit(): void {
this.quizService.getAdminStats().subscribe({
......@@ -27,4 +28,8 @@ export class AdminDashboardComponent implements OnInit {
error: () => this.loading.set(false)
});
}
openUsersPopup(): void {
this.uiService.showManageUsersPopup.set(true);
}
}
......@@ -89,3 +89,78 @@
.dashboard-layout { flex-direction: column; }
.form-row { grid-template-columns: 1fr; }
}
.form-select {
width: 100%; padding: 14px 16px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12);
border-radius: 12px; color: #fff; font-size: 15px; outline: none; transition: all 0.3s; font-family: inherit; box-sizing: border-box;
}
.form-select:focus { border-color: #667eea; box-shadow: 0 0 0 3px rgba(102,126,234,0.15); }
.assignment-box {
margin-top: 12px;
padding: 16px;
background: rgba(255,255,255,0.02);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
}
.list-container {
max-height: 220px;
overflow-y: auto;
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
background: rgba(0,0,0,0.2);
display: flex;
flex-direction: column;
}
.mt-2 { margin-top: 8px; }
.list-item {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid rgba(255,255,255,0.05);
cursor: pointer;
transition: background 0.15s ease;
}
.list-item:last-child {
border-bottom: none;
}
.list-item:hover {
background: rgba(255,255,255,0.05);
}
.list-item input[type="checkbox"] {
width: 16px;
height: 16px;
cursor: pointer;
}
.item-details {
display: flex;
flex-direction: column;
}
.item-name {
font-size: 14px;
font-weight: 500;
color: #fff;
}
.item-sub {
font-size: 12px;
color: rgba(255,255,255,0.5);
}
.empty-state {
padding: 20px;
text-align: center;
color: rgba(255,255,255,0.5);
font-size: 13px;
font-style: italic;
}
......@@ -64,26 +64,56 @@
</div>
<div class="form-group">
<label>Assign Quiz</label>
<label>Assign Quiz To</label>
<select class="form-select" [ngModel]="assignmentType()" (ngModelChange)="assignmentType.set($event)" name="assignmentType">
<option value="all">All Candidates</option>
<option value="users">Individual People</option>
<option value="groups">Specific Groups</option>
</select>
</div>
<div style="margin-bottom: 10px;">
<label>
<input type="checkbox" [checked]="assignToAll()" (change)="assignToAll.set(!assignToAll())" />
Assign to All Users
</label>
@if (assignmentType() === 'users') {
<div class="form-group assignment-box">
<label>Search Candidates</label>
<input class="form-input" type="text" [ngModel]="userSearchQuery()" (ngModelChange)="userSearchQuery.set($event); onUserSearch()" name="userSearchQuery" placeholder="Search by name or email...">
<div class="list-container mt-2">
@for (user of filteredUsers; track user._id) {
<div class="list-item" (click)="toggleUserSelection(user._id)">
<input type="checkbox" [checked]="selectedUsers().includes(user._id)"
(change)="toggleUserSelection(user._id)">
<div class="item-details">
<span class="item-name">{{ user.name }}</span>
<span class="item-sub">{{ user.email }}</span>
</div>
</div>
}
@if (filteredUsers.length === 0) {
<div class="empty-state">No candidates found</div>
}
</div>
</div>
}
@if (!assignToAll()) {
<div class="group-list">
@for (group of groups(); track group._id) {
<label style="display:block; margin-bottom:6px;">
<input type="checkbox" [value]="group.name" (change)="onGroupToggle(group.name, $event)" />
{{ group.name }}
</label>
}
@if (assignmentType() === 'groups') {
<div class="form-group assignment-box">
<label>Select Groups</label>
<div class="list-container mt-2">
@for (group of groups(); track group) {
<div class="list-item" (click)="onGroupToggle(group, {target: {checked: !selectedGroups().includes(group)}})">
<input type="checkbox" [checked]="selectedGroups().includes(group)"
(change)="onGroupToggle(group, $event)">
<div class="item-details">
<span class="item-name">{{ group }}</span>
</div>
</div>
}
@if (groups().length === 0) {
<div class="empty-state">No groups found</div>
}
</div>
</div>
}
</div>
}
<button mat-raised-button color="primary" type="submit" class="btn btn-primary" [disabled]="loading()">
@if (loading()) {
<span class="spinner"></span> Creating Quiz...
......
......@@ -18,9 +18,14 @@ export class GenerateQuizComponent implements OnInit {
timer = 30;
selectedFile: File | null = null;
fileName = signal<string>('');
groups = signal<any[]>([]);
groups = signal<any[]>([]); // Actually used for groups now, let's keep it as string[] if the backend returns strings
availableUsers: any[] = [];
filteredUsers: any[] = [];
assignmentType = signal<string>('all'); // 'all', 'users', 'groups'
selectedUsers = signal<string[]>([]);
selectedGroups = signal<string[]>([]);
assignToAll = signal<boolean>(true);
userSearchQuery = signal<string>('');
loading = signal<boolean>(false);
success = signal<string>('');
error = signal<string>('');
......@@ -32,16 +37,46 @@ export class GenerateQuizComponent implements OnInit {
ngOnInit(): void {
this.loadQuizzes();
this.fetchUsersAndGroups();
}
fetchUsersAndGroups(): void {
this.quizService.getAdminGroups().subscribe({
next: (res) => {
this.groups.set(res.groups || []);
},
error: () => {
console.log("Failed to load groups");
}
error: () => console.log("Failed to load groups")
});
this.quizService.getUsers('candidate').subscribe({
next: (res) => {
this.availableUsers = res.users || [];
this.filteredUsers = [...this.availableUsers];
},
error: () => console.log("Failed to load users")
});
}
onUserSearch(): void {
const q = this.userSearchQuery().toLowerCase().trim();
if (!q) {
this.filteredUsers = [...this.availableUsers];
} else {
this.filteredUsers = this.availableUsers.filter(u =>
u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
);
}
}
toggleUserSelection(userId: string): void {
const selected = this.selectedUsers();
if (selected.includes(userId)) {
this.selectedUsers.set(selected.filter(id => id !== userId));
} else {
this.selectedUsers.set([...selected, userId]);
}
}
loadQuizzes(): void {
this.quizService.getAdminQuizzes().subscribe({
next: (res) => {
......@@ -82,8 +117,17 @@ export class GenerateQuizComponent implements OnInit {
formData.append('title', this.title);
formData.append('timer', this.timer.toString());
formData.append('questionsFile', this.selectedFile);
formData.append('assignToAll', this.assignToAll().toString());
formData.append('assignedGroups', JSON.stringify(this.selectedGroups()));
const aType = this.assignmentType();
if (aType === 'all') {
formData.append('assignToAll', 'true');
} else if (aType === 'users') {
formData.append('assignToAll', 'false');
formData.append('assignees', JSON.stringify(this.selectedUsers()));
} else if (aType === 'groups') {
formData.append('assignToAll', 'false');
formData.append('assignedGroups', JSON.stringify(this.selectedGroups()));
}
this.quizService.createQuiz(formData).subscribe({
next: (res) => {
this.loading.set(false);
......
.page-container { padding: 32px 40px; max-width: 900px; }
.page-header { margin-bottom: 28px; }
.page-header h1 { font-size: 26px; font-weight: 700; color: var(--text-primary); margin: 0 0 8px; }
.page-subtitle { color: var(--text-muted); font-size: 14px; margin: 0; }
.section-title { font-size: 18px; font-weight: 600; color: var(--text-primary); margin-bottom: 20px; border-bottom: 1px solid var(--border-color); padding-bottom: 8px; }
.form-card .section-title { margin-top: 0; border: none; padding: 0; margin-bottom: 16px; }
.card { background: var(--bg-card); border-radius: var(--radius-lg); border: 1px solid var(--border-color); }
.card-padding { padding: 24px; }
.group-form { display: flex; flex-direction: column; }
.row-align { display: flex; gap: 16px; align-items: center; }
.input-container { flex: 1; position: relative; display: flex; align-items: center; }
.input-container .block-icon { position: absolute; left: 16px; color: var(--text-muted); font-size: 20px; }
.form-input { width: 100%; padding: 14px 16px 14px 44px; background: var(--bg-input); border: 1px solid var(--border-color); border-radius: 12px; color: var(--text-primary); outline: none; transition: all 0.3s; font-size: 15px; box-sizing: border-box; }
.form-input:focus { border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.15); }
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; padding: 14px 24px; border: none; border-radius: 12px; font-weight: 600; cursor: pointer; transition: all 0.3s; font-family: inherit; font-size: 15px; }
.btn-primary { background: var(--accent-gradient); color: #fff; box-shadow: 0 4px 15px rgba(102,126,234,0.3); }
.btn-primary:hover:not(:disabled) { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(102,126,234,0.4); }
.btn-primary:disabled { opacity: 0.6; cursor: not-allowed; transform: none; box-shadow: none; }
.groups-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
.group-card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 16px; padding: 20px; display: flex; align-items: center; gap: 16px; transition: all 0.3s; }
.group-card:hover { border-color: var(--accent-primary); transform: translateY(-3px); box-shadow: var(--shadow-md); }
.group-icon-wrapper { width: 50px; height: 50px; border-radius: 12px; background: linear-gradient(135deg, rgba(102,126,234,0.1) 0%, rgba(102,126,234,0.2) 100%); color: var(--accent-primary); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.group-icon-wrapper .material-symbols-rounded { font-size: 26px; }
.group-details h4 { margin: 0; font-size: 17px; font-weight: 600; color: var(--text-primary); }
.alert { display: flex; align-items: center; gap: 10px; padding: 14px 20px; border-radius: 12px; margin-bottom: 24px; font-size: 14px; font-weight: 500; }
.alert-success { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.2); color: #22c55e; }
.alert-error { background: rgba(239,68,68,0.1); border: 1px solid rgba(239,68,68,0.2); color: #ef4444; }
.spinner { width: 18px; height: 18px; border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff; border-radius: 50%; animation: spin 0.6s linear infinite; }
.loader { width: 40px; height: 40px; border: 3px solid rgba(102,126,234,0.2); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 0.8s linear infinite; }
.loading-state { display: flex; justify-content: center; padding: 40px; }
@keyframes spin { 100% { transform: rotate(360deg); } }
.empty-state { text-align: center; padding: 60px 0; border: 1px dashed var(--border-color); border-radius: 16px; background: rgba(255,255,255,0.02); }
.empty-icon { font-size: 48px; color: var(--text-muted); display: block; margin-bottom: 12px; }
.empty-state h3 { color: var(--text-primary); margin: 0 0 8px; font-size: 18px; }
.empty-state p { color: var(--text-muted); margin: 0; font-size: 14px; }
@media (max-width: 600px) {
.page-container { padding: 20px 16px; }
.row-align { flex-direction: column; align-items: stretch; }
.groups-grid { grid-template-columns: 1fr; }
}
<div class="page-container animate-fade-in">
<div class="page-header">
<h1>Manage Groups</h1>
<p class="page-subtitle">Create and oversee organizational groups for candidates</p>
</div>
@if (error()) {
<div class="alert alert-error"><span class="material-symbols-rounded">error</span> {{ error() }}</div>
}
@if (success()) {
<div class="alert alert-success"><span class="material-symbols-rounded">check_circle</span> {{ success() }}</div>
}
<div class="card card-padding form-card" style="margin-bottom: 40px;">
<h2 class="section-title">Create New Group</h2>
<form (ngSubmit)="createGroup()" class="group-form">
<div class="form-group row-align">
<div class="input-container">
<span class="material-symbols-rounded block-icon">group_add</span>
<input class="form-input" [ngModel]="newGroupName()" (ngModelChange)="newGroupName.set($event)" name="groupName" placeholder="e.g., Spring Cohort 2026">
</div>
<button type="submit" class="btn btn-primary" [disabled]="loading() || !newGroupName().trim()">
@if (loading()) {
<span class="spinner"></span>
} @else {
<span class="material-symbols-rounded">add</span> Add Group
}
</button>
</div>
</form>
</div>
<h2 class="section-title">Existing Groups</h2>
@if (loadingGroups()) {
<div class="loading-state">
<div class="loader"></div>
</div>
} @else if (groups().length === 0) {
<div class="empty-state">
<span class="material-symbols-rounded empty-icon">group_off</span>
<h3>No groups available</h3>
<p>Create your first group above</p>
</div>
} @else {
<div class="groups-grid">
@for (group of groups(); track group) {
<div class="group-card">
<div class="group-icon-wrapper">
<span class="material-symbols-rounded">group</span>
</div>
<div class="group-details" style="flex: 1;">
@if (editingGroup() === group) {
<input type="text" class="form-input form-input-small" [ngModel]="editName()" (ngModelChange)="editName.set($event)" autofocus>
} @else {
<h4>{{ group }}</h4>
}
</div>
<div class="group-actions">
@if (editingGroup() === group) {
<button class="action-btn text-success" (click)="saveEdit(group)" title="Save"><span class="material-symbols-rounded">check</span></button>
<button class="action-btn text-danger" (click)="cancelEdit()" title="Cancel"><span class="material-symbols-rounded">close</span></button>
} @else {
<button class="action-btn" (click)="startEdit(group)" title="Edit"><span class="material-symbols-rounded">edit</span></button>
<button class="action-btn text-danger" (click)="deleteGroup(group)" title="Delete"><span class="material-symbols-rounded">delete</span></button>
}
</div>
</div>
}
</div>
}
</div>
import { Component, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { QuizService } from '../../../services/quiz.service';
@Component({
selector: 'app-manage-groups',
standalone: true,
imports: [CommonModule, FormsModule, RouterLink],
templateUrl: './manage-groups.html',
styleUrl: './manage-groups.css'
})
export class ManageGroupsComponent implements OnInit {
groups = signal<string[]>([]);
newGroupName = signal<string>('');
loading = signal<boolean>(false);
error = signal<string>('');
success = signal<string>('');
loadingGroups = signal<boolean>(true);
editingGroup = signal<string | null>(null);
editName = signal<string>('');
constructor(private quizService: QuizService) {}
ngOnInit(): void {
this.loadGroups();
}
loadGroups(): void {
this.loadingGroups.set(true);
this.quizService.getGroups().subscribe({
next: (res) => {
this.groups.set(res.groups || []);
this.loadingGroups.set(false);
},
error: (err) => {
this.error.set('Failed to load groups');
this.loadingGroups.set(false);
}
});
}
createGroup(): void {
if (!this.newGroupName().trim()) {
this.error.set('Group name cannot be blank');
return;
}
this.loading.set(true);
this.error.set('');
this.success.set('');
this.quizService.createGroup(this.newGroupName()).subscribe({
next: () => {
this.success.set('Group created successfully!');
this.loading.set(false);
this.newGroupName.set('');
this.loadGroups();
},
error: (err: any) => {
this.error.set(err.error?.message || 'Failed to create group');
this.loading.set(false);
}
});
}
startEdit(name: string): void {
this.editingGroup.set(name);
this.editName.set(name);
}
cancelEdit(): void {
this.editingGroup.set(null);
}
saveEdit(oldName: string): void {
const trimmed = this.editName().trim();
if (!trimmed || trimmed === oldName) {
this.cancelEdit();
return;
}
this.error.set('');
this.success.set('');
this.quizService.updateGroup(oldName, trimmed).subscribe({
next: () => {
this.success.set('Group updated successfully!');
this.cancelEdit();
this.loadGroups();
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to update group');
}
});
}
deleteGroup(name: string): void {
if (confirm(`Are you sure you want to delete the group "${name}"?\nUsers dynamically mapped to this group will lose group context.`)) {
this.error.set('');
this.success.set('');
this.quizService.deleteGroup(name).subscribe({
next: () => {
this.success.set('Group deleted successfully!');
this.loadGroups();
},
error: (err) => {
this.error.set(err.error?.message || 'Failed to delete group');
}
});
}
}
}
......@@ -237,7 +237,8 @@
transition: color 0.2s;
}
.input-group input {
.input-group input,
.input-group select {
width: 100%;
padding: 13px 16px 13px 48px;
background: #f8f9fb;
......@@ -250,11 +251,17 @@
transition: all 0.2s ease;
}
.input-group select {
cursor: pointer;
appearance: auto;
}
.input-group input::placeholder {
color: #b0b8c4;
}
.input-group input:focus {
.input-group input:focus,
.input-group select:focus {
border-color: #4f6ef7;
background: #fff;
box-shadow: 0 0 0 3px rgba(79, 110, 247, 0.08);
......
......@@ -76,6 +76,25 @@
</div>
</div>
@if (availableGroups().length > 0) {
<div class="field">
<label for="group">Student Group (Optional)</label>
<div class="input-group">
<span class="material-symbols-rounded input-icon">group</span>
<select
id="group"
[(ngModel)]="group"
name="group"
>
<option value="" disabled selected>Select your group</option>
@for (grp of availableGroups(); track grp) {
<option [value]="grp">{{ grp }}</option>
}
</select>
</div>
</div>
}
<div class="fields-row">
<div class="field">
<label for="password">Password</label>
......
import { Component, signal } from '@angular/core';
import { Component, signal, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
......@@ -11,16 +11,25 @@ import { AuthService } from '../../services/auth.service';
templateUrl: './register.html',
styleUrl: './register.css'
})
export class RegisterComponent {
export class RegisterComponent implements OnInit {
name = '';
email = '';
password = '';
confirmPassword = '';
group = '';
availableGroups = signal<string[]>([]);
error = signal<string>('');
loading = signal<boolean>(false);
constructor(private authService: AuthService, private router: Router) {}
ngOnInit(): void {
this.authService.getRegistrationGroups().subscribe({
next: (res) => this.availableGroups.set(res.groups || []),
error: () => console.error('Failed to load groups for registration')
});
}
onSubmit(): void {
if (!this.name || !this.email || !this.password || !this.confirmPassword) {
this.error.set('Please fill in all fields');
......@@ -40,7 +49,7 @@ export class RegisterComponent {
this.loading.set(true);
this.error.set('');
this.authService.register(this.name, this.email, this.password).subscribe({
this.authService.register(this.name, this.email, this.password, this.group).subscribe({
next: () => {
this.loading.set(false);
alert('Registration successful! Please sign in.');
......
......@@ -39,8 +39,12 @@ export class AuthService {
}
}
register(name: string, email: string, password: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${this.apiUrl}/register`, { name, email, password });
getRegistrationGroups(): Observable<any> {
return this.http.get(`${this.apiUrl}/groups`);
}
register(name: string, email: string, password: string, group?: string): Observable<AuthResponse> {
return this.http.post<AuthResponse>(`${this.apiUrl}/register`, { name, email, password, group });
}
login(email: string, password: string): Observable<AuthResponse> {
......
......@@ -150,4 +150,33 @@
getResultDetails(submissionId: string): Observable<any> {
return this.http.get(`${this.candidateUrl}/results/${submissionId}`);
}
// ========== GENERIC GROUP MANAGEMENT ==========
private getBaseUrl(): string {
const userStr = sessionStorage.getItem('user');
if (userStr) {
try {
const user = JSON.parse(userStr);
if (user.role === 'hr') return this.hrUrl;
} catch (e) {}
}
return this.adminUrl;
}
createGroup(name: string): Observable<any> {
return this.http.post(`${this.getBaseUrl()}/groups`, { name });
}
getGroups(): Observable<any> {
return this.http.get(`${this.getBaseUrl()}/groups`);
}
updateGroup(oldName: string, newName: string): Observable<any> {
return this.http.put(`${this.getBaseUrl()}/groups/${encodeURIComponent(oldName)}`, { newName });
}
deleteGroup(name: string): Observable<any> {
return this.http.delete(`${this.getBaseUrl()}/groups/${encodeURIComponent(name)}`);
}
}
import { Injectable, signal } from '@angular/core';
@Injectable({
providedIn: 'root'
})
export class UiService {
showManageUsersPopup = signal<boolean>(false);
}
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment