diff --git a/app/Domains/Management/Recruitment/Http/Controllers/CandidateAssessmentController.php b/app/Domains/Management/Recruitment/Http/Controllers/CandidateAssessmentController.php index ddb61dcd9..591bc9f8c 100644 --- a/app/Domains/Management/Recruitment/Http/Controllers/CandidateAssessmentController.php +++ b/app/Domains/Management/Recruitment/Http/Controllers/CandidateAssessmentController.php @@ -3,17 +3,30 @@ namespace App\Domains\Management\Recruitment\Http\Controllers; use App\Core\Traits\InteractWithResponse; +use App\Domains\Management\Recruitment\Models\Candidate; use App\Domains\Management\Recruitment\Models\CandidateAssessment; +use App\Domains\Management\Recruitment\Models\JobOpening; use App\Domains\Management\Recruitment\Http\Resources\CandidateAssessmentResource; +use App\Domains\Management\Recruitment\Services\EvaluationFormService; use Illuminate\Http\JsonResponse; class CandidateAssessmentController { use InteractWithResponse; + public function __construct( + protected EvaluationFormService $evaluationFormService + ) {} + public function index(): JsonResponse { - $assessments = CandidateAssessment::with(['candidate', 'hiringStage', 'jobOpening', 'assessor', 'approvedByUser']) + $assessments = CandidateAssessment::with([ + 'candidate', + 'hiringStage.evaluationCriteria', + 'jobOpening', + 'assessor', + 'approvedByUser', + ]) ->orderByDesc('created_at') ->paginate(15); @@ -24,77 +37,188 @@ public function index(): JsonResponse ); } + /** + * Return the dynamic evaluation form schema for a candidate at their current stage. + * Query params: candidate_id, job_opening_id + */ + public function getFormSchema(): JsonResponse + { + $candidateId = request('candidate_id'); + $jobOpeningId = request('job_opening_id'); + + $candidate = Candidate::find($candidateId); + $job = JobOpening::find($jobOpeningId); + + if (!$candidate || !$job) { + return $this->sendFailedResponse('المرشح أو الوظيفة غير موجودة', 404); + } + + try { + $schema = $this->evaluationFormService->buildFormForCandidate($candidate, $job); + return $this->sendSuccessResponse($schema, 'تم جلب نموذج التقييم بنجاح'); + } catch (\Exception $e) { + return $this->sendFailedResponse($e->getMessage()); + } + } + + /** + * Create a new assessment (draft or submitted). + */ public function store(): JsonResponse { $validated = request()->validate([ - 'candidate_id' => 'required|exists:candidates,id', - 'hiring_stage_id' => 'required|exists:hiring_stages,id', - 'job_opening_id' => 'required|exists:job_openings,id', - 'assessor_id' => 'required|exists:users,id', - 'assessment_date' => 'required|date', - 'availability_date' => 'nullable|date', - 'expected_salary' => 'nullable|numeric|min:0', - 'scores' => 'required|array', - 'comments' => 'nullable|string', - 'status' => 'nullable|in:pending,completed,approved,rejected', - 'approved_by' => 'nullable|exists:users,id', - 'approved_at' => 'nullable|date', + 'candidate_id' => 'required|exists:candidates,id', + 'job_opening_id' => 'required|exists:job_openings,id', + 'overall_rating' => 'nullable|integer|min:1|max:10', + 'availability_date' => 'nullable|date', + 'expected_salary' => 'nullable|numeric|min:0', + 'position_expected_salary' => 'nullable|numeric|min:0', + 'general_notes' => 'nullable|string', + 'criteria_values' => 'nullable|array', + 'skill_verifications' => 'nullable|array', + 'scores' => 'nullable|array', + 'comments' => 'nullable|string', + 'save_mode' => 'in:draft,submitted', ]); - $assessment = CandidateAssessment::create($validated); + $candidate = Candidate::find($validated['candidate_id']); + $job = JobOpening::find($validated['job_opening_id']); - return $this->sendSuccessResponse( - new CandidateAssessmentResource($assessment->load(['candidate', 'hiringStage', 'jobOpening', 'assessor', 'approvedByUser'])), - 'تم إنشاء التقييم بنجاح', - 201 - ); + try { + $assessment = $this->evaluationFormService->createEvaluation( + $candidate, + $job, + $validated, + auth()->user() + ); + + return $this->sendSuccessResponse( + new CandidateAssessmentResource( + $assessment->load(['candidate', 'hiringStage.evaluationCriteria', 'jobOpening', 'assessor', 'approvedByUser']) + ), + 'تم إنشاء التقييم بنجاح', + 201 + ); + } catch (\Illuminate\Validation\ValidationException $e) { + return $this->sendFailedResponse($e->errors(), 422); + } catch (\Exception $e) { + return $this->sendFailedResponse($e->getMessage()); + } } public function show(CandidateAssessment $assessment): JsonResponse { return $this->sendSuccessResponse( - new CandidateAssessmentResource($assessment->load(['candidate', 'hiringStage', 'jobOpening', 'assessor', 'approvedByUser'])), + new CandidateAssessmentResource( + $assessment->load(['candidate', 'hiringStage.evaluationCriteria', 'jobOpening', 'assessor', 'approvedByUser']) + ), 'تم جلب تفاصيل التقييم بنجاح' ); } + /** + * Update an assessment — only allowed while still in draft mode. + */ public function update(CandidateAssessment $assessment): JsonResponse { + if ($assessment->isSubmitted()) { + return $this->sendFailedResponse('لا يمكن تعديل التقييم المُقدَّم', 422); + } + $validated = request()->validate([ - 'scores' => 'required|array', - 'notes' => 'nullable|string', + 'overall_rating' => 'nullable|integer|min:1|max:10', + 'availability_date' => 'nullable|date', + 'expected_salary' => 'nullable|numeric|min:0', + 'position_expected_salary' => 'nullable|numeric|min:0', + 'general_notes' => 'nullable|string', + 'criteria_values' => 'nullable|array', + 'skill_verifications' => 'nullable|array', + 'scores' => 'nullable|array', + 'comments' => 'nullable|string', + 'save_mode' => 'in:draft,submitted', ]); - if ($assessment->status !== 'pending') { - return $this->sendFailedResponse('لا يمكن تحديث التقييم المعتمد'); + try { + $mode = $validated['save_mode'] ?? 'draft'; + unset($validated['save_mode']); + + $assessment = $this->evaluationFormService->saveEvaluation( + $assessment, + $validated, + $mode, + auth()->user() + ); + + return $this->sendSuccessResponse( + new CandidateAssessmentResource( + $assessment->load(['candidate', 'hiringStage.evaluationCriteria', 'jobOpening', 'assessor', 'approvedByUser']) + ), + 'تم تحديث التقييم بنجاح' + ); + } catch (\Illuminate\Validation\ValidationException $e) { + return $this->sendFailedResponse($e->errors(), 422); + } catch (\Exception $e) { + return $this->sendFailedResponse($e->getMessage()); } + } - $assessment->update($validated); + /** + * Submit (approve) an evaluation — moves it from draft to submitted. + * Only the responsible user of the current stage can do this. + */ + public function submitEvaluation(CandidateAssessment $assessment): JsonResponse + { + if ($assessment->isSubmitted()) { + return $this->sendFailedResponse('التقييم مُقدَّم بالفعل', 422); + } - return $this->sendSuccessResponse( - new CandidateAssessmentResource($assessment), - 'تم تحديث التقييم بنجاح' - ); + $extra = request()->only([ + 'overall_rating', + 'availability_date', + 'expected_salary', + 'position_expected_salary', + 'general_notes', + 'criteria_values', + 'skill_verifications', + 'comments', + ]); + + try { + $assessment = $this->evaluationFormService->saveEvaluation( + $assessment, + $extra, + 'submitted', + auth()->user() + ); + + return $this->sendSuccessResponse( + new CandidateAssessmentResource( + $assessment->load(['candidate', 'hiringStage.evaluationCriteria', 'jobOpening', 'assessor', 'approvedByUser']) + ), + 'تم تقديم التقييم بنجاح' + ); + } catch (\Illuminate\Validation\ValidationException $e) { + return $this->sendFailedResponse($e->errors(), 422); + } catch (\Exception $e) { + return $this->sendFailedResponse($e->getMessage()); + } } public function destroy(CandidateAssessment $assessment): JsonResponse { - if ($assessment->status === 'approved') { - return $this->sendFailedResponse('لا يمكن حذف التقييم المعتمد'); + if ($assessment->isSubmitted()) { + return $this->sendFailedResponse('لا يمكن حذف التقييم المُقدَّم', 422); } $assessment->delete(); - return $this->sendSuccessResponse( - null, - 'تم حذف التقييم بنجاح' - ); + return $this->sendSuccessResponse(null, 'تم حذف التقييم بنجاح'); } public function approve(CandidateAssessment $assessment): JsonResponse { $assessment->update([ - 'status' => 'approved', + 'status' => 'approved', 'approved_by' => auth()->id(), 'approved_at' => now(), ]); @@ -110,7 +234,7 @@ public function reject(CandidateAssessment $assessment): JsonResponse $reason = request('reason', 'تم الرفض دون تحديد السبب'); $assessment->update([ - 'status' => 'rejected', + 'status' => 'rejected', 'rejection_reason' => $reason, ]); @@ -122,13 +246,22 @@ public function reject(CandidateAssessment $assessment): JsonResponse public function getCandidateAssessments(): JsonResponse { - $candidateId = request('candidate_id'); + $candidateId = request('candidate_id'); + $jobOpeningId = request('job_opening_id'); - $assessments = CandidateAssessment::whereHas('candidate', function ($query) use ($candidateId) { - $query->where('id', $candidateId); - }) - ->orderByDesc('created_at') - ->get(); + $query = CandidateAssessment::with([ + 'hiringStage.evaluationCriteria', + 'jobOpening', + 'assessor', + 'approvedByUser', + ]) + ->where('candidate_id', $candidateId); + + if ($jobOpeningId) { + $query->where('job_opening_id', $jobOpeningId); + } + + $assessments = $query->orderByDesc('created_at')->get(); return $this->sendSuccessResponse( CandidateAssessmentResource::collection($assessments), diff --git a/app/Domains/Management/Recruitment/Http/Controllers/JobOpeningController.php b/app/Domains/Management/Recruitment/Http/Controllers/JobOpeningController.php index 1d0d57909..8c105b1f6 100644 --- a/app/Domains/Management/Recruitment/Http/Controllers/JobOpeningController.php +++ b/app/Domains/Management/Recruitment/Http/Controllers/JobOpeningController.php @@ -4,11 +4,14 @@ use App\Core\Traits\InteractWithResponse; use App\Domains\Management\Recruitment\Models\Candidate; +use App\Domains\Management\Recruitment\Models\HiringStage; use App\Domains\Management\Recruitment\Models\JobOpening; +use App\Domains\Management\Recruitment\Models\CandidateStageTransition; use App\Domains\Management\Recruitment\Http\Requests\CreateJobOpeningRequest; use App\Domains\Management\Recruitment\Http\Requests\FilterCandidatesRequest; use App\Domains\Management\Recruitment\Services\FavoriteService; use App\Domains\Management\Recruitment\Services\JobOpeningService; +use App\Domains\Management\Recruitment\Services\CandidateService; use App\Domains\Management\Recruitment\Http\Resources\JobCandidatesResource; use App\Domains\Management\Recruitment\Http\Resources\JobOpeningResource; use Illuminate\Http\JsonResponse; @@ -317,4 +320,123 @@ public function getPublished(): JsonResponse 'تم جلب الوظائف المنشورة بنجاح' ); } + + // ── Candidate Quick Actions (vacancy-scoped) ───────────────────────────── + + /** + * Move a candidate to a specific stage within this vacancy. + * POST /job-openings/{job}/candidates/{candidate}/move-stage + */ + public function moveCandidateStage(JobOpening $job, Candidate $candidate): JsonResponse + { + $stageId = request('stage_id'); + $nextStepDate = request('next_step_date'); + $responsibleUserId = request('responsible_user_id'); + $notes = request('notes'); + + $stage = HiringStage::find($stageId); + if (!$stage || $stage->job_opening_id !== $job->id) { + return $this->sendFailedResponse('المرحلة غير موجودة أو لا تنتمي لهذه الوظيفة', 404); + } + + $application = $candidate->application($job); + if (!$application) { + return $this->sendFailedResponse('لا يوجد طلب تقديم لهذه الوظيفة', 404); + } + if (!$application->canAdvance()) { + return $this->sendFailedResponse('لا يمكن نقل مرشح مرفوض', 422); + } + + $oldStageId = $application->current_stage_id; + + $application->update([ + 'current_stage_id' => $stageId, + 'next_step_date' => $nextStepDate, + 'responsible_user_id' => $responsibleUserId, + ]); + + CandidateStageTransition::create([ + 'candidate_id' => $candidate->id, + 'job_opening_id' => $job->id, + 'application_id' => $application->id, + 'from_stage_id' => $oldStageId, + 'to_stage_id' => $stageId, + 'responsible_user_id' => $responsibleUserId ?? auth()->id(), + 'notes' => $notes, + ]); + + return $this->sendSuccessResponse( + null, + 'تم نقل المرشح للمرحلة بنجاح' + ); + } + + /** + * Assign an interview manager to a candidate's application within this vacancy. + * POST /job-openings/{job}/candidates/{candidate}/assign-interviewer + */ + public function assignInterviewer(JobOpening $job, Candidate $candidate): JsonResponse + { + $responsibleUserId = request()->validate([ + 'responsible_user_id' => 'required|exists:users,id', + ])['responsible_user_id']; + + $application = $candidate->application($job); + if (!$application) { + return $this->sendFailedResponse('لا يوجد طلب تقديم لهذه الوظيفة', 404); + } + + $application->update(['responsible_user_id' => $responsibleUserId]); + + return $this->sendSuccessResponse( + null, + 'تم تعيين مدير المقابلة بنجاح' + ); + } + + /** + * Reject a candidate within this specific vacancy. + * POST /job-openings/{job}/candidates/{candidate}/reject + */ + public function rejectCandidate(JobOpening $job, Candidate $candidate): JsonResponse + { + $reason = request('reason', 'لم يتم تحديد السبب'); + + $application = $candidate->application($job); + if (!$application) { + return $this->sendFailedResponse('لا يوجد طلب تقديم لهذه الوظيفة', 404); + } + if ($application->isRejected()) { + return $this->sendFailedResponse('المرشح مرفوض بالفعل', 422); + } + + $application->reject($reason); + + return $this->sendSuccessResponse( + null, + 'تم رفض المرشح بنجاح' + ); + } + + /** + * Mark a candidate as preliminarily accepted within this vacancy. + * POST /job-openings/{job}/candidates/{candidate}/preliminary-accept + */ + public function preliminaryAccept(JobOpening $job, Candidate $candidate): JsonResponse + { + $application = $candidate->application($job); + if (!$application) { + return $this->sendFailedResponse('لا يوجد طلب تقديم لهذه الوظيفة', 404); + } + if ($application->isRejected()) { + return $this->sendFailedResponse('لا يمكن قبول مرشح مرفوض', 422); + } + + $application->markAsInitiallyAccepted(); + + return $this->sendSuccessResponse( + null, + 'تم القبول الأولي للمرشح بنجاح' + ); + } } diff --git a/app/Domains/Management/Recruitment/Http/Controllers/StageEvaluationCriteriaController.php b/app/Domains/Management/Recruitment/Http/Controllers/StageEvaluationCriteriaController.php new file mode 100644 index 000000000..a428cf8ae --- /dev/null +++ b/app/Domains/Management/Recruitment/Http/Controllers/StageEvaluationCriteriaController.php @@ -0,0 +1,101 @@ +service->getCriteriaForStage($hiringStage); + + return $this->sendSuccessResponse( + StageEvaluationCriteriaResource::collection($criteria), + 'تم جلب معايير التقييم بنجاح' + ); + } + + public function store(Request $request, HiringStage $hiringStage): JsonResponse + { + $validated = $request->validate([ + 'label' => 'required|string|max:255', + 'input_type' => 'required|in:numeric,yes_no,dropdown,free_text', + 'options' => 'nullable|array', + 'options.*' => 'required_if:input_type,dropdown', + 'is_required' => 'boolean', + 'order' => 'nullable|integer|min:0', + ]); + + $criterion = $this->service->addCriterion($hiringStage, $validated); + + return $this->sendSuccessResponse( + new StageEvaluationCriteriaResource($criterion), + 'تم إضافة المعيار بنجاح', + 201 + ); + } + + public function show(HiringStage $hiringStage, StageEvaluationCriteria $criterion): JsonResponse + { + abort_if($criterion->hiring_stage_id !== $hiringStage->id, 404, 'المعيار لا ينتمي لهذه المرحلة'); + + return $this->sendSuccessResponse( + new StageEvaluationCriteriaResource($criterion), + 'تم جلب تفاصيل المعيار بنجاح' + ); + } + + public function update(Request $request, HiringStage $hiringStage, StageEvaluationCriteria $criterion): JsonResponse + { + abort_if($criterion->hiring_stage_id !== $hiringStage->id, 404, 'المعيار لا ينتمي لهذه المرحلة'); + + $validated = $request->validate([ + 'label' => 'sometimes|string|max:255', + 'input_type' => 'sometimes|in:numeric,yes_no,dropdown,free_text', + 'options' => 'nullable|array', + 'is_required' => 'boolean', + 'order' => 'nullable|integer|min:0', + ]); + + $criterion = $this->service->updateCriterion($criterion, $validated); + + return $this->sendSuccessResponse( + new StageEvaluationCriteriaResource($criterion), + 'تم تحديث المعيار بنجاح' + ); + } + + public function destroy(HiringStage $hiringStage, StageEvaluationCriteria $criterion): JsonResponse + { + abort_if($criterion->hiring_stage_id !== $hiringStage->id, 404, 'المعيار لا ينتمي لهذه المرحلة'); + + $this->service->deleteCriterion($criterion); + + return $this->sendSuccessResponse(null, 'تم حذف المعيار بنجاح'); + } + + public function reorder(Request $request, HiringStage $hiringStage): JsonResponse + { + $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'required|integer|exists:stage_evaluation_criteria,id', + ]); + + $this->service->reorderCriteria($hiringStage, $request->get('ids')); + + return $this->sendSuccessResponse(null, 'تم إعادة ترتيب المعايير بنجاح'); + } +} diff --git a/app/Domains/Management/Recruitment/Http/Resources/CandidateAssessmentResource.php b/app/Domains/Management/Recruitment/Http/Resources/CandidateAssessmentResource.php index f1ceba18f..c8b4f51f4 100644 --- a/app/Domains/Management/Recruitment/Http/Resources/CandidateAssessmentResource.php +++ b/app/Domains/Management/Recruitment/Http/Resources/CandidateAssessmentResource.php @@ -6,37 +6,86 @@ class CandidateAssessmentResource extends JsonResource { - public function toArray($request) + public function toArray($request): array { return [ - 'id' => $this->id, - 'candidate_id' => $this->candidate_id, - 'candidate' => new CandidateResource($this->whenLoaded('candidate')), - 'hiring_stage_id' => $this->hiring_stage_id, - 'hiring_stage' => new HiringStageResource($this->whenLoaded('hiringStage')), - 'job_opening_id' => $this->job_opening_id, - 'job_opening' => new JobOpeningResource($this->whenLoaded('jobOpening')), - 'assessor_id' => $this->assessor_id, - 'assessor' => [ - 'id' => $this->assessor?->id, - 'name' => $this->assessor?->name, + 'id' => $this->id, + 'candidate_id' => $this->candidate_id, + 'candidate' => new CandidateResource($this->whenLoaded('candidate')), + 'hiring_stage_id' => $this->hiring_stage_id, + 'hiring_stage' => new HiringStageResource($this->whenLoaded('hiringStage')), + 'job_opening_id' => $this->job_opening_id, + 'job_opening' => new JobOpeningResource($this->whenLoaded('jobOpening')), + 'assessor_id' => $this->assessor_id, + 'assessor' => [ + 'id' => $this->assessor?->id, + 'name' => $this->assessor?->name, 'email' => $this->assessor?->email, ], - 'assessment_date' => $this->assessment_date, - 'scores' => $this->scores, - 'average_score' => $this->getAverageScore(), - 'is_passed' => $this->isPassed(), - 'comments' => $this->comments, - 'status' => $this->status, - 'approved_by' => $this->approved_by, - 'approved_by_user' => [ - 'id' => $this->approvedByUser?->id, - 'name' => $this->approvedByUser?->name, + 'assessment_date' => $this->assessment_date, + // Standard evaluation fields + 'overall_rating' => $this->overall_rating, + 'availability_date' => $this->availability_date, + 'expected_salary' => $this->expected_salary, + 'position_expected_salary'=> $this->position_expected_salary, + 'general_notes' => $this->general_notes, + // Dynamic criteria — full Q&A pairs for assessments tab + 'criteria_values' => $this->criteria_values, + 'criteria_answers' => $this->buildCriteriaAnswers(), + 'skill_verifications' => $this->skill_verifications, + // Legacy scores field + 'scores' => $this->scores, + 'average_score' => $this->getAverageScore(), + 'is_passed' => $this->isPassed(), + 'comments' => $this->comments, + 'save_mode' => $this->save_mode, + 'status' => $this->status, + 'approved_by' => $this->approved_by, + 'approved_by_user' => [ + 'id' => $this->approvedByUser?->id, + 'name' => $this->approvedByUser?->name, 'email' => $this->approvedByUser?->email, ], - 'approved_at' => $this->approved_at, - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'approved_at' => $this->approved_at, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ]; } + + /** + * Build full Q&A pairs by resolving criterion labels from the stage's criteria. + * Used in the assessments tab to show question+answer for each criterion. + */ + private function buildCriteriaAnswers(): array + { + $values = $this->criteria_values ?? []; + $answers = []; + + // If the hiringStage relation with evaluationCriteria is loaded, use it + if ($this->relationLoaded('hiringStage') && $this->hiringStage?->relationLoaded('evaluationCriteria')) { + foreach ($this->hiringStage->evaluationCriteria as $criterion) { + $answers[] = [ + 'criterion_id' => $criterion->id, + 'label' => $criterion->label, + 'input_type' => $criterion->input_type, + 'is_required' => $criterion->is_required, + 'value' => $values[$criterion->id] ?? null, + ]; + } + return $answers; + } + + // Fallback: return raw id→value pairs if stage not loaded + foreach ($values as $criterionId => $value) { + $answers[] = [ + 'criterion_id' => $criterionId, + 'label' => null, + 'input_type' => null, + 'is_required' => null, + 'value' => $value, + ]; + } + + return $answers; + } } diff --git a/app/Domains/Management/Recruitment/Http/Resources/HiringStageResource.php b/app/Domains/Management/Recruitment/Http/Resources/HiringStageResource.php index 93045ccb3..b990a6aa0 100644 --- a/app/Domains/Management/Recruitment/Http/Resources/HiringStageResource.php +++ b/app/Domains/Management/Recruitment/Http/Resources/HiringStageResource.php @@ -10,19 +10,20 @@ class HiringStageResource extends JsonResource public function toArray(Request $request): array { return [ - 'id' => $this->id, - 'job_opening_id' => $this->job_opening_id, - 'stage_name' => $this->stage_name, - 'order' => $this->order, - 'is_enabled' => $this->is_enabled, - 'assessment_form_id' => $this->assessment_form_id, - 'candidate_count' => $this->candidate_count, - 'job_opening' => $this->whenLoaded('jobOpening', fn() => new JobOpeningResource($this->jobOpening)), - 'applications_count' => $this->applications()->count(), - 'assessments_count' => $this->assessments()->count(), - 'assessments' => CandidateAssessmentResource::collection($this->whenLoaded('assessments')), - 'created_at' => $this->created_at, - 'updated_at' => $this->updated_at, + 'id' => $this->id, + 'job_opening_id' => $this->job_opening_id, + 'stage_name' => $this->stage_name, + 'order' => $this->order, + 'is_enabled' => $this->is_enabled, + 'assessment_form_id' => $this->assessment_form_id, + 'candidate_count' => $this->candidate_count, + 'job_opening' => $this->whenLoaded('jobOpening', fn() => new JobOpeningResource($this->jobOpening)), + 'evaluation_criteria' => StageEvaluationCriteriaResource::collection($this->whenLoaded('evaluationCriteria')), + 'applications_count' => $this->applications()->count(), + 'assessments_count' => $this->assessments()->count(), + 'assessments' => CandidateAssessmentResource::collection($this->whenLoaded('assessments')), + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ]; } } diff --git a/app/Domains/Management/Recruitment/Http/Resources/JobCandidatesResource.php b/app/Domains/Management/Recruitment/Http/Resources/JobCandidatesResource.php index 75d388fd6..dd8272149 100644 --- a/app/Domains/Management/Recruitment/Http/Resources/JobCandidatesResource.php +++ b/app/Domains/Management/Recruitment/Http/Resources/JobCandidatesResource.php @@ -12,48 +12,83 @@ class JobCandidatesResource extends JsonResource public function __construct($resource, $job = null, $user = null) { parent::__construct($resource); - $this->job = $job; + $this->job = $job; $this->user = $user; } - public function toArray($request) + public function toArray($request): array { - $application = $this->application($this->job); - $atsService = app(\App\Domains\Management\Recruitment\Services\ATSEngineService::class); + $application = $this->application($this->job); + $atsService = app(\App\Domains\Management\Recruitment\Services\ATSEngineService::class); $favoriteService = app(\App\Domains\Management\Recruitment\Services\FavoriteService::class); + // Available stages for the vacancy (for move-to-stage action) + $availableStages = $this->when($this->job, function () { + return $this->job->hiringStages() + ->where('is_enabled', true) + ->orderBy('order') + ->get(['id', 'stage_name', 'order']) + ->toArray(); + }); + + // Interview managers from job opening JSON field + $interviewManagers = $this->when($this->job, function () { + $managerIds = $this->job->interview_managers ?? []; + if (empty($managerIds)) return []; + return \App\Domains\Core\User\Models\User::whereIn('id', $managerIds) + ->get(['id', 'name', 'email']) + ->toArray(); + }); + return [ - 'id' => $this->id, - 'full_name' => $this->full_name, - 'email' => $this->email, - 'phone' => $this->phone, - 'city' => $this->city, - 'years_of_experience' => $this->years_of_experience, - 'expected_salary' => $this->expected_salary, - 'available_date' => $this->available_date, - 'match_score' => $this->match_score, - 'match_rating' => $this->match_score >= 80 ? 'excellent' : ($this->match_score >= 60 ? 'suitable' : ($this->match_score >= 40 ? 'fair' : 'poor')), - 'match_color' => $this->match_score !== null ? $atsService->getMatchColor($this->match_score) : null, - 'match_details_url' => $this->when($this->job, function () { + 'id' => $this->id, + 'full_name' => $this->full_name, + 'email' => $this->email, + 'phone' => $this->phone, + 'city' => $this->city, + 'years_of_experience'=> $this->years_of_experience, + 'expected_salary' => $this->expected_salary, + 'available_date' => $this->available_date, + 'match_score' => $this->match_score, + 'match_rating' => $this->match_score >= 80 ? 'excellent' : ($this->match_score >= 60 ? 'suitable' : ($this->match_score >= 40 ? 'fair' : 'poor')), + 'match_color' => $this->match_score !== null ? $atsService->getMatchColor($this->match_score) : null, + 'match_details_url' => $this->when($this->job, function () { return url("/api/recruitment/job-openings/{$this->job->id}/candidates/{$this->id}/match-details"); }), - 'current_stage' => $this->when($application && $application->currentStage, function () use ($application) { + 'current_stage' => $this->when($application && $application->currentStage, function () use ($application) { return [ - 'id' => $application->currentStage->id, + 'id' => $application->currentStage->id, 'stage_name' => $application->currentStage->stage_name, - 'order' => $application->currentStage->order, + 'order' => $application->currentStage->order, ]; }), 'application_status' => $application ? $application->status : null, - 'application_id' => $application ? $application->id : null, - 'is_favorite' => $this->when($this->job && $this->user, function () use ($favoriteService) { + 'application_id' => $application ? $application->id : null, + 'is_favorite' => $this->when($this->job && $this->user, function () use ($favoriteService) { return $favoriteService->isFavorite($this->resource, $this->job, $this->user); }), - 'can_advance' => $application ? $application->canAdvance() : false, - 'status' => $this->status, - 'status_label' => $this->status?->label(), - 'status_color' => $this->status?->color(), - 'created_at' => $this->created_at, + 'can_advance' => $application ? $application->canAdvance() : false, + 'status' => $this->status, + 'status_label' => $this->status?->label(), + 'status_color' => $this->status?->color(), + // ── Quick Actions ────────────────────────────────────────────── + 'quick_actions' => $this->when($this->job, function () use ($application, $availableStages, $interviewManagers) { + $isRejected = $application?->isRejected() ?? false; + return [ + 'can_move_stage' => !$isRejected && $application !== null, + 'can_reject' => !$isRejected && $application !== null, + 'can_preliminary_accept' => !$isRejected && $application !== null, + 'can_assign_interviewer' => $application !== null, + 'can_create_job_offer' => !$isRejected && $application !== null, + 'available_stages' => $availableStages, + 'interview_managers' => $interviewManagers, + 'move_stage_url' => url("/api/recruitment/job-openings/{$this->job->id}/candidates/{$this->id}/move-stage"), + 'assign_interviewer_url' => url("/api/recruitment/job-openings/{$this->job->id}/candidates/{$this->id}/assign-interviewer"), + 'reject_url' => url("/api/recruitment/job-openings/{$this->job->id}/candidates/{$this->id}/reject"), + 'preliminary_accept_url' => url("/api/recruitment/job-openings/{$this->job->id}/candidates/{$this->id}/preliminary-accept"), + ]; + }), + 'created_at' => $this->created_at, ]; } } diff --git a/app/Domains/Management/Recruitment/Http/Resources/StageEvaluationCriteriaResource.php b/app/Domains/Management/Recruitment/Http/Resources/StageEvaluationCriteriaResource.php new file mode 100644 index 000000000..b70f6824a --- /dev/null +++ b/app/Domains/Management/Recruitment/Http/Resources/StageEvaluationCriteriaResource.php @@ -0,0 +1,23 @@ + $this->id, + 'hiring_stage_id' => $this->hiring_stage_id, + 'label' => $this->label, + 'input_type' => $this->input_type, + 'options' => $this->options, + 'is_required' => $this->is_required, + 'order' => $this->order, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + ]; + } +} diff --git a/app/Domains/Management/Recruitment/Models/CandidateAssessment.php b/app/Domains/Management/Recruitment/Models/CandidateAssessment.php index da34f88b2..7db816a8c 100644 --- a/app/Domains/Management/Recruitment/Models/CandidateAssessment.php +++ b/app/Domains/Management/Recruitment/Models/CandidateAssessment.php @@ -18,8 +18,14 @@ class CandidateAssessment extends Model 'job_opening_id', 'assessor_id', 'assessment_date', + 'overall_rating', 'availability_date', 'expected_salary', + 'position_expected_salary', + 'general_notes', + 'criteria_values', + 'skill_verifications', + 'save_mode', 'scores', 'comments', 'status', @@ -28,15 +34,28 @@ class CandidateAssessment extends Model ]; protected $casts = [ - 'scores' => 'array', - 'assessment_date' => 'datetime', - 'availability_date' => 'date', - 'expected_salary' => 'decimal:2', - 'approved_at' => 'datetime', - 'created_at' => 'datetime', - 'updated_at' => 'datetime', + 'scores' => 'array', + 'criteria_values' => 'array', + 'skill_verifications' => 'array', + 'assessment_date' => 'datetime', + 'availability_date' => 'date', + 'expected_salary' => 'decimal:2', + 'position_expected_salary' => 'decimal:2', + 'approved_at' => 'datetime', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', ]; + public function isDraft(): bool + { + return $this->save_mode === 'draft'; + } + + public function isSubmitted(): bool + { + return $this->save_mode === 'submitted'; + } + public function candidate() { return $this->belongsTo(Candidate::class); diff --git a/app/Domains/Management/Recruitment/Models/HiringStage.php b/app/Domains/Management/Recruitment/Models/HiringStage.php index 357e6bc70..6b70a53c9 100644 --- a/app/Domains/Management/Recruitment/Models/HiringStage.php +++ b/app/Domains/Management/Recruitment/Models/HiringStage.php @@ -41,6 +41,11 @@ public function assessments() return $this->hasMany(CandidateAssessment::class); } + public function evaluationCriteria() + { + return $this->hasMany(StageEvaluationCriteria::class)->orderBy('order'); + } + public static function createDefaultStages(JobOpening $jobOpening): void { $stages = [ diff --git a/app/Domains/Management/Recruitment/Models/StageEvaluationCriteria.php b/app/Domains/Management/Recruitment/Models/StageEvaluationCriteria.php new file mode 100644 index 000000000..586ee4f77 --- /dev/null +++ b/app/Domains/Management/Recruitment/Models/StageEvaluationCriteria.php @@ -0,0 +1,34 @@ + 'array', + 'is_required' => 'boolean', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + + public function hiringStage() + { + return $this->belongsTo(HiringStage::class); + } +} diff --git a/app/Domains/Management/Recruitment/Routes/api.php b/app/Domains/Management/Recruitment/Routes/api.php index dfd032576..0215d9503 100644 --- a/app/Domains/Management/Recruitment/Routes/api.php +++ b/app/Domains/Management/Recruitment/Routes/api.php @@ -10,6 +10,7 @@ use App\Domains\Management\Recruitment\Http\Controllers\PublicApplicationController; use App\Domains\Management\Recruitment\Http\Controllers\JobTitleController; use App\Domains\Management\Recruitment\Http\Controllers\HiringStageController; +use App\Domains\Management\Recruitment\Http\Controllers\StageEvaluationCriteriaController; use App\Domains\Management\Recruitment\Http\Controllers\DepartmentController; use App\Domains\Management\Recruitment\Http\Controllers\BulkActionController; use App\Domains\Management\Recruitment\Http\Controllers\InterviewController; @@ -54,6 +55,22 @@ ->middleware('permission:recruitment.hiring_stages.toggle_status')->name('recruitment.hiring_stages.toggle_status'); Route::delete('{hiringStage}', [HiringStageController::class, 'destroy']) ->middleware('permission:recruitment.hiring_stages.destroy')->name('recruitment.hiring_stages.destroy'); + + // Evaluation Criteria (per-stage, scoped to vacancy via stage) + Route::prefix('{hiringStage}/criteria')->group(function () { + Route::get('/', [StageEvaluationCriteriaController::class, 'index']) + ->name('recruitment.hiring_stages.criteria.index'); + Route::post('/', [StageEvaluationCriteriaController::class, 'store']) + ->name('recruitment.hiring_stages.criteria.store'); + Route::post('/reorder', [StageEvaluationCriteriaController::class, 'reorder']) + ->name('recruitment.hiring_stages.criteria.reorder'); + Route::get('{criterion}', [StageEvaluationCriteriaController::class, 'show']) + ->name('recruitment.hiring_stages.criteria.show'); + Route::patch('{criterion}', [StageEvaluationCriteriaController::class, 'update']) + ->name('recruitment.hiring_stages.criteria.update'); + Route::delete('{criterion}', [StageEvaluationCriteriaController::class, 'destroy']) + ->name('recruitment.hiring_stages.criteria.destroy'); + }); }); // Job Titles @@ -140,17 +157,23 @@ // Job Questions Route::prefix('{job}/questions')->group(function () { Route::get('/', [JobQuestionController::class, 'index']); - // ->middleware('permission:recruitment.job_openings.view_candidates')->name('recruitment.job_openings.questions.index'); Route::post('/', [JobQuestionController::class, 'store']); - // ->middleware('permission:recruitment.job_openings.update')->name('recruitment.job_openings.questions.store'); Route::post('/reorder', [JobQuestionController::class, 'reorder']); - // ->middleware('permission:recruitment.job_openings.update')->name('recruitment.job_openings.questions.reorder'); Route::get('{question}', [JobQuestionController::class, 'show']); - // ->middleware('permission:recruitment.job_openings.view_candidates')->name('recruitment.job_openings.questions.show'); Route::patch('{question}', [JobQuestionController::class, 'update']); - // ->middleware('permission:recruitment.job_openings.update')->name('recruitment.job_openings.questions.update'); Route::delete('{question}', [JobQuestionController::class, 'destroy']); - // ->middleware('permission:recruitment.job_openings.update')->name('recruitment.job_openings.questions.destroy'); + }); + + // Candidate Quick Actions (vacancy-scoped) + Route::prefix('{job}/candidates/{candidate}')->group(function () { + Route::post('move-stage', [JobOpeningController::class, 'moveCandidateStage']) + ->name('recruitment.job_openings.candidates.move_stage'); + Route::post('assign-interviewer', [JobOpeningController::class, 'assignInterviewer']) + ->name('recruitment.job_openings.candidates.assign_interviewer'); + Route::post('reject', [JobOpeningController::class, 'rejectCandidate']) + ->name('recruitment.job_openings.candidates.reject'); + Route::post('preliminary-accept', [JobOpeningController::class, 'preliminaryAccept']) + ->name('recruitment.job_openings.candidates.preliminary_accept'); }); }); @@ -216,6 +239,9 @@ // Candidate Assessments Route::prefix('assessments')->group(function () { + // Form schema endpoint — returns dynamic form for a candidate at their current stage + Route::get('form-schema', [CandidateAssessmentController::class, 'getFormSchema']) + ->name('recruitment.assessments.form_schema'); Route::get('/', [CandidateAssessmentController::class, 'index']) ->middleware('permission:recruitment.assessments.index')->name('recruitment.assessments.index'); Route::post('/', [CandidateAssessmentController::class, 'store']) @@ -224,6 +250,8 @@ ->middleware('permission:recruitment.assessments.show')->name('recruitment.assessments.show'); Route::patch('{assessment}', [CandidateAssessmentController::class, 'update']) ->middleware('permission:recruitment.assessments.update')->name('recruitment.assessments.update'); + Route::post('{assessment}/submit', [CandidateAssessmentController::class, 'submitEvaluation']) + ->name('recruitment.assessments.submit'); Route::delete('{assessment}', [CandidateAssessmentController::class, 'destroy']) ->middleware('permission:recruitment.assessments.destroy')->name('recruitment.assessments.destroy'); Route::post('{assessment}/approve', [CandidateAssessmentController::class, 'approve']) diff --git a/app/Domains/Management/Recruitment/Services/ATSEngineService.php b/app/Domains/Management/Recruitment/Services/ATSEngineService.php index d58880b4b..ccdd5caa4 100644 --- a/app/Domains/Management/Recruitment/Services/ATSEngineService.php +++ b/app/Domains/Management/Recruitment/Services/ATSEngineService.php @@ -156,4 +156,59 @@ private function extractSkills(Candidate $candidate): array // In a real scenario, extract from CV or profile return []; } + + /** + * Update the candidate's ATS match score after an evaluation is submitted. + * Incorporates skill verifications and numeric criteria scores. + */ + public function incorporateEvaluationData(\App\Domains\Management\Recruitment\Models\CandidateAssessment $assessment): void + { + $candidate = $assessment->candidate; + $job = $assessment->jobOpening; + + if (!$candidate || !$job) { + return; + } + + // Update candidate skills from verified skill verifications + $verifications = $assessment->skill_verifications ?? []; + $verifiedSkills = collect($verifications) + ->filter(fn($v) => $v === true || $v === 1 || $v === 'true') + ->keys() + ->toArray(); + + // Update overall rating influence on match_score + $overallRating = $assessment->overall_rating ?? 0; // 1..10 + $ratingScore = $overallRating * 10; // convert to 0-100 + + // Compute criteria bonus (numeric criteria average) + $criteriaValues = $assessment->criteria_values ?? []; + $numericValues = collect($criteriaValues)->filter(fn($v) => is_numeric($v)); + $criteriaAverage = $numericValues->isNotEmpty() ? $numericValues->avg() : null; + + // Build updated score + $baseScore = $this->calculateMatchScore($candidate, $job); + $bonusScore = 0; + + if ($criteriaAverage !== null) { + // Criteria average is out of 10 — convert and blend 20% + $bonusScore += ($criteriaAverage / 10 * 100) * 0.2; + } + + // Overall rating blends 20% + $bonusScore += $ratingScore * 0.2; + + // Skill verifications: verified skills count toward skills match (40%) + if (!empty($verifiedSkills) && !empty($job->required_skills)) { + $requiredSkills = $job->required_skills; + $verifiedCount = count(array_intersect($verifiedSkills, $requiredSkills)); + $skillBonus = ($verifiedCount / count($requiredSkills)) * 100 * 0.4; + $newScore = (int) min(100, ($baseScore['percentage'] * 0.6) + $bonusScore + $skillBonus); + } else { + $newScore = (int) min(100, ($baseScore['percentage'] * 0.6) + $bonusScore); + } + + $candidate->update(['match_score' => $newScore]); + } } + diff --git a/app/Domains/Management/Recruitment/Services/EvaluationFormService.php b/app/Domains/Management/Recruitment/Services/EvaluationFormService.php new file mode 100644 index 000000000..b5660a3b8 --- /dev/null +++ b/app/Domains/Management/Recruitment/Services/EvaluationFormService.php @@ -0,0 +1,180 @@ +application($job); + + if (!$application) { + throw new \RuntimeException('المرشح لا يملك طلباً لهذه الوظيفة'); + } + + $stage = $application->currentStage; + if (!$stage) { + throw new \RuntimeException('المرشح غير موجود في أي مرحلة حالياً'); + } + + $criteria = $stage->evaluationCriteria()->get()->map(fn($c) => [ + 'criterion_id' => $c->id, + 'label' => $c->label, + 'input_type' => $c->input_type, + 'options' => $c->options, + 'is_required' => $c->is_required, + ])->values()->toArray(); + + $requiredSkills = $job->required_skills ?? []; + + return [ + 'stage' => [ + 'id' => $stage->id, + 'stage_name' => $stage->stage_name, + 'order' => $stage->order, + ], + 'standard_fields' => [ + ['field' => 'overall_rating', 'label' => 'التقييم العام (1-10)', 'input_type' => 'numeric', 'is_required' => true], + ['field' => 'availability_date', 'label' => 'تاريخ توفر المرشح', 'input_type' => 'date', 'is_required' => true], + ['field' => 'expected_salary', 'label' => 'الراتب المتوقع للمرشح', 'input_type' => 'numeric', 'is_required' => true], + ['field' => 'position_expected_salary', 'label' => 'الراتب المتوقع للوظيفة', 'input_type' => 'numeric', 'is_required' => false], + ['field' => 'general_notes', 'label' => 'ملاحظات عامة', 'input_type' => 'free_text', 'is_required' => false], + ], + 'custom_criteria' => $criteria, + 'skill_verifications' => collect($requiredSkills)->map(fn($skill) => [ + 'skill' => $skill, + 'is_required' => true, + ])->values()->toArray(), + ]; + } + + /** + * Save an evaluation in draft or submitted mode. + * Only the responsible user for the stage can submit. + */ + public function saveEvaluation( + CandidateAssessment $assessment, + array $data, + string $mode, + ?User $user = null + ): CandidateAssessment { + if ($mode === 'submitted') { + // Validate required standard fields + $this->validateSubmitRequirements($data, $assessment); + + // Only responsible_user_id of the application can submit + if ($user) { + $application = CandidateAssessment::with('hiringStage')->find($assessment->id); + $candidate = \App\Domains\Management\Recruitment\Models\Candidate::find($assessment->candidate_id); + $job = \App\Domains\Management\Recruitment\Models\JobOpening::find($assessment->job_opening_id); + $app = $candidate ? $candidate->application($job) : null; + + if ($app && $app->responsible_user_id && $app->responsible_user_id !== $user->id) { + throw new \RuntimeException('فقط المسؤول المعين على هذه المرحلة يمكنه تقديم التقييم'); + } + } + + $data['status'] = 'submitted'; + $data['save_mode'] = 'submitted'; + + if (!isset($data['approved_by'])) { + $data['approved_by'] = $user?->id; + $data['approved_at'] = now(); + } + } else { + $data['save_mode'] = 'draft'; + $data['status'] = 'draft'; + } + + $assessment->update($data); + + // Feed into ATS engine on submission + if ($mode === 'submitted') { + $this->atsEngine->incorporateEvaluationData($assessment); + } + + return $assessment->fresh(); + } + + /** + * Create a new assessment record (draft) for a candidate at their current stage. + */ + public function createEvaluation(Candidate $candidate, JobOpening $job, array $data, ?User $assessor = null): CandidateAssessment + { + $application = $candidate->application($job); + if (!$application) { + throw new \RuntimeException('المرشح لا يملك طلباً لهذه الوظيفة'); + } + + $stage = $application->currentStage; + if (!$stage) { + throw new \RuntimeException('المرشح غير محدد في مرحلة'); + } + + // Enforce: only the form for this stage may be used + if (isset($data['hiring_stage_id']) && (int)$data['hiring_stage_id'] !== $stage->id) { + throw new \RuntimeException('لا يمكن استخدام نموذج مرحلة أخرى'); + } + + $mode = $data['save_mode'] ?? 'draft'; + unset($data['save_mode']); + + $assessment = CandidateAssessment::create(array_merge($data, [ + 'candidate_id' => $candidate->id, + 'job_opening_id' => $job->id, + 'hiring_stage_id' => $stage->id, + 'assessor_id' => $assessor?->id ?? ($data['assessor_id'] ?? null), + 'assessment_date' => now(), + 'save_mode' => 'draft', + 'status' => 'draft', + ])); + + return $this->saveEvaluation($assessment, [], $mode, $assessor); + } + + private function validateSubmitRequirements(array $data, CandidateAssessment $assessment): void + { + $merged = array_merge($assessment->toArray(), $data); + $errors = []; + + if (empty($merged['overall_rating'])) { + $errors['overall_rating'] = 'التقييم العام مطلوب عند التقديم'; + } + if (empty($merged['availability_date'])) { + $errors['availability_date'] = 'تاريخ التوفر مطلوب عند التقديم'; + } + if (empty($merged['expected_salary'])) { + $errors['expected_salary'] = 'الراتب المتوقع مطلوب عند التقديم'; + } + + // Validate required custom criteria + $stage = \App\Domains\Management\Recruitment\Models\HiringStage::with('evaluationCriteria')->find($assessment->hiring_stage_id); + if ($stage) { + $criteriaValues = $merged['criteria_values'] ?? []; + foreach ($stage->evaluationCriteria as $criterion) { + if ($criterion->is_required && empty($criteriaValues[$criterion->id])) { + $errors["criteria_values.{$criterion->id}"] = "المعيار '{$criterion->label}' مطلوب"; + } + } + } + + if (!empty($errors)) { + throw ValidationException::withMessages($errors); + } + } +} diff --git a/app/Domains/Management/Recruitment/Services/StageEvaluationCriteriaService.php b/app/Domains/Management/Recruitment/Services/StageEvaluationCriteriaService.php new file mode 100644 index 000000000..d935bf0c3 --- /dev/null +++ b/app/Domains/Management/Recruitment/Services/StageEvaluationCriteriaService.php @@ -0,0 +1,47 @@ +evaluationCriteria()->get(); + } + + public function addCriterion(HiringStage $stage, array $data): StageEvaluationCriteria + { + // Auto-assign order if not provided + if (!isset($data['order'])) { + $maxOrder = $stage->evaluationCriteria()->max('order') ?? 0; + $data['order'] = $maxOrder + 1; + } + + return $stage->evaluationCriteria()->create($data); + } + + public function updateCriterion(StageEvaluationCriteria $criterion, array $data): StageEvaluationCriteria + { + $criterion->update($data); + return $criterion->fresh(); + } + + public function deleteCriterion(StageEvaluationCriteria $criterion): bool + { + return $criterion->delete(); + } + + public function reorderCriteria(HiringStage $stage, array $orderedIds): bool + { + foreach ($orderedIds as $index => $criterionId) { + StageEvaluationCriteria::where('id', $criterionId) + ->where('hiring_stage_id', $stage->id) + ->update(['order' => $index + 1]); + } + return true; + } +} diff --git a/database/migrations/2026_04_04_153700_create_stage_evaluation_criteria_table.php b/database/migrations/2026_04_04_153700_create_stage_evaluation_criteria_table.php new file mode 100644 index 000000000..bfc38c4ea --- /dev/null +++ b/database/migrations/2026_04_04_153700_create_stage_evaluation_criteria_table.php @@ -0,0 +1,27 @@ +id(); + $table->foreignId('hiring_stage_id')->constrained()->onDelete('cascade'); + $table->string('label', 255); + $table->enum('input_type', ['numeric', 'yes_no', 'dropdown', 'free_text'])->default('free_text'); + $table->json('options')->nullable(); // For dropdown: array of {value, label} + $table->boolean('is_required')->default(true); + $table->integer('order')->default(0); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('stage_evaluation_criteria'); + } +}; diff --git a/database/migrations/2026_04_04_153701_alter_candidate_assessments_add_evaluation_fields.php b/database/migrations/2026_04_04_153701_alter_candidate_assessments_add_evaluation_fields.php new file mode 100644 index 000000000..22187ba5a --- /dev/null +++ b/database/migrations/2026_04_04_153701_alter_candidate_assessments_add_evaluation_fields.php @@ -0,0 +1,33 @@ +decimal('position_expected_salary', 12, 2)->nullable()->after('expected_salary'); + $table->text('general_notes')->nullable()->after('position_expected_salary'); + $table->json('criteria_values')->nullable()->after('general_notes'); + $table->json('skill_verifications')->nullable()->after('criteria_values'); + $table->enum('save_mode', ['draft', 'submitted'])->default('draft')->after('skill_verifications'); + }); + } + + public function down(): void + { + Schema::table('candidate_assessments', function (Blueprint $table) { + $table->dropColumn([ + 'position_expected_salary', + 'general_notes', + 'criteria_values', + 'skill_verifications', + 'save_mode', + ]); + }); + } +}; diff --git a/public/api-docs.yaml b/public/api-docs.yaml index fc2065688..1e02f10ca 100644 --- a/public/api-docs.yaml +++ b/public/api-docs.yaml @@ -2989,6 +2989,11 @@ components: start_time: { type: string } end_time: { type: string } max_break_minutes: { type: integer } + users: + type: array + items: + type: integer + description: List of user IDs to associate with this shift (Optional) RewardPenaltyInput: type: object