From 685ac1eabd47f26eaf75a42bbbf95de68582f6fc Mon Sep 17 00:00:00 2001 From: mohanmd Date: Wed, 1 Jul 2026 14:24:17 +0530 Subject: [PATCH] job api, menu --- .../Controllers/Api/WorkerJobController.php | 4 + .../Controllers/Employer/JobController.php | 98 +++++++++++++ public/swagger.json | 111 +++++---------- resources/js/Layouts/EmployerLayout.jsx | 129 ++++++++++++++++-- .../js/Pages/Employer/Jobs/Applicants.jsx | 48 +++++-- resources/js/lang/en.json | 10 +- resources/js/lang/hi.json | 10 +- routes/web.php | 2 + tests/Feature/EmployerJobTest.php | 88 ++++++++++++ tests/Feature/WorkerJobApiTest.php | 4 + 10 files changed, 405 insertions(+), 99 deletions(-) diff --git a/app/Http/Controllers/Api/WorkerJobController.php b/app/Http/Controllers/Api/WorkerJobController.php index 256a2ca..69ebc2a 100644 --- a/app/Http/Controllers/Api/WorkerJobController.php +++ b/app/Http/Controllers/Api/WorkerJobController.php @@ -34,6 +34,7 @@ public function listJobs(Request $request) 'description' => $job->description, 'requirements' => $job->requirements, 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_by' => $job->employer->name ?? 'Private Sponsor', 'posted_at' => $job->created_at->toIso8601String(), ]; }); @@ -77,6 +78,7 @@ public function jobDetails($id) 'description' => $job->description, 'requirements' => $job->requirements, 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_by' => $job->employer->name ?? 'Private Sponsor', 'posted_at' => $job->created_at->toIso8601String(), ] ] @@ -154,6 +156,7 @@ public function appliedJobs(Request $request) 'salary' => (int) $job->salary, 'job_type' => $job->job_type, 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_by' => $job->employer->name ?? 'Private Sponsor', ] : null ]; }); @@ -697,6 +700,7 @@ public function appliedJobDetail(Request $request, $id) 'description' => $job->description, 'requirements' => $job->requirements, 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_by' => $job->employer->name ?? 'Private Sponsor', 'posted_at' => $job->created_at->toIso8601String(), ] : null ] diff --git a/app/Http/Controllers/Employer/JobController.php b/app/Http/Controllers/Employer/JobController.php index ca4d4e9..d25646c 100644 --- a/app/Http/Controllers/Employer/JobController.php +++ b/app/Http/Controllers/Employer/JobController.php @@ -309,4 +309,102 @@ public function destroy($id) return redirect()->route('employer.jobs')->with('success', 'Job deleted successfully.'); } + + public function allApplicants(Request $request) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $jobIds = JobPost::where('employer_id', $user->id)->pluck('id'); + + $dbApplications = JobApplication::whereIn('job_id', $jobIds) + ->with(['worker.skills', 'jobPost']) + ->get(); + + $applicants = $dbApplications->map(function ($app) use ($user) { + $worker = $app->worker; + if (!$worker) return null; + $isSaved = \App\Models\Shortlist::where('employer_id', $user->id) + ->where('worker_id', $worker->id) + ->exists(); + return [ + 'id' => $worker->id, + 'application_id' => $app->id, + 'name' => $worker->name, + 'nationality' => $worker->nationality, + 'salary' => (int) $worker->salary, + 'experience' => ($worker->experience_years ?? 3) . ' Years', + 'status' => ucfirst($app->status), + 'notes' => $app->notes, + 'status_history' => $app->status_history ?: [], + 'match_score' => $worker->match_score ?? 90, + 'is_saved' => $isSaved, + 'job_title' => $app->jobPost->title ?? 'N/A', + 'job_id' => $app->job_id, + ]; + })->filter()->values()->toArray(); + + return Inertia::render('Employer/Jobs/Applicants', [ + 'job' => null, + 'applicants' => $applicants, + ]); + } + + public function shortlistedApplicants(Request $request) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + + $jobIds = JobPost::where('employer_id', $user->id)->pluck('id'); + + $dbApplications = JobApplication::whereIn('job_id', $jobIds) + ->where('status', 'shortlisted') + ->with(['worker.skills', 'jobPost']) + ->get(); + + $applicants = $dbApplications->map(function ($app) use ($user) { + $worker = $app->worker; + if (!$worker) return null; + $isSaved = \App\Models\Shortlist::where('employer_id', $user->id) + ->where('worker_id', $worker->id) + ->exists(); + return [ + 'id' => $worker->id, + 'application_id' => $app->id, + 'name' => $worker->name, + 'nationality' => $worker->nationality, + 'salary' => (int) $worker->salary, + 'experience' => ($worker->experience_years ?? 3) . ' Years', + 'status' => ucfirst($app->status), + 'notes' => $app->notes, + 'status_history' => $app->status_history ?: [], + 'match_score' => $worker->match_score ?? 90, + 'is_saved' => $isSaved, + 'job_title' => $app->jobPost->title ?? 'N/A', + 'job_id' => $app->job_id, + ]; + })->filter()->values()->toArray(); + + return Inertia::render('Employer/Jobs/Applicants', [ + 'job' => null, + 'applicants' => $applicants, + 'isShortlistedOnly' => true, + ]); + } } + + diff --git a/public/swagger.json b/public/swagger.json index 6268ff4..eae0d4a 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -3932,6 +3932,10 @@ "type": "string", "example": "Ahmad Tech Ltd" }, + "posted_by": { + "type": "string", + "example": "Jane Sponsor" + }, "posted_at": { "type": "string", "format": "date-time", @@ -4036,6 +4040,10 @@ "type": "string", "example": "Ahmad Tech Ltd" }, + "posted_by": { + "type": "string", + "example": "Jane Sponsor" + }, "posted_at": { "type": "string", "format": "date-time", @@ -4238,6 +4246,10 @@ "company_name": { "type": "string", "example": "Ahmad Tech Ltd" + }, + "posted_by": { + "type": "string", + "example": "Jane Sponsor" } } } @@ -6521,7 +6533,7 @@ "Employer/JobApplicants" ], "summary": "Update Job Application Status", - "description": "Allows employers to update the status of a job application (shortlisted, hired, rejected, etc.). Subscription limits (contact limits) are enforced when moving a worker to shortlisted or hired status for the first time.", + "description": "Allows employers to update the status of a job application (e.g. shortlist, contacted, interview scheduled, selected, hired, rejected) with optional internal notes. Subscription limits (contact limits) are enforced when moving a worker to a contact status (shortlisted, contacted, interview_scheduled, selected, hired) for the first time.", "security": [ { "bearerAuth": [] @@ -6553,11 +6565,19 @@ "enum": [ "applied", "shortlisted", - "hired", - "rejected" + "contacted", + "interview scheduled", + "interview_scheduled", + "selected", + "rejected", + "hired" ], "example": "shortlisted", "description": "The new status of the application." + }, + "notes": { + "type": "string", + "description": "Optional internal notes about the applicant/interview" } } } @@ -6602,6 +6622,17 @@ "type": "string", "example": "shortlisted" }, + "notes": { + "type": "string", + "example": "Scheduled phone screen for tomorrow.", + "nullable": true + }, + "status_history": { + "type": "array", + "items": { + "type": "object" + } + }, "created_at": { "type": "string", "format": "date-time" @@ -6623,7 +6654,7 @@ "description": "Unauthenticated or invalid Bearer token." }, "403": { - "description": "Contact limit reached under the current subscription plan.", + "description": "Contact limit reached under the current subscription plan or unauthorized.", "content": { "application/json": { "schema": { @@ -7423,78 +7454,6 @@ } } }, - "/employers/applications/{id}/status": { - "post": { - "tags": [ - "Employer/Jobs" - ], - "summary": "Update Job Application Status", - "description": "Allows employers to update the status of a job application (e.g. shortlist, interview, hire, reject) with optional internal notes. Enforces plan-based contact limits and automatically adds to shortlist/Saved Workers.", - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "description": "The Job Application ID", - "schema": { - "type": "integer" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": [ - "status" - ], - "properties": { - "status": { - "type": "string", - "enum": [ - "applied", - "shortlisted", - "contacted", - "interview scheduled", - "interview_scheduled", - "selected", - "rejected", - "hired" - ], - "description": "New hiring stage status" - }, - "notes": { - "type": "string", - "description": "Optional internal notes about the applicant/interview" - } - } - } - } - } - }, - "responses": { - "200": { - "description": "Status updated successfully.", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { "type": "boolean", "example": true }, - "message": { "type": "string", "example": "Application status updated successfully." } - } - } - } - } - }, - "403": { - "description": "Plan limits reached or unauthorized." - } - } - } - }, "/workers/applied-jobs/{id}": { "get": { "tags": [ diff --git a/resources/js/Layouts/EmployerLayout.jsx b/resources/js/Layouts/EmployerLayout.jsx index d5cf296..66c8892 100644 --- a/resources/js/Layouts/EmployerLayout.jsx +++ b/resources/js/Layouts/EmployerLayout.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { Link, usePage } from '@inertiajs/react'; import { toast } from 'sonner'; import { @@ -19,7 +19,9 @@ import { Heart, FileText, ChevronDown, - LifeBuoy + ChevronRight, + LifeBuoy, + Users } from 'lucide-react'; import { DropdownMenu, @@ -63,12 +65,72 @@ export default function EmployerLayout({ children, title, fullPage = false }) { const isSubActive = true; + const [openGroups, setOpenGroups] = useState({ + workers: url.startsWith('/employer/workers') || url.startsWith('/employer/shortlist') || url.startsWith('/employer/candidates'), + jobs: url.startsWith('/employer/jobs'), + }); + + useEffect(() => { + setOpenGroups({ + workers: url.startsWith('/employer/workers') || url.startsWith('/employer/shortlist') || url.startsWith('/employer/candidates'), + jobs: url.startsWith('/employer/jobs'), + }); + }, [url]); + + const toggleGroup = (groupKey) => { + setOpenGroups(prev => ({ + ...prev, + [groupKey]: !prev[groupKey] + })); + }; + + const isChildActive = (childHref) => { + let isActive = url.startsWith(childHref); + + if (url.startsWith('/employer/workers/')) { + const isHired = props.worker?.status === 'Hired' || props.worker?.availability_status === 'Hired'; + if (isHired) { + if (childHref === '/employer/candidates') { + isActive = true; + } else if (childHref === '/employer/workers') { + isActive = false; + } + } else { + if (childHref === '/employer/workers') { + isActive = true; + } + } + } + + return isActive; + }; + const navItems = [ { name: 'Dashboard', translationKey: 'dashboard', href: '/employer/dashboard', icon: LayoutDashboard }, - { name: 'Find Workers', translationKey: 'find_workers', href: '/employer/workers', icon: Search }, - { name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark }, - { name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck }, - ...(user.enable_job_apply ? [{ name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs', icon: Briefcase }] : []), + { + name: 'Workers', + translationKey: 'workers_group', + icon: Users, + isGroup: true, + groupKey: 'workers', + children: [ + { name: 'Workers List', translationKey: 'workers_list', href: '/employer/workers' }, + { name: 'Saved Workers', translationKey: 'saved_workers', href: '/employer/shortlist' }, + { name: 'Hired Workers', translationKey: 'hired_workers', href: '/employer/candidates' }, + ] + }, + ...(user.enable_job_apply ? [{ + name: 'Jobs', + translationKey: 'jobs_group', + icon: Briefcase, + isGroup: true, + groupKey: 'jobs', + children: [ + { name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs' }, + { name: 'Applicants', translationKey: 'applicants', href: '/employer/jobs/all-applicants' }, + { name: 'Shortlisted Workers', translationKey: 'shortlisted_workers', href: '/employer/jobs/shortlisted' }, + ] + }] : []), { name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count }, { name: 'Charity Events', translationKey: 'charity_events', href: '/employer/events', icon: Heart }, { name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard }, @@ -79,8 +141,8 @@ export default function EmployerLayout({ children, title, fullPage = false }) { const mobileTabs = [ { name: 'Search', translationKey: 'search', href: '/employer/workers', icon: Search }, - { name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark }, - { name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck }, + { name: 'Saved Workers', translationKey: 'saved_workers', href: '/employer/shortlist', icon: Bookmark }, + { name: 'Hired Workers', translationKey: 'hired_workers', href: '/employer/candidates', icon: UserCheck }, { name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count }, { name: 'Profile', translationKey: 'profile', href: '/employer/profile', icon: User }, ]; @@ -165,6 +227,57 @@ export default function EmployerLayout({ children, title, fullPage = false }) { {/* Navigation Items */}