From 6bb00f21533f886f0846e2f407411824f5aa3bb4 Mon Sep 17 00:00:00 2001 From: mohanmd Date: Thu, 2 Jul 2026 11:44:26 +0530 Subject: [PATCH] plan missing fields, employer/plan and employer/dashboard --- .../Api/EmployerAuthController.php | 60 +- .../Api/EmployerProfileController.php | 35 +- .../Controllers/Employer/JobController.php | 24 +- resources/js/Pages/Employer/Jobs/Index.jsx | 549 +++++++++++++----- resources/js/Pages/Employer/Jobs/Show.jsx | 23 + routes/web.php | 1 + tests/Feature/EmployerJobTest.php | 18 + tests/Feature/EmployerProfileApiTest.php | 173 ++++++ 8 files changed, 731 insertions(+), 152 deletions(-) diff --git a/app/Http/Controllers/Api/EmployerAuthController.php b/app/Http/Controllers/Api/EmployerAuthController.php index 031576b..017be9e 100644 --- a/app/Http/Controllers/Api/EmployerAuthController.php +++ b/app/Http/Controllers/Api/EmployerAuthController.php @@ -451,7 +451,7 @@ public function payment(Request $request) { $validator = Validator::make($request->all(), [ 'email' => 'required|email', - 'plan_id' => 'required|string', + 'plan_id' => 'required', 'amount_aed' => 'required|numeric', 'paytabs_transaction_id' => 'required|string', ]); @@ -481,7 +481,21 @@ public function payment(Request $request) ], 403); } - \Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor) { + $planMap = [ + '1' => 'basic', + '2' => 'premium', + '3' => 'vip', + 1 => 'basic', + 2 => 'premium', + 3 => 'vip', + 'basic' => 'basic', + 'premium' => 'premium', + 'enterprise' => 'vip', + 'vip' => 'vip', + ]; + $dbPlanId = $planMap[$request->plan_id] ?? $request->plan_id; + + \Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $dbPlanId) { // Update User subscription $user->update([ 'subscription_status' => 'active', @@ -491,7 +505,7 @@ public function payment(Request $request) // Update Sponsor status $sponsor->update([ 'subscription_status' => 'active', - 'subscription_plan' => $request->plan_id, + 'subscription_plan' => $dbPlanId, 'subscription_start_date' => now(), 'subscription_end_date' => now()->addDays(30), 'payment_status' => 'paid', @@ -500,7 +514,7 @@ public function payment(Request $request) // Insert into subscriptions table \Illuminate\Support\Facades\DB::table('subscriptions')->insert([ 'user_id' => $user->id, - 'plan_id' => $request->plan_id, + 'plan_id' => $dbPlanId, 'amount_aed' => $request->amount_aed, 'starts_at' => now(), 'expires_at' => now()->addDays(30), @@ -614,43 +628,69 @@ public function plans() if ($dbPlans->isEmpty()) { $plans = [ [ - 'id' => 'basic', + 'id' => 1, 'name' => 'Basic Search', 'price' => 99.00, 'currency' => 'AED', 'period' => 'month', 'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'], 'popular' => false, + 'max_contact_people' => 10, + 'unlimited_contacts' => false, + 'enable_job_apply' => false, + 'status' => 'Active', + 'duration' => 'Monthly', ], [ - 'id' => 'premium', + 'id' => 2, 'name' => 'Premium Employer Pass', 'price' => 199.00, 'currency' => 'AED', 'period' => 'month', 'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'], 'popular' => true, + 'max_contact_people' => 50, + 'unlimited_contacts' => false, + 'enable_job_apply' => true, + 'status' => 'Active', + 'duration' => 'Monthly', ], [ - 'id' => 'enterprise', + 'id' => 3, 'name' => 'VIP Concierge', 'price' => 499.00, 'currency' => 'AED', 'period' => 'month', 'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'], 'popular' => false, + 'max_contact_people' => null, + 'unlimited_contacts' => true, + 'enable_job_apply' => true, + 'status' => 'Active', + 'duration' => 'Monthly', ] ]; } else { - $plans = $dbPlans->map(function ($plan) { + $planIdNumberMap = [ + 'basic' => 1, + 'premium' => 2, + 'vip' => 3, + 'enterprise' => 3, + ]; + $plans = $dbPlans->map(function ($plan) use ($planIdNumberMap) { return [ - 'id' => $plan->id === 'vip' ? 'enterprise' : $plan->id, + 'id' => $planIdNumberMap[$plan->id] ?? 2, 'name' => $plan->name, 'price' => (float)$plan->price, 'currency' => 'AED', - 'period' => 'month', + 'period' => strtolower($plan->duration) === 'monthly' ? 'month' : $plan->duration, 'features' => $plan->features, 'popular' => $plan->id === 'premium', + 'max_contact_people' => $plan->max_contact_people, + 'unlimited_contacts' => (bool)$plan->unlimited_contacts, + 'enable_job_apply' => (bool)$plan->enable_job_apply, + 'status' => $plan->status, + 'duration' => $plan->duration, ]; })->toArray(); } diff --git a/app/Http/Controllers/Api/EmployerProfileController.php b/app/Http/Controllers/Api/EmployerProfileController.php index 3b605e7..d93cfb2 100644 --- a/app/Http/Controllers/Api/EmployerProfileController.php +++ b/app/Http/Controllers/Api/EmployerProfileController.php @@ -349,12 +349,43 @@ public function getDashboard(Request $request) ->latest('id') ->first(); + $planId = $sub ? $sub->plan_id : 'premium'; + + // Map raw subscription plan_id to database plan ID string + $planMap = [ + '1' => 'basic', + '2' => 'premium', + '3' => 'vip', + 1 => 'basic', + 2 => 'premium', + 3 => 'vip', + 'basic' => 'basic', + 'premium' => 'premium', + 'enterprise' => 'vip', + 'vip' => 'vip', + ]; + + $dbPlanId = $planMap[$planId] ?? 'premium'; + $associatedPlan = \App\Models\Plan::find($dbPlanId); + + $planIdNumberMap = [ + 'basic' => 1, + 'premium' => 2, + 'vip' => 3, + ]; + $currentPlan = [ - 'plan_id' => $sub ? $sub->plan_id : 'premium', - 'name' => $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass', + 'plan_id' => $associatedPlan ? ($planIdNumberMap[$associatedPlan->id] ?? 2) : 2, + 'name' => $associatedPlan ? $associatedPlan->name : (ucfirst($planId) . ' Pass'), 'status' => $sub ? $sub->status : 'active', 'starts_at' => $sub ? date('Y-m-d', strtotime($sub->starts_at)) : now()->subDays(6)->format('Y-m-d'), 'expires_at' => $sub ? date('Y-m-d', strtotime($sub->expires_at)) : now()->addDays(24)->format('Y-m-d'), + 'max_contact_people' => $associatedPlan ? $associatedPlan->max_contact_people : null, + 'unlimited_contacts' => $associatedPlan ? (bool)$associatedPlan->unlimited_contacts : false, + 'enable_job_apply' => $associatedPlan ? (bool)$associatedPlan->enable_job_apply : false, + 'features' => $associatedPlan ? $associatedPlan->features : [], + 'price' => $associatedPlan ? (float)$associatedPlan->price : 0.0, + 'duration' => $associatedPlan ? $associatedPlan->duration : 'Monthly', ]; // Recent Announcements / Events diff --git a/app/Http/Controllers/Employer/JobController.php b/app/Http/Controllers/Employer/JobController.php index d25646c..1562691 100644 --- a/app/Http/Controllers/Employer/JobController.php +++ b/app/Http/Controllers/Employer/JobController.php @@ -48,7 +48,9 @@ private function checkJobAccess($user) ->first(); $planId = $sub ? $sub->plan_id : 'basic'; - return $planId !== 'basic'; + $associatedPlan = \App\Models\Plan::find($planId); + + return $associatedPlan ? (bool)$associatedPlan->enable_job_apply : ($planId !== 'basic'); } public function index(Request $request) @@ -76,6 +78,7 @@ public function index(Request $request) 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, 'applied_count' => $job->applications->count(), + 'hired_count' => $job->applications->where('status', 'hired')->count(), 'posted_at' => $job->created_at->format('M d, Y'), 'status' => ucfirst($job->status), // Active, Closed, Draft ]; @@ -115,6 +118,7 @@ public function show($id) 'requirements' => $job->requirements, 'status' => ucfirst($job->status), 'applied_count' => $job->applications->count(), + 'hired_count' => $job->applications->where('status', 'hired')->count(), 'posted_at' => $job->created_at->format('M d, Y'), ]; @@ -310,6 +314,24 @@ public function destroy($id) return redirect()->route('employer.jobs')->with('success', 'Job deleted successfully.'); } + public function close($id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return back()->withErrors(['general' => 'User session not found.']); + } + + 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.'); + } + + $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + $job->update(['status' => 'closed']); + + return redirect()->route('employer.jobs.show', $id)->with('success', 'Job closed successfully.'); + } + public function allApplicants(Request $request) { $user = $this->resolveCurrentUser(); diff --git a/resources/js/Pages/Employer/Jobs/Index.jsx b/resources/js/Pages/Employer/Jobs/Index.jsx index ed6fac9..d05a6b2 100644 --- a/resources/js/Pages/Employer/Jobs/Index.jsx +++ b/resources/js/Pages/Employer/Jobs/Index.jsx @@ -17,7 +17,12 @@ import { Clock, Eye, Edit2, - Trash2 + Trash2, + LayoutGrid, + List, + Shield, + Navigation, + Layers } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { @@ -30,10 +35,72 @@ import { } from "@/components/ui/dropdown-menu"; import { toast } from 'sonner'; +// Helper function to map jobs dynamically to categories and skills based on title +const getJobMetadata = (title = '') => { + const t = title.toLowerCase(); + if (t.includes('electrician') || t.includes('electrical')) { + return { + category: 'Skilled Worker', + skills: ['Electrical Wiring', 'Maintenance', 'Safety Inspection', 'Wiring Diagrams'] + }; + } + if (t.includes('clean') || t.includes('housekeep') || t.includes('maid') || t.includes('laundry')) { + return { + category: 'General Worker', + skills: ['Cleaning', 'Housekeeping', 'Laundry', 'Sanitization'] + }; + } + if (t.includes('plumb')) { + return { + category: 'Skilled Worker', + skills: ['Plumbing', 'Pipe Fitting', 'Leak Repair', 'Blueprint Reading'] + }; + } + if (t.includes('security') || t.includes('guard') || t.includes('watchman')) { + return { + category: 'Security', + skills: ['Security', 'Surveillance', 'Access Control', 'Patrolling'] + }; + } + if (t.includes('driver') || t.includes('chauffeur') || t.includes('delivery')) { + return { + category: 'Driver', + skills: ['Driving', 'Route Knowledge', 'Vehicle Safety', 'Navigation'] + }; + } + if (t.includes('mason') || t.includes('carpenter') || t.includes('woodwork') || t.includes('construction')) { + return { + category: 'Skilled Worker', + skills: ['Carpentry', 'Woodwork', 'Masonry', 'Tool Operation'] + }; + } + if (t.includes('store') || t.includes('warehouse') || t.includes('inventory') || t.includes('keeper')) { + return { + category: 'Logistics', + skills: ['Inventory', 'Stock Management', 'Packaging', 'Labeling'] + }; + } + if (t.includes('helper') || t.includes('labor') || t.includes('assistant')) { + return { + category: 'General Worker', + skills: ['Loading', 'Unloading', 'Physical Labor', 'Material Handling'] + }; + } + // Fallback + return { + category: 'Skilled Worker', + skills: ['General Support', 'Task Execution', 'Operations'] + }; +}; + export default function Index({ initialJobs }) { const [searchTerm, setSearchTerm] = useState(''); const [statusFilter, setStatusFilter] = useState('All'); - + const [categoryFilter, setCategoryFilter] = useState('All'); + const [skillFilter, setSkillFilter] = useState('All'); + const [viewMode, setViewMode] = useState('grid'); + const [jobs, setJobs] = useState(initialJobs || []); + const handleDelete = (jobId) => { if (confirm('Are you sure you want to delete this job posting? This action cannot be undone.')) { router.delete(`/employer/jobs/${jobId}`, { @@ -46,21 +113,43 @@ export default function Index({ initialJobs }) { }); } }; - - const [jobs, setJobs] = useState(initialJobs || [ - { id: 1, title: 'Experienced Mason', location: 'Dubai Marina', salary: 2800, workers_needed: 5, applied_count: 3, posted_at: 'Nov 12, 2026', status: 'Active' }, - { id: 2, title: 'Villa Cleaner', location: 'Al Barsha', salary: 1800, workers_needed: 2, applied_count: 12, posted_at: 'Nov 10, 2026', status: 'Active' }, - { id: 3, title: 'Site Supervisor', location: 'Sharjah', salary: 4500, workers_needed: 1, applied_count: 8, posted_at: 'Nov 05, 2026', status: 'Closed' }, - { id: 4, title: 'Plumber for Project', location: 'Jumeirah', salary: 2600, workers_needed: 3, applied_count: 0, posted_at: 'Nov 14, 2026', status: 'Draft' }, - ]); + + // Derived category list + const categories = ['All', 'Skilled Worker', 'General Worker', 'Security', 'Driver', 'Logistics']; + + // Derived skills list + const skills = [ + 'All', + 'Electrical Wiring', + 'Cleaning', + 'Housekeeping', + 'Plumbing', + 'Pipe Fitting', + 'Security', + 'Surveillance', + 'Driving', + 'Route Knowledge', + 'Carpentry', + 'Woodwork', + 'Inventory', + 'Stock Management', + 'Loading', + 'Unloading' + ]; const filteredJobs = useMemo(() => { return jobs.filter(job => { - const matchesSearch = job.title.toLowerCase().includes(searchTerm.toLowerCase()); - const matchesStatus = statusFilter === 'All' || job.status === statusFilter; - return matchesSearch && matchesStatus; + const meta = getJobMetadata(job.title); + const matchesSearch = job.title.toLowerCase().includes(searchTerm.toLowerCase()) || + job.location.toLowerCase().includes(searchTerm.toLowerCase()); + + const matchesStatus = statusFilter === 'All' || job.status.toLowerCase() === statusFilter.toLowerCase(); + const matchesCategory = categoryFilter === 'All' || meta.category === categoryFilter; + const matchesSkill = skillFilter === 'All' || meta.skills.includes(skillFilter); + + return matchesSearch && matchesStatus && matchesCategory && matchesSkill; }); - }, [jobs, searchTerm, statusFilter]); + }, [jobs, searchTerm, statusFilter, categoryFilter, skillFilter]); return ( @@ -70,33 +159,60 @@ export default function Index({ initialJobs }) { {/* Header Actions */}
-

Active Job Postings

-

Manage and track your recruitment status

+

Active Job Postings

+

Manage and track your recruitment status

- + Post a Job
{/* Filters & Search Bar */} -
-
+
+ {/* Search */} +
setSearchTerm(e.target.value)} />
-
-
+ {/* Select Dropdowns */} +
+ + + +
+ + {/* Status Toggle & Views */} +
+
{['All', 'Active', 'Closed', 'Draft'].map(status => ( +
+ +
- {/* Jobs Grid */} -
- {filteredJobs.length > 0 ? ( - filteredJobs.map((job) => ( -
-
-
-
-
- + {/* Jobs Display */} + {filteredJobs.length > 0 ? ( + viewMode === 'grid' ? ( + /* Grid Layout */ +
+ {filteredJobs.map((job) => { + const meta = getJobMetadata(job.title); + const isFilled = job.hired_count >= job.workers_needed; + const percentFilled = Math.min(100, Math.round((job.hired_count / job.workers_needed) * 100)); + + return ( +
+
+ {/* Header */} +
+
+
+ {meta.category === 'Security' ? : + meta.category === 'Driver' ? : + meta.category === 'Logistics' ? : + } +
+
+

+ {job.title} +

+ {job.posted_at} +
+
+ + + + {job.status} +
-
-

- {job.title} -

-
- {job.posted_at} + + {/* Location & Salary */} +
+
+ + {job.location} +
+
+ + $ {job.salary.toLocaleString()} AED / month +
+
+ + {/* Stats & Progress Bar */} +
+
+
+
{job.workers_needed}
+
Total Req
+
+
+
{job.hired_count}
+
Hired
+
+
+
{job.applied_count}
+
Applications
+
+
+ +
+
+
+
+
+ {isFilled ? ( + All positions filled + ) : ( + {job.workers_needed - job.hired_count} more to hire + )} +
+
+
+ + {/* Skills Tags */} +
+
Skills
+
+ {meta.skills.slice(0, 2).map((skill, idx) => ( + + {skill} + + ))} + {meta.skills.length > 2 && ( + + +{meta.skills.length - 2} + + )} +
+
+ + {/* Category Row */} +
+ Category + {meta.category} +
+ + {/* Actions footer */} +
+ + + View Applicants + + + + + + + + Quick Actions + + + + + View Details + + + + + + Edit Posting + + + handleDelete(job.id)}> +
+ + Delete Job +
+
+
+
+
+
+
+ ); + })} +
+ ) : ( + /* List Layout */ +
+ {filteredJobs.map((job) => { + const meta = getJobMetadata(job.title); + const isFilled = job.hired_count >= job.workers_needed; + + return ( +
+
+
+ {meta.category === 'Security' ? : + meta.category === 'Driver' ? : + meta.category === 'Logistics' ? : + } +
+
+
+

+ {job.title} +

+ + {job.status} + +
+
+ Posted {job.posted_at} + {job.location} + $ {job.salary.toLocaleString()} AED + | + {meta.category}
- - {job.status === 'Active' && } - {job.status === 'Closed' && } - {job.status === 'Draft' && } - {job.status} - -
-
-
-
Location
-
- - {job.location} +
+
+
+ Hired: {job.hired_count} / {job.workers_needed} ({job.applied_count} applied) +
+
+ {isFilled ? Filled : {job.workers_needed - job.hired_count} needed} +
-
-
-
Monthly Salary
-
- - {job.salary} AED -
-
-
-
-
-
- {[...Array(3)].map((_, i) => ( -
- {i === 2 ? `+${job.applied_count}` : ''} -
- ))} -
-
- {job.applied_count} Applications +
+ + + Applicants + + + + + + + + Quick Actions + + + + + View Details + + + + + + Edit Posting + + + handleDelete(job.id)}> +
+ + Delete Job +
+
+
+
-
- {job.applied_count < job.workers_needed ? ( - {job.applied_count}/{job.workers_needed} Filled - ) : ( - Fully Staffed - )} -
- -
- - - View Applicants - - - - - - - - Quick Actions - - - - - View Details - - - - - - Edit Posting - - - handleDelete(job.id)}> -
- - Delete Job -
-
-
-
-
-
-
- )) - ) : ( -
- -

No jobs found

- - - Post your first job - + ); + })}
- )} -
+ ) + ) : ( + /* Empty State */ +
+ +

No jobs found

+ + + Post your first job + +
+ )}
); diff --git a/resources/js/Pages/Employer/Jobs/Show.jsx b/resources/js/Pages/Employer/Jobs/Show.jsx index 2529c1d..edc1979 100644 --- a/resources/js/Pages/Employer/Jobs/Show.jsx +++ b/resources/js/Pages/Employer/Jobs/Show.jsx @@ -33,6 +33,19 @@ export default function Show({ job }) { } }; + const handleClose = () => { + if (confirm('Are you sure you want to close this job posting? No more workers will be able to apply.')) { + router.post(`/employer/jobs/${job.id}/close`, {}, { + onSuccess: () => { + toast.success('Job closed successfully.'); + }, + onError: () => { + toast.error('Failed to close job.'); + } + }); + } + }; + return ( @@ -164,6 +177,16 @@ export default function Show({ job }) { Edit Posting + {job.status !== 'Closed' && ( + + )} +