diff --git a/app/Actions/Interview/ScheduleInterviewAction.php b/app/Actions/Interview/ScheduleInterviewAction.php index 44fdb71..6e30b66 100644 --- a/app/Actions/Interview/ScheduleInterviewAction.php +++ b/app/Actions/Interview/ScheduleInterviewAction.php @@ -27,7 +27,14 @@ public function execute(ScheduleInterviewDto $dto, ?int $creatorId = null): Inte $scheduledAt = $dto->scheduledAt ? Carbon::parse($dto->scheduledAt) : now(); $expiresAt = $scheduledAt->copy()->addHours($dto->validHours); - $interview = DB::transaction(function () use ($dto, $tempPassword, $submissionUniqueId, $scheduledAt, $expiresAt, $creatorId) { + $resumePath = null; + if ($dto->resume) { + $resumePath = $dto->resume->store('interview_resumes', 'public'); + } elseif ($dto->resumePath) { + $resumePath = $dto->resumePath; + } + + $interview = DB::transaction(function () use ($dto, $tempPassword, $submissionUniqueId, $scheduledAt, $expiresAt, $creatorId, $resumePath) { return Interview::create([ 'candidate_name' => $dto->candidateName, 'candidate_email' => $dto->candidateEmail, @@ -35,6 +42,7 @@ public function execute(ScheduleInterviewDto $dto, ?int $creatorId = null): Inte 'job_title' => $dto->jobTitle, 'round' => $dto->round, 'description' => $dto->description, + 'resume_path' => $resumePath, 'temp_password' => $tempPassword, 'scheduled_at' => $scheduledAt, 'expires_at' => $expiresAt, diff --git a/app/DTOs/Interview/ScheduleInterviewDto.php b/app/DTOs/Interview/ScheduleInterviewDto.php index 52ab611..cf5e178 100644 --- a/app/DTOs/Interview/ScheduleInterviewDto.php +++ b/app/DTOs/Interview/ScheduleInterviewDto.php @@ -5,6 +5,7 @@ use App\Http\Requests\Interview\StoreInterviewRequest; use Carbon\Carbon; use Illuminate\Contracts\Support\Arrayable; +use Illuminate\Http\UploadedFile; use JsonSerializable; class ScheduleInterviewDto implements Arrayable, JsonSerializable @@ -23,6 +24,8 @@ public function __construct( public readonly string $language, public readonly array $assignedInterviewers = [], public readonly ?string $description = null, + public readonly ?UploadedFile $resume = null, + public readonly ?string $resumePath = null, ) {} public static function fromRequest(StoreInterviewRequest $request): self @@ -42,6 +45,7 @@ public static function fromRequest(StoreInterviewRequest $request): self language: $request->input('language', 'python'), assignedInterviewers: array_map('intval', (array) $request->input('assigned_interviewers', [])), description: $request->filled('description') ? trim($request->input('description')) : null, + resume: $request->file('resume'), ); } diff --git a/app/Http/Controllers/InterviewController.php b/app/Http/Controllers/InterviewController.php index 03a7321..d929531 100644 --- a/app/Http/Controllers/InterviewController.php +++ b/app/Http/Controllers/InterviewController.php @@ -1230,4 +1230,25 @@ public function generateReport($id) 'codeScore' )); } + + /** + * Serve / stream candidate resume file. + */ + public function viewResume($id) + { + $interview = Interview::findOrFail($id); + + if (! $interview->resume_path || ! Storage::disk('public')->exists($interview->resume_path)) { + abort(404, 'Candidate resume file not found.'); + } + + $filePath = Storage::disk('public')->path($interview->resume_path); + $mimeType = Storage::disk('public')->mimeType($interview->resume_path) ?: 'application/pdf'; + + return response()->file($filePath, [ + 'Content-Type' => $mimeType, + 'Content-Disposition' => 'inline; filename="'.basename($filePath).'"', + 'Cache-Control' => 'no-cache, private', + ]); + } } diff --git a/app/Http/Requests/Interview/StoreInterviewRequest.php b/app/Http/Requests/Interview/StoreInterviewRequest.php index 08a22c6..395d39c 100644 --- a/app/Http/Requests/Interview/StoreInterviewRequest.php +++ b/app/Http/Requests/Interview/StoreInterviewRequest.php @@ -32,6 +32,7 @@ public function rules(): array 'scheduled_at' => ['nullable', 'date'], 'valid_hours' => ['required', 'integer', 'min:1', 'max:72'], 'language' => ['required', 'string', 'in:python,cpp,c,java,php,javascript'], + 'resume' => ['nullable', 'file', 'mimes:pdf,doc,docx', 'max:10240'], 'assigned_interviewers' => ['nullable', 'array'], 'assigned_interviewers.*' => ['integer', 'exists:users,id'], ]; @@ -53,6 +54,8 @@ public function messages(): array 'valid_hours.required' => 'Access duration is required.', 'language.required' => 'Coding language selection is required.', 'language.in' => 'Selected coding language is not supported.', + 'resume.mimes' => 'The candidate resume must be a PDF or Word document (.pdf, .doc, .docx).', + 'resume.max' => 'The candidate resume size must not exceed 10MB.', 'assigned_interviewers.*.exists' => 'One or more selected interviewers are invalid.', ]; } diff --git a/app/Mail/InterviewerAssessmentNotificationMail.php b/app/Mail/InterviewerAssessmentNotificationMail.php index 3d08c63..74d4de4 100644 --- a/app/Mail/InterviewerAssessmentNotificationMail.php +++ b/app/Mail/InterviewerAssessmentNotificationMail.php @@ -11,6 +11,8 @@ use Illuminate\Mail\Mailables\Content; use Illuminate\Mail\Mailables\Envelope; use Illuminate\Queue\SerializesModels; +use Illuminate\Support\Facades\Storage; +use Illuminate\Support\Str; class InterviewerAssessmentNotificationMail extends Mailable implements ShouldQueue { @@ -58,9 +60,21 @@ public function content(): Content */ public function attachments(): array { - return [ + $attachments = [ Attachment::fromData(fn () => $this->icsContent, $this->icsFilename) ->withMime('text/calendar; charset=UTF-8; method=REQUEST'), ]; + + if (! empty($this->interview->resume_path) && Storage::disk('public')->exists($this->interview->resume_path)) { + $resumeFullPath = Storage::disk('public')->path($this->interview->resume_path); + $cleanCandidateName = Str::slug($this->interview->candidate_name ?: 'Candidate'); + $ext = pathinfo($this->interview->resume_path, PATHINFO_EXTENSION) ?: 'pdf'; + $downloadFilename = "Resume_{$cleanCandidateName}.{$ext}"; + + $attachments[] = Attachment::fromPath($resumeFullPath) + ->as($downloadFilename); + } + + return $attachments; } } diff --git a/app/Models/Interview.php b/app/Models/Interview.php index 0895d37..ee0173d 100644 --- a/app/Models/Interview.php +++ b/app/Models/Interview.php @@ -16,6 +16,7 @@ class Interview extends Model 'job_title', 'round', 'description', + 'resume_path', 'temp_password', 'scheduled_at', 'expires_at', @@ -191,4 +192,16 @@ public function assignedUsers() return User::whereIn('id', (array) $this->assigned_interviewers)->get(); } + + /** + * Get the URL to view the candidate's resume. + */ + public function getResumeUrlAttribute(): ?string + { + if (! $this->resume_path) { + return null; + } + + return route('interview.resume', $this->id); + } } diff --git a/resources/js/app.js b/resources/js/app.js index 56f8114..51eed41 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -89,3 +89,101 @@ window.copyInterviewCredentials = function (btn) { }); }; +// Candidate Resume Lightbox Previewer +window.openResumeLightbox = function (url, title = 'Candidate Resume') { + if (!url) return; + const modal = document.getElementById('resume-lightbox-modal'); + const frame = document.getElementById('resume-lightbox-frame'); + const loader = document.getElementById('resume-lightbox-loader'); + const titleEl = document.getElementById('resume-lightbox-title'); + const newTabBtn = document.getElementById('resume-lightbox-newtab'); + + if (!modal || !frame) return; + + if (titleEl) titleEl.textContent = title; + if (newTabBtn) newTabBtn.href = url; + + if (loader) loader.classList.remove('hidden'); + frame.classList.add('opacity-0'); + + frame.onload = function () { + if (loader) loader.classList.add('hidden'); + frame.classList.remove('opacity-0'); + }; + + frame.src = url; + modal.classList.add('active'); + + // Disable background page scrolling + if (typeof document !== 'undefined') { + document.body.classList.add('overflow-hidden'); + document.documentElement.classList.add('overflow-hidden'); + } +}; + +window.closeResumeLightbox = function (e = null) { + if (e && e.stopPropagation) { + e.stopPropagation(); + } + const modal = document.getElementById('resume-lightbox-modal'); + const frame = document.getElementById('resume-lightbox-frame'); + if (modal) modal.classList.remove('active'); + if (frame) frame.src = 'about:blank'; + + // Restore background page scrolling + if (typeof document !== 'undefined') { + document.body.classList.remove('overflow-hidden'); + document.documentElement.classList.remove('overflow-hidden'); + } +}; + +// Close on Escape key +if (typeof document !== 'undefined') { + document.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + window.closeResumeLightbox(); + } + }); +} + +// Drawer Candidate Resume Upload Handlers +window.handleDrawerResumeSelect = function (input) { + if (!input || !input.files || !input.files[0]) return; + const file = input.files[0]; + + const dropzone = document.getElementById('drawer-resume-dropzone'); + const selectedBadge = document.getElementById('drawer-resume-selected'); + const filenameEl = document.getElementById('drawer-resume-filename'); + const filesizeEl = document.getElementById('drawer-resume-filesize'); + + if (filenameEl) filenameEl.textContent = file.name; + if (filesizeEl) { + const sizeKb = file.size / 1024; + filesizeEl.textContent = sizeKb >= 1024 + ? (sizeKb / 1024).toFixed(2) + ' MB' + : sizeKb.toFixed(1) + ' KB'; + } + + if (dropzone) dropzone.classList.add('hidden'); + if (selectedBadge) selectedBadge.classList.remove('hidden'); +}; + +window.previewSelectedDrawerResume = function () { + const input = document.getElementById('drawer_resume_input'); + if (!input || !input.files || !input.files[0]) return; + const file = input.files[0]; + const objectUrl = URL.createObjectURL(file); + window.openResumeLightbox(objectUrl, `${file.name} (Uploaded Preview)`); +}; + +window.clearSelectedDrawerResume = function () { + const input = document.getElementById('drawer_resume_input'); + const dropzone = document.getElementById('drawer-resume-dropzone'); + const selectedBadge = document.getElementById('drawer-resume-selected'); + + if (input) input.value = ''; + if (selectedBadge) selectedBadge.classList.add('hidden'); + if (dropzone) dropzone.classList.remove('hidden'); +}; + + diff --git a/resources/js/interview-call.js b/resources/js/interview-call.js index 93cff5b..bd893ea 100644 --- a/resources/js/interview-call.js +++ b/resources/js/interview-call.js @@ -379,7 +379,7 @@ export function closeInspectorPanel() { const details = document.getElementById('inspector-details'); if (details) details.open = false; - const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots']; + const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots', 'resume']; tabs.forEach(t => { const contentEl = document.getElementById(`tab-content-${t}`); const btnEl = document.getElementById(`tab-btn-${t}`); @@ -389,6 +389,11 @@ export function closeInspectorPanel() { btnEl.classList.add('text-slate-400', 'hover:bg-white/5'); } }); + + const expandResumeBtn = document.getElementById('inspector-expand-resume-btn'); + if (expandResumeBtn) { + expandResumeBtn.style.display = 'none'; + } } export function switchIdeTab(tab, event = null) { @@ -413,24 +418,30 @@ export function switchIdeTab(tab, event = null) { if (details) details.open = true; - const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots']; + const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots', 'resume']; const tabMeta = { logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' }, notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' }, drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' }, recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' }, screenshots: { title: 'Tab Switch Screenshots', icon: 'fa-solid fa-image' }, - code: { title: 'Candidate Code', icon: 'fa-solid fa-code' } + code: { title: 'Candidate Code', icon: 'fa-solid fa-code' }, + resume: { title: 'Candidate Resume (CV)', icon: 'fa-solid fa-file-lines' } }; const floatingTitle = document.getElementById('inspector-floating-text'); const floatingIcon = document.getElementById('inspector-floating-icon'); + const expandResumeBtn = document.getElementById('inspector-expand-resume-btn'); if (tabMeta[tab]) { if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title; if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`; } + if (expandResumeBtn) { + expandResumeBtn.style.display = (tab === 'resume') ? 'inline-flex' : 'none'; + } + tabs.forEach(t => { const contentEl = document.getElementById(`tab-content-${t}`); const btnEl = document.getElementById(`tab-btn-${t}`); @@ -454,6 +465,15 @@ if (typeof document !== 'undefined') { document.addEventListener('click', function(e) { const details = document.getElementById('inspector-details'); if (details && details.open && !details.contains(e.target)) { + // If click was inside or on the resume lightbox modal or any other modal dialog, do NOT close inspector panel + const lightbox = document.getElementById('resume-lightbox-modal'); + if (lightbox && (lightbox.contains(e.target) || lightbox === e.target)) { + return; + } + const anyModal = e.target.closest('.admin-modal, [id*="modal"]'); + if (anyModal) { + return; + } closeInspectorPanel(); } }); diff --git a/resources/views/components/icons/cv.blade.php b/resources/views/components/icons/cv.blade.php new file mode 100644 index 0000000..c31e0fd --- /dev/null +++ b/resources/views/components/icons/cv.blade.php @@ -0,0 +1,7 @@ +@props([ + 'class' => 'w-3.5 h-3.5', +]) + +merge(['class' => $class]) }} viewBox="0 0 43.916 43.916" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> + + diff --git a/resources/views/components/interview/inspector-panel.blade.php b/resources/views/components/interview/inspector-panel.blade.php index 217784f..da71d43 100644 --- a/resources/views/components/interview/inspector-panel.blade.php +++ b/resources/views/components/interview/inspector-panel.blade.php @@ -58,6 +58,15 @@ class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold > + @@ -70,15 +79,26 @@ class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold Proctoring Logs - +
+ + +
@@ -90,6 +110,7 @@ class="w-6 h-6 rounded-md bg-white/5 hover:bg-white/15 text-slate-400 hover:text + diff --git a/resources/views/components/interview/resume-lightbox-modal.blade.php b/resources/views/components/interview/resume-lightbox-modal.blade.php new file mode 100644 index 0000000..b0b995b --- /dev/null +++ b/resources/views/components/interview/resume-lightbox-modal.blade.php @@ -0,0 +1,65 @@ +
+ +
+ + +
+ +
+
+
+ +
+
+

