job api, menu
This commit is contained in:
parent
e191516573
commit
685ac1eabd
@ -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
|
||||
]
|
||||
|
||||
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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 */}
|
||||
<nav className="p-4 space-y-1">
|
||||
{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;
|
||||
let isActive = url.startsWith(item.href);
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ import {
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 [applicants, setApplicants] = useState(initialApplicants || []);
|
||||
|
||||
@ -55,39 +55,56 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
setSelectedApp(null);
|
||||
},
|
||||
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 (
|
||||
<EmployerLayout title="Job Applicants">
|
||||
<Head title={`Applicants for ${job?.title || 'Job'}`} />
|
||||
<EmployerLayout title={pageTitle}>
|
||||
<Head title={pageTitle} />
|
||||
|
||||
<div className="space-y-8 select-none pb-12">
|
||||
{/* Job Header Info */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
<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>
|
||||
{job && (
|
||||
<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 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-[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">
|
||||
{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 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>
|
||||
<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>
|
||||
</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 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...",
|
||||
"post_trust_review_action": "Post Trust Review",
|
||||
"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": "अन्य प्रायोजकों को बाल देखभाल कौशल, सफाई, खाना पकाने की शैली, ड्राइविंग सुरक्षा आदि के बारे में बताएं...",
|
||||
"post_trust_review_action": "समीक्षा पोस्ट करें",
|
||||
"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/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::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::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');
|
||||
|
||||
@ -186,4 +186,92 @@ public function test_employer_can_view_applicants()
|
||||
$response->assertStatus(200);
|
||||
$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->assertJsonCount(1, 'data.jobs');
|
||||
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
|
||||
$response->assertJsonPath('data.jobs.0.posted_by', 'Employer User');
|
||||
|
||||
// 2. Retrieve job details
|
||||
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
|
||||
@ -162,6 +163,7 @@ public function test_worker_job_apis()
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
|
||||
$response->assertJsonPath('data.job.posted_by', 'Employer User');
|
||||
|
||||
// 3. Apply for job
|
||||
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||
@ -191,6 +193,7 @@ public function test_worker_job_apis()
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonCount(1, 'data.applications');
|
||||
$response->assertJsonPath('data.applications.0.job.title', 'Available Plumber Job');
|
||||
$response->assertJsonPath('data.applications.0.job.posted_by', 'Employer User');
|
||||
|
||||
$application = JobApplication::first();
|
||||
|
||||
@ -463,6 +466,7 @@ public function test_job_hiring_workflow_enhancements()
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.application.status', 'applied');
|
||||
$response->assertJsonPath('data.application.job.posted_by', 'Employer User');
|
||||
$response->assertJsonCount(0, 'data.application.status_history');
|
||||
|
||||
// 4. Employer updates status to shortlisted with notes
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user