plan missing fields, employer/plan and employer/dashboard

This commit is contained in:
mohanmd 2026-07-02 11:44:26 +05:30
parent 685ac1eabd
commit 6bb00f2153
8 changed files with 731 additions and 152 deletions

View File

@ -451,7 +451,7 @@ public function payment(Request $request)
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'required|email', 'email' => 'required|email',
'plan_id' => 'required|string', 'plan_id' => 'required',
'amount_aed' => 'required|numeric', 'amount_aed' => 'required|numeric',
'paytabs_transaction_id' => 'required|string', 'paytabs_transaction_id' => 'required|string',
]); ]);
@ -481,7 +481,21 @@ public function payment(Request $request)
], 403); ], 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 // Update User subscription
$user->update([ $user->update([
'subscription_status' => 'active', 'subscription_status' => 'active',
@ -491,7 +505,7 @@ public function payment(Request $request)
// Update Sponsor status // Update Sponsor status
$sponsor->update([ $sponsor->update([
'subscription_status' => 'active', 'subscription_status' => 'active',
'subscription_plan' => $request->plan_id, 'subscription_plan' => $dbPlanId,
'subscription_start_date' => now(), 'subscription_start_date' => now(),
'subscription_end_date' => now()->addDays(30), 'subscription_end_date' => now()->addDays(30),
'payment_status' => 'paid', 'payment_status' => 'paid',
@ -500,7 +514,7 @@ public function payment(Request $request)
// Insert into subscriptions table // Insert into subscriptions table
\Illuminate\Support\Facades\DB::table('subscriptions')->insert([ \Illuminate\Support\Facades\DB::table('subscriptions')->insert([
'user_id' => $user->id, 'user_id' => $user->id,
'plan_id' => $request->plan_id, 'plan_id' => $dbPlanId,
'amount_aed' => $request->amount_aed, 'amount_aed' => $request->amount_aed,
'starts_at' => now(), 'starts_at' => now(),
'expires_at' => now()->addDays(30), 'expires_at' => now()->addDays(30),
@ -614,43 +628,69 @@ public function plans()
if ($dbPlans->isEmpty()) { if ($dbPlans->isEmpty()) {
$plans = [ $plans = [
[ [
'id' => 'basic', 'id' => 1,
'name' => 'Basic Search', 'name' => 'Basic Search',
'price' => 99.00, 'price' => 99.00,
'currency' => 'AED', 'currency' => 'AED',
'period' => 'month', 'period' => 'month',
'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'], 'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'],
'popular' => false, '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', 'name' => 'Premium Employer Pass',
'price' => 199.00, 'price' => 199.00,
'currency' => 'AED', 'currency' => 'AED',
'period' => 'month', 'period' => 'month',
'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'], 'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'],
'popular' => true, 'popular' => true,
'max_contact_people' => 50,
'unlimited_contacts' => false,
'enable_job_apply' => true,
'status' => 'Active',
'duration' => 'Monthly',
], ],
[ [
'id' => 'enterprise', 'id' => 3,
'name' => 'VIP Concierge', 'name' => 'VIP Concierge',
'price' => 499.00, 'price' => 499.00,
'currency' => 'AED', 'currency' => 'AED',
'period' => 'month', 'period' => 'month',
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'], 'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
'popular' => false, 'popular' => false,
'max_contact_people' => null,
'unlimited_contacts' => true,
'enable_job_apply' => true,
'status' => 'Active',
'duration' => 'Monthly',
] ]
]; ];
} else { } else {
$plans = $dbPlans->map(function ($plan) { $planIdNumberMap = [
'basic' => 1,
'premium' => 2,
'vip' => 3,
'enterprise' => 3,
];
$plans = $dbPlans->map(function ($plan) use ($planIdNumberMap) {
return [ return [
'id' => $plan->id === 'vip' ? 'enterprise' : $plan->id, 'id' => $planIdNumberMap[$plan->id] ?? 2,
'name' => $plan->name, 'name' => $plan->name,
'price' => (float)$plan->price, 'price' => (float)$plan->price,
'currency' => 'AED', 'currency' => 'AED',
'period' => 'month', 'period' => strtolower($plan->duration) === 'monthly' ? 'month' : $plan->duration,
'features' => $plan->features, 'features' => $plan->features,
'popular' => $plan->id === 'premium', '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(); })->toArray();
} }

View File

@ -349,12 +349,43 @@ public function getDashboard(Request $request)
->latest('id') ->latest('id')
->first(); ->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 = [ $currentPlan = [
'plan_id' => $sub ? $sub->plan_id : 'premium', 'plan_id' => $associatedPlan ? ($planIdNumberMap[$associatedPlan->id] ?? 2) : 2,
'name' => $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass', 'name' => $associatedPlan ? $associatedPlan->name : (ucfirst($planId) . ' Pass'),
'status' => $sub ? $sub->status : 'active', 'status' => $sub ? $sub->status : 'active',
'starts_at' => $sub ? date('Y-m-d', strtotime($sub->starts_at)) : now()->subDays(6)->format('Y-m-d'), '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'), '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 // Recent Announcements / Events

View File

@ -48,7 +48,9 @@ private function checkJobAccess($user)
->first(); ->first();
$planId = $sub ? $sub->plan_id : 'basic'; $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) public function index(Request $request)
@ -76,6 +78,7 @@ public function index(Request $request)
'salary' => (int) $job->salary, 'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed, 'workers_needed' => $job->workers_needed,
'applied_count' => $job->applications->count(), 'applied_count' => $job->applications->count(),
'hired_count' => $job->applications->where('status', 'hired')->count(),
'posted_at' => $job->created_at->format('M d, Y'), 'posted_at' => $job->created_at->format('M d, Y'),
'status' => ucfirst($job->status), // Active, Closed, Draft 'status' => ucfirst($job->status), // Active, Closed, Draft
]; ];
@ -115,6 +118,7 @@ public function show($id)
'requirements' => $job->requirements, 'requirements' => $job->requirements,
'status' => ucfirst($job->status), 'status' => ucfirst($job->status),
'applied_count' => $job->applications->count(), 'applied_count' => $job->applications->count(),
'hired_count' => $job->applications->where('status', 'hired')->count(),
'posted_at' => $job->created_at->format('M d, Y'), '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.'); 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) public function allApplicants(Request $request)
{ {
$user = $this->resolveCurrentUser(); $user = $this->resolveCurrentUser();

View File

@ -17,7 +17,12 @@ import {
Clock, Clock,
Eye, Eye,
Edit2, Edit2,
Trash2 Trash2,
LayoutGrid,
List,
Shield,
Navigation,
Layers
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@ -30,9 +35,71 @@ import {
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { toast } from 'sonner'; 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 }) { export default function Index({ initialJobs }) {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('All'); 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) => { const handleDelete = (jobId) => {
if (confirm('Are you sure you want to delete this job posting? This action cannot be undone.')) { if (confirm('Are you sure you want to delete this job posting? This action cannot be undone.')) {
@ -47,20 +114,42 @@ export default function Index({ initialJobs }) {
} }
}; };
const [jobs, setJobs] = useState(initialJobs || [ // Derived category list
{ id: 1, title: 'Experienced Mason', location: 'Dubai Marina', salary: 2800, workers_needed: 5, applied_count: 3, posted_at: 'Nov 12, 2026', status: 'Active' }, const categories = ['All', 'Skilled Worker', 'General Worker', 'Security', 'Driver', 'Logistics'];
{ 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' }, // Derived skills list
{ id: 4, title: 'Plumber for Project', location: 'Jumeirah', salary: 2600, workers_needed: 3, applied_count: 0, posted_at: 'Nov 14, 2026', status: 'Draft' }, 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(() => { const filteredJobs = useMemo(() => {
return jobs.filter(job => { return jobs.filter(job => {
const matchesSearch = job.title.toLowerCase().includes(searchTerm.toLowerCase()); const meta = getJobMetadata(job.title);
const matchesStatus = statusFilter === 'All' || job.status === statusFilter; const matchesSearch = job.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
return matchesSearch && matchesStatus; 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 ( return (
<EmployerLayout title="My Jobs"> <EmployerLayout title="My Jobs">
@ -70,33 +159,60 @@ export default function Index({ initialJobs }) {
{/* Header Actions */} {/* Header Actions */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-black text-slate-900 tracking-tight">Active Job Postings</h1> <h1 className="text-[28px] font-black text-[#0f172a] tracking-tight">Active Job Postings</h1>
<p className="text-xs font-medium text-slate-500 mt-1">Manage and track your recruitment status</p> <p className="text-sm font-medium text-slate-500 mt-1">Manage and track your recruitment status</p>
</div> </div>
<Link <Link
href="/employer/jobs/create" href="/employer/jobs/create"
className="bg-[#185FA5] text-white px-6 py-3 rounded-2xl font-black text-xs uppercase tracking-widest shadow-xl shadow-blue-500/20 hover:bg-[#144f8a] transition-all flex items-center justify-center space-x-2" className="bg-[#185FA5] text-white px-7 py-3.5 rounded-[18px] font-extrabold text-xs uppercase tracking-widest shadow-xl shadow-blue-500/10 hover:bg-[#144f8a] transition-all flex items-center justify-center space-x-2"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4 stroke-[3px]" />
<span>Post a Job</span> <span>Post a Job</span>
</Link> </Link>
</div> </div>
{/* Filters & Search Bar */} {/* Filters & Search Bar */}
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4"> <div className="bg-white p-6 rounded-[28px] border border-slate-200/80 shadow-sm flex flex-col xl:flex-row gap-4 items-stretch xl:items-center">
<div className="relative flex-1"> {/* Search */}
<div className="relative flex-1 min-w-[280px]">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Search className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input <input
type="text" type="text"
placeholder="Search by job title..." placeholder="Search by job title, skills, or location..."
className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all" className="w-full pl-11 pr-4 py-3 bg-slate-50/50 border border-slate-200/80 rounded-2xl text-xs font-bold text-slate-800 focus:ring-4 focus:ring-blue-500/5 focus:border-[#185FA5] focus:bg-white outline-none transition-all placeholder-slate-400"
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
/> />
</div> </div>
<div className="flex items-center space-x-3"> {/* Select Dropdowns */}
<div className="flex items-center bg-slate-50 border border-slate-100 p-1.5 rounded-2xl"> <div className="flex flex-col sm:flex-row gap-3">
<select
className="px-4 py-3 bg-slate-50/50 border border-slate-200/80 rounded-2xl text-xs font-bold text-slate-700 focus:ring-4 focus:ring-blue-500/5 focus:border-[#185FA5] outline-none transition-all min-w-[160px]"
value={categoryFilter}
onChange={e => setCategoryFilter(e.target.value)}
>
<option value="All">All Categories</option>
{categories.filter(c => c !== 'All').map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
<select
className="px-4 py-3 bg-slate-50/50 border border-slate-200/80 rounded-2xl text-xs font-bold text-slate-700 focus:ring-4 focus:ring-blue-500/5 focus:border-[#185FA5] outline-none transition-all min-w-[160px]"
value={skillFilter}
onChange={e => setSkillFilter(e.target.value)}
>
<option value="All">All Skills</option>
{skills.filter(s => s !== 'All').map(s => (
<option key={s} value={s}>{s}</option>
))}
</select>
</div>
{/* Status Toggle & Views */}
<div className="flex flex-wrap items-center gap-4 justify-between xl:justify-end flex-shrink-0">
<div className="flex items-center bg-slate-50 border border-slate-100 p-1 rounded-2xl">
{['All', 'Active', 'Closed', 'Draft'].map(status => ( {['All', 'Active', 'Closed', 'Draft'].map(status => (
<button <button
key={status} key={status}
@ -116,137 +232,292 @@ export default function Index({ initialJobs }) {
<button className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-[#185FA5] rounded-xl transition-all shadow-sm"> <button className="p-3 bg-white border border-slate-200 text-slate-400 hover:text-[#185FA5] rounded-xl transition-all shadow-sm">
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</button> </button>
<div className="h-6 w-px bg-slate-200" />
<button
onClick={() => setViewMode('grid')}
className={`p-3 rounded-xl transition-all border ${viewMode === 'grid' ? 'bg-blue-50 border-blue-100 text-[#185FA5]' : 'bg-white border-slate-200 text-slate-400 hover:text-[#185FA5]'}`}
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-3 rounded-xl transition-all border ${viewMode === 'list' ? 'bg-blue-50 border-blue-100 text-[#185FA5]' : 'bg-white border-slate-200 text-slate-400 hover:text-[#185FA5]'}`}
>
<List className="w-4 h-4" />
</button>
</div> </div>
</div> </div>
</div> </div>
{/* Jobs Grid */} {/* Jobs Display */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> {filteredJobs.length > 0 ? (
{filteredJobs.length > 0 ? ( viewMode === 'grid' ? (
filteredJobs.map((job) => ( /* Grid Layout */
<div key={job.id} className="bg-white rounded-[32px] border border-slate-200 shadow-sm hover:shadow-md transition-all overflow-hidden group"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div className="p-8 space-y-6"> {filteredJobs.map((job) => {
<div className="flex items-start justify-between"> const meta = getJobMetadata(job.title);
<div className="flex items-start space-x-4"> const isFilled = job.hired_count >= job.workers_needed;
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center flex-shrink-0"> const percentFilled = Math.min(100, Math.round((job.hired_count / job.workers_needed) * 100));
<Briefcase className="w-6 h-6 text-[#185FA5]" />
return (
<div key={job.id} className="bg-white rounded-[28px] border border-slate-200/80 shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden flex flex-col group">
<div className="p-6 flex-1 flex flex-col justify-between space-y-5">
{/* Header */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-start space-x-3.5">
<div className="w-11 h-11 bg-blue-50/50 rounded-2xl flex items-center justify-center flex-shrink-0 border border-blue-100/50">
{meta.category === 'Security' ? <Shield className="w-5.5 h-5.5 text-[#185FA5]" /> :
meta.category === 'Driver' ? <Navigation className="w-5.5 h-5.5 text-[#185FA5]" /> :
meta.category === 'Logistics' ? <Layers className="w-5.5 h-5.5 text-[#185FA5]" /> :
<Briefcase className="w-5.5 h-5.5 text-[#185FA5]" />}
</div>
<div>
<h3 className="font-extrabold text-slate-900 text-[14px] leading-snug tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-2">
<Link href={`/employer/jobs/${job.id}`}>{job.title}</Link>
</h3>
<span className="text-[10px] font-bold text-slate-400 block mt-1 uppercase tracking-wider">{job.posted_at}</span>
</div>
</div>
<Badge
variant="outline"
className={`px-2.5 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider flex items-center flex-shrink-0 border-0 ${
job.status.toLowerCase() === 'active' ? 'bg-emerald-50 text-emerald-700' :
job.status.toLowerCase() === 'closed' ? 'bg-rose-50 text-rose-700' :
'bg-slate-50 text-slate-500'
}`}
>
<span className={`w-1.5 h-1.5 rounded-full mr-1.5 ${
job.status.toLowerCase() === 'active' ? 'bg-emerald-500' :
job.status.toLowerCase() === 'closed' ? 'bg-rose-500' :
'bg-slate-400'
}`} />
{job.status}
</Badge>
</div> </div>
<div>
<h3 className="font-black text-slate-900 tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-1"> {/* Location & Salary */}
<Link href={`/employer/jobs/${job.id}`}>{job.title}</Link> <div className="space-y-2 text-xs font-bold text-slate-600">
</h3> <div className="flex items-center space-x-2">
<div className="flex items-center space-x-2 mt-1"> <MapPin className="w-4 h-4 text-slate-400" />
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{job.posted_at}</span> <span className="line-clamp-1">{job.location}</span>
</div>
<div className="flex items-center space-x-2">
<DollarSign className="w-4 h-4 text-emerald-600" />
<span>$ {job.salary.toLocaleString()} AED / month</span>
</div>
</div>
{/* Stats & Progress Bar */}
<div className="space-y-2 bg-slate-50/50 p-4 rounded-2xl border border-slate-100">
<div className="grid grid-cols-3 gap-2 text-center">
<div>
<div className="text-[14px] font-black text-slate-900">{job.workers_needed}</div>
<div className="text-[9px] font-extrabold text-slate-400 uppercase tracking-wider">Total Req</div>
</div>
<div>
<div className="text-[14px] font-black text-slate-900">{job.hired_count}</div>
<div className="text-[9px] font-extrabold text-slate-400 uppercase tracking-wider">Hired</div>
</div>
<div>
<div className="text-[14px] font-black text-slate-900">{job.applied_count}</div>
<div className="text-[9px] font-extrabold text-slate-400 uppercase tracking-wider">Applications</div>
</div>
</div>
<div className="space-y-1">
<div className="w-full h-1.5 bg-slate-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${isFilled ? 'bg-emerald-500' : 'bg-[#185FA5]'}`}
style={{ width: `${percentFilled}%` }}
/>
</div>
<div className="flex justify-end text-[9px] font-black uppercase tracking-wider">
{isFilled ? (
<span className="text-emerald-600">All positions filled</span>
) : (
<span className="text-slate-500">{job.workers_needed - job.hired_count} more to hire</span>
)}
</div>
</div>
</div>
{/* Skills Tags */}
<div className="space-y-1">
<div className="text-[9px] font-extrabold text-slate-400 uppercase tracking-wider">Skills</div>
<div className="flex flex-wrap gap-1.5">
{meta.skills.slice(0, 2).map((skill, idx) => (
<span key={idx} className="bg-slate-50 text-slate-600 border border-slate-100 px-2.5 py-1 rounded-lg text-[10px] font-bold">
{skill}
</span>
))}
{meta.skills.length > 2 && (
<span className="bg-blue-50 text-[#185FA5] px-2 py-1 rounded-lg text-[10px] font-black">
+{meta.skills.length - 2}
</span>
)}
</div>
</div>
{/* Category Row */}
<div className="flex items-center justify-between text-xs border-t border-slate-100 pt-3.5">
<span className="font-bold text-slate-400">Category</span>
<span className="font-extrabold text-slate-700">{meta.category}</span>
</div>
{/* Actions footer */}
<div className="flex items-center space-x-3 pt-1">
<Link
href={`/employer/jobs/${job.id}/applicants`}
className="flex-1 bg-[#185FA5] text-white py-3 rounded-xl font-black text-[10px] uppercase tracking-[0.15em] shadow-lg shadow-blue-500/5 hover:bg-[#144f8a] transition-all flex items-center justify-center space-x-2"
>
<Eye className="w-3.5 h-3.5" />
<span>View Applicants</span>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-3 bg-slate-50 hover:bg-slate-100 border border-slate-100 text-slate-400 hover:text-slate-900 rounded-xl transition-all">
<MoreHorizontal className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 p-2 rounded-2xl shadow-xl border border-slate-100">
<DropdownMenuLabel className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2 py-2">Quick Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="p-2.5 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
<Link href={`/employer/jobs/${job.id}`} className="flex items-center space-x-3 text-slate-700 w-full">
<Eye className="w-4 h-4 text-blue-500" />
<span className="text-xs font-bold">View Details</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="p-2.5 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
<Link href={`/employer/jobs/${job.id}/edit`} className="flex items-center space-x-3 text-[#185FA5] w-full">
<Edit2 className="w-4 h-4" />
<span className="text-xs font-bold">Edit Posting</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="p-2.5 rounded-xl focus:bg-rose-50 cursor-pointer" onClick={() => handleDelete(job.id)}>
<div className="flex items-center space-x-3 text-rose-600 w-full">
<Trash2 className="w-4 h-4" />
<span className="text-xs font-bold">Delete Job</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
);
})}
</div>
) : (
/* List Layout */
<div className="bg-white rounded-[28px] border border-slate-200/80 shadow-sm overflow-hidden divide-y divide-slate-100">
{filteredJobs.map((job) => {
const meta = getJobMetadata(job.title);
const isFilled = job.hired_count >= job.workers_needed;
return (
<div key={job.id} className="p-6 flex flex-col lg:flex-row lg:items-center justify-between gap-6 hover:bg-slate-50/50 transition-colors">
<div className="flex items-start space-x-4 flex-1">
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center flex-shrink-0 border border-blue-100/50">
{meta.category === 'Security' ? <Shield className="w-6 h-6 text-[#185FA5]" /> :
meta.category === 'Driver' ? <Navigation className="w-6 h-6 text-[#185FA5]" /> :
meta.category === 'Logistics' ? <Layers className="w-6 h-6 text-[#185FA5]" /> :
<Briefcase className="w-6 h-6 text-[#185FA5]" />}
</div>
<div className="space-y-1 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-extrabold text-slate-900 text-base leading-snug tracking-tight hover:text-[#185FA5] transition-colors">
<Link href={`/employer/jobs/${job.id}`}>{job.title}</Link>
</h3>
<Badge
variant="outline"
className={`px-2.5 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider flex items-center border-0 ${
job.status.toLowerCase() === 'active' ? 'bg-emerald-50 text-emerald-700' :
job.status.toLowerCase() === 'closed' ? 'bg-rose-50 text-rose-700' :
'bg-slate-50 text-slate-500'
}`}
>
{job.status}
</Badge>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs font-bold text-slate-500">
<span className="uppercase text-[9px] tracking-wider font-extrabold text-slate-400">Posted {job.posted_at}</span>
<span className="flex items-center space-x-1"><MapPin className="w-3.5 h-3.5 text-slate-400" /> <span>{job.location}</span></span>
<span className="flex items-center space-x-1"><DollarSign className="w-3.5 h-3.5 text-emerald-600" /> <span>$ {job.salary.toLocaleString()} AED</span></span>
<span className="text-slate-400">|</span>
<span className="text-[#185FA5]">{meta.category}</span>
</div> </div>
</div> </div>
</div> </div>
<Badge
variant="outline"
className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${
job.status === 'Active' ? 'bg-emerald-50 text-emerald-700 border-emerald-100' :
job.status === 'Closed' ? 'bg-rose-50 text-rose-700 border-rose-100' :
'bg-slate-50 text-slate-500 border-slate-100'
}`}
>
{job.status === 'Active' && <CheckCircle2 className="w-3 h-3 mr-1" />}
{job.status === 'Closed' && <XCircle className="w-3 h-3 mr-1" />}
{job.status === 'Draft' && <Clock className="w-3 h-3 mr-1" />}
{job.status}
</Badge>
</div>
<div className="grid grid-cols-2 gap-4 py-4 border-y border-slate-50"> <div className="flex flex-wrap items-center gap-6 justify-between lg:justify-end">
<div className="space-y-1"> <div className="text-right space-y-1">
<div className="text-[9px] font-black text-slate-400 uppercase tracking-[0.15em]">Location</div> <div className="text-xs font-bold text-slate-500">
<div className="flex items-center space-x-1.5 text-xs font-bold text-slate-700"> Hired: <span className="text-slate-950 font-black">{job.hired_count}</span> / <span className="text-slate-600">{job.workers_needed}</span> ({job.applied_count} applied)
<MapPin className="w-3.5 h-3.5 text-slate-400" /> </div>
<span>{job.location}</span> <div className="text-[10px] font-black uppercase tracking-wider">
{isFilled ? <span className="text-emerald-600">Filled</span> : <span className="text-[#185FA5]">{job.workers_needed - job.hired_count} needed</span>}
</div>
</div> </div>
</div>
<div className="space-y-1"> <div className="flex items-center space-x-3">
<div className="text-[9px] font-black text-slate-400 uppercase tracking-[0.15em]">Monthly Salary</div> <Link
<div className="flex items-center space-x-1.5 text-xs font-bold text-slate-700"> href={`/employer/jobs/${job.id}/applicants`}
<DollarSign className="w-3.5 h-3.5 text-emerald-600" /> className="bg-[#185FA5] text-white px-5 py-2.5 rounded-xl font-black text-[10px] uppercase tracking-[0.15em] shadow-lg shadow-blue-500/5 hover:bg-[#144f8a] transition-all flex items-center space-x-2"
<span>{job.salary} AED</span> >
<Eye className="w-3.5 h-3.5" />
<span>Applicants</span>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-2.5 bg-slate-50 hover:bg-slate-100 border border-slate-100 text-slate-400 hover:text-slate-900 rounded-xl transition-all">
<MoreHorizontal className="w-4 h-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 p-2 rounded-2xl shadow-xl border border-slate-100">
<DropdownMenuLabel className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2 py-2">Quick Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="p-2.5 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
<Link href={`/employer/jobs/${job.id}`} className="flex items-center space-x-3 text-slate-700 w-full">
<Eye className="w-4 h-4 text-blue-500" />
<span className="text-xs font-bold">View Details</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="p-2.5 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
<Link href={`/employer/jobs/${job.id}/edit`} className="flex items-center space-x-3 text-[#185FA5] w-full">
<Edit2 className="w-4 h-4" />
<span className="text-xs font-bold">Edit Posting</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="p-2.5 rounded-xl focus:bg-rose-50 cursor-pointer" onClick={() => handleDelete(job.id)}>
<div className="flex items-center space-x-3 text-rose-600 w-full">
<Trash2 className="w-4 h-4" />
<span className="text-xs font-bold">Delete Job</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div> </div>
</div> </div>
</div> </div>
);
<div className="flex items-center justify-between pt-2"> })}
<div className="flex items-center space-x-3">
<div className="flex -space-x-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="w-6 h-6 rounded-full border-2 border-white bg-slate-100 flex items-center justify-center text-[8px] font-bold text-slate-400">
{i === 2 ? `+${job.applied_count}` : ''}
</div>
))}
</div>
<div className="text-[10px] font-black text-slate-600 uppercase tracking-tight">
{job.applied_count} <span className="text-slate-400">Applications</span>
</div>
</div>
<div className="text-[10px] font-black text-slate-600 uppercase tracking-tight">
{job.applied_count < job.workers_needed ? (
<span>{job.applied_count}/{job.workers_needed} <span className="text-slate-400">Filled</span></span>
) : (
<span className="text-emerald-600 flex items-center"><CheckCircle2 className="w-3 h-3 mr-1" /> Fully Staffed</span>
)}
</div>
</div>
<div className="flex items-center space-x-3 pt-2">
<Link
href={`/employer/jobs/${job.id}/applicants`}
className="flex-1 bg-[#185FA5] text-white py-3 rounded-xl font-black text-[10px] uppercase tracking-[0.15em] shadow-lg shadow-blue-500/10 hover:bg-[#144f8a] transition-all flex items-center justify-center space-x-2"
>
<Eye className="w-3.5 h-3.5" />
<span>View Applicants</span>
</Link>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-3 bg-slate-50 text-slate-400 hover:text-slate-900 rounded-xl transition-all">
<MoreHorizontal className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 p-2 rounded-2xl shadow-xl">
<DropdownMenuLabel className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2 py-2">Quick Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className="p-3 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
<Link href={`/employer/jobs/${job.id}`} className="flex items-center space-x-3 text-slate-700 w-full">
<Eye className="w-4 h-4 text-blue-500" />
<span className="text-xs font-bold">View Details</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="p-3 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
<Link href={`/employer/jobs/${job.id}/edit`} className="flex items-center space-x-3 text-[#185FA5] w-full">
<Edit2 className="w-4 h-4" />
<span className="text-xs font-bold">Edit Posting</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="p-3 rounded-xl focus:bg-rose-50 cursor-pointer" onClick={() => handleDelete(job.id)}>
<div className="flex items-center space-x-3 text-rose-600 w-full">
<Trash2 className="w-4 h-4" />
<span className="text-xs font-bold">Delete Job</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
))
) : (
<div className="col-span-full py-20 text-center bg-white rounded-[32px] border border-dashed border-slate-200">
<Briefcase className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<h3 className="text-base font-bold text-slate-400 uppercase tracking-widest">No jobs found</h3>
<Link href="/employer/jobs/create" className="mt-4 inline-flex items-center space-x-2 text-[#185FA5] font-black text-xs uppercase tracking-widest hover:underline">
<Plus className="w-4 h-4" />
<span>Post your first job</span>
</Link>
</div> </div>
)} )
</div> ) : (
/* Empty State */
<div className="col-span-full py-20 text-center bg-white rounded-[28px] border border-dashed border-slate-200">
<Briefcase className="w-12 h-12 text-slate-200 mx-auto mb-4" />
<h3 className="text-base font-bold text-slate-400 uppercase tracking-widest">No jobs found</h3>
<Link href="/employer/jobs/create" className="mt-4 inline-flex items-center space-x-2 text-[#185FA5] font-black text-xs uppercase tracking-widest hover:underline">
<Plus className="w-4 h-4" />
<span>Post your first job</span>
</Link>
</div>
)}
</div> </div>
</EmployerLayout> </EmployerLayout>
); );

View File

@ -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 ( return (
<EmployerLayout title="Job Details"> <EmployerLayout title="Job Details">
<Head title={`Job Details: ${job.title} - Employer Portal`} /> <Head title={`Job Details: ${job.title} - Employer Portal`} />
@ -164,6 +177,16 @@ export default function Show({ job }) {
<span>Edit Posting</span> <span>Edit Posting</span>
</Link> </Link>
{job.status !== 'Closed' && (
<button
onClick={handleClose}
className="bg-emerald-50 border border-emerald-100 text-emerald-700 py-4 px-6 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-emerald-100/50 transition-all flex items-center justify-center space-x-2"
>
<CheckCircle2 className="w-4 h-4" />
<span>Close Job</span>
</button>
)}
<button <button
onClick={handleDelete} onClick={handleDelete}
className="bg-rose-50 border border-rose-100 text-rose-700 py-4 px-6 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-rose-100/50 transition-all flex items-center justify-center space-x-2" className="bg-rose-50 border border-rose-100 text-rose-700 py-4 px-6 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-rose-100/50 transition-all flex items-center justify-center space-x-2"

View File

@ -419,6 +419,7 @@
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');
Route::get('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'show'])->name('employer.jobs.show'); Route::get('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'show'])->name('employer.jobs.show');
Route::post('/jobs/{id}/close', [\App\Http\Controllers\Employer\JobController::class, 'close'])->name('employer.jobs.close');
Route::get('/jobs/{id}/applicants', [\App\Http\Controllers\Employer\JobController::class, 'applicants'])->name('employer.jobs.applicants'); Route::get('/jobs/{id}/applicants', [\App\Http\Controllers\Employer\JobController::class, 'applicants'])->name('employer.jobs.applicants');
Route::get('/subscription', function () { Route::get('/subscription', function () {
$sess = session('user'); $sess = session('user');

View File

@ -58,6 +58,24 @@ public function test_employer_with_basic_plan_cannot_view_jobs_list()
$response->assertSessionHas('error'); $response->assertSessionHas('error');
} }
/**
* Test displaying the employer jobs list with no subscription.
*/
public function test_employer_with_no_subscription_cannot_view_jobs_list()
{
// Delete subscription
\Illuminate\Support\Facades\DB::table('subscriptions')
->where('user_id', $this->employer->id)
->delete();
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->get('/employer/jobs');
$response->assertRedirect('/employer/subscription');
$response->assertSessionHas('error');
}
/** /**
* Test displaying the employer jobs list. * Test displaying the employer jobs list.
*/ */

View File

@ -410,4 +410,177 @@ public function test_employer_registration_normalizes_dates()
'emirates_id_expiry' => '2025-06-06', 'emirates_id_expiry' => '2025-06-06',
]); ]);
} }
/**
* Test employer dashboard returns subscription plan details and access rights.
*/
public function test_employer_dashboard_returns_plan_details()
{
// 1. First, request dashboard without any subscription in DB.
// It should fallback to the default 'premium' plan.
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
])->getJson('/api/employers/dashboard');
$response->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'current_plan' => [
'plan_id',
'name',
'status',
'starts_at',
'expires_at',
'max_contact_people',
'unlimited_contacts',
'enable_job_apply',
'features',
'price',
'duration',
]
]
]);
$this->assertEquals(2, $response->json('data.current_plan.plan_id'));
$this->assertEquals(50, $response->json('data.current_plan.max_contact_people'));
$this->assertFalse($response->json('data.current_plan.unlimited_contacts'));
$this->assertTrue($response->json('data.current_plan.enable_job_apply'));
// 2. Now update/prepare the plan and create an active subscription for the employer
\App\Models\Plan::updateOrCreate(
['id' => 'basic'],
[
'name' => 'Basic Plan',
'price' => 99.00,
'duration' => 'Monthly',
'features' => ['Feature 1', 'Feature 2'],
'status' => 'Active',
'max_contact_people' => 10,
'unlimited_contacts' => false,
'enable_job_apply' => false,
]
);
\Illuminate\Support\Facades\DB::table('subscriptions')->insert([
'user_id' => $this->employer->id,
'plan_id' => 'basic',
'amount_aed' => 99.00,
'starts_at' => now(),
'expires_at' => now()->addDays(30),
'paytabs_transaction_id' => 'tx_test123',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$responseSub = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
])->getJson('/api/employers/dashboard');
$responseSub->assertStatus(200);
$this->assertEquals(1, $responseSub->json('data.current_plan.plan_id'));
$this->assertEquals('Basic Plan', $responseSub->json('data.current_plan.name'));
$this->assertEquals(10, $responseSub->json('data.current_plan.max_contact_people'));
$this->assertFalse($responseSub->json('data.current_plan.unlimited_contacts'));
$this->assertFalse($responseSub->json('data.current_plan.enable_job_apply'));
$this->assertEquals(99.00, $responseSub->json('data.current_plan.price'));
// 3. Test that a subscription with plan_id 'enterprise' resolves correctly to 'vip' in the response.
\App\Models\Plan::updateOrCreate(
['id' => 'vip'],
[
'name' => 'VIP Concierge',
'price' => 499.00,
'duration' => 'Monthly',
'features' => ['VIP Feature 1'],
'status' => 'Active',
'max_contact_people' => null,
'unlimited_contacts' => true,
'enable_job_apply' => true,
]
);
\Illuminate\Support\Facades\DB::table('subscriptions')->where('user_id', $this->employer->id)->delete();
\Illuminate\Support\Facades\DB::table('subscriptions')->insert([
'user_id' => $this->employer->id,
'plan_id' => 'enterprise',
'amount_aed' => 499.00,
'starts_at' => now(),
'expires_at' => now()->addDays(30),
'paytabs_transaction_id' => 'tx_test456',
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
$responseVip = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->token,
])->getJson('/api/employers/dashboard');
$responseVip->assertStatus(200);
$this->assertEquals(3, $responseVip->json('data.current_plan.plan_id'));
$this->assertEquals('VIP Concierge', $responseVip->json('data.current_plan.name'));
$this->assertNull($responseVip->json('data.current_plan.max_contact_people'));
$this->assertTrue($responseVip->json('data.current_plan.unlimited_contacts'));
}
/**
* Test employer can fetch plans and pay with numeric plan ID.
*/
public function test_employer_can_get_plans_and_pay_with_numeric_id()
{
// 1. Fetch plans
$responsePlans = $this->getJson('/api/employers/plans');
$responsePlans->assertStatus(200)
->assertJsonStructure([
'success',
'data' => [
'plans' => [
'*' => [
'id',
'name',
'price',
'currency',
'period',
'features',
'popular',
'max_contact_people',
'unlimited_contacts',
'enable_job_apply',
'status',
'duration',
]
]
]
]);
$plans = $responsePlans->json('data.plans');
$this->assertNotEmpty($plans);
foreach ($plans as $plan) {
$this->assertIsInt($plan['id']);
}
// 2. Submit payment with a numeric plan ID (e.g. 3)
$responsePayment = $this->postJson('/api/employers/payment', [
'email' => $this->employer->email,
'plan_id' => 3,
'amount_aed' => 499.00,
'paytabs_transaction_id' => 'tx_numeric123',
]);
$responsePayment->assertStatus(200)
->assertJson([
'success' => true,
]);
// Assert database subscription resolved to 'vip' string
$this->assertDatabaseHas('subscriptions', [
'user_id' => $this->employer->id,
'plan_id' => 'vip',
'amount_aed' => 499.00,
'paytabs_transaction_id' => 'tx_numeric123',
'status' => 'active',
]);
}
} }