job api, menu
This commit is contained in:
parent
e191516573
commit
685ac1eabd
@ -34,6 +34,7 @@ public function listJobs(Request $request)
|
|||||||
'description' => $job->description,
|
'description' => $job->description,
|
||||||
'requirements' => $job->requirements,
|
'requirements' => $job->requirements,
|
||||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||||
'posted_at' => $job->created_at->toIso8601String(),
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@ -77,6 +78,7 @@ public function jobDetails($id)
|
|||||||
'description' => $job->description,
|
'description' => $job->description,
|
||||||
'requirements' => $job->requirements,
|
'requirements' => $job->requirements,
|
||||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||||
'posted_at' => $job->created_at->toIso8601String(),
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
@ -154,6 +156,7 @@ public function appliedJobs(Request $request)
|
|||||||
'salary' => (int) $job->salary,
|
'salary' => (int) $job->salary,
|
||||||
'job_type' => $job->job_type,
|
'job_type' => $job->job_type,
|
||||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||||
] : null
|
] : null
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@ -697,6 +700,7 @@ public function appliedJobDetail(Request $request, $id)
|
|||||||
'description' => $job->description,
|
'description' => $job->description,
|
||||||
'requirements' => $job->requirements,
|
'requirements' => $job->requirements,
|
||||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||||
'posted_at' => $job->created_at->toIso8601String(),
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
] : null
|
] : null
|
||||||
]
|
]
|
||||||
|
|||||||
@ -309,4 +309,102 @@ public function destroy($id)
|
|||||||
|
|
||||||
return redirect()->route('employer.jobs')->with('success', 'Job deleted successfully.');
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3932,6 +3932,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "Ahmad Tech Ltd"
|
"example": "Ahmad Tech Ltd"
|
||||||
},
|
},
|
||||||
|
"posted_by": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Jane Sponsor"
|
||||||
|
},
|
||||||
"posted_at": {
|
"posted_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
@ -4036,6 +4040,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "Ahmad Tech Ltd"
|
"example": "Ahmad Tech Ltd"
|
||||||
},
|
},
|
||||||
|
"posted_by": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Jane Sponsor"
|
||||||
|
},
|
||||||
"posted_at": {
|
"posted_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time",
|
"format": "date-time",
|
||||||
@ -4238,6 +4246,10 @@
|
|||||||
"company_name": {
|
"company_name": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "Ahmad Tech Ltd"
|
"example": "Ahmad Tech Ltd"
|
||||||
|
},
|
||||||
|
"posted_by": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Jane Sponsor"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6521,7 +6533,7 @@
|
|||||||
"Employer/JobApplicants"
|
"Employer/JobApplicants"
|
||||||
],
|
],
|
||||||
"summary": "Update Job Application Status",
|
"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": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
@ -6553,11 +6565,19 @@
|
|||||||
"enum": [
|
"enum": [
|
||||||
"applied",
|
"applied",
|
||||||
"shortlisted",
|
"shortlisted",
|
||||||
"hired",
|
"contacted",
|
||||||
"rejected"
|
"interview scheduled",
|
||||||
|
"interview_scheduled",
|
||||||
|
"selected",
|
||||||
|
"rejected",
|
||||||
|
"hired"
|
||||||
],
|
],
|
||||||
"example": "shortlisted",
|
"example": "shortlisted",
|
||||||
"description": "The new status of the application."
|
"description": "The new status of the application."
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional internal notes about the applicant/interview"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6602,6 +6622,17 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "shortlisted"
|
"example": "shortlisted"
|
||||||
},
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Scheduled phone screen for tomorrow.",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"status_history": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "date-time"
|
"format": "date-time"
|
||||||
@ -6623,7 +6654,7 @@
|
|||||||
"description": "Unauthenticated or invalid Bearer token."
|
"description": "Unauthenticated or invalid Bearer token."
|
||||||
},
|
},
|
||||||
"403": {
|
"403": {
|
||||||
"description": "Contact limit reached under the current subscription plan.",
|
"description": "Contact limit reached under the current subscription plan or unauthorized.",
|
||||||
"content": {
|
"content": {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
"schema": {
|
"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}": {
|
"/workers/applied-jobs/{id}": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Link, usePage } from '@inertiajs/react';
|
import { Link, usePage } from '@inertiajs/react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
@ -19,7 +19,9 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
FileText,
|
FileText,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
LifeBuoy
|
ChevronRight,
|
||||||
|
LifeBuoy,
|
||||||
|
Users
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@ -63,12 +65,72 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
|||||||
|
|
||||||
const isSubActive = true;
|
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 = [
|
const navItems = [
|
||||||
{ name: 'Dashboard', translationKey: 'dashboard', href: '/employer/dashboard', icon: LayoutDashboard },
|
{ 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: 'Workers',
|
||||||
{ name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck },
|
translationKey: 'workers_group',
|
||||||
...(user.enable_job_apply ? [{ name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs', icon: Briefcase }] : []),
|
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: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count },
|
||||||
{ name: 'Charity Events', translationKey: 'charity_events', href: '/employer/events', icon: Heart },
|
{ name: 'Charity Events', translationKey: 'charity_events', href: '/employer/events', icon: Heart },
|
||||||
{ name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard },
|
{ name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard },
|
||||||
@ -79,8 +141,8 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
|||||||
|
|
||||||
const mobileTabs = [
|
const mobileTabs = [
|
||||||
{ name: 'Search', translationKey: 'search', href: '/employer/workers', icon: Search },
|
{ name: 'Search', translationKey: 'search', href: '/employer/workers', icon: Search },
|
||||||
{ name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark },
|
{ name: 'Saved Workers', translationKey: 'saved_workers', href: '/employer/shortlist', icon: Bookmark },
|
||||||
{ name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck },
|
{ 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: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count },
|
||||||
{ name: 'Profile', translationKey: 'profile', href: '/employer/profile', icon: User },
|
{ name: 'Profile', translationKey: 'profile', href: '/employer/profile', icon: User },
|
||||||
];
|
];
|
||||||
@ -165,6 +227,57 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
|||||||
{/* Navigation Items */}
|
{/* Navigation Items */}
|
||||||
<nav className="p-4 space-y-1">
|
<nav className="p-4 space-y-1">
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
|
if (item.isGroup) {
|
||||||
|
const Icon = item.icon;
|
||||||
|
const isOpen = openGroups[item.groupKey];
|
||||||
|
const hasActiveChild = item.children.some(child => isChildActive(child.href));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={item.name} className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => toggleGroup(item.groupKey)}
|
||||||
|
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl text-sm transition-all duration-200 group cursor-pointer border-0 outline-none bg-transparent ${
|
||||||
|
hasActiveChild
|
||||||
|
? 'text-[#185FA5] font-bold bg-slate-50'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Icon className={`w-5 h-5 transition-colors ${hasActiveChild ? 'text-[#185FA5]' : 'text-slate-400 group-hover:text-slate-900'}`} />
|
||||||
|
<span className="tracking-tight">{t(item.translationKey, item.name)}</span>
|
||||||
|
</div>
|
||||||
|
{isOpen ? (
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${hasActiveChild ? 'text-[#185FA5]' : 'text-slate-400 group-hover:text-slate-900'}`} />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className={`w-4 h-4 transition-transform duration-200 ${hasActiveChild ? 'text-[#185FA5]' : 'text-slate-400 group-hover:text-slate-900'}`} />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="pl-6 border-l border-slate-200 ml-6 space-y-1 py-1">
|
||||||
|
{item.children.map((child) => {
|
||||||
|
const isCurrent = isChildActive(child.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={child.name}
|
||||||
|
href={child.href}
|
||||||
|
className={`flex items-center px-4 py-2 rounded-xl text-xs transition-all duration-200 tracking-tight ${
|
||||||
|
isCurrent
|
||||||
|
? 'bg-[#185FA5] text-white font-bold shadow-md shadow-blue-900/10 scale-[1.01]'
|
||||||
|
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-800 font-medium'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`mr-2.5 text-[10px] leading-none ${isCurrent ? 'text-white' : 'text-slate-300'}`}>•</span>
|
||||||
|
<span>{t(child.translationKey, child.name)}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
let isActive = url.startsWith(item.href);
|
let isActive = url.startsWith(item.href);
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Applicants({ job, applicants: initialApplicants }) {
|
export default function Applicants({ job, applicants: initialApplicants, isShortlistedOnly = false }) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [applicants, setApplicants] = useState(initialApplicants || []);
|
const [applicants, setApplicants] = useState(initialApplicants || []);
|
||||||
|
|
||||||
@ -55,39 +55,56 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
|||||||
setSelectedApp(null);
|
setSelectedApp(null);
|
||||||
},
|
},
|
||||||
onError: (errors) => {
|
onError: (errors) => {
|
||||||
toast.error('Failed to update status: ' . (errors.error || errors.status || 'Error occurred'));
|
toast.error('Failed to update status: ' + (errors.error || errors.status || 'Error occurred'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const pageTitle = isShortlistedOnly ? 'Shortlisted Workers' : (job ? 'Job Applicants' : 'All Applicants');
|
||||||
|
const headerTitle = isShortlistedOnly ? 'Shortlisted Workers' : (job ? `Applicants for ${job.title}` : 'All Job Applicants');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title="Job Applicants">
|
<EmployerLayout title={pageTitle}>
|
||||||
<Head title={`Applicants for ${job?.title || 'Job'}`} />
|
<Head title={pageTitle} />
|
||||||
|
|
||||||
<div className="space-y-8 select-none pb-12">
|
<div className="space-y-8 select-none pb-12">
|
||||||
{/* Job Header Info */}
|
{/* Job Header Info */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Link
|
<Link
|
||||||
href="/employer/jobs"
|
href={job ? "/employer/jobs" : "/employer/dashboard"}
|
||||||
className="inline-flex items-center space-x-2 text-[10px] font-black text-[#185FA5] uppercase tracking-widest hover:translate-x-[-4px] transition-transform"
|
className="inline-flex items-center space-x-2 text-[10px] font-black text-[#185FA5] uppercase tracking-widest hover:translate-x-[-4px] transition-transform"
|
||||||
>
|
>
|
||||||
<ArrowLeft className="w-3.5 h-3.5" />
|
<ArrowLeft className="w-3.5 h-3.5" />
|
||||||
<span>Back to My Jobs</span>
|
<span>{job ? 'Back to My Jobs' : 'Back to Dashboard'}</span>
|
||||||
</Link>
|
</Link>
|
||||||
<h1 className="text-2xl font-black text-slate-900 tracking-tight">
|
<h1 className="text-2xl font-black text-slate-900 tracking-tight">
|
||||||
Applicants for <span className="text-[#185FA5]">{job?.title || 'Senior Mason Project'}</span>
|
{isShortlistedOnly ? (
|
||||||
|
<span>Shortlisted <span className="text-[#185FA5]">Workers</span></span>
|
||||||
|
) : (
|
||||||
|
<span>Applicants for <span className="text-[#185FA5]">{job?.title || 'All Jobs'}</span></span>
|
||||||
|
)}
|
||||||
</h1>
|
</h1>
|
||||||
|
{job && (
|
||||||
<div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
<div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||||
<span className="flex items-center"><DollarSign className="w-3.5 h-3.5 mr-1" /> {job?.salary || '2800'} AED</span>
|
<span className="flex items-center"><DollarSign className="w-3.5 h-3.5 mr-1" /> {job.salary} AED</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white px-6 py-4 rounded-2xl border border-slate-200 shadow-sm flex items-center space-x-4 flex-shrink-0">
|
<div className="bg-white px-6 py-4 rounded-2xl border border-slate-200 shadow-sm flex items-center space-x-4 flex-shrink-0">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Hiring Progress</div>
|
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">
|
||||||
|
{isShortlistedOnly ? 'Total Shortlisted' : (job ? 'Hiring Progress' : 'Total Applicants')}
|
||||||
|
</div>
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tight">
|
<div className="text-lg font-black text-slate-900 tracking-tight">
|
||||||
{applicants.filter(a => a.status?.toLowerCase() === 'hired').length} / {job?.workers_needed || 5} <span className="text-slate-300 text-sm">Positions</span>
|
{isShortlistedOnly ? (
|
||||||
|
<span>{applicants.length} <span className="text-slate-300 text-sm">Workers</span></span>
|
||||||
|
) : job ? (
|
||||||
|
<span>{applicants.filter(a => a.status?.toLowerCase() === 'hired').length} / {job.workers_needed} <span className="text-slate-300 text-sm">Positions</span></span>
|
||||||
|
) : (
|
||||||
|
<span>{applicants.length} <span className="text-slate-300 text-sm">Applicants</span></span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center">
|
||||||
@ -134,9 +151,14 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
|||||||
)}
|
)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center space-x-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">
|
<div className="flex items-center space-x-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">
|
||||||
<Globe2 className="w-3 h-3" />
|
<Globe2 className="w-3.5 h-3.5" />
|
||||||
<span>{applicant.nationality}</span>
|
<span>{applicant.nationality}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{applicant.job_title && (
|
||||||
|
<div className="text-[10px] text-slate-500 font-bold mt-1">
|
||||||
|
Applied: <span className="text-[#185FA5]">{applicant.job_title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end space-y-1">
|
<div className="flex flex-col items-end space-y-1">
|
||||||
|
|||||||
@ -426,5 +426,13 @@
|
|||||||
"share_experience_placeholder": "Tell other sponsors about childcare skills, cleaning, cooking style, driving safety, etc...",
|
"share_experience_placeholder": "Tell other sponsors about childcare skills, cleaning, cooking style, driving safety, etc...",
|
||||||
"post_trust_review_action": "Post Trust Review",
|
"post_trust_review_action": "Post Trust Review",
|
||||||
"no_charity_events_title": "No Charity Events",
|
"no_charity_events_title": "No Charity Events",
|
||||||
"no_charity_events_desc": "You haven't posted any charity events yet"
|
"no_charity_events_desc": "You haven't posted any charity events yet",
|
||||||
|
"workers_group": "Workers",
|
||||||
|
"workers_list": "Workers List",
|
||||||
|
"hired_workers": "Hired Workers",
|
||||||
|
"jobs_group": "Jobs",
|
||||||
|
"applicants": "Applicants",
|
||||||
|
"shortlisted_workers": "Shortlisted Workers",
|
||||||
|
"help_support": "Help & Support",
|
||||||
|
"payment_history": "Payment History"
|
||||||
}
|
}
|
||||||
@ -425,5 +425,13 @@
|
|||||||
"share_experience_placeholder": "अन्य प्रायोजकों को बाल देखभाल कौशल, सफाई, खाना पकाने की शैली, ड्राइविंग सुरक्षा आदि के बारे में बताएं...",
|
"share_experience_placeholder": "अन्य प्रायोजकों को बाल देखभाल कौशल, सफाई, खाना पकाने की शैली, ड्राइविंग सुरक्षा आदि के बारे में बताएं...",
|
||||||
"post_trust_review_action": "समीक्षा पोस्ट करें",
|
"post_trust_review_action": "समीक्षा पोस्ट करें",
|
||||||
"no_charity_events_title": "कोई चैरिटी कार्यक्रम नहीं",
|
"no_charity_events_title": "कोई चैरिटी कार्यक्रम नहीं",
|
||||||
"no_charity_events_desc": "आपने अभी तक कोई चैरिटी कार्यक्रम पोस्ट नहीं किया है"
|
"no_charity_events_desc": "आपने अभी तक कोई चैरिटी कार्यक्रम पोस्ट नहीं किया है",
|
||||||
|
"workers_group": "कामगार",
|
||||||
|
"workers_list": "कामगारों की सूची",
|
||||||
|
"hired_workers": "नियुक्त कर्मचारी",
|
||||||
|
"jobs_group": "नौकरियाँ",
|
||||||
|
"applicants": "आवेदक",
|
||||||
|
"shortlisted_workers": "शॉर्टलिस्ट किए गए कामगार",
|
||||||
|
"help_support": "सहायता और समर्थन",
|
||||||
|
"payment_history": "भुगतान इतिहास"
|
||||||
}
|
}
|
||||||
@ -413,6 +413,8 @@
|
|||||||
Route::get('/jobs', [\App\Http\Controllers\Employer\JobController::class, 'index'])->name('employer.jobs');
|
Route::get('/jobs', [\App\Http\Controllers\Employer\JobController::class, 'index'])->name('employer.jobs');
|
||||||
Route::get('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'create'])->name('employer.jobs.create');
|
Route::get('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'create'])->name('employer.jobs.create');
|
||||||
Route::post('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'store'])->name('employer.jobs.store');
|
Route::post('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'store'])->name('employer.jobs.store');
|
||||||
|
Route::get('/jobs/all-applicants', [\App\Http\Controllers\Employer\JobController::class, 'allApplicants'])->name('employer.jobs.all-applicants');
|
||||||
|
Route::get('/jobs/shortlisted', [\App\Http\Controllers\Employer\JobController::class, 'shortlistedApplicants'])->name('employer.jobs.shortlisted');
|
||||||
Route::get('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'edit'])->name('employer.jobs.edit');
|
Route::get('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'edit'])->name('employer.jobs.edit');
|
||||||
Route::post('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'update'])->name('employer.jobs.update');
|
Route::post('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'update'])->name('employer.jobs.update');
|
||||||
Route::delete('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'destroy'])->name('employer.jobs.destroy');
|
Route::delete('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'destroy'])->name('employer.jobs.destroy');
|
||||||
|
|||||||
@ -186,4 +186,92 @@ public function test_employer_can_view_applicants()
|
|||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertSee('John Worker');
|
$response->assertSee('John Worker');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test viewing all job applicants.
|
||||||
|
*/
|
||||||
|
public function test_employer_can_view_all_applicants()
|
||||||
|
{
|
||||||
|
$job = JobPost::create([
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'title' => 'Mason for Project',
|
||||||
|
'workers_needed' => 3,
|
||||||
|
'job_type' => 'Contract',
|
||||||
|
'location' => 'Sharjah',
|
||||||
|
'salary' => 3000,
|
||||||
|
'start_date' => '2026-07-15',
|
||||||
|
'description' => 'Contract mason project.',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$worker = Worker::create([
|
||||||
|
'name' => 'John Worker',
|
||||||
|
'email' => 'worker@example.com',
|
||||||
|
'phone' => '+971500000001',
|
||||||
|
'nationality' => 'Indian',
|
||||||
|
'age' => 30,
|
||||||
|
'salary' => 2500,
|
||||||
|
'availability' => 'Available',
|
||||||
|
'experience' => '4 Years',
|
||||||
|
'religion' => 'Christian',
|
||||||
|
'bio' => 'Vetted domestic worker with great experience.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$application = JobApplication::create([
|
||||||
|
'job_id' => $job->id,
|
||||||
|
'worker_id' => $worker->id,
|
||||||
|
'status' => 'applied',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get("/employer/jobs/all-applicants");
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertSee('John Worker');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test viewing shortlisted applicants.
|
||||||
|
*/
|
||||||
|
public function test_employer_can_view_shortlisted_applicants()
|
||||||
|
{
|
||||||
|
$job = JobPost::create([
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'title' => 'Mason for Project',
|
||||||
|
'workers_needed' => 3,
|
||||||
|
'job_type' => 'Contract',
|
||||||
|
'location' => 'Sharjah',
|
||||||
|
'salary' => 3000,
|
||||||
|
'start_date' => '2026-07-15',
|
||||||
|
'description' => 'Contract mason project.',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$worker = Worker::create([
|
||||||
|
'name' => 'Shortlisted John',
|
||||||
|
'email' => 'worker_shortlisted@example.com',
|
||||||
|
'phone' => '+971500000002',
|
||||||
|
'nationality' => 'Indian',
|
||||||
|
'age' => 30,
|
||||||
|
'salary' => 2500,
|
||||||
|
'availability' => 'Available',
|
||||||
|
'experience' => '4 Years',
|
||||||
|
'religion' => 'Christian',
|
||||||
|
'bio' => 'Vetted domestic worker with great experience.',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$application = JobApplication::create([
|
||||||
|
'job_id' => $job->id,
|
||||||
|
'worker_id' => $worker->id,
|
||||||
|
'status' => 'shortlisted',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get("/employer/jobs/shortlisted");
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertSee('Shortlisted John');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,6 +154,7 @@ public function test_worker_job_apis()
|
|||||||
$response->assertJsonPath('success', true);
|
$response->assertJsonPath('success', true);
|
||||||
$response->assertJsonCount(1, 'data.jobs');
|
$response->assertJsonCount(1, 'data.jobs');
|
||||||
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
|
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
|
||||||
|
$response->assertJsonPath('data.jobs.0.posted_by', 'Employer User');
|
||||||
|
|
||||||
// 2. Retrieve job details
|
// 2. Retrieve job details
|
||||||
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
|
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
|
||||||
@ -162,6 +163,7 @@ public function test_worker_job_apis()
|
|||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonPath('success', true);
|
$response->assertJsonPath('success', true);
|
||||||
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
|
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
|
||||||
|
$response->assertJsonPath('data.job.posted_by', 'Employer User');
|
||||||
|
|
||||||
// 3. Apply for job
|
// 3. Apply for job
|
||||||
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||||
@ -191,6 +193,7 @@ public function test_worker_job_apis()
|
|||||||
$response->assertJsonPath('success', true);
|
$response->assertJsonPath('success', true);
|
||||||
$response->assertJsonCount(1, 'data.applications');
|
$response->assertJsonCount(1, 'data.applications');
|
||||||
$response->assertJsonPath('data.applications.0.job.title', 'Available Plumber Job');
|
$response->assertJsonPath('data.applications.0.job.title', 'Available Plumber Job');
|
||||||
|
$response->assertJsonPath('data.applications.0.job.posted_by', 'Employer User');
|
||||||
|
|
||||||
$application = JobApplication::first();
|
$application = JobApplication::first();
|
||||||
|
|
||||||
@ -463,6 +466,7 @@ public function test_job_hiring_workflow_enhancements()
|
|||||||
$response->assertStatus(200);
|
$response->assertStatus(200);
|
||||||
$response->assertJsonPath('success', true);
|
$response->assertJsonPath('success', true);
|
||||||
$response->assertJsonPath('data.application.status', 'applied');
|
$response->assertJsonPath('data.application.status', 'applied');
|
||||||
|
$response->assertJsonPath('data.application.job.posted_by', 'Employer User');
|
||||||
$response->assertJsonCount(0, 'data.application.status_history');
|
$response->assertJsonCount(0, 'data.application.status_history');
|
||||||
|
|
||||||
// 4. Employer updates status to shortlisted with notes
|
// 4. Employer updates status to shortlisted with notes
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user