plan missing fields, employer/plan and employer/dashboard
This commit is contained in:
parent
685ac1eabd
commit
6bb00f2153
@ -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();
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,9 +35,71 @@ 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.')) {
|
||||
@ -47,20 +114,42 @@ 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 (
|
||||
<EmployerLayout title="My Jobs">
|
||||
@ -70,33 +159,60 @@ export default function Index({ initialJobs }) {
|
||||
{/* Header Actions */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-slate-900 tracking-tight">Active Job Postings</h1>
|
||||
<p className="text-xs font-medium text-slate-500 mt-1">Manage and track your recruitment status</p>
|
||||
<h1 className="text-[28px] font-black text-[#0f172a] tracking-tight">Active Job Postings</h1>
|
||||
<p className="text-sm font-medium text-slate-500 mt-1">Manage and track your recruitment status</p>
|
||||
</div>
|
||||
<Link
|
||||
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>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 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="relative flex-1">
|
||||
<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">
|
||||
{/* 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" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by job title..."
|
||||
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"
|
||||
placeholder="Search by job title, skills, or location..."
|
||||
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}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center bg-slate-50 border border-slate-100 p-1.5 rounded-2xl">
|
||||
{/* Select Dropdowns */}
|
||||
<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 => (
|
||||
<button
|
||||
key={status}
|
||||
@ -116,88 +232,144 @@ 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">
|
||||
<Download className="w-4 h-4" />
|
||||
</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>
|
||||
|
||||
{/* Jobs Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Jobs Display */}
|
||||
{filteredJobs.length > 0 ? (
|
||||
filteredJobs.map((job) => (
|
||||
<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="p-8 space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="w-12 h-12 bg-blue-50 rounded-2xl flex items-center justify-center flex-shrink-0">
|
||||
<Briefcase className="w-6 h-6 text-[#185FA5]" />
|
||||
viewMode === 'grid' ? (
|
||||
/* Grid Layout */
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{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 (
|
||||
<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-black text-slate-900 tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-1">
|
||||
<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>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{job.posted_at}</span>
|
||||
</div>
|
||||
<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-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'
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{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" />}
|
||||
<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 className="grid grid-cols-2 gap-4 py-4 border-y border-slate-50">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-[0.15em]">Location</div>
|
||||
<div className="flex items-center space-x-1.5 text-xs font-bold text-slate-700">
|
||||
<MapPin className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>{job.location}</span>
|
||||
{/* Location & Salary */}
|
||||
<div className="space-y-2 text-xs font-bold text-slate-600">
|
||||
<div className="flex items-center space-x-2">
|
||||
<MapPin className="w-4 h-4 text-slate-400" />
|
||||
<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="text-[9px] font-black text-slate-400 uppercase tracking-[0.15em]">Monthly Salary</div>
|
||||
<div className="flex items-center space-x-1.5 text-xs font-bold text-slate-700">
|
||||
<DollarSign className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span>{job.salary} AED</span>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
))}
|
||||
</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>
|
||||
{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>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-2">
|
||||
{/* 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/10 hover:bg-[#144f8a] transition-all flex items-center justify-center space-x-2"
|
||||
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>
|
||||
@ -205,26 +377,26 @@ export default function Index({ initialJobs }) {
|
||||
|
||||
<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 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">
|
||||
<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-3 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
|
||||
<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-3 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
|
||||
<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-3 rounded-xl focus:bg-rose-50 cursor-pointer" onClick={() => handleDelete(job.id)}>
|
||||
<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>
|
||||
@ -235,9 +407,109 @@ export default function Index({ initialJobs }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="col-span-full py-20 text-center bg-white rounded-[32px] border border-dashed border-slate-200">
|
||||
/* 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 className="flex flex-wrap items-center gap-6 justify-between lg:justify-end">
|
||||
<div className="text-right space-y-1">
|
||||
<div className="text-xs font-bold text-slate-500">
|
||||
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)
|
||||
</div>
|
||||
<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 className="flex items-center space-x-3">
|
||||
<Link
|
||||
href={`/employer/jobs/${job.id}/applicants`}
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
)
|
||||
) : (
|
||||
/* 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">
|
||||
@ -247,7 +519,6 @@ export default function Index({ initialJobs }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</EmployerLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 (
|
||||
<EmployerLayout title="Job Details">
|
||||
<Head title={`Job Details: ${job.title} - Employer Portal`} />
|
||||
@ -164,6 +177,16 @@ export default function Show({ job }) {
|
||||
<span>Edit Posting</span>
|
||||
</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
|
||||
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"
|
||||
|
||||
@ -419,6 +419,7 @@
|
||||
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::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('/subscription', function () {
|
||||
$sess = session('user');
|
||||
|
||||
@ -58,6 +58,24 @@ public function test_employer_with_basic_plan_cannot_view_jobs_list()
|
||||
$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.
|
||||
*/
|
||||
|
||||
@ -410,4 +410,177 @@ public function test_employer_registration_normalizes_dates()
|
||||
'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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user