Candidate Resume (CV)

+
PDF & Document Lightbox Previewer
+
+
+ +
+ + + + + + + + +
+
+ + +
+ +
+ + Loading resume preview… +
+ + + +
+
+
diff --git a/resources/views/components/interview/schedule-drawer.blade.php b/resources/views/components/interview/schedule-drawer.blade.php index 6f806f9..a36b91e 100644 --- a/resources/views/components/interview/schedule-drawer.blade.php +++ b/resources/views/components/interview/schedule-drawer.blade.php @@ -13,6 +13,7 @@ $errorBag->has('scheduled_at') || $errorBag->has('valid_hours') || $errorBag->has('language') || + $errorBag->has('resume') || $errorBag->has('assigned_interviewers') || $errorBag->has('assigned_interviewers.*') || $errorBag->has('description') @@ -26,7 +27,7 @@ width="lg" class="{{ $hasInterviewErrors ? 'active' : '' }}" > -
+ @csrf @if($hasInterviewErrors) @@ -140,6 +141,80 @@ class="{{ $hasInterviewErrors ? 'active' : '' }}" +
+ Candidate Resume (PDF, DOCX) +
+ +
+
+ +
+
+ Click to upload or drag and drop +
+

PDF, DOC, DOCX (Max 10MB)

+
+ + + + + + + @if($errorBag->has('resume')) +

+ {{ $errorBag->first('resume') }} +

+ @endif +
+
+
Additional Instructions to candidate diff --git a/resources/views/components/interview/tabs/resume-tab.blade.php b/resources/views/components/interview/tabs/resume-tab.blade.php new file mode 100644 index 0000000..c4a6be2 --- /dev/null +++ b/resources/views/components/interview/tabs/resume-tab.blade.php @@ -0,0 +1,24 @@ +@props([ + 'interview', +]) + + diff --git a/resources/views/components/layouts/interview.blade.php b/resources/views/components/layouts/interview.blade.php index 655d366..8e19a96 100644 --- a/resources/views/components/layouts/interview.blade.php +++ b/resources/views/components/layouts/interview.blade.php @@ -44,6 +44,9 @@ + + +