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

feat : Edit profile option is now available

In candidate profile, now you can edit the name, phone number, gmail and add resume to the profile
In admin profile now you can preview the resume and
parent d0ad9642
...@@ -21,13 +21,23 @@ const storage = multer.diskStorage({ ...@@ -21,13 +21,23 @@ const storage = multer.diskStorage({
const fileFilter = (req, file, cb) => { const fileFilter = (req, file, cb) => {
const allowedTypes = [ const allowedTypes = [
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
'application/vnd.ms-excel' // .xls 'application/vnd.ms-excel', // .xls
'application/pdf', // .pdf
'application/msword', // .doc
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' // .docx
]; ];
if (allowedTypes.includes(file.mimetype) || file.originalname.endsWith('.xlsx') || file.originalname.endsWith('.xls')) { if (
allowedTypes.includes(file.mimetype) ||
file.originalname.endsWith('.xlsx') ||
file.originalname.endsWith('.xls') ||
file.originalname.endsWith('.pdf') ||
file.originalname.endsWith('.doc') ||
file.originalname.endsWith('.docx')
) {
cb(null, true); cb(null, true);
} else { } else {
cb(new Error('Only Excel files (.xlsx, .xls) are allowed'), false); cb(new Error('Only Excel, PDF, and Word files are allowed'), false);
} }
}; };
......
...@@ -14,6 +14,15 @@ const userSchema = new mongoose.Schema({ ...@@ -14,6 +14,15 @@ const userSchema = new mongoose.Schema({
lowercase: true, lowercase: true,
trim: true trim: true
}, },
phoneNumber: {
type: String,
trim: true,
default: ''
},
resume: {
type: String,
default: ''
},
password: { password: {
type: String, type: String,
required: [true, 'Password is required'], required: [true, 'Password is required'],
......
...@@ -4,6 +4,9 @@ const Quiz = require('../models/Quiz'); ...@@ -4,6 +4,9 @@ const Quiz = require('../models/Quiz');
const Question = require('../models/Question'); const Question = require('../models/Question');
const Submission = require('../models/Submission'); const Submission = require('../models/Submission');
const { protect, authorize } = require('../middleware/auth'); const { protect, authorize } = require('../middleware/auth');
const upload = require('../middleware/upload');
const fs = require('fs');
const path = require('path');
const router = express.Router(); const router = express.Router();
// All candidate routes require authentication + candidate role // All candidate routes require authentication + candidate role
...@@ -269,21 +272,41 @@ router.get('/profile', async (req, res) => { ...@@ -269,21 +272,41 @@ router.get('/profile', async (req, res) => {
}); });
// @route PUT /api/candidate/profile // @route PUT /api/candidate/profile
// @desc Update candidate profile (topics of interest) // @desc Update candidate profile (name, email, phone, resume, topics of interest)
// @access Candidate // @access Candidate
router.put('/profile', async (req, res) => { router.put('/profile', upload.single('resume'), async (req, res) => {
try { try {
const { topicsOfInterest } = req.body; const { name, email, phoneNumber, topicsOfInterest } = req.body;
if (topicsOfInterest && !Array.isArray(topicsOfInterest)) {
return res.status(400).json({ message: 'Topics of interest must be an array' });
}
const user = await User.findById(req.user._id); const user = await User.findById(req.user._id);
if (!user) return res.status(404).json({ message: 'User not found' }); if (!user) return res.status(404).json({ message: 'User not found' });
if (name) user.name = name;
if (email) user.email = email;
if (phoneNumber !== undefined) user.phoneNumber = phoneNumber;
if (topicsOfInterest) { if (topicsOfInterest) {
user.topicsOfInterest = topicsOfInterest; try {
// If sent as FormData, it will be a JSON string
const parsedTopics = typeof topicsOfInterest === 'string' ? JSON.parse(topicsOfInterest) : topicsOfInterest;
if (Array.isArray(parsedTopics)) {
user.topicsOfInterest = parsedTopics;
}
} catch (e) {
return res.status(400).json({ message: 'Invalid format for topics of interest' });
}
}
// Handle resume upload
if (req.file) {
// If there's an old resume, delete it from the file system
if (user.resume) {
const oldResumePath = path.join(__dirname, '..', 'uploads', path.basename(user.resume));
if (fs.existsSync(oldResumePath)) {
fs.unlinkSync(oldResumePath);
}
}
user.resume = `/uploads/${req.file.filename}`;
} }
await user.save(); await user.save();
...@@ -294,6 +317,8 @@ router.put('/profile', async (req, res) => { ...@@ -294,6 +317,8 @@ router.put('/profile', async (req, res) => {
_id: user._id, _id: user._id,
name: user.name, name: user.name,
email: user.email, email: user.email,
phoneNumber: user.phoneNumber,
resume: user.resume,
level: user.level, level: user.level,
topicsOfInterest: user.topicsOfInterest topicsOfInterest: user.topicsOfInterest
} }
...@@ -303,5 +328,36 @@ router.put('/profile', async (req, res) => { ...@@ -303,5 +328,36 @@ router.put('/profile', async (req, res) => {
} }
}); });
// @route DELETE /api/candidate/profile/resume
// @desc Delete candidate resume
// @access Candidate
router.delete('/profile/resume', async (req, res) => {
try {
const user = await User.findById(req.user._id);
if (!user) return res.status(404).json({ message: 'User not found' });
if (user.resume) {
const resumePath = path.join(__dirname, '..', 'uploads', path.basename(user.resume));
if (fs.existsSync(resumePath)) {
fs.unlinkSync(resumePath);
}
user.resume = '';
await user.save();
}
res.json({ message: 'Resume deleted successfully', user: {
_id: user._id,
name: user.name,
email: user.email,
phoneNumber: user.phoneNumber,
resume: user.resume,
level: user.level,
topicsOfInterest: user.topicsOfInterest
} });
} catch (error) {
res.status(500).json({ message: 'Server error', error: error.message });
}
});
module.exports = router; module.exports = router;
...@@ -47,6 +47,10 @@ ...@@ -47,6 +47,10 @@
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true }));
app.use(cookieParser()); app.use(cookieParser());
// Serve static files from uploads folder (for resumes, etc)
const path = require('path');
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// Routes // Routes
app.use('/api/auth', require('./routes/auth')); app.use('/api/auth', require('./routes/auth'));
app.use('/api/admin', require('./routes/admin')); app.use('/api/admin', require('./routes/admin'));
......
...@@ -343,4 +343,47 @@ ...@@ -343,4 +343,47 @@
.evaluation-summary button:hover { .evaluation-summary button:hover {
background-color: #dc2626; background-color: #dc2626;
color: white; color: white;
} }
\ No newline at end of file
/* Additional Styles for Contact Info & Resume */
.student-contact-info {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-top: 6px;
}
.contact-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.contact-item .material-symbols-rounded {
font-size: 16px;
color: var(--text-muted);
}
.student-resume-info {
margin-top: 14px;
}
.resume-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
}
.resume-badge:hover {
background: #667eea;
color: #fff;
}
.resume-badge .material-symbols-rounded {
font-size: 18px;
}
\ No newline at end of file
...@@ -16,7 +16,25 @@ ...@@ -16,7 +16,25 @@
<div class="student-avatar">{{ user().name?.charAt(0) }}</div> <div class="student-avatar">{{ user().name?.charAt(0) }}</div>
<div class="student-info"> <div class="student-info">
<h1>{{ user().name }}</h1> <h1>{{ user().name }}</h1>
<p>{{ user().email }}</p> <div class="student-contact-info">
<span class="contact-item">
<span class="material-symbols-rounded">mail</span> {{ user().email }}
</span>
@if (user().phoneNumber) {
<span class="contact-item">
<span class="material-symbols-rounded">call</span> {{ user().phoneNumber }}
</span>
}
</div>
@if (user().resume) {
<div class="student-resume-info">
<a [href]="getResumeUrl(user().resume)" target="_blank" class="resume-badge">
<span class="material-symbols-rounded">description</span>
View Resume
</a>
</div>
}
</div> </div>
</div> </div>
......
...@@ -95,4 +95,9 @@ export class UserHistoryComponent implements OnInit { ...@@ -95,4 +95,9 @@ export class UserHistoryComponent implements OnInit {
logout(): void { logout(): void {
this.authService.logout(); this.authService.logout();
} }
getResumeUrl(resumePath: string): string {
if (!resumePath) return '';
return `http://localhost:5000${resumePath}`;
}
} }
...@@ -251,3 +251,140 @@ ...@@ -251,3 +251,140 @@
align-items: stretch; align-items: stretch;
} }
} }
/* Edit Profile Modal & Actions */
.profile-header-main {
display: flex;
align-items: center;
gap: 24px;
}
.profile-actions {
margin-left: auto;
}
.profile-phone {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 14px;
margin: 0 0 6px;
}
.profile-email {
display: flex;
align-items: center;
gap: 6px;
}
.profile-email .material-symbols-rounded,
.profile-phone .material-symbols-rounded {
font-size: 16px;
color: var(--text-muted);
}
.resume-display {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.resume-link {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
padding: 6px 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
}
.resume-link:hover {
background: #667eea;
color: #fff;
}
.resume-link .material-symbols-rounded {
font-size: 18px;
}
.remove-resume-btn {
color: var(--danger);
background: rgba(239, 68, 68, 0.1);
}
.remove-resume-btn:hover {
background: var(--danger);
color: #fff;
}
.no-resume-text {
font-size: 13px;
color: var(--text-muted);
font-style: italic;
margin-top: 12px;
}
.modal-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
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;
}
.modal-container {
background: var(--bg-card);
width: 90%; max-width: 500px;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
border: 1px solid var(--border-color);
overflow: hidden;
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
display: flex; justify-content: space-between; align-items: center;
}
.modal-header h2 { font-size: 18px; font-weight: 600; margin: 0; }
.modal-body { padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex; justify-content: flex-end; gap: 12px;
background: var(--bg-hover);
}
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-label { font-size: 13px; font-weight: 600; color: var(--text-primary); }
.form-input {
width: 100%; padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px; background: var(--bg-input);
color: var(--text-primary); font-family: inherit; font-size: 14px;
transition: all 0.2s;
}
.form-input:focus { outline: none; border-color: var(--accent-primary); box-shadow: 0 0 0 3px rgba(102,126,234,0.1); }
.form-hint { font-size: 12px; color: var(--text-muted); margin-top: 4px; display: block; }
.file-upload-wrap {
border: 1px dashed var(--border-color);
border-radius: 8px;
padding: 10px;
background: rgba(0,0,0,0.02);
}
.file-input { border: none; padding: 0; background: transparent; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes slideUp { from { transform: translateY(20px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
...@@ -3,11 +3,43 @@ ...@@ -3,11 +3,43 @@
<div class="loading-center"><div class="spinner spinner-lg"></div><p class="loading-text">Loading profile...</p></div> <div class="loading-center"><div class="spinner spinner-lg"></div><p class="loading-text">Loading profile...</p></div>
} @else if (user()) { } @else if (user()) {
<div class="profile-header card card-padding"> <div class="profile-header card card-padding">
<div class="profile-header-main">
<div class="profile-avatar">{{ user().name?.charAt(0)?.toUpperCase() }}</div> <div class="profile-avatar">{{ user().name?.charAt(0)?.toUpperCase() }}</div>
<div class="profile-info"> <div class="profile-info">
<h1>{{ user().name }}</h1> <h1>{{ user().name }}</h1>
<p class="profile-email">{{ user().email }}</p> <p class="profile-email">
<span class="material-symbols-rounded">mail</span>
{{ user().email }}
</p>
@if (user().phoneNumber) {
<p class="profile-phone">
<span class="material-symbols-rounded">call</span>
{{ user().phoneNumber }}
</p>
}
<p class="profile-joined">Member since {{ user().createdAt | date:'mediumDate' }}</p> <p class="profile-joined">Member since {{ user().createdAt | date:'mediumDate' }}</p>
<!-- Resume Display -->
@if (user().resume) {
<div class="resume-display">
<a [href]="getResumeUrl()" target="_blank" class="resume-link">
<span class="material-symbols-rounded">description</span>
View Resume
</a>
<button class="btn btn-ghost btn-icon-only remove-resume-btn" (click)="deleteResume()" title="Remove Resume">
<span class="material-symbols-rounded">delete</span>
</button>
</div>
} @else {
<p class="no-resume-text">No resume uploaded</p>
}
</div>
</div>
<div class="profile-actions">
<button class="btn btn-primary" (click)="openEditProfile()">
<span class="material-symbols-rounded">edit</span>
Update Profile
</button>
</div> </div>
</div> </div>
...@@ -121,3 +153,50 @@ ...@@ -121,3 +153,50 @@
</div> </div>
} }
</div> </div>
<!-- Edit Profile Modal -->
@if (isEditingProfile()) {
<div class="modal-overlay" (click)="closeEditProfile()">
<div class="modal-container" (click)="$event.stopPropagation()">
<div class="modal-header">
<h2>Update Profile</h2>
<button class="icon-btn" (click)="closeEditProfile()">
<span class="material-symbols-rounded">close</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="form-label">Name</label>
<input class="form-input" [(ngModel)]="editName" placeholder="Enter full name">
</div>
<div class="form-group">
<label class="form-label">Email</label>
<input class="form-input" [(ngModel)]="editEmail" placeholder="Enter email address" type="email">
</div>
<div class="form-group">
<label class="form-label">Phone Number</label>
<input class="form-input" [(ngModel)]="editPhone" placeholder="Enter phone number" type="tel">
</div>
<div class="form-group">
<label class="form-label">Upload New Resume (PDF, DOC)</label>
<div class="file-upload-wrap">
<input type="file" (change)="onFileSelected($event)" accept=".pdf,.doc,.docx" class="form-input file-input">
</div>
@if (user()?.resume) {
<small class="form-hint">Uploading a new resume will replace your existing one.</small>
}
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" (click)="closeEditProfile()">Cancel</button>
<button class="btn btn-primary" (click)="saveProfile()" [disabled]="isSavingProfile()">
@if (isSavingProfile()) {
<span class="spinner spinner-sm"></span> Saving...
} @else {
Save Changes
}
</button>
</div>
</div>
</div>
}
...@@ -27,9 +27,16 @@ export class CandidateProfileComponent implements OnInit { ...@@ -27,9 +27,16 @@ export class CandidateProfileComponent implements OnInit {
// State for new topic form // State for new topic form
newTopicName = signal(''); newTopicName = signal('');
newTopicComfort = signal(50); newTopicComfort = signal(50);
isSavingTopics = signal(false); isSavingTopics = signal(false);
// Edit Profile State
isEditingProfile = signal(false);
isSavingProfile = signal(false);
editName = signal('');
editEmail = signal('');
editPhone = signal('');
selectedResume = signal<File | null>(null);
constructor(public authService: AuthService, private quizService: QuizService) {} constructor(public authService: AuthService, private quizService: QuizService) {}
ngOnInit(): void { ngOnInit(): void {
...@@ -56,6 +63,8 @@ export class CandidateProfileComponent implements OnInit { ...@@ -56,6 +63,8 @@ export class CandidateProfileComponent implements OnInit {
return Math.max(...subs.map(s => s.percentage)); return Math.max(...subs.map(s => s.percentage));
} }
// --- Topic Management ---
addTopic() { addTopic() {
const topic = this.newTopicName().trim(); const topic = this.newTopicName().trim();
if (!topic) return; if (!topic) return;
...@@ -78,9 +87,15 @@ export class CandidateProfileComponent implements OnInit { ...@@ -78,9 +87,15 @@ export class CandidateProfileComponent implements OnInit {
private saveTopics(updatedTopics: TopicOfInterest[]) { private saveTopics(updatedTopics: TopicOfInterest[]) {
this.isSavingTopics.set(true); this.isSavingTopics.set(true);
this.quizService.updateCandidateProfile({ topicsOfInterest: updatedTopics }).subscribe({
next: () => { // We send FormData now so topics need to be stringified
const formData = new FormData();
formData.append('topicsOfInterest', JSON.stringify(updatedTopics));
this.quizService.updateCandidateProfile(formData).subscribe({
next: (res) => {
this.isSavingTopics.set(false); this.isSavingTopics.set(false);
this.user.set(res.user);
}, },
error: () => { error: () => {
this.isSavingTopics.set(false); this.isSavingTopics.set(false);
...@@ -88,5 +103,84 @@ export class CandidateProfileComponent implements OnInit { ...@@ -88,5 +103,84 @@ export class CandidateProfileComponent implements OnInit {
} }
}); });
} }
}
// --- Profile Edit Management ---
openEditProfile() {
const currentUser = this.user();
if (currentUser) {
this.editName.set(currentUser.name || '');
this.editEmail.set(currentUser.email || '');
this.editPhone.set(currentUser.phoneNumber || '');
this.selectedResume.set(null);
this.isEditingProfile.set(true);
}
}
closeEditProfile() {
this.isEditingProfile.set(false);
this.selectedResume.set(null);
}
onFileSelected(event: any) {
const file = event.target.files[0];
if (file) {
this.selectedResume.set(file);
}
}
saveProfile() {
if (!this.editName().trim() || !this.editEmail().trim()) {
alert("Name and Email are required");
return;
}
this.isSavingProfile.set(true);
const formData = new FormData();
formData.append('name', this.editName());
formData.append('email', this.editEmail());
formData.append('phoneNumber', this.editPhone());
const resumeFile = this.selectedResume();
if (resumeFile) {
formData.append('resume', resumeFile);
}
this.quizService.updateCandidateProfile(formData).subscribe({
next: (res) => {
this.user.set(res.user);
this.isSavingProfile.set(false);
this.closeEditProfile();
},
error: (err) => {
console.error(err);
alert(err.error?.message || 'Failed to update profile');
this.isSavingProfile.set(false);
}
});
}
deleteResume() {
if (confirm('Are you sure you want to delete your uploaded resume?')) {
this.quizService.deleteCandidateResume().subscribe({
next: (res) => {
this.user.set(res.user);
},
error: (err) => {
console.error(err);
alert('Failed to delete resume');
}
});
}
}
getResumeUrl(): string {
const resume = this.user()?.resume;
if (!resume) return '';
// Assuming backend is running on 5000 and frontend on 4200 during dev
// For prod, relative path usually works.
// Let's use the API base URL logic or just relative path if proxied
return `http://localhost:5000${resume}`;
}
}
...@@ -326,3 +326,46 @@ ...@@ -326,3 +326,46 @@
.toast .material-symbols-rounded { .toast .material-symbols-rounded {
font-size: 20px; font-size: 20px;
} }
/* Additional Styles for Contact Info & Resume */
.student-contact-info {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
margin-top: 6px;
}
.contact-item {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.contact-item .material-symbols-rounded {
font-size: 16px;
color: var(--text-muted);
}
.student-resume-info {
margin-top: 14px;
}
.resume-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(102, 126, 234, 0.1);
color: #667eea;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
text-decoration: none;
transition: all 0.2s;
}
.resume-badge:hover {
background: #667eea;
color: #fff;
}
.resume-badge .material-symbols-rounded {
font-size: 18px;
}
...@@ -16,7 +16,25 @@ ...@@ -16,7 +16,25 @@
<div class="student-avatar">{{ user().name?.charAt(0) }}</div> <div class="student-avatar">{{ user().name?.charAt(0) }}</div>
<div class="student-info"> <div class="student-info">
<h1>{{ user().name }}</h1> <h1>{{ user().name }}</h1>
<p>{{ user().email }}</p> <div class="student-contact-info">
<span class="contact-item">
<span class="material-symbols-rounded">mail</span> {{ user().email }}
</span>
@if (user().phoneNumber) {
<span class="contact-item">
<span class="material-symbols-rounded">call</span> {{ user().phoneNumber }}
</span>
}
</div>
@if (user().resume) {
<div class="student-resume-info">
<a [href]="getResumeUrl(user().resume)" target="_blank" class="resume-badge">
<span class="material-symbols-rounded">description</span>
View Resume
</a>
</div>
}
</div> </div>
</div> </div>
......
...@@ -94,4 +94,9 @@ export class HRUserHistoryComponent { ...@@ -94,4 +94,9 @@ export class HRUserHistoryComponent {
logout(): void { logout(): void {
this.authService.logout(); this.authService.logout();
} }
getResumeUrl(resumePath: string): string {
if (!resumePath) return '';
return `http://localhost:5000${resumePath}`;
}
} }
...@@ -183,6 +183,10 @@ ...@@ -183,6 +183,10 @@
return this.http.put(`${this.candidateUrl}/profile`, data); return this.http.put(`${this.candidateUrl}/profile`, data);
} }
deleteCandidateResume(): Observable<any> {
return this.http.delete(`${this.candidateUrl}/profile/resume`);
}
getResultDetails(submissionId: string): Observable<any> { getResultDetails(submissionId: string): Observable<any> {
return this.http.get(`${this.candidateUrl}/results/${submissionId}`); return this.http.get(`${this.candidateUrl}/results/${submissionId}`);
} }
......
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