feat(interview): add candidate resume upload, lightbox viewer, and panelist email attachments
- Add resume file upload to schedule drawer with instant preview and validation - Create resume lightbox modal viewer with background scroll lock and CV SVG icon - Integrate CV tab into inspector panel with enlarge action and state - Attach candidate resume to panelist notification emails when - Fix Outlook RFC 5546 iTIP element mismatch and add table credentials copy button
This commit is contained in:
parent
e4fabef1d3
commit
1541c8193e
@ -27,7 +27,14 @@ public function execute(ScheduleInterviewDto $dto, ?int $creatorId = null): Inte
|
|||||||
$scheduledAt = $dto->scheduledAt ? Carbon::parse($dto->scheduledAt) : now();
|
$scheduledAt = $dto->scheduledAt ? Carbon::parse($dto->scheduledAt) : now();
|
||||||
$expiresAt = $scheduledAt->copy()->addHours($dto->validHours);
|
$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([
|
return Interview::create([
|
||||||
'candidate_name' => $dto->candidateName,
|
'candidate_name' => $dto->candidateName,
|
||||||
'candidate_email' => $dto->candidateEmail,
|
'candidate_email' => $dto->candidateEmail,
|
||||||
@ -35,6 +42,7 @@ public function execute(ScheduleInterviewDto $dto, ?int $creatorId = null): Inte
|
|||||||
'job_title' => $dto->jobTitle,
|
'job_title' => $dto->jobTitle,
|
||||||
'round' => $dto->round,
|
'round' => $dto->round,
|
||||||
'description' => $dto->description,
|
'description' => $dto->description,
|
||||||
|
'resume_path' => $resumePath,
|
||||||
'temp_password' => $tempPassword,
|
'temp_password' => $tempPassword,
|
||||||
'scheduled_at' => $scheduledAt,
|
'scheduled_at' => $scheduledAt,
|
||||||
'expires_at' => $expiresAt,
|
'expires_at' => $expiresAt,
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
use App\Http\Requests\Interview\StoreInterviewRequest;
|
use App\Http\Requests\Interview\StoreInterviewRequest;
|
||||||
use Carbon\Carbon;
|
use Carbon\Carbon;
|
||||||
use Illuminate\Contracts\Support\Arrayable;
|
use Illuminate\Contracts\Support\Arrayable;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use JsonSerializable;
|
use JsonSerializable;
|
||||||
|
|
||||||
class ScheduleInterviewDto implements Arrayable, JsonSerializable
|
class ScheduleInterviewDto implements Arrayable, JsonSerializable
|
||||||
@ -23,6 +24,8 @@ public function __construct(
|
|||||||
public readonly string $language,
|
public readonly string $language,
|
||||||
public readonly array $assignedInterviewers = [],
|
public readonly array $assignedInterviewers = [],
|
||||||
public readonly ?string $description = null,
|
public readonly ?string $description = null,
|
||||||
|
public readonly ?UploadedFile $resume = null,
|
||||||
|
public readonly ?string $resumePath = null,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public static function fromRequest(StoreInterviewRequest $request): self
|
public static function fromRequest(StoreInterviewRequest $request): self
|
||||||
@ -42,6 +45,7 @@ public static function fromRequest(StoreInterviewRequest $request): self
|
|||||||
language: $request->input('language', 'python'),
|
language: $request->input('language', 'python'),
|
||||||
assignedInterviewers: array_map('intval', (array) $request->input('assigned_interviewers', [])),
|
assignedInterviewers: array_map('intval', (array) $request->input('assigned_interviewers', [])),
|
||||||
description: $request->filled('description') ? trim($request->input('description')) : null,
|
description: $request->filled('description') ? trim($request->input('description')) : null,
|
||||||
|
resume: $request->file('resume'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1230,4 +1230,25 @@ public function generateReport($id)
|
|||||||
'codeScore'
|
'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',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,7 @@ public function rules(): array
|
|||||||
'scheduled_at' => ['nullable', 'date'],
|
'scheduled_at' => ['nullable', 'date'],
|
||||||
'valid_hours' => ['required', 'integer', 'min:1', 'max:72'],
|
'valid_hours' => ['required', 'integer', 'min:1', 'max:72'],
|
||||||
'language' => ['required', 'string', 'in:python,cpp,c,java,php,javascript'],
|
'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' => ['nullable', 'array'],
|
||||||
'assigned_interviewers.*' => ['integer', 'exists:users,id'],
|
'assigned_interviewers.*' => ['integer', 'exists:users,id'],
|
||||||
];
|
];
|
||||||
@ -53,6 +54,8 @@ public function messages(): array
|
|||||||
'valid_hours.required' => 'Access duration is required.',
|
'valid_hours.required' => 'Access duration is required.',
|
||||||
'language.required' => 'Coding language selection is required.',
|
'language.required' => 'Coding language selection is required.',
|
||||||
'language.in' => 'Selected coding language is not supported.',
|
'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.',
|
'assigned_interviewers.*.exists' => 'One or more selected interviewers are invalid.',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
use Illuminate\Mail\Mailables\Content;
|
use Illuminate\Mail\Mailables\Content;
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
class InterviewerAssessmentNotificationMail extends Mailable implements ShouldQueue
|
class InterviewerAssessmentNotificationMail extends Mailable implements ShouldQueue
|
||||||
{
|
{
|
||||||
@ -58,9 +60,21 @@ public function content(): Content
|
|||||||
*/
|
*/
|
||||||
public function attachments(): array
|
public function attachments(): array
|
||||||
{
|
{
|
||||||
return [
|
$attachments = [
|
||||||
Attachment::fromData(fn () => $this->icsContent, $this->icsFilename)
|
Attachment::fromData(fn () => $this->icsContent, $this->icsFilename)
|
||||||
->withMime('text/calendar; charset=UTF-8; method=REQUEST'),
|
->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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class Interview extends Model
|
|||||||
'job_title',
|
'job_title',
|
||||||
'round',
|
'round',
|
||||||
'description',
|
'description',
|
||||||
|
'resume_path',
|
||||||
'temp_password',
|
'temp_password',
|
||||||
'scheduled_at',
|
'scheduled_at',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
@ -191,4 +192,16 @@ public function assignedUsers()
|
|||||||
|
|
||||||
return User::whereIn('id', (array) $this->assigned_interviewers)->get();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -379,7 +379,7 @@ export function closeInspectorPanel() {
|
|||||||
const details = document.getElementById('inspector-details');
|
const details = document.getElementById('inspector-details');
|
||||||
if (details) details.open = false;
|
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 => {
|
tabs.forEach(t => {
|
||||||
const contentEl = document.getElementById(`tab-content-${t}`);
|
const contentEl = document.getElementById(`tab-content-${t}`);
|
||||||
const btnEl = document.getElementById(`tab-btn-${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');
|
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) {
|
export function switchIdeTab(tab, event = null) {
|
||||||
@ -413,24 +418,30 @@ export function switchIdeTab(tab, event = null) {
|
|||||||
|
|
||||||
if (details) details.open = true;
|
if (details) details.open = true;
|
||||||
|
|
||||||
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots'];
|
const tabs = ['logs', 'code', 'notes', 'drawing', 'recordings', 'screenshots', 'resume'];
|
||||||
const tabMeta = {
|
const tabMeta = {
|
||||||
logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' },
|
logs: { title: 'Proctoring Logs', icon: 'fa-solid fa-shield-halved' },
|
||||||
notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' },
|
notes: { title: 'Candidate Notepad', icon: 'fa-solid fa-note-sticky' },
|
||||||
drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' },
|
drawing: { title: 'Candidate Drawing Board', icon: 'fa-solid fa-pen' },
|
||||||
recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' },
|
recordings: { title: 'Saved Video Recordings', icon: 'fa-solid fa-film' },
|
||||||
screenshots: { title: 'Tab Switch Screenshots', icon: 'fa-solid fa-image' },
|
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 floatingTitle = document.getElementById('inspector-floating-text');
|
||||||
const floatingIcon = document.getElementById('inspector-floating-icon');
|
const floatingIcon = document.getElementById('inspector-floating-icon');
|
||||||
|
const expandResumeBtn = document.getElementById('inspector-expand-resume-btn');
|
||||||
|
|
||||||
if (tabMeta[tab]) {
|
if (tabMeta[tab]) {
|
||||||
if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title;
|
if (floatingTitle) floatingTitle.innerText = tabMeta[tab].title;
|
||||||
if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`;
|
if (floatingIcon) floatingIcon.className = `${tabMeta[tab].icon} text-indigo-400`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (expandResumeBtn) {
|
||||||
|
expandResumeBtn.style.display = (tab === 'resume') ? 'inline-flex' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
tabs.forEach(t => {
|
tabs.forEach(t => {
|
||||||
const contentEl = document.getElementById(`tab-content-${t}`);
|
const contentEl = document.getElementById(`tab-content-${t}`);
|
||||||
const btnEl = document.getElementById(`tab-btn-${t}`);
|
const btnEl = document.getElementById(`tab-btn-${t}`);
|
||||||
@ -454,6 +465,15 @@ if (typeof document !== 'undefined') {
|
|||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
const details = document.getElementById('inspector-details');
|
const details = document.getElementById('inspector-details');
|
||||||
if (details && details.open && !details.contains(e.target)) {
|
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();
|
closeInspectorPanel();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
7
resources/views/components/icons/cv.blade.php
Normal file
7
resources/views/components/icons/cv.blade.php
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
@props([
|
||||||
|
'class' => 'w-3.5 h-3.5',
|
||||||
|
])
|
||||||
|
|
||||||
|
<svg {{ $attributes->merge(['class' => $class]) }} viewBox="0 0 43.916 43.916" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M34.395,0H9.522c-2.762,0-5,2.239-5,5v33.916c0,2.761,2.238,5,5,5h24.871c2.762,0,5-2.239,5-5V5 C39.395,2.239,37.154,0,34.395,0z M9.208,16.855c0-1.172,0.951-2.121,2.121-2.121h0.742c-0.791-0.874-1.277-2.03-1.277-3.304 c0-2.723,2.209-4.931,4.932-4.931c2.725,0,4.932,2.207,4.932,4.932c0,1.272-0.486,2.429-1.279,3.303h0.709 c1.172,0,2.121,0.949,2.121,2.121v3.578c0,1.122-0.875,2.03-1.975,2.106h-9.051c-1.1-0.076-1.975-0.984-1.975-2.106V16.855 L9.208,16.855z M32.708,37.416h-21.5c-1.104,0-2-0.896-2-2s0.896-2,2-2h21.5c1.104,0,2,0.896,2,2S33.812,37.416,32.708,37.416z M32.708,29.916h-21.5c-1.104,0-2-0.896-2-2s0.896-2,2-2h21.5c1.104,0,2,0.896,2,2S33.812,29.916,32.708,29.916z M32.708,22.416 h-6.5c-1.104,0-2-0.896-2-2c0-1.104,0.896-2,2-2h6.5c1.104,0,2,0.896,2,2C34.708,21.52,33.812,22.416,32.708,22.416z"/>
|
||||||
|
</svg>
|
||||||
@ -58,6 +58,15 @@ class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold
|
|||||||
>
|
>
|
||||||
<i class="fa-solid fa-image"></i>
|
<i class="fa-solid fa-image"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="tab-btn-resume"
|
||||||
|
title="Candidate CV / Resume"
|
||||||
|
onclick="switchIdeTab('resume', event)"
|
||||||
|
class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold transition-all cursor-pointer text-slate-400 hover:text-white hover:bg-white/5"
|
||||||
|
>
|
||||||
|
<x-icons.cv class="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
@ -70,15 +79,26 @@ class="w-7 h-7 flex items-center justify-center rounded-md text-xs font-semibold
|
|||||||
<i id="inspector-floating-icon" class="fa-solid fa-shield-halved text-indigo-400"></i>
|
<i id="inspector-floating-icon" class="fa-solid fa-shield-halved text-indigo-400"></i>
|
||||||
<span id="inspector-floating-text">Proctoring Logs</span>
|
<span id="inspector-floating-text">Proctoring Logs</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-1.5">
|
||||||
type="button"
|
<button
|
||||||
id="inspector-close-btn"
|
type="button"
|
||||||
title="Close Inspector"
|
id="inspector-expand-resume-btn"
|
||||||
onclick="closeInspectorPanel()"
|
title="View Resume in Large Dialog"
|
||||||
class="w-6 h-6 rounded-md bg-white/5 hover:bg-white/15 text-slate-400 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors"
|
onclick="openResumeLightbox('{{ $interview->resume_url }}', '{{ addslashes($interview->candidate_name) }} - Resume')"
|
||||||
>
|
class="hidden w-6 h-6 rounded-md bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors"
|
||||||
<i class="fa-solid fa-xmark"></i>
|
>
|
||||||
</button>
|
<i class="fa-solid fa-eye text-[11px]"></i>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="inspector-close-btn"
|
||||||
|
title="Close Inspector"
|
||||||
|
onclick="closeInspectorPanel()"
|
||||||
|
class="w-6 h-6 rounded-md bg-white/5 hover:bg-white/15 text-slate-400 hover:text-white flex items-center justify-center cursor-pointer text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Floating Tab Content Panes -->
|
<!-- Floating Tab Content Panes -->
|
||||||
@ -90,6 +110,7 @@ class="w-6 h-6 rounded-md bg-white/5 hover:bg-white/15 text-slate-400 hover:text
|
|||||||
<x-interview.tabs.drawing-tab />
|
<x-interview.tabs.drawing-tab />
|
||||||
<x-interview.tabs.recordings-tab />
|
<x-interview.tabs.recordings-tab />
|
||||||
<x-interview.tabs.screenshots-tab />
|
<x-interview.tabs.screenshots-tab />
|
||||||
|
<x-interview.tabs.resume-tab :interview="$interview" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@ -0,0 +1,65 @@
|
|||||||
|
<div
|
||||||
|
id="resume-lightbox-modal"
|
||||||
|
class="admin-modal fixed inset-0 w-screen h-screen bg-slate-950/85 backdrop-blur-md flex items-center justify-center z-[1050] opacity-0 pointer-events-none transition-opacity duration-300 [&.active]:opacity-100 [&.active]:pointer-events-auto p-4 sm:p-6"
|
||||||
|
>
|
||||||
|
<!-- Background Backdrop -->
|
||||||
|
<div class="fixed inset-0" onclick="closeResumeLightbox(event)"></div>
|
||||||
|
|
||||||
|
<!-- Modal Content Card -->
|
||||||
|
<div class="admin-modal-content relative z-10 bg-slate-900 border border-slate-700/70 rounded-2xl w-full max-w-5xl h-[92vh] max-h-[92vh] p-4 sm:p-5 shadow-2xl text-white flex flex-col overflow-hidden">
|
||||||
|
<!-- Lightbox Header -->
|
||||||
|
<div class="flex items-center justify-between pb-3 mb-3 border-b border-slate-700/60 shrink-0">
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 border border-indigo-500/30 flex items-center justify-center text-indigo-400 shrink-0">
|
||||||
|
<x-icons.cv class="w-4 h-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h3 id="resume-lightbox-title" class="font-outfit text-base font-bold text-white m-0 truncate">Candidate Resume (CV)</h3>
|
||||||
|
<div class="text-[11px] text-slate-400">PDF & Document Lightbox Previewer</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 shrink-0">
|
||||||
|
<!-- Open in New Tab / Download -->
|
||||||
|
<a
|
||||||
|
id="resume-lightbox-newtab"
|
||||||
|
href="#"
|
||||||
|
target="_blank"
|
||||||
|
download
|
||||||
|
title="Open in new tab / Download"
|
||||||
|
class="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-white/5 border border-slate-700/60 text-slate-300 hover:text-white hover:bg-white/10 text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-arrow-up-right-from-square text-[11px]"></i>
|
||||||
|
<span class="hidden sm:inline">Open in Tab</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Close Lightbox Button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="closeResumeLightbox(event)"
|
||||||
|
title="Close preview"
|
||||||
|
class="w-8 h-8 rounded-lg bg-slate-800 border border-slate-700/60 text-slate-400 hover:text-white hover:bg-slate-700 transition-colors flex items-center justify-center cursor-pointer text-sm"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightbox Preview Body -->
|
||||||
|
<div class="relative flex-1 w-full h-full bg-slate-950/80 rounded-xl overflow-hidden flex items-center justify-center border border-slate-800">
|
||||||
|
<!-- Spinner / Loader -->
|
||||||
|
<div id="resume-lightbox-loader" class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-slate-950/90 z-10 transition-opacity">
|
||||||
|
<i class="fa-solid fa-circle-notch fa-spin text-indigo-400 text-3xl"></i>
|
||||||
|
<span class="text-xs text-slate-400 font-medium">Loading resume preview…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Embedded Document Frame -->
|
||||||
|
<iframe
|
||||||
|
id="resume-lightbox-frame"
|
||||||
|
src="about:blank"
|
||||||
|
class="w-full h-full border-0 rounded-xl opacity-0 transition-opacity duration-300"
|
||||||
|
title="Resume Preview"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -13,6 +13,7 @@
|
|||||||
$errorBag->has('scheduled_at') ||
|
$errorBag->has('scheduled_at') ||
|
||||||
$errorBag->has('valid_hours') ||
|
$errorBag->has('valid_hours') ||
|
||||||
$errorBag->has('language') ||
|
$errorBag->has('language') ||
|
||||||
|
$errorBag->has('resume') ||
|
||||||
$errorBag->has('assigned_interviewers') ||
|
$errorBag->has('assigned_interviewers') ||
|
||||||
$errorBag->has('assigned_interviewers.*') ||
|
$errorBag->has('assigned_interviewers.*') ||
|
||||||
$errorBag->has('description')
|
$errorBag->has('description')
|
||||||
@ -26,7 +27,7 @@
|
|||||||
width="lg"
|
width="lg"
|
||||||
class="{{ $hasInterviewErrors ? 'active' : '' }}"
|
class="{{ $hasInterviewErrors ? 'active' : '' }}"
|
||||||
>
|
>
|
||||||
<form action="{{ route('interview.store') }}" method="POST">
|
<form action="{{ route('interview.store') }}" method="POST" enctype="multipart/form-data">
|
||||||
@csrf
|
@csrf
|
||||||
|
|
||||||
@if($hasInterviewErrors)
|
@if($hasInterviewErrors)
|
||||||
@ -140,6 +141,80 @@ class="{{ $hasInterviewErrors ? 'active' : '' }}"
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-label>Candidate Resume (PDF, DOCX)</x-label>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<!-- Dropzone / File Picker Container -->
|
||||||
|
<div
|
||||||
|
id="drawer-resume-dropzone"
|
||||||
|
onclick="document.getElementById('drawer_resume_input').click()"
|
||||||
|
class="border-2 border-dashed @if($errorBag->has('resume')) border-rose-500/80 bg-rose-500/5 @else border-slate-700/80 hover:border-indigo-500/50 bg-slate-900/60 hover:bg-slate-900 @endif rounded-xl p-3 text-center cursor-pointer transition-all flex flex-col items-center justify-center gap-1"
|
||||||
|
>
|
||||||
|
<div class="w-7 h-7 rounded-full bg-indigo-500/10 text-indigo-400 flex items-center justify-center text-xs">
|
||||||
|
<i class="fa-solid fa-cloud-arrow-up"></i>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-300 font-medium">
|
||||||
|
<span class="text-indigo-400 font-semibold">Click to upload</span> or drag and drop
|
||||||
|
</div>
|
||||||
|
<p class="text-[11px] text-slate-500 m-0">PDF, DOC, DOCX (Max 10MB)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
id="drawer_resume_input"
|
||||||
|
name="resume"
|
||||||
|
accept=".pdf,.doc,.docx,application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||||
|
class="hidden"
|
||||||
|
onchange="handleDrawerResumeSelect(this)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Selected File Info & Preview Badge -->
|
||||||
|
<div
|
||||||
|
id="drawer-resume-selected"
|
||||||
|
class="hidden bg-slate-900 border border-slate-700/80 rounded-xl p-2.5 flex items-center justify-between gap-2"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2.5 min-w-0">
|
||||||
|
<div class="w-8 h-8 rounded-lg bg-indigo-500/20 text-indigo-400 flex items-center justify-center shrink-0">
|
||||||
|
<x-icons.cv class="w-4 h-4 text-indigo-400" />
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p id="drawer-resume-filename" class="text-xs font-semibold text-white truncate m-0">resume.pdf</p>
|
||||||
|
<span id="drawer-resume-filesize" class="text-[10.5px] text-slate-400 font-mono">0 KB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<!-- Eye icon preview button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id="drawer-resume-preview-btn"
|
||||||
|
onclick="previewSelectedDrawerResume()"
|
||||||
|
title="Preview Resume"
|
||||||
|
class="w-7 h-7 rounded-md bg-indigo-500/20 hover:bg-indigo-500/30 text-indigo-300 hover:text-white flex items-center justify-center text-xs transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-eye text-[11px]"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Remove file button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick="clearSelectedDrawerResume()"
|
||||||
|
title="Remove file"
|
||||||
|
class="w-7 h-7 rounded-md bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 flex items-center justify-center text-xs transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<i class="fa-solid fa-trash text-[11px]"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if($errorBag->has('resume'))
|
||||||
|
<p class="text-[11px] text-rose-400 mt-1 font-medium flex items-center gap-1">
|
||||||
|
<i class="fa-solid fa-circle-exclamation text-[10px]"></i> {{ $errorBag->first('resume') }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<x-label>Additional Instructions to candidate</x-label>
|
<x-label>Additional Instructions to candidate</x-label>
|
||||||
<textarea name="description" rows="2" class="w-full bg-slate-900 border @if($errorBag->has('description')) border-rose-500/80 focus:border-rose-500 @else border-slate-700/60 focus:border-indigo-500 @endif rounded-xl p-3 text-xs text-white placeholder-slate-500 focus:outline-none transition-colors" placeholder="Special requirements, interview topics, or instructions...">{{ old('description') }}</textarea>
|
<textarea name="description" rows="2" class="w-full bg-slate-900 border @if($errorBag->has('description')) border-rose-500/80 focus:border-rose-500 @else border-slate-700/60 focus:border-indigo-500 @endif rounded-xl p-3 text-xs text-white placeholder-slate-500 focus:outline-none transition-colors" placeholder="Special requirements, interview topics, or instructions...">{{ old('description') }}</textarea>
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
@props([
|
||||||
|
'interview',
|
||||||
|
])
|
||||||
|
|
||||||
|
<div id="tab-content-resume" class="hidden space-y-2.5">
|
||||||
|
@if(!empty($interview->resume_path))
|
||||||
|
<div class="relative rounded-xl border border-slate-800 overflow-hidden bg-slate-950/80">
|
||||||
|
<iframe
|
||||||
|
src="{{ $interview->resume_url }}"
|
||||||
|
class="w-full h-80 border-0 rounded-xl"
|
||||||
|
title="Candidate Resume Preview"
|
||||||
|
></iframe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@else
|
||||||
|
<div class="p-8 text-center bg-slate-950/50 border border-slate-800/80 rounded-xl">
|
||||||
|
<div class="w-10 h-10 mx-auto rounded-full bg-slate-800/60 flex items-center justify-center text-slate-500 mb-2.5">
|
||||||
|
<x-icons.cv class="w-5 h-5 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<p class="text-xs font-semibold text-slate-300 m-0">No Resume Attached</p>
|
||||||
|
<p class="text-[11px] text-slate-500 mt-1 mb-0">No resume document was uploaded when scheduling this assessment.</p>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
@ -44,6 +44,9 @@
|
|||||||
<!-- Global shared Incoming Call Modal -->
|
<!-- Global shared Incoming Call Modal -->
|
||||||
<x-interview.incoming-call-modal />
|
<x-interview.incoming-call-modal />
|
||||||
|
|
||||||
|
<!-- Global shared Candidate Resume Lightbox Modal -->
|
||||||
|
<x-interview.resume-lightbox-modal />
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }};
|
window.MAX_INTERVIEWERS = {{ config('interview.max_interviewer', 4) }};
|
||||||
window.currentUserId = {{ Auth::id() ?? 'null' }};
|
window.currentUserId = {{ Auth::id() ?? 'null' }};
|
||||||
|
|||||||
@ -155,6 +155,12 @@
|
|||||||
<span class="card-label">Submission ID</span>
|
<span class="card-label">Submission ID</span>
|
||||||
<span class="card-value" style="font-family: monospace; font-size: 11px;">{{ $interview->submission_unique_id }}</span>
|
<span class="card-value" style="font-family: monospace; font-size: 11px;">{{ $interview->submission_unique_id }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
@if(!empty($interview->resume_path))
|
||||||
|
<div class="card-row">
|
||||||
|
<span class="card-label">Candidate Resume</span>
|
||||||
|
<span class="card-value" style="color: #a5b4fc;">📎 Attached to Email</span>
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if(!empty($interview->description))
|
@if(!empty($interview->description))
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\AdminController;
|
use App\Http\Controllers\AdminController;
|
||||||
use App\Http\Controllers\DashboardController;
|
use App\Http\Controllers\DashboardController;
|
||||||
|
use App\Http\Controllers\IceServerController;
|
||||||
use App\Http\Controllers\InterviewController;
|
use App\Http\Controllers\InterviewController;
|
||||||
use App\Http\Controllers\LoginController;
|
use App\Http\Controllers\LoginController;
|
||||||
use App\Http\Controllers\OnboardingController;
|
use App\Http\Controllers\OnboardingController;
|
||||||
use App\Http\Controllers\IceServerController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
// Authentication & Portal landing
|
// Authentication & Portal landing
|
||||||
@ -15,7 +15,7 @@
|
|||||||
// Control Panel Login
|
// Control Panel Login
|
||||||
Route::get('/controlpannel/login', [LoginController::class, 'showAdminLoginForm'])->name('admin.login');
|
Route::get('/controlpannel/login', [LoginController::class, 'showAdminLoginForm'])->name('admin.login');
|
||||||
Route::post('/controlpannel/login', [LoginController::class, 'adminLogin'])->name('admin.login.submit');
|
Route::post('/controlpannel/login', [LoginController::class, 'adminLogin'])->name('admin.login.submit');
|
||||||
Route::get('/admin/login', fn() => redirect()->route('admin.login'));
|
Route::get('/admin/login', fn () => redirect()->route('admin.login'));
|
||||||
|
|
||||||
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
|
||||||
Route::get('/logout', [LoginController::class, 'logout']); // For ease of development/testing via GET
|
Route::get('/logout', [LoginController::class, 'logout']); // For ease of development/testing via GET
|
||||||
@ -47,7 +47,6 @@
|
|||||||
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
|
Route::get('/storage/{path}', [InterviewController::class, 'serveStorageFile'])->where('path', '.*')->name('storage.file');
|
||||||
Route::get('/ice-servers', IceServerController::class)->name('ice-servers');
|
Route::get('/ice-servers', IceServerController::class)->name('ice-servers');
|
||||||
|
|
||||||
|
|
||||||
// User Dashboard Portal (requires authenticated user)
|
// User Dashboard Portal (requires authenticated user)
|
||||||
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
|
Route::middleware(['auth', 'role:user', 'verify-ms-session'])->group(function () {
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
|
||||||
@ -83,12 +82,12 @@
|
|||||||
Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password');
|
Route::post('/interviews/{id}/regenerate-password', [InterviewController::class, 'regeneratePassword'])->name('interview.regenerate-password');
|
||||||
Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
|
Route::post('/interviews/{id}/block-candidate', [InterviewController::class, 'blockCandidate'])->name('interview.block-candidate');
|
||||||
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');
|
Route::get('/interviews/{id}/report', [InterviewController::class, 'generateReport'])->name('interview.report');
|
||||||
|
Route::get('/interviews/{id}/resume', [InterviewController::class, 'viewResume'])->name('interview.resume');
|
||||||
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
|
Route::get('/interviews/{id}/download-recording', [InterviewController::class, 'downloadRecording'])->name('interview.download-recording');
|
||||||
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
|
Route::post('/interviews/{id}/call-status', [InterviewController::class, 'updateCallStatus'])->name('interview.call-status');
|
||||||
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
|
Route::post('/interviews/{id}/toggle-tab-screenshot', [InterviewController::class, 'toggleTabScreenshot'])->name('interview.toggle-tab-screenshot');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// Admin Control Panel (requires admin role)
|
// Admin Control Panel (requires admin role)
|
||||||
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
|
Route::middleware(['auth', 'role:admin'])->prefix('controlpannel')->group(function () {
|
||||||
Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session');
|
Route::get('/', [AdminController::class, 'index'])->middleware('verify-ms-session');
|
||||||
@ -114,4 +113,4 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Legacy /admin redirect to /controlpannel/dashboard
|
// Legacy /admin redirect to /controlpannel/dashboard
|
||||||
Route::get('/admin/{any?}', fn() => redirect()->route('admin.dashboard'))->where('any', '.*');
|
Route::get('/admin/{any?}', fn () => redirect()->route('admin.dashboard'))->where('any', '.*');
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
use App\Models\Interview;
|
use App\Models\Interview;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Facades\Mail;
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class ScheduleInterviewWithInvitationsTest extends TestCase
|
class ScheduleInterviewWithInvitationsTest extends TestCase
|
||||||
@ -166,4 +168,94 @@ public function test_scheduling_validation_errors_render_open_drawer_and_error_d
|
|||||||
$response->assertSee('The job title / position is required.');
|
$response->assertSee('The job title / position is required.');
|
||||||
$response->assertSee('Test instructions');
|
$response->assertSee('Test instructions');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_scheduling_interview_with_resume_file_stores_path_and_serves_via_route(): void
|
||||||
|
{
|
||||||
|
Storage::fake('public');
|
||||||
|
Mail::fake();
|
||||||
|
|
||||||
|
$hr = User::create([
|
||||||
|
'name' => 'HR Manager',
|
||||||
|
'email' => 'hr@company.com',
|
||||||
|
'role' => 'hr',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$interviewer = User::create([
|
||||||
|
'name' => 'Tech Panelist',
|
||||||
|
'email' => 'techpanelist@company.com',
|
||||||
|
'role' => 'user',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$file = UploadedFile::fake()->create('resume.pdf', 500, 'application/pdf');
|
||||||
|
|
||||||
|
$response = $this->actingAs($hr)->post(route('interview.store'), [
|
||||||
|
'candidate_name' => 'Kushal Candidate',
|
||||||
|
'candidate_email' => 'kushal@example.com',
|
||||||
|
'candidate_phone' => '9988776655',
|
||||||
|
'job_title' => 'Lead AI Engineer',
|
||||||
|
'round' => 'Technical',
|
||||||
|
'valid_hours' => 2,
|
||||||
|
'language' => 'python',
|
||||||
|
'assigned_interviewers' => [$interviewer->id],
|
||||||
|
'resume' => $file,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect(route('interview.index'));
|
||||||
|
|
||||||
|
$interview = Interview::where('candidate_email', 'kushal@example.com')->first();
|
||||||
|
$this->assertNotNull($interview);
|
||||||
|
$this->assertNotNull($interview->resume_path);
|
||||||
|
Storage::disk('public')->assertExists($interview->resume_path);
|
||||||
|
|
||||||
|
// Test view / download route
|
||||||
|
$viewResponse = $this->actingAs($hr)->get(route('interview.resume', $interview->id));
|
||||||
|
$viewResponse->assertOk();
|
||||||
|
$viewResponse->assertHeader('Content-Disposition', 'inline; filename="'.basename($interview->resume_path).'"');
|
||||||
|
|
||||||
|
// Test interviewer mail attachments include the resume
|
||||||
|
Mail::assertQueued(InterviewerAssessmentNotificationMail::class, function ($mail) {
|
||||||
|
$attachments = $mail->attachments();
|
||||||
|
$hasIcs = false;
|
||||||
|
$hasResume = false;
|
||||||
|
foreach ($attachments as $att) {
|
||||||
|
if (str_ends_with($att->as ?? '', '.ics') || str_contains($att->mime ?? '', 'text/calendar')) {
|
||||||
|
$hasIcs = true;
|
||||||
|
}
|
||||||
|
if (str_contains($att->as ?? '', 'Resume_kushal-candidate.pdf')) {
|
||||||
|
$hasResume = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $mail->hasTo('techpanelist@company.com') && $hasIcs && $hasResume;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_scheduling_interview_rejects_invalid_resume_mime_type(): void
|
||||||
|
{
|
||||||
|
$this->withMiddleware();
|
||||||
|
|
||||||
|
$hr = User::create([
|
||||||
|
'name' => 'HR Manager',
|
||||||
|
'email' => 'hr@company.com',
|
||||||
|
'role' => 'hr',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$invalidFile = UploadedFile::fake()->create('malicious.exe', 500, 'application/octet-stream');
|
||||||
|
|
||||||
|
$response = $this->actingAs($hr)
|
||||||
|
->from(route('interview.index'))
|
||||||
|
->followingRedirects()
|
||||||
|
->post(route('interview.store'), [
|
||||||
|
'candidate_name' => 'Invalid File Candidate',
|
||||||
|
'candidate_email' => 'invalidfile@example.com',
|
||||||
|
'candidate_phone' => '9988776655',
|
||||||
|
'job_title' => 'Developer',
|
||||||
|
'valid_hours' => 2,
|
||||||
|
'language' => 'python',
|
||||||
|
'resume' => $invalidFile,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
$response->assertSee('The candidate resume must be a PDF or Word document (.pdf, .doc, .docx).');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user