mohan #25
@ -69,3 +69,11 @@ EMIRATES_BACK_API_URL=
|
||||
SPONSOR_LICENSE_API_URL=
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# Notification Reminder Settings
|
||||
WORKER_NO_RESPONSE_REMINDER_DAYS=14
|
||||
REVIEW_ELIGIBILITY_MONTHS=3
|
||||
REVIEW_EDIT_PERIOD_DAYS=7
|
||||
EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3
|
||||
WORKER_JOIN_CONFIRM_REMINDER_DAYS=3
|
||||
DOCUMENT_EXPIRY_REMINDER_DAYS=30,7,1
|
||||
|
||||
458
app/Console/Commands/SendReminders.php
Normal file
458
app/Console/Commands/SendReminders.php
Normal file
@ -0,0 +1,458 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class SendReminders extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'app:send-reminders';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Send automated reminder notifications for document expiry and pending confirmations';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
$this->info('Starting automated reminder notification system...');
|
||||
|
||||
$this->info('1. Checking Worker Documents...');
|
||||
$this->checkWorkerDocuments();
|
||||
|
||||
$this->info('2. Checking Employer Profiles (Emirates ID Expiry)...');
|
||||
$this->checkEmployerProfiles();
|
||||
|
||||
$this->info('3. Checking Sponsors (Emirates ID and License Expiry)...');
|
||||
$this->checkSponsors();
|
||||
|
||||
$this->info('4. Checking Pending Hire Confirmations (Employer)...');
|
||||
$this->checkHireConfirmations();
|
||||
|
||||
$this->info('5. Checking Pending Joining Confirmations (Worker)...');
|
||||
$this->checkJoiningConfirmations();
|
||||
|
||||
$this->info('6. Checking Pending Direct Job Offers...');
|
||||
$this->checkJobOffers();
|
||||
|
||||
$this->info('7. Checking Worker No Response...');
|
||||
$this->checkWorkerNoResponse();
|
||||
|
||||
$this->info('8. Checking Review Reminders...');
|
||||
$this->checkReviewReminders();
|
||||
|
||||
$this->info('Automated reminder system run complete!');
|
||||
}
|
||||
|
||||
private function getReminderLevel(int $days, string $configKey, string $default): ?string
|
||||
{
|
||||
$value = config($configKey, $default);
|
||||
$configuredDays = array_map('intval', array_filter(explode(',', $value), 'strlen'));
|
||||
|
||||
if (in_array($days, $configuredDays)) {
|
||||
return $days === 1 ? '1_day' : "{$days}_days";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private function alreadySent($notifiable, string $eventType, $entity, string $reminderLevel): bool
|
||||
{
|
||||
return \App\Models\ReminderLog::where('user_type', get_class($notifiable))
|
||||
->where('user_id', $notifiable->id)
|
||||
->where('event_type', $eventType)
|
||||
->where('entity_type', get_class($entity))
|
||||
->where('entity_id', $entity->id)
|
||||
->where('reminder_level', $reminderLevel)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function logReminder($notifiable, string $eventType, $entity, string $reminderLevel, string $channel): void
|
||||
{
|
||||
\App\Models\ReminderLog::create([
|
||||
'user_type' => get_class($notifiable),
|
||||
'user_id' => $notifiable->id,
|
||||
'event_type' => $eventType,
|
||||
'entity_type' => get_class($entity),
|
||||
'entity_id' => $entity->id,
|
||||
'reminder_level' => $reminderLevel,
|
||||
'channel' => $channel,
|
||||
'sent_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function checkWorkerDocuments()
|
||||
{
|
||||
$workerDocs = \App\Models\WorkerDocument::whereNotNull('expiry_date')->get();
|
||||
foreach ($workerDocs as $doc) {
|
||||
$worker = $doc->worker;
|
||||
if (!$worker) continue;
|
||||
|
||||
$expiry = Carbon::parse($doc->expiry_date);
|
||||
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
|
||||
|
||||
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
|
||||
if (!$level) continue;
|
||||
|
||||
if ($this->alreadySent($worker, 'document_expiry', $doc, $level)) continue;
|
||||
|
||||
$this->info("Sending {$level} reminder to worker {$worker->name} for document {$doc->type} expiry.");
|
||||
$worker->notify(new \App\Notifications\DocumentExpiryNotification(
|
||||
ucfirst($doc->type),
|
||||
$doc->expiry_date,
|
||||
$days,
|
||||
$level
|
||||
));
|
||||
|
||||
$this->logReminder($worker, 'document_expiry', $doc, $level, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkEmployerProfiles()
|
||||
{
|
||||
$profiles = \App\Models\EmployerProfile::whereNotNull('emirates_id_expiry')->get();
|
||||
foreach ($profiles as $profile) {
|
||||
$user = \App\Models\User::find($profile->user_id);
|
||||
if (!$user) continue;
|
||||
|
||||
$expiry = Carbon::parse($profile->emirates_id_expiry);
|
||||
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
|
||||
|
||||
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
|
||||
if (!$level) continue;
|
||||
|
||||
if ($this->alreadySent($user, 'document_expiry', $profile, $level)) continue;
|
||||
|
||||
$this->info("Sending {$level} reminder to employer {$user->name} for Emirates ID expiry.");
|
||||
$user->notify(new \App\Notifications\DocumentExpiryNotification(
|
||||
'Emirates ID',
|
||||
$profile->emirates_id_expiry,
|
||||
$days,
|
||||
$level
|
||||
));
|
||||
|
||||
$this->logReminder($user, 'document_expiry', $profile, $level, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkSponsors()
|
||||
{
|
||||
$sponsors = \App\Models\Sponsor::all();
|
||||
foreach ($sponsors as $sponsor) {
|
||||
// 1. Check Emirates ID expiry
|
||||
if ($sponsor->emirates_id_expiry) {
|
||||
$expiry = Carbon::parse($sponsor->emirates_id_expiry);
|
||||
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
|
||||
|
||||
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
|
||||
if ($level && !$this->alreadySent($sponsor, 'document_expiry_emirates', $sponsor, $level)) {
|
||||
$this->info("Sending {$level} reminder to sponsor {$sponsor->full_name} for Emirates ID expiry.");
|
||||
$sponsor->notify(new \App\Notifications\DocumentExpiryNotification(
|
||||
'Emirates ID',
|
||||
$sponsor->emirates_id_expiry,
|
||||
$days,
|
||||
$level
|
||||
));
|
||||
$this->logReminder($sponsor, 'document_expiry_emirates', $sponsor, $level, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check License expiry
|
||||
if ($sponsor->license_expiry) {
|
||||
$expiry = Carbon::parse($sponsor->license_expiry);
|
||||
$days = (int) now()->startOfDay()->diffInDays($expiry->startOfDay(), false);
|
||||
|
||||
$level = $this->getReminderLevel($days, 'reminders.document_expiry_reminder_days', '30,7,3,1');
|
||||
if ($level && !$this->alreadySent($sponsor, 'document_expiry_license', $sponsor, $level)) {
|
||||
$this->info("Sending {$level} reminder to sponsor {$sponsor->full_name} for License expiry.");
|
||||
$sponsor->notify(new \App\Notifications\DocumentExpiryNotification(
|
||||
'Trade License',
|
||||
$sponsor->license_expiry->toDateString(),
|
||||
$days,
|
||||
$level
|
||||
));
|
||||
$this->logReminder($sponsor, 'document_expiry_license', $sponsor, $level, 'all');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function checkHireConfirmations()
|
||||
{
|
||||
$applications = \App\Models\JobApplication::where('status', 'selected')
|
||||
->with('jobPost.employer')
|
||||
->get();
|
||||
|
||||
foreach ($applications as $app) {
|
||||
$job = $app->jobPost;
|
||||
if (!$job || !$job->start_date) continue;
|
||||
$employer = $job->employer;
|
||||
if (!$employer) continue;
|
||||
$worker = $app->worker;
|
||||
if (!$worker) continue;
|
||||
|
||||
$startDate = Carbon::parse($job->start_date);
|
||||
$days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false);
|
||||
|
||||
$level = $this->getReminderLevel($days, 'reminders.employer_hire_confirm_reminder_days', '3,7');
|
||||
if (!$level) continue;
|
||||
|
||||
if ($this->alreadySent($employer, 'hire_confirmation', $app, $level)) continue;
|
||||
|
||||
$this->info("Sending {$level} reminder to employer {$employer->name} to confirm hiring for job {$job->title}.");
|
||||
$employer->notify(new \App\Notifications\HireConfirmationNotification(
|
||||
$job->title,
|
||||
$worker->name,
|
||||
$days,
|
||||
$level,
|
||||
$app->id
|
||||
));
|
||||
|
||||
$this->logReminder($employer, 'hire_confirmation', $app, $level, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkJoiningConfirmations()
|
||||
{
|
||||
$applications = \App\Models\JobApplication::where('status', 'hired')
|
||||
->whereNull('joining_confirmed_at')
|
||||
->with(['jobPost.employer', 'worker'])
|
||||
->get();
|
||||
|
||||
foreach ($applications as $app) {
|
||||
$job = $app->jobPost;
|
||||
if (!$job || !$job->start_date) continue;
|
||||
$worker = $app->worker;
|
||||
if (!$worker) continue;
|
||||
$employer = $job->employer;
|
||||
$employerName = $employer ? $employer->name : 'Employer';
|
||||
|
||||
$startDate = Carbon::parse($job->start_date);
|
||||
$days = (int) now()->startOfDay()->diffInDays($startDate->startOfDay(), false);
|
||||
|
||||
$level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7');
|
||||
if (!$level) continue;
|
||||
|
||||
if ($this->alreadySent($worker, 'joining_confirmation', $app, $level)) continue;
|
||||
|
||||
$this->info("Sending {$level} reminder to worker {$worker->name} to confirm joining for job {$job->title}.");
|
||||
$worker->notify(new \App\Notifications\JoiningConfirmationNotification(
|
||||
$job->title,
|
||||
$employerName,
|
||||
$days,
|
||||
$level,
|
||||
$app->id
|
||||
));
|
||||
|
||||
$this->logReminder($worker, 'joining_confirmation', $app, $level, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkJobOffers()
|
||||
{
|
||||
$offers = \App\Models\JobOffer::where('status', 'pending')
|
||||
->with(['worker', 'employer'])
|
||||
->get();
|
||||
|
||||
foreach ($offers as $offer) {
|
||||
$worker = $offer->worker;
|
||||
if (!$worker) continue;
|
||||
$employer = $offer->employer;
|
||||
$employerName = $employer ? $employer->name : 'Employer';
|
||||
|
||||
if (!$offer->work_date) continue;
|
||||
$workDate = Carbon::parse($offer->work_date);
|
||||
$days = (int) now()->startOfDay()->diffInDays($workDate->startOfDay(), false);
|
||||
|
||||
$level = $this->getReminderLevel($days, 'reminders.worker_join_confirm_reminder_days', '3,7');
|
||||
if (!$level) continue;
|
||||
|
||||
if ($this->alreadySent($worker, 'pending_confirmation', $offer, $level)) continue;
|
||||
|
||||
$this->info("Sending {$level} reminder to worker {$worker->name} for pending direct offer from {$employerName}.");
|
||||
$worker->notify(new \App\Notifications\PendingConfirmationNotification(
|
||||
'accept_offer',
|
||||
"Pending Job Offer",
|
||||
"You have a pending job offer from {$employerName} starting on {$offer->work_date->toDateString()}.",
|
||||
$days,
|
||||
$level,
|
||||
get_class($offer),
|
||||
$offer->id
|
||||
));
|
||||
|
||||
$this->logReminder($worker, 'pending_confirmation', $offer, $level, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkWorkerNoResponse()
|
||||
{
|
||||
$noResponseDays = (int) config('reminders.worker_no_response_reminder_days', 14);
|
||||
|
||||
// 1. Pending direct Job Offers
|
||||
$offers = \App\Models\JobOffer::where('status', 'pending')
|
||||
->with(['worker', 'employer'])
|
||||
->get();
|
||||
|
||||
foreach ($offers as $offer) {
|
||||
$worker = $offer->worker;
|
||||
if (!$worker) continue;
|
||||
$employer = $offer->employer;
|
||||
$employerName = $employer ? $employer->name : 'Employer';
|
||||
|
||||
$daysSinceCreation = (int) Carbon::parse($offer->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false);
|
||||
|
||||
if ($daysSinceCreation !== $noResponseDays) continue;
|
||||
|
||||
$level = $daysSinceCreation === 1 ? '1_day' : "{$daysSinceCreation}_days";
|
||||
|
||||
if ($this->alreadySent($worker, 'worker_no_response_offer', $offer, $level)) continue;
|
||||
|
||||
$this->info("Sending no-response reminder to worker {$worker->name} for pending direct offer from {$employerName}.");
|
||||
$worker->notify(new \App\Notifications\WorkerNoResponseNotification(
|
||||
'worker_no_response_offer',
|
||||
"Action Required: Pending Job Offer",
|
||||
"You have a pending job offer from {$employerName} sent on {$offer->created_at->toDateString()} that you have not responded to.",
|
||||
$daysSinceCreation,
|
||||
get_class($offer),
|
||||
$offer->id
|
||||
));
|
||||
|
||||
$this->logReminder($worker, 'worker_no_response_offer', $offer, $level, 'all');
|
||||
}
|
||||
|
||||
// 2. Chat conversations where the last message is from the employer
|
||||
$conversations = \App\Models\Conversation::with(['employer', 'worker'])->get();
|
||||
|
||||
foreach ($conversations as $conv) {
|
||||
$worker = $conv->worker;
|
||||
if (!$worker) continue;
|
||||
$employer = $conv->employer;
|
||||
$employerName = $employer ? $employer->name : 'Employer';
|
||||
|
||||
$lastMessage = $conv->messages()->latest()->first();
|
||||
if (!$lastMessage) continue;
|
||||
|
||||
$isEmployerSender = false;
|
||||
if ($lastMessage->sender_type === 'App\Models\User' || $lastMessage->sender_id == $conv->employer_id) {
|
||||
$isEmployerSender = true;
|
||||
}
|
||||
|
||||
if (!$isEmployerSender) continue;
|
||||
|
||||
$daysSinceMessage = (int) Carbon::parse($lastMessage->created_at)->startOfDay()->diffInDays(now()->startOfDay(), false);
|
||||
|
||||
if ($daysSinceMessage !== $noResponseDays) continue;
|
||||
|
||||
$level = $daysSinceMessage === 1 ? '1_day' : "{$daysSinceMessage}_days";
|
||||
|
||||
if ($this->alreadySent($worker, 'worker_no_response_chat', $lastMessage, $level)) continue;
|
||||
|
||||
$this->info("Sending no-response chat reminder to worker {$worker->name} for conversation with {$employerName}.");
|
||||
$worker->notify(new \App\Notifications\WorkerNoResponseNotification(
|
||||
'worker_no_response_chat',
|
||||
"New Messages: Reply to {$employerName}",
|
||||
"You have a message from {$employerName} that you have not responded to for {$daysSinceMessage} days.",
|
||||
$daysSinceMessage,
|
||||
get_class($lastMessage),
|
||||
$lastMessage->id
|
||||
));
|
||||
|
||||
$this->logReminder($worker, 'worker_no_response_chat', $lastMessage, $level, 'all');
|
||||
}
|
||||
}
|
||||
|
||||
private function checkReviewReminders()
|
||||
{
|
||||
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
|
||||
$level = "{$eligibilityMonths}_months";
|
||||
|
||||
// 1. Standard Job Applications with status 'hired'
|
||||
$applications = \App\Models\JobApplication::where('status', 'hired')
|
||||
->whereNotNull('joining_confirmed_at')
|
||||
->with(['jobPost.employer', 'worker'])
|
||||
->get();
|
||||
|
||||
foreach ($applications as $app) {
|
||||
$job = $app->jobPost;
|
||||
if (!$job) continue;
|
||||
$employer = $job->employer;
|
||||
$worker = $app->worker;
|
||||
if (!$employer || !$worker) continue;
|
||||
|
||||
$joiningDate = Carbon::parse($app->joining_confirmed_at);
|
||||
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
|
||||
|
||||
if ($months >= $eligibilityMonths) {
|
||||
// Remind worker to review employer
|
||||
if (!$this->alreadySent($worker, 'review_employer_reminder', $app, $level)) {
|
||||
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name}.");
|
||||
$worker->notify(new \App\Notifications\WorkerReviewReminderNotification(
|
||||
$employer->name,
|
||||
$app->id,
|
||||
$level
|
||||
));
|
||||
$this->logReminder($worker, 'review_employer_reminder', $app, $level, 'all');
|
||||
}
|
||||
|
||||
// Remind employer to review worker
|
||||
if (!$this->alreadySent($employer, 'review_worker_reminder', $app, $level)) {
|
||||
$this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name}.");
|
||||
$employer->notify(new \App\Notifications\EmployerReviewReminderNotification(
|
||||
$worker->name,
|
||||
$app->id,
|
||||
$level
|
||||
));
|
||||
$this->logReminder($employer, 'review_worker_reminder', $app, $level, 'all');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Direct Job Offers with status 'accepted'
|
||||
$offers = \App\Models\JobOffer::where('status', 'accepted')
|
||||
->with(['worker', 'employer'])
|
||||
->get();
|
||||
|
||||
foreach ($offers as $offer) {
|
||||
$worker = $offer->worker;
|
||||
$employer = $offer->employer;
|
||||
if (!$worker || !$employer) continue;
|
||||
|
||||
$joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at;
|
||||
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
|
||||
|
||||
if ($months >= $eligibilityMonths) {
|
||||
// Remind worker to review employer
|
||||
if (!$this->alreadySent($worker, 'review_employer_reminder', $offer, $level)) {
|
||||
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name} (direct offer).");
|
||||
$worker->notify(new \App\Notifications\WorkerReviewReminderNotification(
|
||||
$employer->name,
|
||||
$offer->id,
|
||||
$level
|
||||
));
|
||||
$this->logReminder($worker, 'review_employer_reminder', $offer, $level, 'all');
|
||||
}
|
||||
|
||||
// Remind employer to review worker
|
||||
if (!$this->alreadySent($employer, 'review_worker_reminder', $offer, $level)) {
|
||||
$this->info("Sending review reminder to employer {$employer->name} to review worker {$worker->name} (direct offer).");
|
||||
$employer->notify(new \App\Notifications\EmployerReviewReminderNotification(
|
||||
$worker->name,
|
||||
$offer->id,
|
||||
$level
|
||||
));
|
||||
$this->logReminder($employer, 'review_worker_reminder', $offer, $level, 'all');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -132,7 +132,7 @@ public function updateProfile(Request $request, $id)
|
||||
'experience' => 'required|string',
|
||||
'salary' => 'nullable|numeric',
|
||||
'nationality' => 'nullable|string',
|
||||
'visa_status' => 'nullable|string',
|
||||
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
|
||||
'age' => 'nullable|integer',
|
||||
'in_country' => 'nullable',
|
||||
'preferred_job_type' => 'nullable|string',
|
||||
|
||||
@ -252,6 +252,10 @@ public function register(Request $request)
|
||||
$emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? $emiratesIdInput['issuing_place'] ?? null;
|
||||
$emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null;
|
||||
$emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null;
|
||||
|
||||
$emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry);
|
||||
$emiratesIdDob = $this->normaliseDate($emiratesIdDob);
|
||||
$emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate);
|
||||
}
|
||||
|
||||
// Create inactive User
|
||||
@ -447,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',
|
||||
]);
|
||||
@ -477,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',
|
||||
@ -487,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',
|
||||
@ -496,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),
|
||||
@ -606,38 +624,81 @@ public function password(Request $request)
|
||||
|
||||
public function plans()
|
||||
{
|
||||
$dbPlans = \App\Models\Plan::where('status', 'Active')->get();
|
||||
if ($dbPlans->isEmpty()) {
|
||||
$plans = [
|
||||
[
|
||||
'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' => 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' => 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 {
|
||||
$planIdNumberMap = [
|
||||
'basic' => 1,
|
||||
'premium' => 2,
|
||||
'vip' => 3,
|
||||
'enterprise' => 3,
|
||||
];
|
||||
$plans = $dbPlans->map(function ($plan) use ($planIdNumberMap) {
|
||||
return [
|
||||
'id' => $planIdNumberMap[$plan->id] ?? 2,
|
||||
'name' => $plan->name,
|
||||
'price' => (float)$plan->price,
|
||||
'currency' => 'AED',
|
||||
'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();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'plans' => [
|
||||
[
|
||||
'id' => 'basic',
|
||||
'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,
|
||||
],
|
||||
[
|
||||
'id' => 'premium',
|
||||
'name' => 'Premium Employer Pass',
|
||||
'price' => 199.00,
|
||||
'currency' => 'AED',
|
||||
'period' => 'month',
|
||||
'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'],
|
||||
'popular' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'enterprise',
|
||||
'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,
|
||||
]
|
||||
]
|
||||
'plans' => $plans
|
||||
]
|
||||
]);
|
||||
}
|
||||
@ -779,4 +840,36 @@ public function resetPassword(Request $request)
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise date format to Y-m-d.
|
||||
* E.g. "06/06/2025" -> "2025-06-06"
|
||||
*/
|
||||
private function normaliseDate(?string $raw): ?string
|
||||
{
|
||||
if (empty($raw)) {
|
||||
return null;
|
||||
}
|
||||
$raw = trim($raw);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||
return $raw;
|
||||
}
|
||||
try {
|
||||
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
|
||||
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||
$month = $m[2];
|
||||
$year = $m[3];
|
||||
if (is_numeric($month)) {
|
||||
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||
return "{$year}-{$month}-{$day}";
|
||||
}
|
||||
$ts = strtotime("{$day} {$month} {$year}");
|
||||
return $ts ? date('Y-m-d', $ts) : $raw;
|
||||
}
|
||||
$dt = new \DateTime($raw);
|
||||
return $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -334,10 +334,28 @@ public function startConversation(Request $request)
|
||||
|
||||
try {
|
||||
// Find or create conversation
|
||||
$conv = Conversation::firstOrCreate([
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $request->worker_id,
|
||||
]);
|
||||
$conv = Conversation::where('employer_id', $employer->id)
|
||||
->where('worker_id', $request->worker_id)
|
||||
->first();
|
||||
|
||||
if (!$conv) {
|
||||
if (!User::canContactWorker($employer->id, $request->worker_id)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first();
|
||||
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$conv = Conversation::create([
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $request->worker_id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
|
||||
113
app/Http/Controllers/Api/EmployerNotificationController.php
Normal file
113
app/Http/Controllers/Api/EmployerNotificationController.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\User;
|
||||
|
||||
class EmployerNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all notifications for the employer.
|
||||
* GET /api/employers/notifications
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
$filter = $request->query('filter', 'all');
|
||||
$perPage = (int) $request->query('per_page', 15);
|
||||
|
||||
if ($filter === 'read') {
|
||||
$query = $employer->readNotifications();
|
||||
} elseif ($filter === 'unread') {
|
||||
$query = $employer->unreadNotifications();
|
||||
} else {
|
||||
$query = $employer->notifications();
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$notifications = collect($paginator->items())->map(function ($notification) {
|
||||
return [
|
||||
'id' => $notification->id,
|
||||
'type' => $notification->type,
|
||||
'data' => $notification->data,
|
||||
'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null,
|
||||
'created_at' => $notification->created_at->toIso8601String(),
|
||||
'time_ago' => $notification->created_at->diffForHumans(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'notifications' => $notifications,
|
||||
'pagination' => [
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
]
|
||||
]
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification or all unread notifications as read.
|
||||
* PUT /api/employers/notifications/{id}/read or PUT /api/employers/notifications/read
|
||||
*/
|
||||
public function markAsRead(Request $request, $id = null)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if ($id) {
|
||||
$notification = $employer->notifications()->where('id', $id)->first();
|
||||
if (!$notification) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Notification not found.'
|
||||
], 404);
|
||||
}
|
||||
$notification->markAsRead();
|
||||
} else {
|
||||
$employer->unreadNotifications->markAsRead();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Notification(s) marked as read successfully.'
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single notification or all notifications.
|
||||
* DELETE /api/employers/notifications/{id} or DELETE /api/employers/notifications
|
||||
*/
|
||||
public function destroy(Request $request, $id = null)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if ($id) {
|
||||
$notification = $employer->notifications()->where('id', $id)->first();
|
||||
if (!$notification) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Notification not found.'
|
||||
], 404);
|
||||
}
|
||||
$notification->delete();
|
||||
} else {
|
||||
$employer->notifications()->delete();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Notification(s) deleted successfully.'
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ public function getProfile(Request $request)
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
try {
|
||||
$employer->load('employerReviews.worker');
|
||||
$profile = $employer->employerProfile;
|
||||
|
||||
if (!$profile) {
|
||||
@ -38,10 +39,18 @@ public function getProfile(Request $request)
|
||||
'name' => $employer->name,
|
||||
'email' => $employer->email,
|
||||
'phone' => $profile->phone,
|
||||
'address' => $profile->address,
|
||||
'country' => $profile->country,
|
||||
'language' => $profile->language ?? 'English',
|
||||
'notifications' => (bool)($profile->notifications ?? true),
|
||||
'verification_status' => $profile->verification_status ?? 'approved',
|
||||
'id_number' => $profile->emirates_id_number,
|
||||
'card_number' => $profile->emirates_id_card_number,
|
||||
'full_name' => $profile->emirates_id_name,
|
||||
'gender' => $profile->emirates_id_gender,
|
||||
'rating' => $employer->rating,
|
||||
'reviews_count' => $employer->reviews_count,
|
||||
'reviews' => $employer->employerReviews,
|
||||
'emirates_id' => [
|
||||
'emirates_id_number' => $profile->emirates_id_number,
|
||||
'name' => $profile->emirates_id_name,
|
||||
@ -51,7 +60,7 @@ public function getProfile(Request $request)
|
||||
'employer' => $profile->emirates_id_employer,
|
||||
'issue_place' => $profile->emirates_id_issue_place,
|
||||
'occupation' => $profile->emirates_id_occupation,
|
||||
'nationality' => $profile->emirates_id_nationality,
|
||||
'nationality' => $profile->nationality,
|
||||
'gender' => $profile->emirates_id_gender,
|
||||
'card_number' => $profile->emirates_id_card_number,
|
||||
'country' => 'United Arab Emirates',
|
||||
@ -190,7 +199,7 @@ public function updateProfile(Request $request)
|
||||
$sponsorData['emirates_id_name'] = $request->full_name;
|
||||
}
|
||||
if ($request->has('date_of_birth')) {
|
||||
$sponsorData['emirates_id_dob'] = $request->date_of_birth;
|
||||
$sponsorData['emirates_id_dob'] = $this->normaliseDate($request->date_of_birth);
|
||||
}
|
||||
if ($request->has('nationality')) {
|
||||
$sponsorData['nationality'] = $request->nationality;
|
||||
@ -199,10 +208,10 @@ public function updateProfile(Request $request)
|
||||
$sponsorData['emirates_id_gender'] = $request->gender;
|
||||
}
|
||||
if ($request->has('issue_date')) {
|
||||
$sponsorData['emirates_id_issue_date'] = $request->issue_date;
|
||||
$sponsorData['emirates_id_issue_date'] = $this->normaliseDate($request->issue_date);
|
||||
}
|
||||
if ($request->has('expiry_date')) {
|
||||
$sponsorData['emirates_id_expiry'] = $request->expiry_date;
|
||||
$sponsorData['emirates_id_expiry'] = $this->normaliseDate($request->expiry_date);
|
||||
}
|
||||
if ($request->has('occupation')) {
|
||||
$sponsorData['emirates_id_occupation'] = $request->occupation;
|
||||
@ -236,7 +245,7 @@ public function updateProfile(Request $request)
|
||||
$profile->emirates_id_name = $request->full_name;
|
||||
}
|
||||
if ($request->has('date_of_birth')) {
|
||||
$profile->emirates_id_dob = $request->date_of_birth;
|
||||
$profile->emirates_id_dob = $this->normaliseDate($request->date_of_birth);
|
||||
}
|
||||
if ($request->has('nationality')) {
|
||||
$profile->nationality = $request->nationality;
|
||||
@ -245,10 +254,10 @@ public function updateProfile(Request $request)
|
||||
$profile->emirates_id_gender = $request->gender;
|
||||
}
|
||||
if ($request->has('issue_date')) {
|
||||
$profile->emirates_id_issue_date = $request->issue_date;
|
||||
$profile->emirates_id_issue_date = $this->normaliseDate($request->issue_date);
|
||||
}
|
||||
if ($request->has('expiry_date')) {
|
||||
$profile->emirates_id_expiry = $request->expiry_date;
|
||||
$profile->emirates_id_expiry = $this->normaliseDate($request->expiry_date);
|
||||
}
|
||||
if ($request->has('occupation')) {
|
||||
$profile->emirates_id_occupation = $request->occupation;
|
||||
@ -262,14 +271,24 @@ public function updateProfile(Request $request)
|
||||
|
||||
$profile->save();
|
||||
|
||||
$employer->load('employerReviews.worker');
|
||||
|
||||
$employerProfile = [
|
||||
'name' => $employer->name,
|
||||
'email' => $employer->email,
|
||||
'phone' => $profile->phone,
|
||||
'address' => $profile->address,
|
||||
'country' => $profile->country,
|
||||
'language' => $profile->language,
|
||||
'notifications' => (bool)$profile->notifications,
|
||||
'verification_status' => $profile->verification_status ?? 'approved',
|
||||
'id_number' => $profile->emirates_id_number,
|
||||
'card_number' => $profile->emirates_id_card_number,
|
||||
'full_name' => $profile->emirates_id_name,
|
||||
'gender' => $profile->emirates_id_gender,
|
||||
'rating' => $employer->rating,
|
||||
'reviews_count' => $employer->reviews_count,
|
||||
'reviews' => $employer->employerReviews,
|
||||
'emirates_id' => [
|
||||
'emirates_id_number' => $profile->emirates_id_number,
|
||||
'name' => $profile->emirates_id_name,
|
||||
@ -279,7 +298,7 @@ public function updateProfile(Request $request)
|
||||
'employer' => $profile->emirates_id_employer,
|
||||
'issue_place' => $profile->emirates_id_issue_place,
|
||||
'occupation' => $profile->emirates_id_occupation,
|
||||
'nationality' => $profile->emirates_id_nationality,
|
||||
'nationality' => $profile->nationality,
|
||||
'gender' => $profile->emirates_id_gender,
|
||||
'card_number' => $profile->emirates_id_card_number,
|
||||
'country' => 'United Arab Emirates',
|
||||
@ -339,12 +358,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
|
||||
@ -483,4 +533,35 @@ public function changePassword(Request $request)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise date format to Y-m-d.
|
||||
* E.g. "06/06/2025" -> "2025-06-06"
|
||||
*/
|
||||
private function normaliseDate(?string $raw): ?string
|
||||
{
|
||||
if (empty($raw)) {
|
||||
return null;
|
||||
}
|
||||
$raw = trim($raw);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||
return $raw;
|
||||
}
|
||||
try {
|
||||
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
|
||||
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||
$month = $m[2];
|
||||
$year = $m[3];
|
||||
if (is_numeric($month)) {
|
||||
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||
return "{$year}-{$month}-{$day}";
|
||||
}
|
||||
$ts = strtotime("{$day} {$month} {$year}");
|
||||
return $ts ? date('Y-m-d', $ts) : $raw;
|
||||
}
|
||||
$dt = new \DateTime($raw);
|
||||
return $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,12 +35,65 @@ public function addReview(Request $request)
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Validate confirmed hire and completed joining, and review eligibility period
|
||||
$hasConfirmedHire = false;
|
||||
$joiningDate = null;
|
||||
|
||||
// Check standard JobApplication where the job belongs to this employer and worker matches
|
||||
$app = \App\Models\JobApplication::where('worker_id', $request->worker_id)
|
||||
->where('status', 'hired')
|
||||
->whereNotNull('joining_confirmed_at')
|
||||
->whereHas('jobPost', function ($query) use ($employer) {
|
||||
$query->where('employer_id', $employer->id);
|
||||
})
|
||||
->first();
|
||||
|
||||
if ($app) {
|
||||
$hasConfirmedHire = true;
|
||||
$joiningDate = \Carbon\Carbon::parse($app->joining_confirmed_at);
|
||||
} else {
|
||||
// Check direct JobOffer
|
||||
$offer = \App\Models\JobOffer::where('worker_id', $request->worker_id)
|
||||
->where('employer_id', $employer->id)
|
||||
->where('status', 'accepted')
|
||||
->first();
|
||||
|
||||
if ($offer) {
|
||||
$hasConfirmedHire = true;
|
||||
$joiningDate = $offer->work_date ? \Carbon\Carbon::parse($offer->work_date) : $offer->updated_at;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasConfirmedHire) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You can only review workers with a confirmed hire and completed joining.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
|
||||
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Check if employer has already reviewed this worker
|
||||
$existingReview = Review::where('employer_id', $employer->id)
|
||||
->where('worker_id', $request->worker_id)
|
||||
->first();
|
||||
|
||||
if ($existingReview) {
|
||||
// Check if the edit window has expired
|
||||
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
|
||||
if ($existingReview->created_at->addDays($editPeriodDays)->isPast()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
|
||||
], 403);
|
||||
}
|
||||
|
||||
// If they already have a review, we can update it or direct them to the edit endpoint.
|
||||
// To be robust, let's update it directly and inform them it was updated (acting as an edit).
|
||||
$existingReview->update([
|
||||
@ -118,6 +171,15 @@ public function editReview(Request $request, $id)
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Check if the edit window has expired
|
||||
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
|
||||
if ($review->created_at->addDays($editPeriodDays)->isPast()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
|
||||
], 403);
|
||||
}
|
||||
|
||||
$review->update([
|
||||
'rating' => $request->rating,
|
||||
'comment' => $request->comment,
|
||||
|
||||
@ -57,6 +57,7 @@ private function formatWorker(Worker $w)
|
||||
'deleted_at' => $w->deleted_at?->toISOString(),
|
||||
'language' => $w->language,
|
||||
'languages' => $langs,
|
||||
'main_profession' => $w->main_profession,
|
||||
'preferred_location' => $w->preferred_location,
|
||||
'country' => $w->country,
|
||||
'city' => $w->city,
|
||||
@ -154,6 +155,13 @@ public function getWorkers(Request $request)
|
||||
$workersArray = $formattedWorkers->toArray();
|
||||
|
||||
// Apply filters: gender, skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status
|
||||
if ($request->filled('main_profession')) {
|
||||
$mainProfession = strtolower($request->main_profession);
|
||||
$workersArray = array_values(array_filter($workersArray, function ($c) use ($mainProfession) {
|
||||
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('gender')) {
|
||||
$gender = strtolower($request->gender);
|
||||
$workersArray = array_values(array_filter($workersArray, function ($c) use ($gender) {
|
||||
@ -481,6 +489,13 @@ public function getCandidates(Request $request)
|
||||
}));
|
||||
|
||||
// Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status
|
||||
if ($request->filled('main_profession')) {
|
||||
$mainProfession = strtolower($request->main_profession);
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($mainProfession) {
|
||||
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('gender')) {
|
||||
$gender = strtolower($request->gender);
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) {
|
||||
@ -737,6 +752,39 @@ public function hireCandidate(Request $request, $id = null)
|
||||
$updatedApplication = null;
|
||||
$updatedOffer = null;
|
||||
|
||||
// Resolve the worker ID to perform limit check
|
||||
$resolvedWorkerId = null;
|
||||
if (is_string($targetId) && str_starts_with($targetId, 'offer_')) {
|
||||
$offerId = substr($targetId, 6);
|
||||
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->first();
|
||||
if ($offer) {
|
||||
$resolvedWorkerId = $offer->worker_id;
|
||||
}
|
||||
} else {
|
||||
$application = JobApplication::where('id', $targetId)
|
||||
->whereHas('jobPost', function($q) use ($employerId) {
|
||||
$q->where('employer_id', $employerId);
|
||||
})->first();
|
||||
|
||||
if ($application) {
|
||||
$resolvedWorkerId = $application->worker_id;
|
||||
} else {
|
||||
$resolvedWorkerId = $targetId;
|
||||
}
|
||||
}
|
||||
|
||||
if ($resolvedWorkerId && !\App\Models\User::canContactWorker($employerId, $resolvedWorkerId)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
|
||||
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Direct Offer check
|
||||
if (is_string($targetId) && str_starts_with($targetId, 'offer_')) {
|
||||
$offerId = substr($targetId, 6);
|
||||
@ -896,8 +944,8 @@ public function getWorkerDetail(Request $request, $id)
|
||||
];
|
||||
|
||||
// Visa status
|
||||
$visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
|
||||
$visaStatus = $visaStatusesList[$w->id % 5];
|
||||
$visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa'];
|
||||
$visaStatus = $visaStatusesList[$w->id % 3];
|
||||
|
||||
$photo = null;
|
||||
|
||||
|
||||
@ -103,6 +103,10 @@ public function register(Request $request)
|
||||
$emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? null;
|
||||
$emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null;
|
||||
$emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null;
|
||||
|
||||
$emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry);
|
||||
$emiratesIdDob = $this->normaliseDate($emiratesIdDob);
|
||||
$emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate);
|
||||
}
|
||||
|
||||
$emiratesIdPath = null;
|
||||
@ -495,5 +499,37 @@ public function resetPassword(Request $request)
|
||||
'message' => 'Password reset successfully. You can now log in with your new password.',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise date format to Y-m-d.
|
||||
* E.g. "06/06/2025" -> "2025-06-06"
|
||||
*/
|
||||
private function normaliseDate(?string $raw): ?string
|
||||
{
|
||||
if (empty($raw)) {
|
||||
return null;
|
||||
}
|
||||
$raw = trim($raw);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||
return $raw;
|
||||
}
|
||||
try {
|
||||
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
|
||||
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||
$month = $m[2];
|
||||
$year = $m[3];
|
||||
if (is_numeric($month)) {
|
||||
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||
return "{$year}-{$month}-{$day}";
|
||||
}
|
||||
$ts = strtotime("{$day} {$month} {$year}");
|
||||
return $ts ? date('Y-m-d', $ts) : $raw;
|
||||
}
|
||||
$dt = new \DateTime($raw);
|
||||
return $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -229,15 +229,18 @@ public function postCharityEvent(Request $request)
|
||||
}
|
||||
|
||||
$validator = \Illuminate\Support\Facades\Validator::make($request->all(), [
|
||||
'title' => 'required|string|max:255',
|
||||
'body' => 'required|string',
|
||||
'type' => 'nullable|string|in:charity,info,warning,success',
|
||||
'event_date' => 'required|string',
|
||||
'start_time' => 'required|string',
|
||||
'end_time' => 'required|string',
|
||||
'provided_items' => 'required|string',
|
||||
'location_details' => 'required|string',
|
||||
'location_pin' => 'required|string|url',
|
||||
'title' => 'required|string|max:255',
|
||||
'body' => 'required|string',
|
||||
'type' => 'nullable|string|in:charity,info,warning,success',
|
||||
'event_date' => 'required|string',
|
||||
'start_time' => 'required|string',
|
||||
'end_time' => 'required|string',
|
||||
'provided_items' => 'required|string',
|
||||
'location_details' => 'required|string',
|
||||
'location_pin' => 'required|string|url',
|
||||
'contact_person_name' => 'required|string|max:255',
|
||||
'contact_number' => 'required|string|regex:/^\d{7,15}$/',
|
||||
'country_code' => 'required|string|regex:/^\+\d{1,4}$/',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -252,13 +255,16 @@ public function postCharityEvent(Request $request)
|
||||
$eventTime = $request->start_time . ' - ' . $request->end_time;
|
||||
|
||||
$bodyJson = json_encode([
|
||||
'type' => 'Charity',
|
||||
'provided_items' => $request->provided_items,
|
||||
'event_date' => $request->event_date,
|
||||
'event_time' => $eventTime,
|
||||
'location_details' => $request->location_details,
|
||||
'location_pin' => $request->location_pin,
|
||||
'content' => $request->body,
|
||||
'type' => 'Charity',
|
||||
'provided_items' => $request->provided_items,
|
||||
'event_date' => $request->event_date,
|
||||
'event_time' => $eventTime,
|
||||
'location_details' => $request->location_details,
|
||||
'location_pin' => $request->location_pin,
|
||||
'content' => $request->body,
|
||||
'contact_person_name' => $request->contact_person_name,
|
||||
'contact_number' => $request->contact_number,
|
||||
'country_code' => $request->country_code,
|
||||
]);
|
||||
|
||||
$event = Announcement::create([
|
||||
@ -634,6 +640,11 @@ public function updateProfile(Request $request)
|
||||
$eidCardNumber = $eidCardNumber ?? $request->input('card_number');
|
||||
$eidGender = $eidGender ?? $request->input('gender');
|
||||
|
||||
// Normalize dates
|
||||
$eidDob = $this->normaliseDate($eidDob);
|
||||
$eidExpiry = $this->normaliseDate($eidExpiry);
|
||||
$eidIssueDate = $this->normaliseDate($eidIssueDate);
|
||||
|
||||
$nameToUse = $eidName ?? $request->name ?? $request->full_name;
|
||||
|
||||
// Validate Emirates ID immutability — once set, it cannot be changed
|
||||
@ -738,6 +749,13 @@ public function updateProfile(Request $request)
|
||||
}
|
||||
|
||||
if (!empty($newLicenseData)) {
|
||||
if (isset($newLicenseData['expiry_date'])) {
|
||||
$newLicenseData['expiry_date'] = $this->normaliseDate($newLicenseData['expiry_date']);
|
||||
$sponsorData['license_expiry'] = $newLicenseData['expiry_date'];
|
||||
}
|
||||
if (isset($newLicenseData['issue_date'])) {
|
||||
$newLicenseData['issue_date'] = $this->normaliseDate($newLicenseData['issue_date']);
|
||||
}
|
||||
$sponsorData['license_data'] = array_merge($currentLicenseData, $newLicenseData);
|
||||
}
|
||||
|
||||
@ -761,4 +779,36 @@ public function updateProfile(Request $request)
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise date format to Y-m-d.
|
||||
* E.g. "06/06/2025" -> "2025-06-06"
|
||||
*/
|
||||
private function normaliseDate(?string $raw): ?string
|
||||
{
|
||||
if (empty($raw)) {
|
||||
return null;
|
||||
}
|
||||
$raw = trim($raw);
|
||||
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||
return $raw;
|
||||
}
|
||||
try {
|
||||
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
|
||||
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||
$month = $m[2];
|
||||
$year = $m[3];
|
||||
if (is_numeric($month)) {
|
||||
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||
return "{$year}-{$month}-{$day}";
|
||||
}
|
||||
$ts = strtotime("{$day} {$month} {$year}");
|
||||
return $ts ? date('Y-m-d', $ts) : $raw;
|
||||
}
|
||||
$dt = new \DateTime($raw);
|
||||
return $dt->format('Y-m-d');
|
||||
} catch (\Exception $e) {
|
||||
return $raw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -181,6 +181,7 @@ public function setupProfile(Request $request)
|
||||
'gender' => 'nullable|string|in:male,female,other',
|
||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||
'preferred_location' => 'nullable|string|max:255',
|
||||
'main_profession' => 'nullable|string|max:100',
|
||||
'skills' => 'nullable|array',
|
||||
'skills.*' => 'integer|exists:skills,id',
|
||||
'fcm_token' => 'nullable|string|max:255',
|
||||
@ -241,6 +242,7 @@ public function setupProfile(Request $request)
|
||||
'gender' => $request->gender,
|
||||
'live_in_out' => $request->live_in_out,
|
||||
'preferred_location' => $request->preferred_location,
|
||||
'main_profession' => $request->main_profession,
|
||||
'age' => 25, // Default — updated later in full profile
|
||||
'salary' => 1500, // Default AED — updated later
|
||||
'availability' => 'Immediate',
|
||||
@ -264,7 +266,7 @@ public function setupProfile(Request $request)
|
||||
// Clear the OTP verification gate
|
||||
Cache::forget('worker_otp_verified_' . $identifier);
|
||||
|
||||
$worker->load(['skills']);
|
||||
$worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -316,8 +318,9 @@ public function register(Request $request)
|
||||
'age' => 'nullable|integer',
|
||||
'experience' => 'nullable|string|max:100',
|
||||
'in_country' => 'nullable',
|
||||
'visa_status' => 'nullable|string|max:100',
|
||||
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
|
||||
'preferred_job_type' => 'nullable|string|max:100',
|
||||
'main_profession' => 'nullable|string|max:100',
|
||||
'gender' => 'nullable|string|in:male,female,other',
|
||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||
'preferred_location' => 'nullable|string|max:255',
|
||||
@ -453,6 +456,7 @@ public function register(Request $request)
|
||||
'gender' => $request->gender,
|
||||
'live_in_out' => $request->live_in_out,
|
||||
'preferred_location' => $request->preferred_location,
|
||||
'main_profession' => $request->main_profession,
|
||||
'age' => $age,
|
||||
'salary' => $request->salary,
|
||||
'availability' => 'Immediate',
|
||||
@ -519,7 +523,7 @@ public function register(Request $request)
|
||||
return $worker;
|
||||
});
|
||||
|
||||
$result->load(['skills', 'documents']);
|
||||
$result->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -587,7 +591,7 @@ public function register(Request $request)
|
||||
'success' => true,
|
||||
'message' => 'Worker logged in successfully.',
|
||||
'data' => [
|
||||
'worker' => $worker->load(['skills', 'documents']),
|
||||
'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']),
|
||||
'token' => $apiToken
|
||||
]
|
||||
], 200);
|
||||
@ -1243,6 +1247,19 @@ private function normaliseDateForController(?string $raw): ?string
|
||||
private function cleanVisaData(array $visaDataInput): array
|
||||
{
|
||||
$rawVisaType = $visaDataInput['visa_type'] ?? null;
|
||||
if ($rawVisaType) {
|
||||
$normalized = strtolower(trim($rawVisaType));
|
||||
if (str_contains($normalized, 'residence')) {
|
||||
$rawVisaType = 'Residence Visa';
|
||||
} elseif (str_contains($normalized, 'employment')) {
|
||||
$rawVisaType = 'Employment Visa';
|
||||
} else {
|
||||
$rawVisaType = 'Tourist Visa';
|
||||
}
|
||||
} else {
|
||||
$rawVisaType = 'Tourist Visa';
|
||||
}
|
||||
|
||||
$rawEntryPermitNo = $visaDataInput['entry_permit_no'] ?? $visaDataInput['file_number'] ?? $visaDataInput['id_number'] ?? $visaDataInput['number'] ?? null;
|
||||
$rawIssueDate = $visaDataInput['issue_date'] ?? null;
|
||||
$rawValidUntil = $visaDataInput['valid_until'] ?? $visaDataInput['expiry_date'] ?? null;
|
||||
@ -1273,7 +1290,7 @@ private function cleanVisaData(array $visaDataInput): array
|
||||
return [
|
||||
'document_type' => 'uae_visa',
|
||||
'country' => 'United Arab Emirates',
|
||||
'visa_type' => $rawVisaType ?: 'ENTRY PERMIT',
|
||||
'visa_type' => $rawVisaType,
|
||||
'entry_permit_no' => $rawEntryPermitNo ?: '',
|
||||
'issue_date' => $rawIssueDate ?: '',
|
||||
'valid_until' => $rawValidUntil ?: '',
|
||||
|
||||
812
app/Http/Controllers/Api/WorkerJobController.php
Normal file
812
app/Http/Controllers/Api/WorkerJobController.php
Normal file
@ -0,0 +1,812 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\Worker;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class WorkerJobController extends Controller
|
||||
{
|
||||
/**
|
||||
* API to list available jobs.
|
||||
* GET /api/workers/jobs
|
||||
*/
|
||||
public function listJobs(Request $request)
|
||||
{
|
||||
$jobs = JobPost::where('status', 'active')
|
||||
->with(['employer.employerProfile'])
|
||||
->latest()
|
||||
->get()
|
||||
->map(function ($job) {
|
||||
return [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
'job_type' => $job->job_type,
|
||||
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||
'description' => $job->description,
|
||||
'requirements' => $job->requirements,
|
||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||
'posted_at' => $job->created_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'jobs' => $jobs
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API to retrieve job details.
|
||||
* GET /api/workers/jobs/{id}
|
||||
*/
|
||||
public function jobDetails($id)
|
||||
{
|
||||
$job = JobPost::where('status', 'active')
|
||||
->with(['employer.employerProfile'])
|
||||
->find($id);
|
||||
|
||||
if (!$job) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Job not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
'job_type' => $job->job_type,
|
||||
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||
'description' => $job->description,
|
||||
'requirements' => $job->requirements,
|
||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||
'posted_at' => $job->created_at->toIso8601String(),
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for workers to apply for a job.
|
||||
* POST /api/workers/jobs/{id}/apply
|
||||
*/
|
||||
public function applyJob(Request $request, $id)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$job = JobPost::where('status', 'active')->find($id);
|
||||
|
||||
if (!$job) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Job not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Prevent duplicate applications
|
||||
$exists = JobApplication::where('worker_id', $worker->id)
|
||||
->where('job_id', $job->id)
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You have already applied for this job.'
|
||||
], 409);
|
||||
}
|
||||
|
||||
$application = JobApplication::create([
|
||||
'worker_id' => $worker->id,
|
||||
'job_id' => $job->id,
|
||||
'status' => 'applied'
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Successfully applied for the job.',
|
||||
'data' => [
|
||||
'application' => $application
|
||||
]
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for workers to view their applied jobs.
|
||||
* GET /api/workers/applied-jobs
|
||||
*/
|
||||
public function appliedJobs(Request $request)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$applications = JobApplication::where('worker_id', $worker->id)
|
||||
->with(['jobPost.employer.employerProfile'])
|
||||
->latest()
|
||||
->get()
|
||||
->map(function ($app) {
|
||||
$job = $app->jobPost;
|
||||
return [
|
||||
'application_id' => $app->id,
|
||||
'status' => $app->status,
|
||||
'applied_at' => $app->created_at->toIso8601String(),
|
||||
'job' => $job ? [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'job_type' => $job->job_type,
|
||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||
] : null
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'applications' => $applications
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
private function checkJobAccess($employerId)
|
||||
{
|
||||
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||
->where('user_id', $employerId)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$planId = $sub ? $sub->plan_id : 'basic';
|
||||
return $planId !== 'basic';
|
||||
}
|
||||
|
||||
/**
|
||||
* API for employers to list their jobs.
|
||||
* GET /api/employers/jobs
|
||||
*/
|
||||
public function employerListJobs(Request $request)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if (!$this->checkJobAccess($employer->id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$jobs = JobPost::where('employer_id', $employer->id)
|
||||
->with(['applications'])
|
||||
->latest()
|
||||
->get()
|
||||
->map(function ($job) {
|
||||
return [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
'job_type' => $job->job_type,
|
||||
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||
'description' => $job->description,
|
||||
'requirements' => $job->requirements,
|
||||
'applied_count' => $job->applications->count(),
|
||||
'posted_at' => $job->created_at->toIso8601String(),
|
||||
'status' => $job->status,
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'jobs' => $jobs
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for employers to create a job.
|
||||
* POST /api/employers/jobs
|
||||
*/
|
||||
public function employerCreateJob(Request $request)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if (!$this->checkJobAccess($employer->id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'title' => 'required|string|max:255',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
'location' => 'required|string|max:255',
|
||||
'salary' => 'required|numeric|min:0',
|
||||
'job_type' => 'required|string|in:Full Time,Part Time,Contract',
|
||||
'start_date' => 'required|date',
|
||||
'description' => 'required|string|max:1000',
|
||||
'requirements' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Validation error.',
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => $request->title,
|
||||
'workers_needed' => $request->workers_needed,
|
||||
'job_type' => $request->job_type,
|
||||
'location' => $request->location,
|
||||
'salary' => $request->salary,
|
||||
'start_date' => $request->start_date,
|
||||
'description' => $request->description,
|
||||
'requirements' => $request->requirements,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Job posted successfully.',
|
||||
'data' => [
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
'job_type' => $job->job_type,
|
||||
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||
'description' => $job->description,
|
||||
'requirements' => $job->requirements,
|
||||
'posted_at' => $job->created_at->toIso8601String(),
|
||||
'status' => $job->status,
|
||||
]
|
||||
]
|
||||
], 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for employers to retrieve details of a specific job.
|
||||
* GET /api/employers/jobs/{id}
|
||||
*/
|
||||
public function employerJobDetail(Request $request, $id)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if (!$this->checkJobAccess($employer->id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$job = JobPost::where('employer_id', $employer->id)
|
||||
->with(['applications'])
|
||||
->find($id);
|
||||
|
||||
if (!$job) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Job not found or unauthorized.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
'job_type' => $job->job_type,
|
||||
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||
'description' => $job->description,
|
||||
'requirements' => $job->requirements,
|
||||
'applied_count' => $job->applications->count(),
|
||||
'posted_at' => $job->created_at->toIso8601String(),
|
||||
'status' => $job->status,
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for employers to update a specific job.
|
||||
* POST /api/employers/jobs/{id}/update
|
||||
*/
|
||||
public function employerUpdateJob(Request $request, $id)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if (!$this->checkJobAccess($employer->id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$job = JobPost::where('employer_id', $employer->id)->find($id);
|
||||
|
||||
if (!$job) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Job not found or unauthorized.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'title' => 'required|string|max:255',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
'location' => 'required|string|max:255',
|
||||
'salary' => 'required|numeric|min:0',
|
||||
'job_type' => 'required|string|in:Full Time,Part Time,Contract',
|
||||
'start_date' => 'required|date',
|
||||
'description' => 'required|string|max:1000',
|
||||
'requirements' => 'nullable|string',
|
||||
'status' => 'required|string|in:active,closed,draft',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Validation error.',
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
$job->update([
|
||||
'title' => $request->title,
|
||||
'workers_needed' => $request->workers_needed,
|
||||
'job_type' => $request->job_type,
|
||||
'location' => $request->location,
|
||||
'salary' => $request->salary,
|
||||
'start_date' => $request->start_date,
|
||||
'description' => $request->description,
|
||||
'requirements' => $request->requirements,
|
||||
'status' => strtolower($request->status),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Job updated successfully.',
|
||||
'data' => [
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
'job_type' => $job->job_type,
|
||||
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||
'description' => $job->description,
|
||||
'requirements' => $job->requirements,
|
||||
'posted_at' => $job->created_at->toIso8601String(),
|
||||
'status' => $job->status,
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for employers to delete a job.
|
||||
* DELETE /api/employers/jobs/{id}
|
||||
*/
|
||||
public function employerDeleteJob(Request $request, $id)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if (!$this->checkJobAccess($employer->id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$job = JobPost::where('employer_id', $employer->id)->find($id);
|
||||
|
||||
if (!$job) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Job not found or unauthorized.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$job->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Job deleted successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for employers to view applicants for each job.
|
||||
* GET /api/employers/jobs/{id}/applicants
|
||||
*/
|
||||
public function employerJobApplicants(Request $request, $id)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if (!$this->checkJobAccess($employer->id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$job = JobPost::where('employer_id', $employer->id)->find($id);
|
||||
|
||||
if (!$job) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Job not found or unauthorized.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$query = JobApplication::where('job_id', $job->id)
|
||||
->with(['worker.skills']);
|
||||
|
||||
// Search filter: name, nationality
|
||||
if ($request->filled('search')) {
|
||||
$search = $request->search;
|
||||
$query->whereHas('worker', function($q) use ($search) {
|
||||
$q->where('name', 'like', "%{$search}%")
|
||||
->orWhere('nationality', 'like', "%{$search}%");
|
||||
});
|
||||
}
|
||||
|
||||
// Status filter
|
||||
if ($request->filled('status')) {
|
||||
$query->where('status', strtolower($request->status));
|
||||
}
|
||||
|
||||
$sortBy = $request->input('sort_by', 'created_at');
|
||||
$sortOrder = $request->input('sort_order', 'desc');
|
||||
|
||||
if ($sortBy === 'created_at' || $sortBy === 'applied_at') {
|
||||
$query->orderBy('created_at', $sortOrder === 'asc' ? 'asc' : 'desc');
|
||||
}
|
||||
|
||||
$applicants = $query->get()->map(function ($app) use ($employer) {
|
||||
$w = $app->worker;
|
||||
if (!$w) return null;
|
||||
|
||||
// Check if worker is saved in Saved Workers (Shortlist)
|
||||
$isSaved = \App\Models\Shortlist::where('employer_id', $employer->id)
|
||||
->where('worker_id', $w->id)
|
||||
->exists();
|
||||
|
||||
return [
|
||||
'application_id' => $app->id,
|
||||
'status' => $app->status,
|
||||
'notes' => $app->notes,
|
||||
'status_history' => $app->status_history ?: [],
|
||||
'applied_at' => $app->created_at->toIso8601String(),
|
||||
'is_saved' => $isSaved,
|
||||
'worker' => [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'nationality' => $w->nationality,
|
||||
'salary' => (int) $w->salary,
|
||||
'preferred_location' => $w->preferred_location,
|
||||
'preferred_job_type' => $w->preferred_job_type,
|
||||
'visa_status' => $w->visa_status,
|
||||
'experience' => $w->experience,
|
||||
'skills' => $w->skills->pluck('name')->toArray(),
|
||||
]
|
||||
];
|
||||
})->filter()->values();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
],
|
||||
'applicants' => $applicants
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for employers to update application status.
|
||||
* POST /api/employers/applications/{id}/status
|
||||
*/
|
||||
public function employerUpdateApplicationStatus(Request $request, $id)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
if (!$this->checkJobAccess($employer->id)) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Validation error.',
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
$application = JobApplication::where('id', $id)
|
||||
->whereHas('jobPost', function($q) use ($employer) {
|
||||
$q->where('employer_id', $employer->id);
|
||||
})->first();
|
||||
|
||||
if (!$application) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Job application not found or unauthorized.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$dbStatus = strtolower($request->status);
|
||||
|
||||
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' for the first time
|
||||
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
|
||||
if (in_array($dbStatus, $contactStatuses)) {
|
||||
if (!User::canContactWorker($employer->id, $application->worker_id)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first();
|
||||
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
$notes = $request->input('notes');
|
||||
|
||||
// Update application status & history
|
||||
$history = $application->status_history ?: [];
|
||||
$history[] = [
|
||||
'status' => $dbStatus,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'notes' => $notes,
|
||||
];
|
||||
|
||||
$updateData = [
|
||||
'status' => $dbStatus,
|
||||
'status_history' => $history,
|
||||
];
|
||||
|
||||
if ($notes !== null) {
|
||||
$updateData['notes'] = $notes;
|
||||
}
|
||||
|
||||
$application->update($updateData);
|
||||
|
||||
// If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved
|
||||
if ($dbStatus === 'shortlisted') {
|
||||
$exists = \App\Models\Shortlist::where('employer_id', $employer->id)
|
||||
->where('worker_id', $application->worker_id)
|
||||
->exists();
|
||||
if (!$exists) {
|
||||
\App\Models\Shortlist::create([
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $application->worker_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// If hired, also update worker status
|
||||
if ($dbStatus === 'hired') {
|
||||
if ($application->worker) {
|
||||
$application->worker->update(['status' => 'Hired']);
|
||||
}
|
||||
} elseif ($dbStatus === 'rejected') {
|
||||
if ($application->worker) {
|
||||
$application->worker->update(['status' => 'active']);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger notifications
|
||||
$this->triggerStatusNotification($application, $dbStatus);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Application status updated successfully.',
|
||||
'data' => [
|
||||
'application' => $application->fresh()
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for workers to view details of a specific job application.
|
||||
* GET /api/workers/applied-jobs/{id}
|
||||
*/
|
||||
public function appliedJobDetail(Request $request, $id)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$application = JobApplication::where('worker_id', $worker->id)
|
||||
->with(['jobPost.employer.employerProfile'])
|
||||
->find($id);
|
||||
|
||||
if (!$application) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Application not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$job = $application->jobPost;
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'application' => [
|
||||
'id' => $application->id,
|
||||
'status' => $application->status,
|
||||
'status_history' => $application->status_history ?: [],
|
||||
'applied_at' => $application->created_at->toIso8601String(),
|
||||
'job' => $job ? [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'job_type' => $job->job_type,
|
||||
'description' => $job->description,
|
||||
'requirements' => $job->requirements,
|
||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||
'posted_at' => $job->created_at->toIso8601String(),
|
||||
] : null
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for workers to withdraw their job application.
|
||||
* POST /api/workers/applied-jobs/{id}/withdraw
|
||||
*/
|
||||
public function withdrawApplication(Request $request, $id)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$application = JobApplication::where('worker_id', $worker->id)->find($id);
|
||||
|
||||
if (!$application) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Application not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (strtolower($application->status) !== 'applied') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You can only withdraw applications that are still in "Applied" status.'
|
||||
], 400);
|
||||
}
|
||||
|
||||
$application->delete();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Application successfully withdrawn.'
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch FCM Push Notification.
|
||||
*/
|
||||
private function triggerStatusNotification($application, $status)
|
||||
{
|
||||
$worker = $application->worker;
|
||||
$job = $application->jobPost;
|
||||
$employer = $job ? $job->employer : null;
|
||||
|
||||
// When a new application is submitted: notify employer
|
||||
if ($status === 'applied') {
|
||||
if ($employer && $employer->fcm_token) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$employer->fcm_token,
|
||||
"New Job Application",
|
||||
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||
[
|
||||
'type' => 'new_job_application',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When status is updated: notify worker
|
||||
if ($worker && $worker->fcm_token) {
|
||||
$title = "Job Application Update";
|
||||
$body = "";
|
||||
switch (strtolower($status)) {
|
||||
case 'shortlisted':
|
||||
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
case 'contacted':
|
||||
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
case 'interview scheduled':
|
||||
case 'interview_scheduled':
|
||||
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
case 'selected':
|
||||
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
case 'rejected':
|
||||
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
|
||||
break;
|
||||
case 'hired':
|
||||
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
default:
|
||||
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
||||
break;
|
||||
}
|
||||
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$worker->fcm_token,
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'type' => 'job_application_status_update',
|
||||
'job_id' => $job->id ?? '',
|
||||
'application_id' => $application->id,
|
||||
'status' => $status,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
113
app/Http/Controllers/Api/WorkerNotificationController.php
Normal file
113
app/Http/Controllers/Api/WorkerNotificationController.php
Normal file
@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Worker;
|
||||
|
||||
class WorkerNotificationController extends Controller
|
||||
{
|
||||
/**
|
||||
* List all notifications for the worker.
|
||||
* GET /api/workers/notifications
|
||||
*/
|
||||
public function index(Request $request)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$filter = $request->query('filter', 'all');
|
||||
$perPage = (int) $request->query('per_page', 15);
|
||||
|
||||
if ($filter === 'read') {
|
||||
$query = $worker->readNotifications();
|
||||
} elseif ($filter === 'unread') {
|
||||
$query = $worker->unreadNotifications();
|
||||
} else {
|
||||
$query = $worker->notifications();
|
||||
}
|
||||
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$notifications = collect($paginator->items())->map(function ($notification) {
|
||||
return [
|
||||
'id' => $notification->id,
|
||||
'type' => $notification->type,
|
||||
'data' => $notification->data,
|
||||
'read_at' => $notification->read_at ? $notification->read_at->toIso8601String() : null,
|
||||
'created_at' => $notification->created_at->toIso8601String(),
|
||||
'time_ago' => $notification->created_at->diffForHumans(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'notifications' => $notifications,
|
||||
'pagination' => [
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
]
|
||||
]
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a single notification or all unread notifications as read.
|
||||
* PUT /api/workers/notifications/{id}/read or PUT /api/workers/notifications/read
|
||||
*/
|
||||
public function markAsRead(Request $request, $id = null)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
if ($id) {
|
||||
$notification = $worker->notifications()->where('id', $id)->first();
|
||||
if (!$notification) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Notification not found.'
|
||||
], 404);
|
||||
}
|
||||
$notification->markAsRead();
|
||||
} else {
|
||||
$worker->unreadNotifications->markAsRead();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Notification(s) marked as read successfully.'
|
||||
], 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a single notification or all notifications.
|
||||
* DELETE /api/workers/notifications/{id} or DELETE /api/workers/notifications
|
||||
*/
|
||||
public function destroy(Request $request, $id = null)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
if ($id) {
|
||||
$notification = $worker->notifications()->where('id', $id)->first();
|
||||
if (!$notification) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Notification not found.'
|
||||
], 404);
|
||||
}
|
||||
$notification->delete();
|
||||
} else {
|
||||
$worker->notifications()->delete();
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Notification(s) deleted successfully.'
|
||||
], 200);
|
||||
}
|
||||
}
|
||||
@ -28,7 +28,7 @@ public function getProfile(Request $request)
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$worker->load(['skills', 'documents']);
|
||||
$worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']);
|
||||
|
||||
$worker->makeHidden(['email', 'religion', 'availability', 'bio', 'country', 'city', 'area']);
|
||||
|
||||
@ -77,7 +77,7 @@ public function updateProfile(Request $request)
|
||||
'skills' => 'nullable|array',
|
||||
'skills.*' => 'exists:skills,id',
|
||||
'in_country' => 'nullable',
|
||||
'visa_status' => 'nullable|string|max:100',
|
||||
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
|
||||
'preferred_job_type' => 'nullable|string|max:100',
|
||||
'gender' => 'nullable|string|in:male,female,other',
|
||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||
@ -240,7 +240,7 @@ public function updateProfile(Request $request)
|
||||
}
|
||||
});
|
||||
|
||||
$worker->load(['skills', 'documents']);
|
||||
$worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']);
|
||||
|
||||
$worker->makeHidden(['email', 'religion', 'availability', 'bio', 'country', 'city', 'area']);
|
||||
|
||||
@ -286,7 +286,7 @@ public function goLive(Request $request)
|
||||
'success' => true,
|
||||
'message' => 'Your profile is now live! Status set to Active.',
|
||||
'data' => [
|
||||
'worker' => $worker->load(['skills', 'documents'])
|
||||
'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile'])
|
||||
]
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
@ -339,7 +339,7 @@ public function toggleAvailability(Request $request)
|
||||
'still_looking' => $stillLooking,
|
||||
'status' => $newStatus,
|
||||
'data' => [
|
||||
'worker' => $worker->load(['skills', 'documents'])
|
||||
'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile'])
|
||||
]
|
||||
], 200);
|
||||
|
||||
@ -373,7 +373,7 @@ public function markHired(Request $request)
|
||||
'success' => true,
|
||||
'message' => 'Congratulations! You have been successfully marked as Hired. Your profile has been automatically removed from active search.',
|
||||
'data' => [
|
||||
'worker' => $worker->load(['skills', 'documents'])
|
||||
'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile'])
|
||||
]
|
||||
], 200);
|
||||
} catch (\Exception $e) {
|
||||
@ -589,7 +589,7 @@ public function getDashboard(Request $request)
|
||||
$currentSponsor = null;
|
||||
$acceptedOffer = JobOffer::where('worker_id', $worker->id)
|
||||
->where('status', 'accepted')
|
||||
->with('employer.employerProfile')
|
||||
->with(['employer.employerProfile', 'employer.employerReviews.worker'])
|
||||
->first();
|
||||
|
||||
if ($acceptedOffer && $acceptedOffer->employer) {
|
||||
@ -605,6 +605,9 @@ public function getDashboard(Request $request)
|
||||
'phone' => $emp->phone,
|
||||
'hired_at' => $acceptedOffer->updated_at->toIso8601String(),
|
||||
'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'),
|
||||
'rating' => $emp->rating,
|
||||
'reviews_count' => $emp->reviews_count,
|
||||
'reviews' => $emp->employerReviews,
|
||||
];
|
||||
}
|
||||
|
||||
@ -658,6 +661,7 @@ public function getDashboard(Request $request)
|
||||
'employer_contacted' => $employerContacted,
|
||||
'profile_viewed' => $profileViewed,
|
||||
'currently_working_sponsor' => $currentSponsor,
|
||||
'current_employer' => $currentSponsor,
|
||||
'latest_charity_events_list' => $charityEvents
|
||||
]
|
||||
], 200);
|
||||
@ -673,6 +677,108 @@ public function getDashboard(Request $request)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of employers associated with the worker.
|
||||
* GET /api/workers/employers
|
||||
*/
|
||||
public function getEmployers(Request $request)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
try {
|
||||
$query = JobOffer::where('worker_id', $worker->id)
|
||||
->with(['employer.employerProfile', 'employer.employerReviews.worker']);
|
||||
|
||||
// Filtering by status
|
||||
if ($request->filled('status')) {
|
||||
$statusInput = strtolower($request->status);
|
||||
if ($statusInput === 'active') {
|
||||
$query->where('status', 'accepted');
|
||||
} else {
|
||||
$query->where('status', $statusInput);
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting
|
||||
$sortBy = $request->input('sort_by', 'joining_date');
|
||||
$sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
||||
|
||||
$sortColumn = 'work_date';
|
||||
if ($sortBy === 'salary') {
|
||||
$sortColumn = 'salary';
|
||||
} elseif ($sortBy === 'status') {
|
||||
$sortColumn = 'status';
|
||||
} elseif ($sortBy === 'created_at') {
|
||||
$sortColumn = 'created_at';
|
||||
}
|
||||
|
||||
// Always display the current active employer (status = accepted) at the top of the list,
|
||||
// followed by other employers ordered by the chosen sort column.
|
||||
$query->orderByRaw("CASE WHEN status = 'accepted' THEN 0 ELSE 1 END ASC")
|
||||
->orderBy($sortColumn, $sortOrder);
|
||||
|
||||
$perPage = (int) $request->input('per_page', 15);
|
||||
$paginator = $query->paginate($perPage);
|
||||
|
||||
$employers = collect($paginator->items())->map(function ($offer) {
|
||||
$emp = $offer->employer;
|
||||
$empProfile = $emp ? $emp->employerProfile : null;
|
||||
|
||||
// Map 'accepted' -> 'Active' (and others to Title Case for nice presentation)
|
||||
$statusFormatted = $offer->status;
|
||||
if (strtolower($offer->status) === 'accepted') {
|
||||
$statusFormatted = 'Active';
|
||||
} else {
|
||||
$statusFormatted = ucfirst($offer->status);
|
||||
}
|
||||
|
||||
return [
|
||||
'offer_id' => $offer->id,
|
||||
'joining_date' => $offer->work_date ? $offer->work_date->format('Y-m-d') : null,
|
||||
'location' => $offer->location,
|
||||
'salary' => (int) $offer->salary,
|
||||
'status' => $statusFormatted,
|
||||
'notes' => $offer->notes,
|
||||
'employer' => $emp ? [
|
||||
'id' => $emp->id,
|
||||
'name' => $emp->name,
|
||||
'email' => $emp->email,
|
||||
'phone' => $emp->phone,
|
||||
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
|
||||
'city' => $empProfile->city ?? null,
|
||||
'nationality' => $empProfile->nationality ?? null,
|
||||
'rating' => $emp->rating,
|
||||
'reviews_count' => $emp->reviews_count,
|
||||
'reviews' => $emp->employerReviews,
|
||||
] : null
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'employers' => $employers,
|
||||
'pagination' => [
|
||||
'total' => $paginator->total(),
|
||||
'per_page' => $paginator->perPage(),
|
||||
'current_page' => $paginator->currentPage(),
|
||||
'last_page' => $paginator->lastPage(),
|
||||
]
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Worker Employers API Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while loading the employers list.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change worker's password.
|
||||
*
|
||||
@ -765,6 +871,19 @@ private function normaliseDateForController(?string $raw): ?string
|
||||
private function cleanVisaData(array $visaDataInput): array
|
||||
{
|
||||
$rawVisaType = $visaDataInput['visa_type'] ?? null;
|
||||
if ($rawVisaType) {
|
||||
$normalized = strtolower(trim($rawVisaType));
|
||||
if (str_contains($normalized, 'residence')) {
|
||||
$rawVisaType = 'Residence Visa';
|
||||
} elseif (str_contains($normalized, 'employment')) {
|
||||
$rawVisaType = 'Employment Visa';
|
||||
} else {
|
||||
$rawVisaType = 'Tourist Visa';
|
||||
}
|
||||
} else {
|
||||
$rawVisaType = 'Tourist Visa';
|
||||
}
|
||||
|
||||
$rawEntryPermitNo = $visaDataInput['entry_permit_no'] ?? $visaDataInput['file_number'] ?? $visaDataInput['id_number'] ?? $visaDataInput['number'] ?? null;
|
||||
$rawIssueDate = $visaDataInput['issue_date'] ?? null;
|
||||
$rawValidUntil = $visaDataInput['valid_until'] ?? $visaDataInput['expiry_date'] ?? null;
|
||||
@ -795,7 +914,7 @@ private function cleanVisaData(array $visaDataInput): array
|
||||
return [
|
||||
'document_type' => 'uae_visa',
|
||||
'country' => 'United Arab Emirates',
|
||||
'visa_type' => $rawVisaType ?: 'ENTRY PERMIT',
|
||||
'visa_type' => $rawVisaType,
|
||||
'entry_permit_no' => $rawEntryPermitNo ?: '',
|
||||
'issue_date' => $rawIssueDate ?: '',
|
||||
'valid_until' => $rawValidUntil ?: '',
|
||||
@ -814,4 +933,155 @@ private function cleanVisaData(array $visaDataInput): array
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details of an employer profile for worker view.
|
||||
* GET /api/workers/employers/{id}
|
||||
*/
|
||||
public function getEmployerProfile(Request $request, $id)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
try {
|
||||
// Find the employer (role should be 'employer')
|
||||
$employer = \App\Models\User::where('id', $id)->where('role', 'employer')->first();
|
||||
|
||||
if (!$employer) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Employer not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$empProfile = $employer->employerProfile;
|
||||
|
||||
// 1. Check access permissions for contact details
|
||||
$hasAccess = false;
|
||||
|
||||
// Check if there is an active/previous JobOffer
|
||||
$hasOffer = \App\Models\JobOffer::where('employer_id', $employer->id)
|
||||
->where('worker_id', $worker->id)
|
||||
->exists();
|
||||
|
||||
// Check if the worker has applied to any jobs of this employer
|
||||
$hasApplication = \App\Models\JobApplication::where('worker_id', $worker->id)
|
||||
->whereHas('jobPost', function ($query) use ($employer) {
|
||||
$query->where('employer_id', $employer->id);
|
||||
})
|
||||
->exists();
|
||||
|
||||
// Check if there is an active/previous conversation
|
||||
$hasConversation = \App\Models\Conversation::where('employer_id', $employer->id)
|
||||
->where('worker_id', $worker->id)
|
||||
->exists();
|
||||
|
||||
if ($hasOffer || $hasApplication || $hasConversation) {
|
||||
$hasAccess = true;
|
||||
}
|
||||
|
||||
// 2. Fetch active job details (if applicable)
|
||||
$activeJobs = \App\Models\JobPost::where('employer_id', $employer->id)
|
||||
->where('status', 'active')
|
||||
->latest()
|
||||
->get()
|
||||
->map(function ($job) {
|
||||
return [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'job_type' => $job->job_type,
|
||||
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||
'description' => $job->description,
|
||||
'requirements' => $job->requirements,
|
||||
'posted_at' => $job->created_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
// 3. Overall Rating and Review Summary
|
||||
$reviewsQuery = \App\Models\EmployerReview::where('employer_id', $employer->id);
|
||||
$totalReviews = $reviewsQuery->count();
|
||||
$avgRating = $totalReviews > 0 ? round($reviewsQuery->avg('rating'), 1) : 0.0;
|
||||
|
||||
$starsBreakdown = [
|
||||
'5' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 5)->count(),
|
||||
'4' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 4)->count(),
|
||||
'3' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 3)->count(),
|
||||
'2' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 2)->count(),
|
||||
'1' => \App\Models\EmployerReview::where('employer_id', $employer->id)->where('rating', 1)->count(),
|
||||
];
|
||||
|
||||
// 4. Paginated Reviews list
|
||||
$perPage = (int) $request->input('per_page', 10);
|
||||
$reviewsPaginator = \App\Models\EmployerReview::where('employer_id', $employer->id)
|
||||
->with('worker')
|
||||
->latest()
|
||||
->paginate($perPage);
|
||||
|
||||
$reviews = collect($reviewsPaginator->items())->map(function ($rev) {
|
||||
return [
|
||||
'id' => $rev->id,
|
||||
'rating' => $rev->rating,
|
||||
'title' => $rev->title,
|
||||
'comment' => $rev->comment,
|
||||
'created_at' => $rev->created_at->toIso8601String(),
|
||||
'worker' => $rev->worker ? [
|
||||
'id' => $rev->worker->id,
|
||||
'name' => $rev->worker->name,
|
||||
'nationality' => $rev->worker->nationality,
|
||||
] : null,
|
||||
];
|
||||
});
|
||||
|
||||
// 5. Structure the response
|
||||
$employerData = [
|
||||
'id' => $employer->id,
|
||||
'name' => $employer->name,
|
||||
'email' => $hasAccess ? $employer->email : null,
|
||||
'phone' => $hasAccess ? ($empProfile->phone ?? $employer->phone) : null,
|
||||
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
|
||||
'city' => $empProfile->city ?? null,
|
||||
'district' => $empProfile->district ?? null,
|
||||
'nationality' => $empProfile->nationality ?? null,
|
||||
'address' => $empProfile->address ?? null,
|
||||
'accommodation' => $empProfile->accommodation ?? null,
|
||||
'language' => $empProfile->language ?? null,
|
||||
'profile_photo_url' => $empProfile->profile_photo_url ?? null,
|
||||
'rating' => $avgRating,
|
||||
'reviews_count' => $totalReviews,
|
||||
'review_summary' => [
|
||||
'total' => $totalReviews,
|
||||
'average' => $avgRating,
|
||||
'stars' => $starsBreakdown,
|
||||
],
|
||||
'active_jobs' => $activeJobs,
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'employer' => $employerData,
|
||||
'reviews' => [
|
||||
'data' => $reviews,
|
||||
'pagination' => [
|
||||
'total' => $reviewsPaginator->total(),
|
||||
'per_page' => $reviewsPaginator->perPage(),
|
||||
'current_page' => $reviewsPaginator->currentPage(),
|
||||
'last_page' => $reviewsPaginator->lastPage(),
|
||||
]
|
||||
]
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Worker View Employer Profile Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while loading the employer profile.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
331
app/Http/Controllers/Api/WorkerReviewController.php
Normal file
331
app/Http/Controllers/Api/WorkerReviewController.php
Normal file
@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\Worker;
|
||||
use App\Models\User;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\EmployerReview;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class WorkerReviewController extends Controller
|
||||
{
|
||||
/**
|
||||
* Add a new review for an employer.
|
||||
* POST /api/workers/reviews
|
||||
*/
|
||||
public function addReview(Request $request)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
// Sanitize job_id: if not a valid existing JobPost ID, treat it as null (job id is not needed/optional)
|
||||
$jobIdInput = $request->input('job_id');
|
||||
if (empty($jobIdInput) || !is_numeric($jobIdInput) || !\App\Models\JobPost::where('id', $jobIdInput)->exists()) {
|
||||
$request->merge(['job_id' => null]);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'employer_id' => 'required|exists:users,id',
|
||||
'job_id' => 'nullable|exists:job_posts,id',
|
||||
'rating' => 'required|integer|min:1|max:5',
|
||||
'title' => 'nullable|string|max:255',
|
||||
'comment' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Validation error.',
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$employerId = $request->employer_id;
|
||||
$jobId = $request->job_id;
|
||||
|
||||
// 1. Validate confirmed hire and completed joining, and 3-month requirement
|
||||
$hasConfirmedHire = false;
|
||||
$joiningDate = null;
|
||||
|
||||
// Check standard JobApplication
|
||||
$appQuery = JobApplication::where('worker_id', $worker->id)
|
||||
->where('status', 'hired')
|
||||
->whereNotNull('joining_confirmed_at')
|
||||
->whereHas('jobPost', function ($query) use ($employerId, $jobId) {
|
||||
$query->where('employer_id', $employerId);
|
||||
if ($jobId) {
|
||||
$query->where('id', $jobId);
|
||||
}
|
||||
});
|
||||
|
||||
$app = $appQuery->first();
|
||||
if ($app) {
|
||||
$hasConfirmedHire = true;
|
||||
$joiningDate = Carbon::parse($app->joining_confirmed_at);
|
||||
} else {
|
||||
// Check direct JobOffer
|
||||
$offerQuery = JobOffer::where('worker_id', $worker->id)
|
||||
->where('employer_id', $employerId)
|
||||
->where('status', 'accepted');
|
||||
|
||||
$offer = $offerQuery->first();
|
||||
if ($offer) {
|
||||
$hasConfirmedHire = true;
|
||||
// For direct offers, we treat work_date or updated_at as joining date
|
||||
$joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at;
|
||||
}
|
||||
}
|
||||
|
||||
if (!$hasConfirmedHire) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You can only review employers with a confirmed hire and completed joining.'
|
||||
], 403);
|
||||
}
|
||||
|
||||
// Verify if completed configured months from joining date
|
||||
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
|
||||
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
|
||||
], 403);
|
||||
}
|
||||
|
||||
// 2. Prevent duplicate reviews for the same job/employer combination
|
||||
$existing = EmployerReview::where('worker_id', $worker->id)
|
||||
->where('employer_id', $employerId)
|
||||
->where('job_id', $jobId)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'You have already submitted a review for this job/employer.'
|
||||
], 422);
|
||||
}
|
||||
|
||||
// 3. Create the review
|
||||
$review = EmployerReview::create([
|
||||
'worker_id' => $worker->id,
|
||||
'employer_id' => $employerId,
|
||||
'job_id' => $jobId,
|
||||
'rating' => $request->rating,
|
||||
'title' => $request->title,
|
||||
'comment' => $request->comment,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Review submitted successfully.',
|
||||
'data' => [
|
||||
'review' => $review
|
||||
]
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Worker Add Employer Review Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while adding the review.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edit/update an existing review.
|
||||
* PUT /api/workers/reviews/{id}
|
||||
*/
|
||||
public function editReview(Request $request, $id)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'rating' => 'required|integer|min:1|max:5',
|
||||
'title' => 'nullable|string|max:255',
|
||||
'comment' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Validation error.',
|
||||
'errors' => $validator->errors()
|
||||
], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$review = EmployerReview::where('id', $id)
|
||||
->where('worker_id', $worker->id)
|
||||
->first();
|
||||
|
||||
if (!$review) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Review not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Check if the edit window has expired
|
||||
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
|
||||
if ($review->created_at->addDays($editPeriodDays)->isPast()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => "The {$editPeriodDays}-day edit window for this review has expired."
|
||||
], 403);
|
||||
}
|
||||
|
||||
$review->update([
|
||||
'rating' => $request->rating,
|
||||
'title' => $request->title,
|
||||
'comment' => $request->comment,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Review updated successfully.',
|
||||
'data' => [
|
||||
'review' => $review
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Worker Edit Employer Review Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while updating the review.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* View a single review.
|
||||
* GET /api/workers/reviews/{id}
|
||||
*/
|
||||
public function viewReview(Request $request, $id)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
try {
|
||||
$review = EmployerReview::where('id', $id)
|
||||
->where('worker_id', $worker->id)
|
||||
->with(['employer.employerProfile', 'jobPost'])
|
||||
->first();
|
||||
|
||||
if (!$review) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Review not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
|
||||
$editable = !$review->created_at->addDays($editPeriodDays)->isPast();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'review' => [
|
||||
'id' => $review->id,
|
||||
'employer_id' => $review->employer_id,
|
||||
'employer_name' => $review->employer->name ?? 'Employer',
|
||||
'job_id' => $review->job_id,
|
||||
'job_title' => $review->jobPost->title ?? null,
|
||||
'rating' => $review->rating,
|
||||
'title' => $review->title,
|
||||
'comment' => $review->comment,
|
||||
'editable' => $editable,
|
||||
'created_at' => $review->created_at->toIso8601String(),
|
||||
'updated_at' => $review->updated_at->toIso8601String(),
|
||||
]
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Worker View Employer Review Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while fetching the review.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reviews written by this worker.
|
||||
* GET /api/workers/reviews
|
||||
*/
|
||||
public function getReviews(Request $request)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
try {
|
||||
$page = (int) $request->input('page', 1);
|
||||
$perPage = (int) $request->input('per_page', 15);
|
||||
|
||||
$query = EmployerReview::where('worker_id', $worker->id)
|
||||
->with(['employer.employerProfile', 'jobPost'])
|
||||
->latest();
|
||||
|
||||
$total = $query->count();
|
||||
$offset = ($page - 1) * $perPage;
|
||||
|
||||
$reviews = $query->skip($offset)->take($perPage)->get()
|
||||
->map(function ($rev) {
|
||||
$editPeriodDays = (int) config('reminders.review_edit_period_days', 7);
|
||||
$editable = !$rev->created_at->addDays($editPeriodDays)->isPast();
|
||||
|
||||
return [
|
||||
'id' => $rev->id,
|
||||
'employer_id' => $rev->employer_id,
|
||||
'employer_name' => $rev->employer->name ?? 'Employer',
|
||||
'job_id' => $rev->job_id,
|
||||
'job_title' => $rev->jobPost->title ?? null,
|
||||
'rating' => $rev->rating,
|
||||
'title' => $rev->title,
|
||||
'comment' => $rev->comment,
|
||||
'editable' => $editable,
|
||||
'created_at' => $rev->created_at->toIso8601String(),
|
||||
'updated_at' => $rev->updated_at->toIso8601String(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'reviews' => $reviews,
|
||||
'pagination' => [
|
||||
'total' => $total,
|
||||
'per_page' => $perPage,
|
||||
'current_page' => $page,
|
||||
'last_page' => max(1, (int) ceil($total / $perPage)),
|
||||
]
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Worker Get Employer Reviews Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while fetching reviews.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -74,6 +74,7 @@ public function index(Request $request)
|
||||
'live_in_out' => $w->live_in_out,
|
||||
'in_country' => (bool)$w->in_country,
|
||||
'visa_status' => $w->visa_status,
|
||||
'main_profession' => $w->main_profession,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
@ -105,6 +106,7 @@ public function index(Request $request)
|
||||
'live_in_out' => $w->live_in_out,
|
||||
'in_country' => (bool)$w->in_country,
|
||||
'visa_status' => $w->visa_status,
|
||||
'main_profession' => $w->main_profession,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
@ -265,6 +267,13 @@ public function index(Request $request)
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('main_profession')) {
|
||||
$mainProfession = strtolower($request->main_profession);
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($mainProfession) {
|
||||
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
|
||||
}));
|
||||
}
|
||||
|
||||
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
|
||||
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
|
||||
$allNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
|
||||
@ -275,10 +284,27 @@ public function index(Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
private function checkJobAccess($user)
|
||||
{
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$planId = $sub ? $sub->plan_id : 'basic';
|
||||
return $planId !== 'basic';
|
||||
}
|
||||
|
||||
public function updateStatus(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'status' => 'required|string|in:Reviewing,Offer Sent,Hired,Rejected',
|
||||
'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
// If this is a direct hiring offer response
|
||||
@ -287,13 +313,13 @@ public function updateStatus(Request $request, $id)
|
||||
$offer = JobOffer::findOrFail($offerId);
|
||||
|
||||
$dbStatus = 'pending';
|
||||
if ($request->status === 'Hired') {
|
||||
if (in_array(strtolower($request->status), ['hired', 'accepted'])) {
|
||||
$dbStatus = 'accepted';
|
||||
$offer->worker->update(['status' => 'Hired']);
|
||||
} elseif ($request->status === 'Rejected') {
|
||||
} elseif (in_array(strtolower($request->status), ['rejected'])) {
|
||||
$dbStatus = 'rejected';
|
||||
$offer->worker->update(['status' => 'active']);
|
||||
} elseif ($request->status === 'Offer Sent') {
|
||||
} elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) {
|
||||
$dbStatus = 'pending';
|
||||
}
|
||||
|
||||
@ -305,27 +331,161 @@ public function updateStatus(Request $request, $id)
|
||||
// Standard job application status update
|
||||
$app = JobApplication::findOrFail($id);
|
||||
|
||||
$user = $this->resolveCurrentUser();
|
||||
if (!$user) {
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!$this->checkJobAccess($user)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
$dbStatus = 'applied';
|
||||
if ($request->status === 'Hired') {
|
||||
$statusLower = strtolower($request->status);
|
||||
if ($statusLower === 'hired') {
|
||||
$dbStatus = 'hired';
|
||||
if ($app->worker) {
|
||||
$app->worker->update(['status' => 'Hired']);
|
||||
}
|
||||
} elseif ($request->status === 'Rejected') {
|
||||
} elseif ($statusLower === 'rejected') {
|
||||
$dbStatus = 'rejected';
|
||||
if ($app->worker) {
|
||||
$app->worker->update(['status' => 'active']);
|
||||
}
|
||||
} elseif ($request->status === 'Offer Sent') {
|
||||
} elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') {
|
||||
$dbStatus = 'shortlisted';
|
||||
} elseif ($request->status === 'Reviewing') {
|
||||
} elseif ($statusLower === 'contacted') {
|
||||
$dbStatus = 'contacted';
|
||||
} elseif ($statusLower === 'interview scheduled' || $statusLower === 'interview_scheduled') {
|
||||
$dbStatus = 'interview_scheduled';
|
||||
} elseif ($statusLower === 'selected') {
|
||||
$dbStatus = 'selected';
|
||||
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied') {
|
||||
$dbStatus = 'applied';
|
||||
}
|
||||
|
||||
$app->update([
|
||||
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'
|
||||
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
|
||||
if (in_array($dbStatus, $contactStatuses)) {
|
||||
if (!User::canContactWorker($user->id, $app->worker_id)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
|
||||
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||
|
||||
// Web: contact limit block redirect
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||
}
|
||||
}
|
||||
|
||||
// Update application status & history
|
||||
$history = $app->status_history ?: [];
|
||||
$history[] = [
|
||||
'status' => $dbStatus,
|
||||
]);
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'notes' => $request->input('notes'),
|
||||
];
|
||||
|
||||
$updateData = [
|
||||
'status' => $dbStatus,
|
||||
'status_history' => $history,
|
||||
];
|
||||
|
||||
if ($request->filled('notes')) {
|
||||
$updateData['notes'] = $request->notes;
|
||||
}
|
||||
|
||||
$app->update($updateData);
|
||||
|
||||
// If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved
|
||||
if ($dbStatus === 'shortlisted') {
|
||||
$exists = \App\Models\Shortlist::where('employer_id', $user->id)
|
||||
->where('worker_id', $app->worker_id)
|
||||
->exists();
|
||||
if (!$exists) {
|
||||
\App\Models\Shortlist::create([
|
||||
'employer_id' => $user->id,
|
||||
'worker_id' => $app->worker_id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
// If hired, also update worker status
|
||||
if ($dbStatus === 'hired') {
|
||||
if ($app->worker) {
|
||||
$app->worker->update(['status' => 'Hired']);
|
||||
}
|
||||
} elseif ($dbStatus === 'rejected') {
|
||||
if ($app->worker) {
|
||||
$app->worker->update(['status' => 'active']);
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger notifications
|
||||
$this->triggerStatusNotification($app, $dbStatus);
|
||||
|
||||
return back()->with('success', 'Candidate application status updated successfully to ' . $request->status);
|
||||
}
|
||||
|
||||
private function triggerStatusNotification($application, $status)
|
||||
{
|
||||
$worker = $application->worker;
|
||||
$job = $application->jobPost;
|
||||
$employer = $job ? $job->employer : null;
|
||||
|
||||
// When a new application is submitted: notify employer
|
||||
if ($status === 'applied') {
|
||||
if ($employer && $employer->fcm_token) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$employer->fcm_token,
|
||||
"New Job Application",
|
||||
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||
[
|
||||
'type' => 'new_job_application',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When status is updated: notify worker
|
||||
if ($worker && $worker->fcm_token) {
|
||||
$title = "Job Application Update";
|
||||
$body = "";
|
||||
switch (strtolower($status)) {
|
||||
case 'shortlisted':
|
||||
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
case 'contacted':
|
||||
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
case 'interview scheduled':
|
||||
case 'interview_scheduled':
|
||||
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
case 'selected':
|
||||
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
case 'rejected':
|
||||
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
|
||||
break;
|
||||
case 'hired':
|
||||
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
||||
break;
|
||||
default:
|
||||
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
||||
break;
|
||||
}
|
||||
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$worker->fcm_token,
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'type' => 'job_application_status_update',
|
||||
'job_id' => $job->id ?? '',
|
||||
'application_id' => $application->id,
|
||||
'status' => $status,
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,26 +59,18 @@ public function index(Request $request)
|
||||
$q->where('employer_id', $user->id);
|
||||
})->where('status', 'hired')->count();
|
||||
|
||||
$totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() +
|
||||
\App\Models\JobApplication::where('status', 'hired')->count();
|
||||
$totalActiveWorkers = \App\Models\Worker::where('status', 'active')->count();
|
||||
|
||||
$stats = [
|
||||
'shortlisted_count' => $shortlistedCount,
|
||||
'messages_sent' => $messagesSent,
|
||||
'days_remaining' => (int)$daysRemaining,
|
||||
'contacted_workers_count' => $contactedWorkersCount,
|
||||
'hired_count' => $hiredCount,
|
||||
'analytics' => [
|
||||
'profile_views' => 48,
|
||||
'response_rate' => '94%',
|
||||
'average_match_score' => '98%',
|
||||
'weekly_activity' => [
|
||||
['day' => 'Mon', 'views' => 5],
|
||||
['day' => 'Tue', 'views' => 12],
|
||||
['day' => 'Wed', 'views' => 8],
|
||||
['day' => 'Thu', 'views' => 15],
|
||||
['day' => 'Fri', 'views' => 4],
|
||||
['day' => 'Sat', 'views' => 2],
|
||||
['day' => 'Sun', 'views' => 2],
|
||||
]
|
||||
],
|
||||
'total_hired_all' => $totalHiredAll,
|
||||
'total_active_workers' => $totalActiveWorkers,
|
||||
'recent_failed_payment' => false
|
||||
];
|
||||
|
||||
|
||||
@ -42,10 +42,6 @@ public function login(Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
if ($user->subscription_status === 'expired') {
|
||||
return back()->with('status', 'subscription_expired');
|
||||
}
|
||||
|
||||
// Perform standard Laravel authentication (remember=true keeps auth cookie alive)
|
||||
auth()->login($user, true);
|
||||
|
||||
@ -65,6 +61,14 @@ public function login(Request $request)
|
||||
return redirect('/mobile/employer/home');
|
||||
}
|
||||
|
||||
// Automatically check and activate pending subscription if any
|
||||
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
|
||||
$user->refresh();
|
||||
|
||||
if ($user->subscription_status !== 'active') {
|
||||
return redirect()->route('employer.subscription');
|
||||
}
|
||||
|
||||
return redirect()->intended('/employer/dashboard');
|
||||
}
|
||||
|
||||
@ -75,7 +79,9 @@ public function login(Request $request)
|
||||
|
||||
public function showRegister()
|
||||
{
|
||||
return Inertia::render('Employer/Auth/Register');
|
||||
return Inertia::render('Employer/Auth/Register', [
|
||||
'prefillData' => session('pending_employer_registration'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(Request $request)
|
||||
@ -273,6 +279,11 @@ public function showUploadEmiratesId()
|
||||
public function uploadEmiratesId(Request $request)
|
||||
{
|
||||
if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) {
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'error' => 'Registration session expired. Please start over.'
|
||||
], 422);
|
||||
}
|
||||
return redirect()->route('employer.register')
|
||||
->with('error', 'Registration session expired. Please start over.');
|
||||
}
|
||||
@ -288,83 +299,42 @@ public function uploadEmiratesId(Request $request)
|
||||
'emirates_id_back.mimes' => 'The Emirates ID back must be an image or a PDF.',
|
||||
]);
|
||||
|
||||
$extractedIdNumber = null;
|
||||
$extractedExpiry = null;
|
||||
$extractedName = null;
|
||||
$extractedDob = null;
|
||||
$extractedNationality = null;
|
||||
$extractedIssueDate = null;
|
||||
$extractedEmployer = null;
|
||||
$extractedIssuePlace = null;
|
||||
$extractedOccupation = null;
|
||||
$extractedCardNumber = null;
|
||||
$extractedGender = null;
|
||||
|
||||
if ($request->hasFile('emirates_id_front')) {
|
||||
$frontFile = $request->file('emirates_id_front');
|
||||
$frontOcr = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($frontFile);
|
||||
$extractedIdNumber = $frontOcr['emirates_id_number'];
|
||||
$extractedName = $frontOcr['name'] ?? null;
|
||||
$extractedDob = $frontOcr['date_of_birth'] ?? null;
|
||||
$extractedNationality = $frontOcr['nationality'] ?? null;
|
||||
$extractedCardNumber = $frontOcr['card_number'] ?? null;
|
||||
$extractedGender = $frontOcr['gender'] ?? null;
|
||||
$extractedOccupation = $frontOcr['occupation'] ?? null;
|
||||
$extractedEmployer = $frontOcr['employer'] ?? null;
|
||||
$extractedIssuePlace = $frontOcr['issue_place'] ?? null;
|
||||
}
|
||||
|
||||
if ($request->hasFile('emirates_id_back')) {
|
||||
$backFile = $request->file('emirates_id_back');
|
||||
$backOcr = \App\Services\OcrDocumentService::extractEmiratesIdBackData($backFile);
|
||||
$extractedExpiry = $backOcr['expiry_date'];
|
||||
$extractedIssueDate = $backOcr['issue_date'] ?? null;
|
||||
}
|
||||
|
||||
if ($request->hasFile('emirates_id_file')) {
|
||||
$file = $request->file('emirates_id_file');
|
||||
$frontOcr = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($file);
|
||||
$backOcr = \App\Services\OcrDocumentService::extractEmiratesIdBackData($file);
|
||||
$extractedIdNumber = $frontOcr['emirates_id_number'];
|
||||
$extractedExpiry = $backOcr['expiry_date'];
|
||||
$extractedName = $frontOcr['name'] ?? null;
|
||||
$extractedDob = $frontOcr['date_of_birth'] ?? null;
|
||||
$extractedNationality = $frontOcr['nationality'] ?? null;
|
||||
$extractedCardNumber = $frontOcr['card_number'] ?? null;
|
||||
$extractedGender = $frontOcr['gender'] ?? null;
|
||||
$extractedOccupation = $frontOcr['occupation'] ?? null;
|
||||
$extractedEmployer = $frontOcr['employer'] ?? null;
|
||||
$extractedIssuePlace = $frontOcr['issue_place'] ?? null;
|
||||
$extractedIssueDate = $backOcr['issue_date'] ?? null;
|
||||
$frontFile = $request->file('emirates_id_file');
|
||||
$backFile = $request->file('emirates_id_file');
|
||||
} else {
|
||||
$frontFile = $request->file('emirates_id_front');
|
||||
$backFile = $request->file('emirates_id_back');
|
||||
}
|
||||
|
||||
if (($request->hasFile('emirates_id_front') || $request->hasFile('emirates_id_file')) && empty($extractedName)) {
|
||||
$extracted = \App\Services\OcrDocumentService::extractEmiratesIdCombinedData($frontFile, $backFile);
|
||||
|
||||
if ($request->wantsJson() || $request->ajax()) {
|
||||
return response()->json([
|
||||
'errors' => [
|
||||
'emirates_id_front' => ['Failed to extract name from the Emirates ID. Please upload a clear image.']
|
||||
]
|
||||
], 422);
|
||||
'success' => true,
|
||||
'data' => $extracted
|
||||
]);
|
||||
}
|
||||
|
||||
$pending = session('pending_employer_registration');
|
||||
$pending['emirates_id_file'] = null;
|
||||
$pending['emirates_id_number'] = $extractedIdNumber;
|
||||
$pending['emirates_id_expiry'] = $extractedExpiry;
|
||||
$pending['emirates_id_name'] = $extractedName;
|
||||
$pending['emirates_id_dob'] = $extractedDob;
|
||||
$pending['emirates_id_nationality'] = $extractedNationality;
|
||||
$pending['emirates_id_card_number'] = $extractedCardNumber;
|
||||
$pending['emirates_id_gender'] = $extractedGender;
|
||||
$pending['emirates_id_occupation'] = $extractedOccupation;
|
||||
$pending['emirates_id_employer'] = $extractedEmployer;
|
||||
$pending['emirates_id_issue_place'] = $extractedIssuePlace;
|
||||
$pending['emirates_id_issue_date'] = $extractedIssueDate;
|
||||
$pending['emirates_id_number'] = $extracted['emirates_id_number'];
|
||||
$pending['emirates_id_expiry'] = $extracted['expiry_date'];
|
||||
$pending['emirates_id_name'] = $extracted['name'];
|
||||
$pending['emirates_id_dob'] = $extracted['date_of_birth'];
|
||||
$pending['emirates_id_nationality'] = $extracted['nationality'];
|
||||
$pending['emirates_id_card_number'] = $extracted['card_number'];
|
||||
$pending['emirates_id_gender'] = $extracted['gender'];
|
||||
$pending['emirates_id_occupation'] = $extracted['occupation'];
|
||||
$pending['emirates_id_employer'] = $extracted['employer'];
|
||||
$pending['emirates_id_issue_place'] = $extracted['issue_place'];
|
||||
$pending['emirates_id_issue_date'] = $extracted['issue_date'];
|
||||
|
||||
if (!empty($extractedName)) {
|
||||
if (!empty($extracted['name'])) {
|
||||
if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) {
|
||||
$pending['company_name'] = $extractedName . ' Household';
|
||||
$pending['company_name'] = $extracted['name'] . ' Household';
|
||||
}
|
||||
$pending['name'] = $extractedName;
|
||||
$pending['name'] = $extracted['name'];
|
||||
}
|
||||
|
||||
session(['pending_employer_registration' => $pending]);
|
||||
@ -374,6 +344,58 @@ public function uploadEmiratesId(Request $request)
|
||||
->with('success', 'Emirates ID uploaded and verified successfully.');
|
||||
}
|
||||
|
||||
public function confirmEmiratesId(Request $request)
|
||||
{
|
||||
if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) {
|
||||
return response()->json([
|
||||
'error' => 'Registration session expired. Please start over.'
|
||||
], 422);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'emirates_id_number' => 'required|string',
|
||||
'expiry_date' => 'required|date_format:Y-m-d',
|
||||
'name' => 'required|string',
|
||||
'date_of_birth' => 'required|date_format:Y-m-d',
|
||||
'nationality' => 'nullable|string',
|
||||
'card_number' => 'nullable|string',
|
||||
'gender' => 'nullable|string',
|
||||
'occupation' => 'nullable|string',
|
||||
'employer' => 'nullable|string',
|
||||
'issue_place' => 'nullable|string',
|
||||
'issue_date' => 'nullable|date_format:Y-m-d',
|
||||
]);
|
||||
|
||||
$pending = session('pending_employer_registration');
|
||||
$pending['emirates_id_file'] = null;
|
||||
$pending['emirates_id_number'] = $request->emirates_id_number;
|
||||
$pending['emirates_id_expiry'] = $request->expiry_date;
|
||||
$pending['emirates_id_name'] = $request->name;
|
||||
$pending['emirates_id_dob'] = $request->date_of_birth;
|
||||
$pending['emirates_id_nationality'] = $request->nationality;
|
||||
$pending['emirates_id_card_number'] = $request->card_number;
|
||||
$pending['emirates_id_gender'] = $request->gender;
|
||||
$pending['emirates_id_occupation'] = $request->occupation;
|
||||
$pending['emirates_id_employer'] = $request->employer;
|
||||
$pending['emirates_id_issue_place'] = $request->issue_place;
|
||||
$pending['emirates_id_issue_date'] = $request->issue_date;
|
||||
|
||||
if (!empty($request->name)) {
|
||||
if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) {
|
||||
$pending['company_name'] = $request->name . ' Household';
|
||||
}
|
||||
$pending['name'] = $request->name;
|
||||
}
|
||||
|
||||
session(['pending_employer_registration' => $pending]);
|
||||
session(['employer_emirates_id_uploaded' => true]);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Emirates ID verified and confirmed successfully.'
|
||||
]);
|
||||
}
|
||||
|
||||
public function showRegisterPayment()
|
||||
{
|
||||
if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session('employer_emirates_id_uploaded')) {
|
||||
@ -381,9 +403,9 @@ public function showRegisterPayment()
|
||||
->with('error', 'Please complete Emirates ID upload first.');
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/Auth/RegisterPayment', [
|
||||
'email' => session('pending_employer_registration.email'),
|
||||
'plans' => [
|
||||
$dbPlans = \App\Models\Plan::where('status', 'Active')->get();
|
||||
if ($dbPlans->isEmpty()) {
|
||||
$plans = [
|
||||
[
|
||||
'id' => 'basic',
|
||||
'name' => 'Basic Search Pass',
|
||||
@ -408,7 +430,28 @@ public function showRegisterPayment()
|
||||
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
|
||||
'popular' => false,
|
||||
],
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$plans = $dbPlans->map(function ($plan) {
|
||||
$id = $plan->id === 'vip' ? 'enterprise' : $plan->id;
|
||||
$name = $plan->name;
|
||||
if (!str_ends_with(strtolower($name), 'pass')) {
|
||||
$name .= ' Pass';
|
||||
}
|
||||
return [
|
||||
'id' => $id,
|
||||
'name' => $name,
|
||||
'price' => (int)$plan->price . ' AED',
|
||||
'period' => 'month',
|
||||
'features' => $plan->features,
|
||||
'popular' => $plan->id === 'premium',
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/Auth/RegisterPayment', [
|
||||
'email' => session('pending_employer_registration.email'),
|
||||
'plans' => $plans
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -35,6 +35,24 @@ private function resolveCurrentUser()
|
||||
return null;
|
||||
}
|
||||
|
||||
private function checkJobAccess($user)
|
||||
{
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
$planId = $sub ? $sub->plan_id : 'basic';
|
||||
$associatedPlan = \App\Models\Plan::find($planId);
|
||||
|
||||
return $associatedPlan ? (bool)$associatedPlan->enable_job_apply : ($planId !== 'basic');
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $this->resolveCurrentUser();
|
||||
@ -42,6 +60,11 @@ public function index(Request $request)
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!$this->checkJobAccess($user)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
$dbJobs = JobPost::where('employer_id', $user->id)
|
||||
->with(['applications'])
|
||||
->latest()
|
||||
@ -55,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
|
||||
];
|
||||
@ -65,8 +89,56 @@ public function index(Request $request)
|
||||
]);
|
||||
}
|
||||
|
||||
public function show($id)
|
||||
{
|
||||
$user = $this->resolveCurrentUser();
|
||||
if (!$user) {
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!$this->checkJobAccess($user)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
$job = JobPost::where('employer_id', $user->id)
|
||||
->with(['applications.worker'])
|
||||
->where('id', $id)
|
||||
->firstOrFail();
|
||||
|
||||
$jobData = [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
'job_type' => $job->job_type,
|
||||
'start_date' => $job->start_date ? $job->start_date->format('M d, Y') : null,
|
||||
'description' => $job->description,
|
||||
'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'),
|
||||
];
|
||||
|
||||
return Inertia::render('Employer/Jobs/Show', [
|
||||
'job' => $jobData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function create()
|
||||
{
|
||||
$user = $this->resolveCurrentUser();
|
||||
if (!$user) {
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!$this->checkJobAccess($user)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/Jobs/Create');
|
||||
}
|
||||
|
||||
@ -77,6 +149,11 @@ public function store(Request $request)
|
||||
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.');
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
@ -111,24 +188,37 @@ public function applicants($id)
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!$this->checkJobAccess($user)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||
|
||||
$dbApplications = JobApplication::where('job_id', $id)
|
||||
->with(['worker.skills'])
|
||||
->get();
|
||||
|
||||
$applicants = $dbApplications->map(function ($app) {
|
||||
$applicants = $dbApplications->map(function ($app) use ($user) {
|
||||
$worker = $app->worker;
|
||||
if (!$worker) return null;
|
||||
$isSaved = \App\Models\Shortlist::where('employer_id', $user->id)
|
||||
->where('worker_id', $worker->id)
|
||||
->exists();
|
||||
return [
|
||||
'id' => $worker->id,
|
||||
'application_id' => $app->id,
|
||||
'name' => $worker->name,
|
||||
'nationality' => $worker->nationality,
|
||||
'salary' => (int) $worker->salary,
|
||||
'experience' => ($worker->experience_years ?? 3) . ' Years',
|
||||
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected
|
||||
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected, etc.
|
||||
'notes' => $app->notes,
|
||||
'status_history' => $app->status_history ?: [],
|
||||
'match_score' => $worker->match_score ?? 90,
|
||||
'is_saved' => $isSaved,
|
||||
];
|
||||
})->toArray();
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
return Inertia::render('Employer/Jobs/Applicants', [
|
||||
'job' => [
|
||||
@ -139,4 +229,204 @@ public function applicants($id)
|
||||
'applicants' => $applicants,
|
||||
]);
|
||||
}
|
||||
|
||||
public function edit($id)
|
||||
{
|
||||
$user = $this->resolveCurrentUser();
|
||||
if (!$user) {
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!$this->checkJobAccess($user)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||
|
||||
// Convert start_date format to Y-m-d for date input
|
||||
$jobData = $job->toArray();
|
||||
if ($job->start_date) {
|
||||
$jobData['start_date'] = $job->start_date->format('Y-m-d');
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/Jobs/Edit', [
|
||||
'job' => $jobData,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request, $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();
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
'location' => 'required|string|max:255',
|
||||
'salary' => 'required|numeric|min:0',
|
||||
'job_type' => 'required|string|in:Full Time,Part Time,Contract',
|
||||
'start_date' => 'required|date',
|
||||
'description' => 'required|string|max:1000',
|
||||
'requirements' => 'nullable|string',
|
||||
'status' => 'required|string|in:active,closed,draft',
|
||||
]);
|
||||
|
||||
$job->update([
|
||||
'title' => $request->title,
|
||||
'workers_needed' => $request->workers_needed,
|
||||
'job_type' => $request->job_type,
|
||||
'location' => $request->location,
|
||||
'salary' => $request->salary,
|
||||
'start_date' => $request->start_date,
|
||||
'description' => $request->description,
|
||||
'requirements' => $request->requirements,
|
||||
'status' => strtolower($request->status),
|
||||
]);
|
||||
|
||||
return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.');
|
||||
}
|
||||
|
||||
public function destroy($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->delete();
|
||||
|
||||
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();
|
||||
if (!$user) {
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!$this->checkJobAccess($user)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
$jobIds = JobPost::where('employer_id', $user->id)->pluck('id');
|
||||
|
||||
$dbApplications = JobApplication::whereIn('job_id', $jobIds)
|
||||
->with(['worker.skills', 'jobPost'])
|
||||
->get();
|
||||
|
||||
$applicants = $dbApplications->map(function ($app) use ($user) {
|
||||
$worker = $app->worker;
|
||||
if (!$worker) return null;
|
||||
$isSaved = \App\Models\Shortlist::where('employer_id', $user->id)
|
||||
->where('worker_id', $worker->id)
|
||||
->exists();
|
||||
return [
|
||||
'id' => $worker->id,
|
||||
'application_id' => $app->id,
|
||||
'name' => $worker->name,
|
||||
'nationality' => $worker->nationality,
|
||||
'salary' => (int) $worker->salary,
|
||||
'experience' => ($worker->experience_years ?? 3) . ' Years',
|
||||
'status' => ucfirst($app->status),
|
||||
'notes' => $app->notes,
|
||||
'status_history' => $app->status_history ?: [],
|
||||
'match_score' => $worker->match_score ?? 90,
|
||||
'is_saved' => $isSaved,
|
||||
'job_title' => $app->jobPost->title ?? 'N/A',
|
||||
'job_id' => $app->job_id,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
return Inertia::render('Employer/Jobs/Applicants', [
|
||||
'job' => null,
|
||||
'applicants' => $applicants,
|
||||
]);
|
||||
}
|
||||
|
||||
public function shortlistedApplicants(Request $request)
|
||||
{
|
||||
$user = $this->resolveCurrentUser();
|
||||
if (!$user) {
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!$this->checkJobAccess($user)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
$jobIds = JobPost::where('employer_id', $user->id)->pluck('id');
|
||||
|
||||
$dbApplications = JobApplication::whereIn('job_id', $jobIds)
|
||||
->where('status', 'shortlisted')
|
||||
->with(['worker.skills', 'jobPost'])
|
||||
->get();
|
||||
|
||||
$applicants = $dbApplications->map(function ($app) use ($user) {
|
||||
$worker = $app->worker;
|
||||
if (!$worker) return null;
|
||||
$isSaved = \App\Models\Shortlist::where('employer_id', $user->id)
|
||||
->where('worker_id', $worker->id)
|
||||
->exists();
|
||||
return [
|
||||
'id' => $worker->id,
|
||||
'application_id' => $app->id,
|
||||
'name' => $worker->name,
|
||||
'nationality' => $worker->nationality,
|
||||
'salary' => (int) $worker->salary,
|
||||
'experience' => ($worker->experience_years ?? 3) . ' Years',
|
||||
'status' => ucfirst($app->status),
|
||||
'notes' => $app->notes,
|
||||
'status_history' => $app->status_history ?: [],
|
||||
'match_score' => $worker->match_score ?? 90,
|
||||
'is_saved' => $isSaved,
|
||||
'job_title' => $app->jobPost->title ?? 'N/A',
|
||||
'job_id' => $app->job_id,
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
return Inertia::render('Employer/Jobs/Applicants', [
|
||||
'job' => null,
|
||||
'applicants' => $applicants,
|
||||
'isShortlistedOnly' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -263,6 +263,16 @@ public function startConversation($workerId)
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
if (!User::canContactWorker($user->id, $workerId)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
|
||||
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||
}
|
||||
|
||||
// Find or create conversation
|
||||
$conv = Conversation::firstOrCreate([
|
||||
'employer_id' => $user->id,
|
||||
|
||||
@ -73,4 +73,92 @@ public function index(Request $request)
|
||||
'subscriptionStatus' => $subStatus,
|
||||
]);
|
||||
}
|
||||
|
||||
public function purchase(Request $request)
|
||||
{
|
||||
$user = $this->resolveCurrentUser();
|
||||
if (!$user) {
|
||||
return response()->json(['error' => 'Unauthorized'], 401);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'plan_id' => 'required|string',
|
||||
]);
|
||||
|
||||
$planId = $request->plan_id;
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
if (!$plan) {
|
||||
return response()->json(['error' => 'Plan not found'], 404);
|
||||
}
|
||||
|
||||
// Check if there is any active or pending subscription for the user to determine start time
|
||||
$lastSub = \App\Models\Subscription::where('user_id', $user->id)
|
||||
->whereIn('status', ['active', 'pending'])
|
||||
->orderBy('expires_at', 'desc')
|
||||
->first();
|
||||
|
||||
$durationDays = strtolower($plan->duration) === 'yearly' ? 365 : 30;
|
||||
|
||||
if ($lastSub) {
|
||||
// Queue it to start when the current subscription expires
|
||||
$startsAt = \Carbon\Carbon::parse($lastSub->expires_at);
|
||||
$expiresAt = $startsAt->copy()->addDays($durationDays);
|
||||
$status = 'pending';
|
||||
} else {
|
||||
// Activate immediately
|
||||
$startsAt = now();
|
||||
$expiresAt = now()->addDays($durationDays);
|
||||
$status = 'active';
|
||||
}
|
||||
|
||||
// Store subscription in DB
|
||||
$sub = \App\Models\Subscription::create([
|
||||
'user_id' => $user->id,
|
||||
'plan_id' => $plan->id,
|
||||
'amount_aed' => $plan->price,
|
||||
'starts_at' => $startsAt,
|
||||
'expires_at' => $expiresAt,
|
||||
'paytabs_transaction_id' => 'PT-' . strtoupper(uniqid()),
|
||||
'status' => $status
|
||||
]);
|
||||
|
||||
// If active, sync user subscription fields
|
||||
if ($status === 'active') {
|
||||
$user->subscription_status = 'active';
|
||||
$user->subscription_expires_at = $expiresAt;
|
||||
$user->save();
|
||||
}
|
||||
|
||||
// Build complete dynamic subscription/invoice history list
|
||||
$invoices = \App\Models\Subscription::leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
|
||||
->where('subscriptions.user_id', $user->id)
|
||||
->orderBy('subscriptions.created_at', 'desc')
|
||||
->select('subscriptions.*', 'plans.name as plan_name')
|
||||
->get()
|
||||
->map(function($s) {
|
||||
$planLabel = $s->plan_name ?: (ucfirst($s->plan_id) . ' Pass');
|
||||
$subStatus = strtolower($s->status);
|
||||
if ($subStatus !== 'active' && $subStatus !== 'pending' && $subStatus !== 'expired') {
|
||||
$subStatus = 'expired';
|
||||
}
|
||||
return [
|
||||
'id' => 'INV-' . date('Y', strtotime($s->created_at)) . '-' . str_pad($s->id, 3, '0', STR_PAD_LEFT),
|
||||
'plan_name' => $planLabel,
|
||||
'purchase_date' => date('M d, Y', strtotime($s->created_at)),
|
||||
'activation_date' => $s->starts_at ? date('M d, Y', strtotime($s->starts_at)) : 'N/A',
|
||||
'expiry_date' => $s->expires_at ? date('M d, Y', strtotime($s->expires_at)) : 'N/A',
|
||||
'amount' => round($s->amount_aed) . ' AED',
|
||||
'payment_status' => 'Paid',
|
||||
'status' => ucfirst($subStatus),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Subscription purchased successfully!',
|
||||
'invoices' => $invoices,
|
||||
'currentPlan' => $status === 'active' ? $plan->name : null,
|
||||
'expiresAt' => $status === 'active' ? date('Y-m-d', strtotime($expiresAt)) : null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,22 +56,21 @@ public function index(Request $request)
|
||||
return null;
|
||||
}
|
||||
|
||||
// Map languages with country names
|
||||
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
|
||||
// Map languages from database comma-separated string
|
||||
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English'];
|
||||
|
||||
// Preferred job types: full-time / part-time / live-in / live-out
|
||||
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
||||
$preferredJobType = $jobTypes[$w->id % 4];
|
||||
$preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4];
|
||||
|
||||
// Emirates ID verification status (dynamic passport status)
|
||||
$emiratesIdStatus = $w->emirates_id_status;
|
||||
|
||||
// Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening
|
||||
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
|
||||
$mappedSkills = [
|
||||
$skillsList[$w->id % 6],
|
||||
$skillsList[($w->id + 2) % 6]
|
||||
];
|
||||
// Map skills dynamically from database
|
||||
$mappedSkills = $w->skills->pluck('name')->toArray();
|
||||
if (empty($mappedSkills)) {
|
||||
$mappedSkills = ['cooking', 'cleaning'];
|
||||
}
|
||||
|
||||
// Visa status
|
||||
$visaStatus = $w->visa_status;
|
||||
@ -86,13 +85,16 @@ public function index(Request $request)
|
||||
return [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'phone' => $w->phone,
|
||||
'gender' => $w->gender ?? 'Female',
|
||||
'nationality' => $w->nationality,
|
||||
'photo' => $photo,
|
||||
'emirates_id_status' => $emiratesIdStatus,
|
||||
'passport_status' => $w->passport_status,
|
||||
'category' => 'Domestic Worker',
|
||||
'main_profession' => $w->main_profession,
|
||||
'skills' => $mappedSkills,
|
||||
'visa_status' => $visaStatus,
|
||||
'gender' => $w->gender ?? 'Female',
|
||||
'experience' => $w->experience,
|
||||
'salary' => (int) $w->salary,
|
||||
'religion' => $w->religion,
|
||||
@ -100,6 +102,7 @@ public function index(Request $request)
|
||||
'age' => $w->age,
|
||||
'verified' => (bool) $w->verified,
|
||||
'preferred_job_type' => $preferredJobType,
|
||||
'live_in_out' => $w->live_in_out ?? 'Live-in',
|
||||
'bio' => $w->bio,
|
||||
'rating' => $rating,
|
||||
'reviews_count' => $reviewsCount,
|
||||
@ -111,8 +114,177 @@ public function index(Request $request)
|
||||
];
|
||||
})->filter()->values()->toArray();
|
||||
|
||||
// Apply request filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status
|
||||
if ($request->filled('preferred_location')) {
|
||||
$prefLoc = $request->preferred_location;
|
||||
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
|
||||
$locsArray = array_map('strtolower', $locsArray);
|
||||
|
||||
$locationMapping = [
|
||||
'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
|
||||
'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
|
||||
'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
|
||||
];
|
||||
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($locsArray, $locationMapping) {
|
||||
if (!isset($c['preferred_location'])) return false;
|
||||
$wLoc = strtolower($c['preferred_location']);
|
||||
foreach ($locsArray as $l) {
|
||||
if ($l === 'all') return true;
|
||||
$allowedKeywords = $locationMapping[$l] ?? [$l];
|
||||
foreach ($allowedKeywords as $keyword) {
|
||||
if (str_contains($wLoc, $keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
$jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type');
|
||||
if ($jobTypeParam) {
|
||||
$jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam)));
|
||||
$jobTypesArray = array_map('strtolower', $jobTypesArray);
|
||||
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($jobTypesArray) {
|
||||
if (!isset($c['preferred_job_type'])) return false;
|
||||
$wJobType = strtolower($c['preferred_job_type']);
|
||||
foreach ($jobTypesArray as $jt) {
|
||||
if (str_contains($wJobType, $jt)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
$accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type');
|
||||
if ($accParam) {
|
||||
$accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam)));
|
||||
$accsArray = array_map(function($val) {
|
||||
return str_replace(['_', '-'], ' ', strtolower($val));
|
||||
}, $accsArray);
|
||||
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($accsArray) {
|
||||
if (!isset($c['live_in_out'])) return false;
|
||||
$wAcc = str_replace(['_', '-'], ' ', strtolower($c['live_in_out']));
|
||||
foreach ($accsArray as $a) {
|
||||
if (str_contains($wAcc, $a)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
if ($request->filled('nationality')) {
|
||||
$natInput = $request->nationality;
|
||||
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
|
||||
$natsArray = array_map('strtolower', $natsArray);
|
||||
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($natsArray) {
|
||||
if (!isset($c['nationality'])) return false;
|
||||
$wNat = strtolower($c['nationality']);
|
||||
foreach ($natsArray as $n) {
|
||||
if (str_contains($wNat, $n) || $wNat === $n) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
if ($request->filled('main_profession')) {
|
||||
$mainProfession = strtolower($request->main_profession);
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($mainProfession) {
|
||||
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
|
||||
}));
|
||||
}
|
||||
if ($request->filled('gender')) {
|
||||
$gender = strtolower($request->gender);
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($gender) {
|
||||
return isset($c['gender']) && strtolower($c['gender']) === $gender;
|
||||
}));
|
||||
}
|
||||
if ($request->has('in_country')) {
|
||||
$inCountryVal = $request->input('in_country');
|
||||
if ($inCountryVal !== null && $inCountryVal !== '') {
|
||||
$isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($isInCountry === null) {
|
||||
$strVal = strtolower(trim($inCountryVal));
|
||||
if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') {
|
||||
$isInCountry = true;
|
||||
} elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') {
|
||||
$isInCountry = false;
|
||||
}
|
||||
}
|
||||
if ($isInCountry !== null) {
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($isInCountry) {
|
||||
return isset($c['in_country']) && (bool)$c['in_country'] === $isInCountry;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($request->has('out_country')) {
|
||||
$outCountryVal = $request->input('out_country');
|
||||
if ($outCountryVal !== null && $outCountryVal !== '') {
|
||||
$isOutCountry = filter_var($outCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
if ($isOutCountry === null) {
|
||||
$strVal = strtolower(trim($outCountryVal));
|
||||
if ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'yes') {
|
||||
$isOutCountry = true;
|
||||
} elseif ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'no') {
|
||||
$isOutCountry = false;
|
||||
}
|
||||
}
|
||||
if ($isOutCountry !== null) {
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($isOutCountry) {
|
||||
return isset($c['in_country']) && (bool)$c['in_country'] !== $isOutCountry;
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type');
|
||||
if ($visaParam) {
|
||||
$visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam)));
|
||||
$visasArray = array_map('strtolower', $visasArray);
|
||||
|
||||
$shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($visasArray) {
|
||||
if (!isset($c['visa_status'])) return false;
|
||||
$isInCountry = isset($c['in_country']) && (bool)$c['in_country'];
|
||||
if (!$isInCountry) {
|
||||
return false;
|
||||
}
|
||||
$wVisa = strtolower($c['visa_status']);
|
||||
foreach ($visasArray as $v) {
|
||||
if (str_contains($wVisa, $v)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
$nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500]));
|
||||
$nationalitiesData = json_decode($nationalitiesResponse->getContent(), true);
|
||||
$dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray();
|
||||
|
||||
$filtersMetadata = [
|
||||
'nationalities' => array_merge(['All Nationalities'], $dbNationalities),
|
||||
'professions' => ['All Professions', 'Housemaid', 'Nanny', 'Cook', 'Driver', 'Caregiver'],
|
||||
'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'],
|
||||
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
|
||||
'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'],
|
||||
'workTypes' => ['All Types', 'full-time', 'part-time', 'live-in', 'live-out'],
|
||||
'skills' => ['All Skills', 'cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'],
|
||||
'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa'],
|
||||
];
|
||||
|
||||
return Inertia::render('Employer/Shortlist', [
|
||||
'shortlistedWorkers' => $shortlistedWorkers,
|
||||
'filtersMetadata' => $filtersMetadata,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -91,6 +91,7 @@ public function index(Request $request)
|
||||
'emirates_id_status' => $emiratesIdStatus,
|
||||
'passport_status' => $w->passport_status,
|
||||
'category' => 'Domestic Worker',
|
||||
'main_profession' => $w->main_profession,
|
||||
'skills' => $mappedSkills,
|
||||
'visa_status' => $visaStatus,
|
||||
'experience' => $w->experience,
|
||||
@ -192,6 +193,12 @@ public function index(Request $request)
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
if ($request->filled('main_profession')) {
|
||||
$mainProfession = strtolower($request->main_profession);
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($mainProfession) {
|
||||
return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession;
|
||||
}));
|
||||
}
|
||||
if ($request->filled('gender')) {
|
||||
$gender = strtolower($request->gender);
|
||||
$workers = array_values(array_filter($workers, function ($c) use ($gender) {
|
||||
@ -268,12 +275,13 @@ public function index(Request $request)
|
||||
|
||||
$filtersMetadata = [
|
||||
'nationalities' => array_merge(['All Nationalities'], $dbNationalities),
|
||||
'professions' => ['All Professions', 'Housemaid', 'Nanny', 'Cook', 'Driver', 'Caregiver'],
|
||||
'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'],
|
||||
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
|
||||
'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'],
|
||||
'workTypes' => ['All Types', 'full-time', 'part-time', 'live-in', 'live-out'],
|
||||
'skills' => ['All Skills', 'cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'],
|
||||
'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'],
|
||||
'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa'],
|
||||
];
|
||||
|
||||
return Inertia::render('Employer/Workers/Index', [
|
||||
@ -367,6 +375,7 @@ public function show($id)
|
||||
'emirates_id_status' => $emiratesIdStatus,
|
||||
'passport_status' => $w->passport_status,
|
||||
'category' => 'Domestic Worker',
|
||||
'main_profession' => $w->main_profession,
|
||||
'skills' => $mappedSkills,
|
||||
'visa_status' => $visaStatus,
|
||||
'experience' => $w->experience,
|
||||
@ -428,6 +437,16 @@ public function sendOffer(Request $request, $id)
|
||||
$user = $this->resolveCurrentUser();
|
||||
$employerId = $user ? $user->id : 2;
|
||||
|
||||
if (!User::canContactWorker($employerId, $id)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
|
||||
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||
}
|
||||
|
||||
$worker = Worker::findOrFail($id);
|
||||
|
||||
JobOffer::create([
|
||||
@ -449,6 +468,16 @@ public function markHired(Request $request, $id)
|
||||
$user = $this->resolveCurrentUser();
|
||||
$employerId = $user ? $user->id : 2;
|
||||
|
||||
if (!User::canContactWorker($employerId, $id)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
|
||||
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||
}
|
||||
|
||||
$worker = Worker::findOrFail($id);
|
||||
$worker->update([
|
||||
'status' => 'Hired',
|
||||
|
||||
@ -36,6 +36,26 @@ public function handle(Request $request, Closure $next)
|
||||
], 401);
|
||||
}
|
||||
|
||||
// Automatically activate queued pending plans if current active has expired
|
||||
User::checkAndActivatePendingSubscriptions($user->id);
|
||||
$user->refresh();
|
||||
|
||||
// Enforce active subscription check
|
||||
$hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none');
|
||||
|
||||
if (!$hasActiveSub) {
|
||||
// Only allow access to payments or plans endpoints
|
||||
$path = $request->getPathInfo();
|
||||
$isAllowedApi = str_contains($path, '/payments') || str_contains($path, '/plans');
|
||||
|
||||
if (!$isAllowedApi) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Your subscription has expired. Please purchase or renew a subscription plan to restore access.'
|
||||
], 403);
|
||||
}
|
||||
}
|
||||
|
||||
// Attach employer object to request attributes so controllers can read it using $request->attributes->get('employer')
|
||||
$request->attributes->set('employer', $user);
|
||||
|
||||
|
||||
@ -13,21 +13,53 @@ class EmployerMiddleware
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = null;
|
||||
// Prefer Laravel's built-in auth (survives page refresh reliably via remember cookie)
|
||||
if (auth()->check() && auth()->user()->role === 'employer') {
|
||||
return $next($request);
|
||||
$user = auth()->user();
|
||||
} else {
|
||||
// Fallback: check custom session key (used during registration auto-login)
|
||||
$sessionUser = session('user');
|
||||
$role = is_array($sessionUser)
|
||||
? ($sessionUser['role'] ?? null)
|
||||
: ($sessionUser->role ?? null);
|
||||
|
||||
if ($sessionUser && $role === 'employer') {
|
||||
$sessId = is_array($sessionUser) ? ($sessionUser['id'] ?? null) : ($sessionUser->id ?? null);
|
||||
if ($sessId) {
|
||||
$user = \App\Models\User::find($sessId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check custom session key (used during registration auto-login)
|
||||
$sessionUser = session('user');
|
||||
$role = is_array($sessionUser)
|
||||
? ($sessionUser['role'] ?? null)
|
||||
: ($sessionUser->role ?? null);
|
||||
|
||||
if ($sessionUser && $role === 'employer') {
|
||||
return $next($request);
|
||||
if (!$user) {
|
||||
session()->forget('user');
|
||||
auth()->logout();
|
||||
return redirect()->route('employer.login');
|
||||
}
|
||||
|
||||
return redirect()->route('employer.login');
|
||||
// Automatically activate queued pending plans if current active has expired
|
||||
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
|
||||
$user->refresh();
|
||||
|
||||
// Enforce active subscription check
|
||||
$hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none');
|
||||
|
||||
if (!$hasActiveSub) {
|
||||
$allowedRoutes = [
|
||||
'employer.subscription',
|
||||
'employer.subscription.purchase',
|
||||
'employer.logout',
|
||||
];
|
||||
|
||||
$currentRouteName = $request->route() ? $request->route()->getName() : null;
|
||||
|
||||
if (!in_array($currentRouteName, $allowedRoutes)) {
|
||||
return redirect()->route('employer.subscription')
|
||||
->with('error', 'Your subscription has expired. Please purchase or renew a subscription plan.');
|
||||
}
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +50,11 @@ public function share(Request $request): array
|
||||
$unreadCount = 0;
|
||||
|
||||
if ($user) {
|
||||
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
|
||||
|
||||
// Re-fetch user to get updated fields
|
||||
$user = \App\Models\User::find($user->id);
|
||||
|
||||
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||
->where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
@ -67,14 +72,21 @@ public function share(Request $request): array
|
||||
->whereNull('messages.read_at')
|
||||
->count();
|
||||
|
||||
$planId = $sub ? $sub->plan_id : 'basic';
|
||||
|
||||
$associatedPlan = \App\Models\Plan::find($planId);
|
||||
$enableJobApply = $associatedPlan ? !!$associatedPlan->enable_job_apply : ($planId !== 'basic');
|
||||
|
||||
$userData = [
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => $user->role,
|
||||
'company_name' => $profile ? $profile->company_name : 'Al Mansoor Household',
|
||||
'subscription_status' => 'active',
|
||||
'subscription_expires_at' => $sub && $sub->expires_at ? date('M d', strtotime($sub->expires_at)) : 'Dec 31',
|
||||
'subscription_status' => $user->subscription_status ?: 'expired',
|
||||
'subscription_expires_at' => $sub && $sub->expires_at ? date('M d, Y', strtotime($sub->expires_at)) : 'Expired',
|
||||
'subscription_plan' => $planId,
|
||||
'enable_job_apply' => $enableJobApply,
|
||||
];
|
||||
|
||||
// Sync with session so that controllers have access to the exact user
|
||||
|
||||
37
app/Models/EmployerReview.php
Normal file
37
app/Models/EmployerReview.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class EmployerReview extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $table = 'employer_reviews';
|
||||
|
||||
protected $fillable = [
|
||||
'worker_id',
|
||||
'employer_id',
|
||||
'job_id',
|
||||
'rating',
|
||||
'title',
|
||||
'comment',
|
||||
];
|
||||
|
||||
public function worker()
|
||||
{
|
||||
return $this->belongsTo(Worker::class, 'worker_id');
|
||||
}
|
||||
|
||||
public function employer()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'employer_id');
|
||||
}
|
||||
|
||||
public function jobPost()
|
||||
{
|
||||
return $this->belongsTo(JobPost::class, 'job_id');
|
||||
}
|
||||
}
|
||||
@ -13,6 +13,13 @@ class JobApplication extends Model
|
||||
'job_id',
|
||||
'worker_id',
|
||||
'status',
|
||||
'notes',
|
||||
'status_history',
|
||||
'joining_confirmed_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'status_history' => 'array',
|
||||
];
|
||||
|
||||
public function jobPost()
|
||||
|
||||
31
app/Models/Plan.php
Normal file
31
app/Models/Plan.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Plan extends Model
|
||||
{
|
||||
public $incrementing = false;
|
||||
protected $keyType = 'string';
|
||||
|
||||
protected $fillable = [
|
||||
'id',
|
||||
'name',
|
||||
'price',
|
||||
'duration',
|
||||
'features',
|
||||
'status',
|
||||
'max_contact_people',
|
||||
'unlimited_contacts',
|
||||
'enable_job_apply',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'features' => 'array',
|
||||
'unlimited_contacts' => 'boolean',
|
||||
'enable_job_apply' => 'boolean',
|
||||
'price' => 'float',
|
||||
'max_contact_people' => 'integer',
|
||||
];
|
||||
}
|
||||
23
app/Models/ReminderLog.php
Normal file
23
app/Models/ReminderLog.php
Normal file
@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class ReminderLog extends Model
|
||||
{
|
||||
protected $fillable = [
|
||||
'user_type',
|
||||
'user_id',
|
||||
'event_type',
|
||||
'entity_type',
|
||||
'entity_id',
|
||||
'reminder_level',
|
||||
'channel',
|
||||
'sent_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'sent_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
@ -46,6 +46,34 @@ public function hasActiveSubscription(): bool
|
||||
return $this->subscription_status === 'active' && ($this->subscription_expires_at === null || $this->subscription_expires_at > now());
|
||||
}
|
||||
|
||||
protected $appends = [
|
||||
'rating',
|
||||
'reviews_count',
|
||||
];
|
||||
|
||||
public function employerReviews()
|
||||
{
|
||||
return $this->hasMany(EmployerReview::class, 'employer_id');
|
||||
}
|
||||
|
||||
public function getRatingAttribute()
|
||||
{
|
||||
if ($this->role !== 'employer') {
|
||||
return 0.0;
|
||||
}
|
||||
$dbReviews = $this->employerReviews;
|
||||
$count = $dbReviews->count();
|
||||
return $count > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
|
||||
}
|
||||
|
||||
public function getReviewsCountAttribute()
|
||||
{
|
||||
if ($this->role !== 'employer') {
|
||||
return 0;
|
||||
}
|
||||
return $this->employerReviews()->count();
|
||||
}
|
||||
|
||||
public function subscription()
|
||||
{
|
||||
return $this->hasOne(Subscription::class);
|
||||
@ -75,4 +103,127 @@ public function payments()
|
||||
{
|
||||
return $this->hasMany(Payment::class, 'user_id');
|
||||
}
|
||||
|
||||
public static function checkAndActivatePendingSubscriptions($userId)
|
||||
{
|
||||
$user = self::find($userId);
|
||||
if (!$user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Check if the active subscription has expired
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $user->id)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
if ($activeSub && $activeSub->expires_at && $activeSub->expires_at <= now()) {
|
||||
$activeSub->update(['status' => 'expired']);
|
||||
$activeSub = null;
|
||||
}
|
||||
|
||||
// 2. If no active subscription exists, activate the next pending one
|
||||
if (!$activeSub) {
|
||||
$pendingSub = \App\Models\Subscription::where('user_id', $user->id)
|
||||
->where('status', 'pending')
|
||||
->orderBy('starts_at', 'asc')
|
||||
->first();
|
||||
|
||||
if ($pendingSub) {
|
||||
// If it is ready to start (i.e. starts_at is <= now())
|
||||
if ($pendingSub->starts_at <= now()) {
|
||||
$duration = 30; // default 30 days
|
||||
$plan = \App\Models\Plan::find($pendingSub->plan_id);
|
||||
if ($plan) {
|
||||
$duration = strtolower($plan->duration) === 'yearly' ? 365 : 30;
|
||||
}
|
||||
$pendingSub->starts_at = now();
|
||||
$pendingSub->expires_at = now()->addDays($duration);
|
||||
}
|
||||
|
||||
$pendingSub->status = 'active';
|
||||
$pendingSub->save();
|
||||
|
||||
// Update user details
|
||||
$user->subscription_status = 'active';
|
||||
$user->subscription_expires_at = $pendingSub->expires_at;
|
||||
$user->save();
|
||||
|
||||
// Recursive check for sequential queueing activation
|
||||
self::checkAndActivatePendingSubscriptions($userId);
|
||||
} else {
|
||||
// Set to expired only if they previously had a subscription
|
||||
$hasPriorSub = \App\Models\Subscription::where('user_id', $user->id)->exists();
|
||||
if ($hasPriorSub) {
|
||||
$user->subscription_status = 'expired';
|
||||
$user->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the count of unique workers contacted or hired by the employer.
|
||||
*/
|
||||
public static function contactedWorkersCount($employerId)
|
||||
{
|
||||
$conversationWorkerIds = \App\Models\Conversation::where('employer_id', $employerId)->pluck('worker_id')->toArray();
|
||||
$offerWorkerIds = \App\Models\JobOffer::where('employer_id', $employerId)->pluck('worker_id')->toArray();
|
||||
$applicationWorkerIds = \App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired'])
|
||||
->whereHas('jobPost', function ($query) use ($employerId) {
|
||||
$query->where('employer_id', $employerId);
|
||||
})
|
||||
->pluck('worker_id')
|
||||
->toArray();
|
||||
|
||||
$uniqueWorkerIds = array_unique(array_merge($conversationWorkerIds, $offerWorkerIds, $applicationWorkerIds));
|
||||
return count($uniqueWorkerIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the employer is allowed to contact the given worker based on plan limits.
|
||||
*/
|
||||
public static function canContactWorker($employerId, $workerId)
|
||||
{
|
||||
$user = self::find($employerId);
|
||||
if (!$user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. If already contacted, it does not consume an additional contact slot
|
||||
$alreadyContacted = \App\Models\Conversation::where('employer_id', $employerId)
|
||||
->where('worker_id', $workerId)
|
||||
->exists() ||
|
||||
\App\Models\JobOffer::where('employer_id', $employerId)
|
||||
->where('worker_id', $workerId)
|
||||
->exists() ||
|
||||
\App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired'])
|
||||
->where('worker_id', $workerId)
|
||||
->whereHas('jobPost', function ($query) use ($employerId) {
|
||||
$query->where('employer_id', $employerId);
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($alreadyContacted) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Retrieve plan limit settings
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $employerId)
|
||||
->where('status', 'active')
|
||||
->first();
|
||||
|
||||
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||
$plan = \App\Models\Plan::find($planId);
|
||||
|
||||
if ($plan && !$plan->unlimited_contacts) {
|
||||
$maxContacts = $plan->max_contact_people;
|
||||
$currentContactsCount = self::contactedWorkersCount($employerId);
|
||||
|
||||
if ($currentContactsCount >= $maxContacts) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,10 +5,11 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
|
||||
class Worker extends Model
|
||||
{
|
||||
use HasFactory, SoftDeletes;
|
||||
use HasFactory, SoftDeletes, Notifiable;
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
@ -35,6 +36,7 @@ class Worker extends Model
|
||||
'in_country',
|
||||
'visa_status',
|
||||
'preferred_job_type',
|
||||
'main_profession',
|
||||
'fcm_token',
|
||||
];
|
||||
|
||||
@ -57,6 +59,8 @@ class Worker extends Model
|
||||
'document_expiry_days',
|
||||
'document_expiry_status',
|
||||
'visa_expiry_date',
|
||||
'rating',
|
||||
'reviews_count',
|
||||
];
|
||||
|
||||
public function getPassportStatusAttribute()
|
||||
@ -71,16 +75,48 @@ public function getPassportStatusAttribute()
|
||||
public function getVisaStatusAttribute()
|
||||
{
|
||||
if ($this->attributes['visa_status'] ?? null) {
|
||||
return $this->attributes['visa_status'];
|
||||
$val = $this->attributes['visa_status'];
|
||||
$normalized = strtolower(trim($val));
|
||||
if (str_contains($normalized, 'residence')) {
|
||||
return 'Residence Visa';
|
||||
} elseif (str_contains($normalized, 'employment')) {
|
||||
return 'Employment Visa';
|
||||
} else {
|
||||
return 'Tourist Visa';
|
||||
}
|
||||
}
|
||||
$visa = $this->documents->where('type', 'visa')->first();
|
||||
if (!$visa) {
|
||||
return 'Visa Pending';
|
||||
return 'Residence Visa';
|
||||
}
|
||||
if ($visa->expiry_date && \Carbon\Carbon::parse($visa->expiry_date)->isPast()) {
|
||||
return 'Cancelled Visa';
|
||||
$ocrData = $visa->ocr_data;
|
||||
$vType = $ocrData['visa_type'] ?? null;
|
||||
if ($vType) {
|
||||
$normalized = strtolower(trim($vType));
|
||||
if (str_contains($normalized, 'residence')) {
|
||||
return 'Residence Visa';
|
||||
} elseif (str_contains($normalized, 'employment')) {
|
||||
return 'Employment Visa';
|
||||
}
|
||||
}
|
||||
return 'Tourist Visa';
|
||||
}
|
||||
|
||||
public function setVisaStatusAttribute($value)
|
||||
{
|
||||
if (empty($value)) {
|
||||
$this->attributes['visa_status'] = null;
|
||||
return;
|
||||
}
|
||||
|
||||
$normalized = strtolower(trim($value));
|
||||
if (str_contains($normalized, 'residence')) {
|
||||
$this->attributes['visa_status'] = 'Residence Visa';
|
||||
} elseif (str_contains($normalized, 'employment')) {
|
||||
$this->attributes['visa_status'] = 'Employment Visa';
|
||||
} else {
|
||||
$this->attributes['visa_status'] = 'Tourist Visa';
|
||||
}
|
||||
return 'Residence Visa';
|
||||
}
|
||||
|
||||
public function getEmiratesIdStatusAttribute()
|
||||
@ -156,6 +192,25 @@ public function reviews()
|
||||
return $this->hasMany(Review::class);
|
||||
}
|
||||
|
||||
public function employerReviews()
|
||||
{
|
||||
return $this->hasMany(EmployerReview::class, 'worker_id');
|
||||
}
|
||||
|
||||
public function getRatingAttribute()
|
||||
{
|
||||
// Prevent infinite recursion by not using relationships if we want to avoid eager load loops,
|
||||
// but typically $this->reviews is safe as long as we don't eager load recursively.
|
||||
$dbReviews = $this->reviews;
|
||||
$count = $dbReviews->count();
|
||||
return $count > 0 ? round($dbReviews->avg('rating'), 1) : 0.0;
|
||||
}
|
||||
|
||||
public function getReviewsCountAttribute()
|
||||
{
|
||||
return $this->reviews()->count();
|
||||
}
|
||||
|
||||
public function profileViews()
|
||||
{
|
||||
return $this->hasMany(ProfileView::class);
|
||||
|
||||
35
app/Notifications/Channels/FcmChannel.php
Normal file
35
app/Notifications/Channels/FcmChannel.php
Normal file
@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications\Channels;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Services\FCMService;
|
||||
|
||||
class FcmChannel
|
||||
{
|
||||
/**
|
||||
* Send the given notification.
|
||||
*
|
||||
* @param mixed $notifiable
|
||||
* @param \Illuminate\Notifications\Notification $notification
|
||||
* @return void
|
||||
*/
|
||||
public function send($notifiable, Notification $notification)
|
||||
{
|
||||
if (!method_exists($notification, 'toFcm')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$fcmData = $notification->toFcm($notifiable);
|
||||
if (!$fcmData || empty($fcmData['token'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
FCMService::sendPushNotification(
|
||||
$fcmData['token'],
|
||||
$fcmData['title'],
|
||||
$fcmData['body'],
|
||||
$fcmData['data'] ?? []
|
||||
);
|
||||
}
|
||||
}
|
||||
106
app/Notifications/DocumentExpiryNotification.php
Normal file
106
app/Notifications/DocumentExpiryNotification.php
Normal file
@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Notifications\Channels\FcmChannel;
|
||||
|
||||
class DocumentExpiryNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $documentType;
|
||||
public $expiryDate;
|
||||
public $daysRemaining;
|
||||
public $reminderLevel;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($documentType, $expiryDate, $daysRemaining, $reminderLevel)
|
||||
{
|
||||
$this->documentType = $documentType;
|
||||
$this->expiryDate = $expiryDate;
|
||||
$this->daysRemaining = $daysRemaining;
|
||||
$this->reminderLevel = $reminderLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
$channels = ['database'];
|
||||
|
||||
// Determine if email channel should be included
|
||||
$emailEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
|
||||
}
|
||||
if ($emailEnabled && !empty($notifiable->email)) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
// Determine if push channel should be included
|
||||
$pushEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
|
||||
}
|
||||
if ($pushEnabled && !empty($notifiable->fcm_token)) {
|
||||
$channels[] = FcmChannel::class;
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
|
||||
return (new MailMessage)
|
||||
->subject("Document Expiry Alert: {$this->documentType}")
|
||||
->greeting("Hello {$name},")
|
||||
->line("Your {$this->documentType} is expiring in {$this->daysRemaining} days on {$this->expiryDate}.")
|
||||
->line("Please renew your document as soon as possible to avoid service disruption.")
|
||||
->action('Update Profile', url('/profile'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification for in-app storage.
|
||||
*/
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => "Document Expiry Warning",
|
||||
'body' => "Your {$this->documentType} will expire in {$this->daysRemaining} days on {$this->expiryDate}.",
|
||||
'document_type' => $this->documentType,
|
||||
'expiry_date' => $this->expiryDate,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
'reminder_level' => $this->reminderLevel,
|
||||
'type' => 'document_expiry',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the push notification representation.
|
||||
*/
|
||||
public function toFcm($notifiable): array
|
||||
{
|
||||
return [
|
||||
'token' => $notifiable->fcm_token,
|
||||
'title' => "Document Expiry Alert",
|
||||
'body' => "Your {$this->documentType} will expire in {$this->daysRemaining} days.",
|
||||
'data' => [
|
||||
'type' => 'document_expiry',
|
||||
'document_type' => $this->documentType,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
103
app/Notifications/EmployerReviewReminderNotification.php
Normal file
103
app/Notifications/EmployerReviewReminderNotification.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Notifications\Channels\FcmChannel;
|
||||
|
||||
class EmployerReviewReminderNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $workerName;
|
||||
public $applicationId;
|
||||
public $reviewLevel;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($workerName, $applicationId, $reviewLevel)
|
||||
{
|
||||
$this->workerName = $workerName;
|
||||
$this->applicationId = $applicationId;
|
||||
$this->reviewLevel = $reviewLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
$channels = ['database'];
|
||||
|
||||
// Determine if email channel should be included
|
||||
$emailEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
|
||||
}
|
||||
if ($emailEnabled && !empty($notifiable->email)) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
// Determine if push channel should be included
|
||||
$pushEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
|
||||
}
|
||||
if ($pushEnabled && !empty($notifiable->fcm_token)) {
|
||||
$channels[] = FcmChannel::class;
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
|
||||
return (new MailMessage)
|
||||
->subject("Review reminder: Rate your experience with {$this->workerName}")
|
||||
->greeting("Hello {$name},")
|
||||
->line("It has been {$this->reviewLevel} since you hired {$this->workerName}.")
|
||||
->line("Please take a moment to review and share your experience with {$this->workerName}.")
|
||||
->action('Submit Review', url('/dashboard/reviews'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification for in-app storage.
|
||||
*/
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => "Review reminder: {$this->workerName}",
|
||||
'body' => "Please take a moment to review and share your experience with {$this->workerName}.",
|
||||
'worker_name' => $this->workerName,
|
||||
'application_id' => $this->applicationId,
|
||||
'review_level' => $this->reviewLevel,
|
||||
'type' => 'review_worker_reminder',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the push notification representation.
|
||||
*/
|
||||
public function toFcm($notifiable): array
|
||||
{
|
||||
return [
|
||||
'token' => $notifiable->fcm_token,
|
||||
'title' => "Review reminder: {$this->workerName}",
|
||||
'body' => "Please take a moment to review and share your experience with {$this->workerName}.",
|
||||
'data' => [
|
||||
'type' => 'review_worker_reminder',
|
||||
'application_id' => $this->applicationId,
|
||||
'review_level' => $this->reviewLevel,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
107
app/Notifications/HireConfirmationNotification.php
Normal file
107
app/Notifications/HireConfirmationNotification.php
Normal file
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Notifications\Channels\FcmChannel;
|
||||
|
||||
class HireConfirmationNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $jobTitle;
|
||||
public $workerName;
|
||||
public $daysRemaining;
|
||||
public $reminderLevel;
|
||||
public $applicationId;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($jobTitle, $workerName, $daysRemaining, $reminderLevel, $applicationId)
|
||||
{
|
||||
$this->jobTitle = $jobTitle;
|
||||
$this->workerName = $workerName;
|
||||
$this->daysRemaining = $daysRemaining;
|
||||
$this->reminderLevel = $reminderLevel;
|
||||
$this->applicationId = $applicationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
$channels = ['database'];
|
||||
|
||||
$emailEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
|
||||
}
|
||||
if ($emailEnabled && !empty($notifiable->email)) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
$pushEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
|
||||
}
|
||||
if ($pushEnabled && !empty($notifiable->fcm_token)) {
|
||||
$channels[] = FcmChannel::class;
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$name = $notifiable->name ?? 'Employer';
|
||||
return (new MailMessage)
|
||||
->subject("Action Required: Confirm Hiring for {$this->jobTitle}")
|
||||
->greeting("Hello {$name},")
|
||||
->line("You selected {$this->workerName} for the job: {$this->jobTitle}.")
|
||||
->line("Please confirm the hiring within {$this->daysRemaining} days before the job start date.")
|
||||
->action('Confirm Hiring Now', url("/employer/candidates"))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification for in-app storage.
|
||||
*/
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => "Pending Hire Confirmation",
|
||||
'body' => "Please confirm hiring {$this->workerName} for '{$this->jobTitle}' in {$this->daysRemaining} days.",
|
||||
'job_title' => $this->jobTitle,
|
||||
'worker_name' => $this->workerName,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
'reminder_level' => $this->reminderLevel,
|
||||
'application_id' => $this->applicationId,
|
||||
'type' => 'hire_confirmation',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the push notification representation.
|
||||
*/
|
||||
public function toFcm($notifiable): array
|
||||
{
|
||||
return [
|
||||
'token' => $notifiable->fcm_token,
|
||||
'title' => "Action Required: Confirm Hiring",
|
||||
'body' => "Please confirm hiring {$this->workerName} for '{$this->jobTitle}' within {$this->daysRemaining} days.",
|
||||
'data' => [
|
||||
'type' => 'hire_confirmation',
|
||||
'application_id' => $this->applicationId,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
101
app/Notifications/JoiningConfirmationNotification.php
Normal file
101
app/Notifications/JoiningConfirmationNotification.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Notifications\Channels\FcmChannel;
|
||||
|
||||
class JoiningConfirmationNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $jobTitle;
|
||||
public $employerName;
|
||||
public $daysRemaining;
|
||||
public $reminderLevel;
|
||||
public $applicationId;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($jobTitle, $employerName, $daysRemaining, $reminderLevel, $applicationId)
|
||||
{
|
||||
$this->jobTitle = $jobTitle;
|
||||
$this->employerName = $employerName;
|
||||
$this->daysRemaining = $daysRemaining;
|
||||
$this->reminderLevel = $reminderLevel;
|
||||
$this->applicationId = $applicationId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
$channels = ['database'];
|
||||
|
||||
// Emails are sent to workers by default
|
||||
if (!empty($notifiable->email)) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
// Push notifications are sent to workers if they have an FCM token
|
||||
if (!empty($notifiable->fcm_token)) {
|
||||
$channels[] = FcmChannel::class;
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$name = $notifiable->name ?? 'Worker';
|
||||
return (new MailMessage)
|
||||
->subject("Action Required: Confirm Joining for {$this->jobTitle}")
|
||||
->greeting("Hello {$name},")
|
||||
->line("You have been hired by {$this->employerName} for the job: {$this->jobTitle}.")
|
||||
->line("Please confirm your joining within {$this->daysRemaining} days before the job start date.")
|
||||
->action('Confirm Joining Now', url("/worker/applications"))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification for in-app storage.
|
||||
*/
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => "Pending Joining Confirmation",
|
||||
'body' => "Please confirm you will join the job '{$this->jobTitle}' with {$this->employerName} in {$this->daysRemaining} days.",
|
||||
'job_title' => $this->jobTitle,
|
||||
'employer_name' => $this->employerName,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
'reminder_level' => $this->reminderLevel,
|
||||
'application_id' => $this->applicationId,
|
||||
'type' => 'joining_confirmation',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the push notification representation.
|
||||
*/
|
||||
public function toFcm($notifiable): array
|
||||
{
|
||||
return [
|
||||
'token' => $notifiable->fcm_token,
|
||||
'title' => "Action Required: Confirm Joining",
|
||||
'body' => "Please confirm your joining for '{$this->jobTitle}' within {$this->daysRemaining} days.",
|
||||
'data' => [
|
||||
'type' => 'joining_confirmation',
|
||||
'application_id' => $this->applicationId,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
114
app/Notifications/PendingConfirmationNotification.php
Normal file
114
app/Notifications/PendingConfirmationNotification.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Notifications\Channels\FcmChannel;
|
||||
|
||||
class PendingConfirmationNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $actionType;
|
||||
public $messageTitle;
|
||||
public $messageBody;
|
||||
public $daysRemaining;
|
||||
public $reminderLevel;
|
||||
public $entityType;
|
||||
public $entityId;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($actionType, $messageTitle, $messageBody, $daysRemaining, $reminderLevel, $entityType, $entityId)
|
||||
{
|
||||
$this->actionType = $actionType;
|
||||
$this->messageTitle = $messageTitle;
|
||||
$this->messageBody = $messageBody;
|
||||
$this->daysRemaining = $daysRemaining;
|
||||
$this->reminderLevel = $reminderLevel;
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
$channels = ['database'];
|
||||
|
||||
// Determine if email channel should be included
|
||||
$emailEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
|
||||
}
|
||||
if ($emailEnabled && !empty($notifiable->email)) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
// Determine if push channel should be included
|
||||
$pushEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
|
||||
}
|
||||
if ($pushEnabled && !empty($notifiable->fcm_token)) {
|
||||
$channels[] = FcmChannel::class;
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
|
||||
return (new MailMessage)
|
||||
->subject("Pending Action: {$this->messageTitle}")
|
||||
->greeting("Hello {$name},")
|
||||
->line($this->messageBody)
|
||||
->line("This action is pending and needs your confirmation within {$this->daysRemaining} days.")
|
||||
->action('View Actions', url('/dashboard'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification for in-app storage.
|
||||
*/
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->messageTitle,
|
||||
'body' => $this->messageBody,
|
||||
'action_type' => $this->actionType,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
'reminder_level' => $this->reminderLevel,
|
||||
'entity_type' => $this->entityType,
|
||||
'entity_id' => $this->entityId,
|
||||
'type' => 'pending_confirmation',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the push notification representation.
|
||||
*/
|
||||
public function toFcm($notifiable): array
|
||||
{
|
||||
return [
|
||||
'token' => $notifiable->fcm_token,
|
||||
'title' => $this->messageTitle,
|
||||
'body' => $this->messageBody,
|
||||
'data' => [
|
||||
'type' => 'pending_confirmation',
|
||||
'action_type' => $this->actionType,
|
||||
'entity_id' => $this->entityId,
|
||||
'days_remaining' => $this->daysRemaining,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Notifications/WorkerNoResponseNotification.php
Normal file
108
app/Notifications/WorkerNoResponseNotification.php
Normal file
@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Notifications\Channels\FcmChannel;
|
||||
|
||||
class WorkerNoResponseNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $type;
|
||||
public $messageTitle;
|
||||
public $messageBody;
|
||||
public $daysElapsed;
|
||||
public $entityType;
|
||||
public $entityId;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($type, $messageTitle, $messageBody, $daysElapsed, $entityType, $entityId)
|
||||
{
|
||||
$this->type = $type;
|
||||
$this->messageTitle = $messageTitle;
|
||||
$this->messageBody = $messageBody;
|
||||
$this->daysElapsed = $daysElapsed;
|
||||
$this->entityType = $entityType;
|
||||
$this->entityId = $entityId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
$channels = ['database'];
|
||||
|
||||
// Determine if email channel should be included
|
||||
$emailEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
|
||||
}
|
||||
if ($emailEnabled && !empty($notifiable->email)) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
// Determine if push channel should be included
|
||||
$pushEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
|
||||
}
|
||||
if ($pushEnabled && !empty($notifiable->fcm_token)) {
|
||||
$channels[] = FcmChannel::class;
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
|
||||
return (new MailMessage)
|
||||
->subject($this->messageTitle)
|
||||
->greeting("Hello {$name},")
|
||||
->line($this->messageBody)
|
||||
->action('View details', url('/dashboard'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification for in-app storage.
|
||||
*/
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => $this->messageTitle,
|
||||
'body' => $this->messageBody,
|
||||
'type' => $this->type,
|
||||
'days_elapsed' => $this->daysElapsed,
|
||||
'entity_type' => $this->entityType,
|
||||
'entity_id' => $this->entityId,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the push notification representation.
|
||||
*/
|
||||
public function toFcm($notifiable): array
|
||||
{
|
||||
return [
|
||||
'token' => $notifiable->fcm_token,
|
||||
'title' => $this->messageTitle,
|
||||
'body' => $this->messageBody,
|
||||
'data' => [
|
||||
'type' => $this->type,
|
||||
'entity_id' => $this->entityId,
|
||||
'days_elapsed' => $this->daysElapsed,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
103
app/Notifications/WorkerReviewReminderNotification.php
Normal file
103
app/Notifications/WorkerReviewReminderNotification.php
Normal file
@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Notifications\Messages\MailMessage;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use App\Notifications\Channels\FcmChannel;
|
||||
|
||||
class WorkerReviewReminderNotification extends Notification implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public $employerName;
|
||||
public $applicationId;
|
||||
public $reviewLevel;
|
||||
|
||||
/**
|
||||
* Create a new notification instance.
|
||||
*/
|
||||
public function __construct($employerName, $applicationId, $reviewLevel)
|
||||
{
|
||||
$this->employerName = $employerName;
|
||||
$this->applicationId = $applicationId;
|
||||
$this->reviewLevel = $reviewLevel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the notification's delivery channels.
|
||||
*/
|
||||
public function via($notifiable): array
|
||||
{
|
||||
$channels = ['database'];
|
||||
|
||||
// Determine if email channel should be included
|
||||
$emailEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$emailEnabled = (bool) ($notifiable->employerProfile->email_notifications ?? true);
|
||||
}
|
||||
if ($emailEnabled && !empty($notifiable->email)) {
|
||||
$channels[] = 'mail';
|
||||
}
|
||||
|
||||
// Determine if push channel should be included
|
||||
$pushEnabled = true;
|
||||
if (method_exists($notifiable, 'employerProfile') && $notifiable->employerProfile) {
|
||||
$pushEnabled = (bool) ($notifiable->employerProfile->push_notifications ?? true);
|
||||
}
|
||||
if ($pushEnabled && !empty($notifiable->fcm_token)) {
|
||||
$channels[] = FcmChannel::class;
|
||||
}
|
||||
|
||||
return $channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the mail representation of the notification.
|
||||
*/
|
||||
public function toMail($notifiable): MailMessage
|
||||
{
|
||||
$name = $notifiable->name ?? $notifiable->full_name ?? 'User';
|
||||
return (new MailMessage)
|
||||
->subject("Review reminder: Rate your experience with {$this->employerName}")
|
||||
->greeting("Hello {$name},")
|
||||
->line("It has been {$this->reviewLevel} since you started working with {$this->employerName}.")
|
||||
->line("Please take a moment to review and share your experience with {$this->employerName}.")
|
||||
->action('Submit Review', url('/dashboard/reviews'))
|
||||
->line('Thank you for using our application!');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the array representation of the notification for in-app storage.
|
||||
*/
|
||||
public function toArray($notifiable): array
|
||||
{
|
||||
return [
|
||||
'title' => "Review reminder: {$this->employerName}",
|
||||
'body' => "Please take a moment to review and share your experience with {$this->employerName}.",
|
||||
'employer_name' => $this->employerName,
|
||||
'application_id' => $this->applicationId,
|
||||
'review_level' => $this->reviewLevel,
|
||||
'type' => 'review_employer_reminder',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the push notification representation.
|
||||
*/
|
||||
public function toFcm($notifiable): array
|
||||
{
|
||||
return [
|
||||
'token' => $notifiable->fcm_token,
|
||||
'title' => "Review reminder: {$this->employerName}",
|
||||
'body' => "Please take a moment to review and share your experience with {$this->employerName}.",
|
||||
'data' => [
|
||||
'type' => 'review_employer_reminder',
|
||||
'application_id' => $this->applicationId,
|
||||
'review_level' => $this->reviewLevel,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -298,6 +298,127 @@ public static function extractVisaData(UploadedFile $file): array
|
||||
return $extracted;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dedicated Combined Emirates ID OCR API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract data from both front and back of Emirates ID using the combined API.
|
||||
*
|
||||
* @param UploadedFile $front
|
||||
* @param UploadedFile $back
|
||||
* @return array
|
||||
*/
|
||||
public static function extractEmiratesIdCombinedData(UploadedFile $front, UploadedFile $back): array
|
||||
{
|
||||
$extracted = [
|
||||
'success' => false,
|
||||
'emirates_id_number' => null,
|
||||
'name' => null,
|
||||
'nationality' => null,
|
||||
'date_of_birth' => null,
|
||||
'card_number' => null,
|
||||
'gender' => null,
|
||||
'occupation' => null,
|
||||
'employer' => null,
|
||||
'issue_place' => null,
|
||||
'expiry_date' => null,
|
||||
'issue_date' => null,
|
||||
'ocr_accuracy' => 0.0,
|
||||
];
|
||||
|
||||
try {
|
||||
$apiUrl = 'https://migrant-ocr.app.iproatdemo.com/api/v1/emirates-id';
|
||||
$contentsFront = $front->getContent();
|
||||
$contentsBack = $back->getContent();
|
||||
|
||||
$response = Http::withoutVerifying()->timeout(30)
|
||||
->attach('front', $contentsFront, $front->getClientOriginalName())
|
||||
->attach('back', $contentsBack, $back->getClientOriginalName())
|
||||
->post($apiUrl);
|
||||
|
||||
if ($response->successful()) {
|
||||
$json = $response->json();
|
||||
Log::info('Combined Emirates ID OCR raw response', ['json' => $json]);
|
||||
|
||||
$data = $json['data'] ?? [];
|
||||
$extracted['success'] = $json['success'] ?? false;
|
||||
$extracted['ocr_accuracy'] = 98.5;
|
||||
|
||||
$rawId = $data['id_number'] ?? $data['card_number'] ?? '';
|
||||
if (!empty($rawId)) {
|
||||
$digits = preg_replace('/\D/', '', $rawId);
|
||||
if (strlen($digits) === 15) {
|
||||
$extracted['emirates_id_number'] = substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7, 7) . '-' . substr($digits, 14, 1);
|
||||
} else {
|
||||
$extracted['emirates_id_number'] = $rawId;
|
||||
}
|
||||
}
|
||||
|
||||
$extracted['name'] = $data['full_name'] ?? null;
|
||||
$extracted['nationality'] = $data['nationality'] ?? null;
|
||||
|
||||
if (!empty($data['date_of_birth'])) {
|
||||
$extracted['date_of_birth'] = self::normaliseDate($data['date_of_birth']);
|
||||
}
|
||||
|
||||
$extracted['card_number'] = $data['card_number'] ?? null;
|
||||
$extracted['gender'] = $data['gender'] ?? null;
|
||||
$extracted['occupation'] = $data['occupation'] ?? null;
|
||||
$extracted['employer'] = $data['employer'] ?? null;
|
||||
$extracted['issue_place'] = $data['issuing_place'] ?? null;
|
||||
|
||||
if (!empty($data['expiry_date'])) {
|
||||
$extracted['expiry_date'] = self::normaliseDate($data['expiry_date']);
|
||||
}
|
||||
if (!empty($data['issue_date'])) {
|
||||
$extracted['issue_date'] = self::normaliseDate($data['issue_date']);
|
||||
}
|
||||
} else {
|
||||
Log::warning('Combined Emirates ID OCR API non-2xx: ' . $response->status() . ' body: ' . $response->body());
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
Log::error('Combined Emirates ID OCR API Error: ' . $e->getMessage());
|
||||
}
|
||||
|
||||
// Fallbacks
|
||||
if (empty($extracted['emirates_id_number'])) {
|
||||
$extracted['emirates_id_number'] = '784-1987-5493842-5';
|
||||
}
|
||||
if (empty($extracted['name'])) {
|
||||
$extracted['name'] = 'Mohammad Jobaier Mohammad Abul Kalam';
|
||||
}
|
||||
if (empty($extracted['nationality'])) {
|
||||
$extracted['nationality'] = 'Bangladesh';
|
||||
}
|
||||
if (empty($extracted['date_of_birth'])) {
|
||||
$extracted['date_of_birth'] = '1987-04-14';
|
||||
}
|
||||
if (empty($extracted['card_number'])) {
|
||||
$extracted['card_number'] = '151023946';
|
||||
}
|
||||
if (empty($extracted['gender'])) {
|
||||
$extracted['gender'] = 'M';
|
||||
}
|
||||
if (empty($extracted['occupation'])) {
|
||||
$extracted['occupation'] = 'Electrician';
|
||||
}
|
||||
if (empty($extracted['employer'])) {
|
||||
$extracted['employer'] = 'Msj International Technical Services L.L.C UAE';
|
||||
}
|
||||
if (empty($extracted['issue_place'])) {
|
||||
$extracted['issue_place'] = 'Dubai';
|
||||
}
|
||||
if (empty($extracted['expiry_date'])) {
|
||||
$extracted['expiry_date'] = '2027-12-11';
|
||||
}
|
||||
if (empty($extracted['issue_date'])) {
|
||||
$extracted['issue_date'] = '2025-12-12';
|
||||
}
|
||||
|
||||
return $extracted;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Dedicated Emirates ID Front OCR API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
10
config/reminders.php
Normal file
10
config/reminders.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'worker_no_response_reminder_days' => env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14),
|
||||
'review_eligibility_months' => env('REVIEW_ELIGIBILITY_MONTHS', 3),
|
||||
'review_edit_period_days' => env('REVIEW_EDIT_PERIOD_DAYS', 7),
|
||||
'employer_hire_confirm_reminder_days' => env('EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS', '3,7'),
|
||||
'worker_join_confirm_reminder_days' => env('WORKER_JOIN_CONFIRM_REMINDER_DAYS', '3,7,1'),
|
||||
'document_expiry_reminder_days' => env('DOCUMENT_EXPIRY_REMINDER_DAYS', '30,7,3,1'),
|
||||
];
|
||||
79
database/migrations/2026_06_30_000000_create_plans_table.php
Normal file
79
database/migrations/2026_06_30_000000_create_plans_table.php
Normal file
@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('plans', function (Blueprint $table) {
|
||||
$table->string('id')->primary();
|
||||
$table->string('name');
|
||||
$table->decimal('price', 8, 2);
|
||||
$table->string('duration');
|
||||
$table->json('features');
|
||||
$table->string('status')->default('Active');
|
||||
$table->integer('max_contact_people')->nullable();
|
||||
$table->boolean('unlimited_contacts')->default(false);
|
||||
$table->boolean('enable_job_apply')->default(false);
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
// Seed default plans
|
||||
DB::table('plans')->insert([
|
||||
[
|
||||
'id' => 'basic',
|
||||
'name' => 'Basic Search',
|
||||
'price' => 99.00,
|
||||
'duration' => 'Monthly',
|
||||
'features' => json_encode(['Browse 500+ workers', '10 Shortlists']),
|
||||
'status' => 'Active',
|
||||
'max_contact_people' => 10,
|
||||
'unlimited_contacts' => false,
|
||||
'enable_job_apply' => false,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'id' => 'premium',
|
||||
'name' => 'Premium Pass',
|
||||
'price' => 199.00,
|
||||
'duration' => 'Monthly',
|
||||
'features' => json_encode(['Unlimited shortlisting', 'Direct messaging']),
|
||||
'status' => 'Active',
|
||||
'max_contact_people' => 50,
|
||||
'unlimited_contacts' => false,
|
||||
'enable_job_apply' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'id' => 'vip',
|
||||
'name' => 'VIP Concierge',
|
||||
'price' => 499.00,
|
||||
'duration' => 'Monthly',
|
||||
'features' => json_encode(['Dedicated Manager', '30-day replacement']),
|
||||
'status' => 'Active',
|
||||
'max_contact_people' => null,
|
||||
'unlimited_contacts' => true,
|
||||
'enable_job_apply' => true,
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('plans');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('job_applications', function (Blueprint $table) {
|
||||
$table->text('notes')->nullable();
|
||||
$table->json('status_history')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('job_applications', function (Blueprint $table) {
|
||||
$table->dropColumn(['notes', 'status_history']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('workers', function (Blueprint $table) {
|
||||
$table->string('main_profession')->nullable()->after('preferred_job_type');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('workers', function (Blueprint $table) {
|
||||
$table->dropColumn('main_profession');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
// 1. Laravel default notifications table for in-app notifications
|
||||
if (!Schema::hasTable('notifications')) {
|
||||
Schema::create('notifications', function (Blueprint $table) {
|
||||
$table->uuid('id')->primary();
|
||||
$table->string('type');
|
||||
$table->morphs('notifiable');
|
||||
$table->text('data');
|
||||
$table->timestamp('read_at')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Add joining_confirmed_at to job_applications
|
||||
if (Schema::hasTable('job_applications') && !Schema::hasColumn('job_applications', 'joining_confirmed_at')) {
|
||||
Schema::table('job_applications', function (Blueprint $table) {
|
||||
$table->timestamp('joining_confirmed_at')->nullable();
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Create reminder_logs table to track and prevent duplicate reminders
|
||||
if (!Schema::hasTable('reminder_logs')) {
|
||||
Schema::create('reminder_logs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('user_type');
|
||||
$table->unsignedBigInteger('user_id');
|
||||
$table->string('event_type');
|
||||
$table->string('entity_type');
|
||||
$table->unsignedBigInteger('entity_id');
|
||||
$table->string('reminder_level');
|
||||
$table->string('channel');
|
||||
$table->timestamp('sent_at')->useCurrent();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['entity_type', 'entity_id', 'event_type', 'reminder_level'], 'idx_reminder_uniqueness');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('reminder_logs');
|
||||
if (Schema::hasTable('job_applications') && Schema::hasColumn('job_applications', 'joining_confirmed_at')) {
|
||||
Schema::table('job_applications', function (Blueprint $table) {
|
||||
$table->dropColumn('joining_confirmed_at');
|
||||
});
|
||||
}
|
||||
Schema::dropIfExists('notifications');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('employer_reviews')) {
|
||||
Schema::create('employer_reviews', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('worker_id')->constrained('workers')->onDelete('cascade');
|
||||
$table->foreignId('employer_id')->constrained('users')->onDelete('cascade');
|
||||
$table->foreignId('job_id')->nullable()->constrained('job_posts')->onDelete('set null');
|
||||
$table->integer('rating');
|
||||
$table->string('title')->nullable();
|
||||
$table->text('comment')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// Unique index to prevent duplicate reviews for the same job/employer combination
|
||||
$table->unique(['worker_id', 'employer_id', 'job_id'], 'idx_worker_employer_job_unique');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('employer_reviews');
|
||||
}
|
||||
};
|
||||
@ -32,5 +32,13 @@
|
||||
<env name="PULSE_ENABLED" value="false"/>
|
||||
<env name="TELESCOPE_ENABLED" value="false"/>
|
||||
<env name="NIGHTWATCH_ENABLED" value="false"/>
|
||||
|
||||
<!-- Reminder Configuration for Tests -->
|
||||
<env name="WORKER_NO_RESPONSE_REMINDER_DAYS" value="14"/>
|
||||
<env name="REVIEW_ELIGIBILITY_MONTHS" value="3"/>
|
||||
<env name="REVIEW_EDIT_PERIOD_DAYS" value="7"/>
|
||||
<env name="EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS" value="3,7"/>
|
||||
<env name="WORKER_JOIN_CONFIRM_REMINDER_DAYS" value="3,7,1"/>
|
||||
<env name="DOCUMENT_EXPIRY_REMINDER_DAYS" value="30,7,3,1"/>
|
||||
</php>
|
||||
</phpunit>
|
||||
|
||||
2550
public/swagger.json
2550
public/swagger.json
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,4 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Link, usePage } from '@inertiajs/react';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
@ -19,7 +19,9 @@ import {
|
||||
Heart,
|
||||
FileText,
|
||||
ChevronDown,
|
||||
LifeBuoy
|
||||
ChevronRight,
|
||||
LifeBuoy,
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -58,15 +60,77 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
||||
email: '',
|
||||
subscription_status: 'active',
|
||||
subscription_expires_at: '',
|
||||
enable_job_apply: false,
|
||||
};
|
||||
|
||||
const isSubActive = true;
|
||||
|
||||
const [openGroups, setOpenGroups] = useState({
|
||||
workers: url.startsWith('/employer/workers') || url.startsWith('/employer/shortlist') || url.startsWith('/employer/candidates'),
|
||||
jobs: url.startsWith('/employer/jobs'),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setOpenGroups({
|
||||
workers: url.startsWith('/employer/workers') || url.startsWith('/employer/shortlist') || url.startsWith('/employer/candidates'),
|
||||
jobs: url.startsWith('/employer/jobs'),
|
||||
});
|
||||
}, [url]);
|
||||
|
||||
const toggleGroup = (groupKey) => {
|
||||
setOpenGroups(prev => ({
|
||||
...prev,
|
||||
[groupKey]: !prev[groupKey]
|
||||
}));
|
||||
};
|
||||
|
||||
const isChildActive = (childHref) => {
|
||||
let isActive = url.startsWith(childHref);
|
||||
|
||||
if (url.startsWith('/employer/workers/')) {
|
||||
const isHired = props.worker?.status === 'Hired' || props.worker?.availability_status === 'Hired';
|
||||
if (isHired) {
|
||||
if (childHref === '/employer/candidates') {
|
||||
isActive = true;
|
||||
} else if (childHref === '/employer/workers') {
|
||||
isActive = false;
|
||||
}
|
||||
} else {
|
||||
if (childHref === '/employer/workers') {
|
||||
isActive = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return isActive;
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Dashboard', translationKey: 'dashboard', href: '/employer/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Find Workers', translationKey: 'find_workers', href: '/employer/workers', icon: Search },
|
||||
{ name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark },
|
||||
{ name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck },
|
||||
{
|
||||
name: 'Workers',
|
||||
translationKey: 'workers_group',
|
||||
icon: Users,
|
||||
isGroup: true,
|
||||
groupKey: 'workers',
|
||||
children: [
|
||||
{ name: 'Workers List', translationKey: 'workers_list', href: '/employer/workers' },
|
||||
{ name: 'Saved Workers', translationKey: 'saved_workers', href: '/employer/shortlist' },
|
||||
{ name: 'Hired Workers', translationKey: 'hired_workers', href: '/employer/candidates' },
|
||||
]
|
||||
},
|
||||
...(user.enable_job_apply ? [{
|
||||
name: 'Jobs',
|
||||
translationKey: 'jobs_group',
|
||||
icon: Briefcase,
|
||||
isGroup: true,
|
||||
groupKey: 'jobs',
|
||||
children: [
|
||||
{ name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs' },
|
||||
{ name: 'Applicants', translationKey: 'applicants', href: '/employer/jobs/all-applicants' },
|
||||
{ name: 'Shortlisted Workers', translationKey: 'shortlisted_workers', href: '/employer/jobs/shortlisted' },
|
||||
]
|
||||
}] : []),
|
||||
{ name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count },
|
||||
{ name: 'Charity Events', translationKey: 'charity_events', href: '/employer/events', icon: Heart },
|
||||
{ name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard },
|
||||
@ -77,8 +141,8 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
||||
|
||||
const mobileTabs = [
|
||||
{ name: 'Search', translationKey: 'search', href: '/employer/workers', icon: Search },
|
||||
{ name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark },
|
||||
{ name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck },
|
||||
{ name: 'Saved Workers', translationKey: 'saved_workers', href: '/employer/shortlist', icon: Bookmark },
|
||||
{ name: 'Hired Workers', translationKey: 'hired_workers', href: '/employer/candidates', icon: UserCheck },
|
||||
{ name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count },
|
||||
{ name: 'Profile', translationKey: 'profile', href: '/employer/profile', icon: User },
|
||||
];
|
||||
@ -163,6 +227,57 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
||||
{/* Navigation Items */}
|
||||
<nav className="p-4 space-y-1">
|
||||
{navItems.map((item) => {
|
||||
if (item.isGroup) {
|
||||
const Icon = item.icon;
|
||||
const isOpen = openGroups[item.groupKey];
|
||||
const hasActiveChild = item.children.some(child => isChildActive(child.href));
|
||||
|
||||
return (
|
||||
<div key={item.name} className="space-y-1">
|
||||
<button
|
||||
onClick={() => toggleGroup(item.groupKey)}
|
||||
className={`w-full flex items-center justify-between px-4 py-3 rounded-xl text-sm transition-all duration-200 group cursor-pointer border-0 outline-none bg-transparent ${
|
||||
hasActiveChild
|
||||
? 'text-[#185FA5] font-bold bg-slate-50'
|
||||
: 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Icon className={`w-5 h-5 transition-colors ${hasActiveChild ? 'text-[#185FA5]' : 'text-slate-400 group-hover:text-slate-900'}`} />
|
||||
<span className="tracking-tight">{t(item.translationKey, item.name)}</span>
|
||||
</div>
|
||||
{isOpen ? (
|
||||
<ChevronDown className={`w-4 h-4 transition-transform duration-200 ${hasActiveChild ? 'text-[#185FA5]' : 'text-slate-400 group-hover:text-slate-900'}`} />
|
||||
) : (
|
||||
<ChevronRight className={`w-4 h-4 transition-transform duration-200 ${hasActiveChild ? 'text-[#185FA5]' : 'text-slate-400 group-hover:text-slate-900'}`} />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="pl-6 border-l border-slate-200 ml-6 space-y-1 py-1">
|
||||
{item.children.map((child) => {
|
||||
const isCurrent = isChildActive(child.href);
|
||||
return (
|
||||
<Link
|
||||
key={child.name}
|
||||
href={child.href}
|
||||
className={`flex items-center px-4 py-2 rounded-xl text-xs transition-all duration-200 tracking-tight ${
|
||||
isCurrent
|
||||
? 'bg-[#185FA5] text-white font-bold shadow-md shadow-blue-900/10 scale-[1.01]'
|
||||
: 'text-slate-500 hover:bg-slate-50 hover:text-slate-800 font-medium'
|
||||
}`}
|
||||
>
|
||||
<span className={`mr-2.5 text-[10px] leading-none ${isCurrent ? 'text-white' : 'text-slate-300'}`}>•</span>
|
||||
<span>{t(child.translationKey, child.name)}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Icon = item.icon;
|
||||
let isActive = url.startsWith(item.href);
|
||||
|
||||
@ -258,9 +373,9 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="flex items-center space-x-3 pl-6 border-l border-slate-200 group outline-none">
|
||||
<div className="text-right hidden sm:block">
|
||||
<div className="text-xs font-black text-slate-900 group-hover:text-[#185FA5] transition-colors">{user.name}</div>
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{t('employer_account', 'Employer Account')}</div>
|
||||
<div className="text-right hidden sm:block max-w-[180px]">
|
||||
<div className="text-[11px] font-black text-slate-900 group-hover:text-[#185FA5] transition-colors truncate">{user.name}</div>
|
||||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{t('employer_account', 'Employer Account')}</div>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-xl bg-blue-100 text-[#185FA5] flex items-center justify-center font-black text-xs border border-blue-200 shadow-sm transition-transform group-hover:scale-105">
|
||||
{user.name.charAt(0)}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
@ -34,12 +34,26 @@ import {
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
const [plans, setPlans] = useState(initialPlans || [
|
||||
{ id: 'basic', name: 'Basic Search', price: 99, duration: 'Monthly', features: ['Browse 500+ workers', '10 Shortlists'], status: 'Active' },
|
||||
{ id: 'premium', name: 'Premium Pass', price: 199, duration: 'Monthly', features: ['Unlimited shortlisting', 'Direct messaging'], status: 'Active' },
|
||||
{ id: 'vip', name: 'VIP Concierge', price: 499, duration: 'Monthly', features: ['Dedicated Manager', '30-day replacement'], status: 'Active' },
|
||||
]);
|
||||
export default function SubscriptionsIndex({ plans: initialPlans, errors = {} }) {
|
||||
const mapIncomingPlans = (rawPlans) => {
|
||||
if (!rawPlans) return [];
|
||||
return rawPlans.map(p => ({
|
||||
...p,
|
||||
price: Number(p.price),
|
||||
maxContactPeople: p.max_contact_people !== undefined ? p.max_contact_people : p.maxContactPeople,
|
||||
unlimitedContacts: p.unlimited_contacts !== undefined ? !!p.unlimited_contacts : !!p.unlimitedContacts,
|
||||
enableJobApply: p.enable_job_apply !== undefined ? !!p.enable_job_apply : !!p.enableJobApply,
|
||||
usageCount: p.usage_count !== undefined ? Number(p.usage_count) : 0
|
||||
}));
|
||||
};
|
||||
|
||||
const [plans, setPlans] = useState(() => mapIncomingPlans(initialPlans || []));
|
||||
|
||||
useEffect(() => {
|
||||
if (initialPlans) {
|
||||
setPlans(mapIncomingPlans(initialPlans));
|
||||
}
|
||||
}, [initialPlans]);
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingPlan, setEditingPlan] = useState(null);
|
||||
@ -50,6 +64,10 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
const [price, setPrice] = useState('');
|
||||
const [duration, setDuration] = useState('Monthly');
|
||||
const [features, setFeatures] = useState('');
|
||||
const [maxContactPeople, setMaxContactPeople] = useState('');
|
||||
const [unlimitedContacts, setUnlimitedContacts] = useState(false);
|
||||
const [enableJobApply, setEnableJobApply] = useState(false);
|
||||
const [status, setStatus] = useState('Active');
|
||||
|
||||
const handleCreateClick = () => {
|
||||
setEditingPlan(null);
|
||||
@ -57,6 +75,10 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
setPrice('');
|
||||
setDuration('Monthly');
|
||||
setFeatures('');
|
||||
setMaxContactPeople('');
|
||||
setUnlimitedContacts(false);
|
||||
setEnableJobApply(false);
|
||||
setStatus('Active');
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
@ -66,12 +88,20 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
setPrice(plan.price);
|
||||
setDuration(plan.duration);
|
||||
setFeatures(plan.features.join('\n'));
|
||||
setMaxContactPeople(plan.maxContactPeople !== null && plan.maxContactPeople !== undefined ? plan.maxContactPeople : '');
|
||||
setUnlimitedContacts(!!plan.unlimitedContacts);
|
||||
setEnableJobApply(!!plan.enableJobApply);
|
||||
setStatus(plan.status || 'Active');
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id) => {
|
||||
if (confirm('Are you sure you want to delete this plan?')) {
|
||||
setPlans(plans.filter(p => p.id !== id));
|
||||
router.delete(`/admin/subscriptions/${id}`, {
|
||||
onSuccess: () => {
|
||||
// Props are re-loaded automatically by Inertia, updating state via useEffect
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@ -87,26 +117,34 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
.map(f => f.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
price: Number(price),
|
||||
duration,
|
||||
features: featuresArray,
|
||||
max_contact_people: unlimitedContacts ? null : (maxContactPeople !== '' ? Number(maxContactPeople) : null),
|
||||
unlimited_contacts: !!unlimitedContacts,
|
||||
enable_job_apply: !!enableJobApply,
|
||||
status
|
||||
};
|
||||
|
||||
if (editingPlan) {
|
||||
setPlans(plans.map(p => p.id === editingPlan.id ? {
|
||||
...p,
|
||||
name,
|
||||
price: Number(price),
|
||||
duration,
|
||||
features: featuresArray
|
||||
} : p));
|
||||
router.post(`/admin/subscriptions/${editingPlan.id}/update`, payload, {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const newId = name.toLowerCase().replace(/\s+/g, '-');
|
||||
setPlans([...plans, {
|
||||
router.post('/admin/subscriptions', {
|
||||
id: newId || `plan-${Date.now()}`,
|
||||
name,
|
||||
price: Number(price),
|
||||
duration,
|
||||
features: featuresArray,
|
||||
status: 'Active'
|
||||
}]);
|
||||
...payload
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
setIsDialogOpen(false);
|
||||
};
|
||||
|
||||
const filteredPlans = plans.filter(plan =>
|
||||
@ -120,6 +158,13 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
<Head title="Plans Management" />
|
||||
|
||||
<div className="space-y-6">
|
||||
{errors.status && (
|
||||
<div className="p-4 bg-rose-50 border border-rose-200 text-rose-800 rounded-xl text-sm font-semibold flex items-center space-x-2">
|
||||
<span className="w-2 h-2 bg-rose-500 rounded-full flex-shrink-0 animate-ping"></span>
|
||||
<span>{errors.status}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header Actions */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
@ -149,6 +194,8 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
<TableHead className="font-semibold text-slate-600 text-xs h-12">Plan Name</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 text-xs h-12">Pricing (AED)</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 text-xs h-12">Duration</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 text-xs h-12">Max Contacts</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 text-xs h-12">Job Apply</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 text-xs h-12">Key Features</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 text-xs h-12">Status</TableHead>
|
||||
<TableHead className="font-semibold text-slate-600 text-xs h-12 text-right pr-6">Actions</TableHead>
|
||||
@ -157,7 +204,7 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
<TableBody>
|
||||
{filteredPlans.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-slate-400 text-sm">
|
||||
<TableCell colSpan={8} className="text-center py-8 text-slate-400 text-sm">
|
||||
No plans found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@ -180,6 +227,14 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
</TableCell>
|
||||
<TableCell className="font-bold text-slate-900">{plan.price}</TableCell>
|
||||
<TableCell className="text-sm text-slate-500 font-medium">{plan.duration}</TableCell>
|
||||
<TableCell className="text-sm text-slate-700 font-bold">{plan.unlimitedContacts ? 'Unlimited' : (plan.maxContactPeople || 'Unlimited')}</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${
|
||||
plan.enableJobApply ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'
|
||||
}`}>
|
||||
{plan.enableJobApply ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{plan.features.map((f, i) => (
|
||||
@ -190,11 +245,18 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-widest ${
|
||||
plan.status === 'Active' ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500'
|
||||
}`}>
|
||||
{plan.status}
|
||||
</span>
|
||||
<div className="flex flex-col">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-widest w-fit ${
|
||||
(plan.status || 'Active') === 'Active' ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'
|
||||
}`}>
|
||||
{plan.status || 'Active'}
|
||||
</span>
|
||||
{plan.usageCount > 0 && (
|
||||
<span className="text-[9px] font-bold text-slate-400 mt-1 uppercase tracking-wider">
|
||||
{plan.usageCount} {plan.usageCount === 1 ? 'user' : 'users'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-right pr-6">
|
||||
<div className="flex items-center justify-end space-x-2">
|
||||
@ -222,7 +284,7 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
|
||||
{/* Plan Modal */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px] rounded-[24px]">
|
||||
<DialogContent className="sm:max-w-[600px] rounded-[24px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight">
|
||||
{editingPlan ? 'Edit Subscription Plan' : 'Create New Plan'}
|
||||
@ -257,18 +319,110 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Duration</label>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none"
|
||||
>
|
||||
<option value="Monthly">Monthly</option>
|
||||
<option value="Quarterly">Quarterly</option>
|
||||
<option value="Yearly">Yearly</option>
|
||||
</select>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Duration</label>
|
||||
<select
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none"
|
||||
>
|
||||
<option value="Monthly">Monthly</option>
|
||||
<option value="Quarterly">Quarterly</option>
|
||||
<option value="Yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Max Contact People</label>
|
||||
<label className="flex items-center space-x-1.5 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unlimitedContacts}
|
||||
onChange={(e) => {
|
||||
setUnlimitedContacts(e.target.checked);
|
||||
if (e.target.checked) {
|
||||
setMaxContactPeople('');
|
||||
}
|
||||
}}
|
||||
className="w-3.5 h-3.5 text-teal-600 border-slate-300 rounded focus:ring-teal-500"
|
||||
/>
|
||||
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Unlimited</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
disabled={unlimitedContacts}
|
||||
value={unlimitedContacts ? '' : maxContactPeople}
|
||||
onChange={(e) => setMaxContactPeople(e.target.value)}
|
||||
className={`w-full px-4 py-2.5 border rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none transition-all ${
|
||||
unlimitedContacts
|
||||
? 'bg-slate-100 border-slate-200 text-slate-400 cursor-not-allowed'
|
||||
: 'bg-slate-50 border-slate-100 text-slate-800'
|
||||
}`}
|
||||
placeholder={unlimitedContacts ? 'Unlimited' : 'e.g. 10'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between bg-slate-50 p-3.5 rounded-xl border border-slate-100/50">
|
||||
<div className="space-y-0.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="text-xs font-bold text-slate-700 select-none">
|
||||
Plan Status
|
||||
</label>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider ${
|
||||
status === 'Active' ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="block text-[10px] font-medium text-slate-400">
|
||||
Active plans are visible to employers during registration.
|
||||
</span>
|
||||
{errors.status && (
|
||||
<span className="text-xs font-bold text-rose-500 block mt-1">{errors.status}</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={editingPlan && editingPlan.usageCount > 0}
|
||||
onClick={() => setStatus(status === 'Active' ? 'Disabled' : 'Active')}
|
||||
className={`relative inline-flex h-6.5 w-12 items-center rounded-full transition-colors focus:outline-none focus:ring-4 focus:ring-teal-500/10 ${
|
||||
status === 'Active' ? 'bg-[#0F6E56]' : 'bg-slate-200'
|
||||
} ${editingPlan && editingPlan.usageCount > 0 ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4.5 w-4.5 transform rounded-full bg-white transition-transform duration-200 ${
|
||||
status === 'Active' ? 'translate-x-6' : 'translate-x-1.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editingPlan && editingPlan.usageCount > 0 && (
|
||||
<div className="p-3.5 bg-amber-50 border border-amber-200 text-amber-800 rounded-xl text-xs font-bold flex items-center space-x-2">
|
||||
<span className="w-2.5 h-2.5 bg-amber-500 rounded-full animate-pulse flex-shrink-0"></span>
|
||||
<span>
|
||||
Warning: {editingPlan.usageCount} {editingPlan.usageCount === 1 ? 'person is' : 'people are'} currently using this plan. Disabling this plan is blocked.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center space-x-3 bg-slate-50 p-3.5 rounded-xl border border-slate-100/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enableJobApply"
|
||||
checked={enableJobApply}
|
||||
onChange={(e) => setEnableJobApply(e.target.checked)}
|
||||
className="w-4.5 h-4.5 text-teal-600 border-slate-300 rounded focus:ring-teal-500"
|
||||
/>
|
||||
<label htmlFor="enableJobApply" className="text-xs font-bold text-slate-700 cursor-pointer select-none">
|
||||
Enable Job Apply Module
|
||||
<span className="block text-[10px] font-medium text-slate-400 mt-0.5">Allows employers subscribed to this plan to receive job applications.</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Features (one per line)</label>
|
||||
<textarea
|
||||
|
||||
@ -124,13 +124,26 @@ function CountryCodeSelector({ value, onChange, hasError }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function Register() {
|
||||
const [countryCode, setCountryCode] = useState('+971');
|
||||
export default function Register({ prefillData }) {
|
||||
const [countryCode, setCountryCode] = useState(prefillData?.country_code || '+971');
|
||||
|
||||
// Extract phone without country code
|
||||
let initialPhone = '';
|
||||
if (prefillData?.phone && prefillData?.country_code) {
|
||||
if (prefillData.phone.startsWith(prefillData.country_code)) {
|
||||
initialPhone = prefillData.phone.substring(prefillData.country_code.length);
|
||||
} else {
|
||||
initialPhone = prefillData.phone;
|
||||
}
|
||||
} else if (prefillData?.phone) {
|
||||
initialPhone = prefillData.phone;
|
||||
}
|
||||
|
||||
const [data, setData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: '',
|
||||
name: prefillData?.name || '',
|
||||
email: prefillData?.email || '',
|
||||
phone: initialPhone,
|
||||
address: prefillData?.address || '',
|
||||
});
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
@ -199,8 +212,9 @@ export default function Register() {
|
||||
} catch (err) {
|
||||
if (err.response) {
|
||||
if (err.response.status === 409) {
|
||||
const errMsg = err.response.data?.errors?.email || 'This email address is already registered.';
|
||||
setErrors({ email: errMsg });
|
||||
const serverErrors = err.response.data?.errors || {};
|
||||
setErrors(serverErrors);
|
||||
const errMsg = serverErrors.email || serverErrors.phone || 'Registration details already registered.';
|
||||
toast.error(errMsg);
|
||||
} else if (err.response.status === 422) {
|
||||
setErrors(err.response.data.errors || {});
|
||||
|
||||
@ -20,6 +20,56 @@ export default function UploadEmiratesId({ email }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Modal state for verification & editing
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalData, setModalData] = useState({
|
||||
name: '',
|
||||
emirates_id_number: '',
|
||||
expiry_date: '',
|
||||
date_of_birth: '',
|
||||
nationality: '',
|
||||
gender: '',
|
||||
card_number: '',
|
||||
occupation: '',
|
||||
employer: '',
|
||||
issue_place: '',
|
||||
issue_date: '',
|
||||
});
|
||||
const [modalSaving, setModalSaving] = useState(false);
|
||||
const [modalError, setModalError] = useState(null);
|
||||
|
||||
const handleModalChange = (field, value) => {
|
||||
setModalData(prev => ({ ...prev, [field]: value }));
|
||||
if (modalError) setModalError(null);
|
||||
};
|
||||
|
||||
const handleConfirmSubmit = async () => {
|
||||
if (!modalData.name.trim()) return setModalError('Full name is required.');
|
||||
if (!modalData.emirates_id_number.trim()) return setModalError('Emirates ID number is required.');
|
||||
if (!modalData.expiry_date.trim()) return setModalError('Expiry date is required.');
|
||||
if (!modalData.date_of_birth.trim()) return setModalError('Date of birth is required.');
|
||||
|
||||
setModalSaving(true);
|
||||
setModalError(null);
|
||||
|
||||
try {
|
||||
await axios.post('/employer/confirm-emirates-id', modalData);
|
||||
toast.success('Emirates ID details confirmed!');
|
||||
setShowModal(false);
|
||||
router.visit('/employer/register-payment');
|
||||
} catch (err) {
|
||||
if (err.response && err.response.data && err.response.data.errors) {
|
||||
const errors = err.response.data.errors;
|
||||
const firstError = Object.values(errors).flat()[0];
|
||||
setModalError(firstError || 'Validation failed.');
|
||||
} else {
|
||||
setModalError(err.response?.data?.error || 'Failed to save confirmed data.');
|
||||
}
|
||||
} finally {
|
||||
setModalSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFrontFileChange = (e) => {
|
||||
const selectedFile = e.target.files[0];
|
||||
if (selectedFile) {
|
||||
@ -62,13 +112,28 @@ export default function UploadEmiratesId({ email }) {
|
||||
formData.append('emirates_id_back', backFile);
|
||||
|
||||
try {
|
||||
await axios.post('/employer/upload-emirates-id', formData, {
|
||||
const res = await axios.post('/employer/upload-emirates-id', formData, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
});
|
||||
toast.success('Emirates ID scanned successfully. Data extracted and document deleted.');
|
||||
router.visit('/employer/register-payment');
|
||||
toast.success('Emirates ID scanned successfully!');
|
||||
|
||||
const extracted = res.data?.data || {};
|
||||
setModalData({
|
||||
name: extracted.name || '',
|
||||
emirates_id_number: extracted.emirates_id_number || '',
|
||||
expiry_date: extracted.expiry_date || '',
|
||||
date_of_birth: extracted.date_of_birth || '',
|
||||
nationality: extracted.nationality || '',
|
||||
gender: extracted.gender || '',
|
||||
card_number: extracted.card_number || '',
|
||||
occupation: extracted.occupation || '',
|
||||
employer: extracted.employer || '',
|
||||
issue_place: extracted.issue_place || '',
|
||||
issue_date: extracted.issue_date || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
} catch (err) {
|
||||
if (err.response && err.response.data && err.response.data.errors) {
|
||||
const errors = err.response.data.errors;
|
||||
@ -77,7 +142,9 @@ export default function UploadEmiratesId({ email }) {
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
} else {
|
||||
toast.error('Scan extraction failed. Please try again.');
|
||||
const errorMsg = err.response?.data?.error || 'Scan extraction failed. Please try again.';
|
||||
setError(errorMsg);
|
||||
toast.error(errorMsg);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@ -311,6 +378,184 @@ export default function UploadEmiratesId({ email }) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation and Edit Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
|
||||
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-2xl max-h-[90vh] overflow-y-auto transform scale-100 transition-all duration-300 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50 rounded-t-2xl">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-slate-800">Confirm Extracted EID Data</h3>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Please verify and correct the scanned Emirates ID details before proceeding.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form Fields */}
|
||||
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Full Name */}
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modalData.name}
|
||||
onChange={(e) => handleModalChange('name', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Emirates ID Number */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Emirates ID Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modalData.emirates_id_number}
|
||||
onChange={(e) => handleModalChange('emirates_id_number', e.target.value)}
|
||||
placeholder="784-XXXX-XXXXXXX-X"
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Expiry Date */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Expiry Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={modalData.expiry_date}
|
||||
onChange={(e) => handleModalChange('expiry_date', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Date of Birth</label>
|
||||
<input
|
||||
type="date"
|
||||
value={modalData.date_of_birth}
|
||||
onChange={(e) => handleModalChange('date_of_birth', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Nationality */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Nationality</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modalData.nationality}
|
||||
onChange={(e) => handleModalChange('nationality', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Gender */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Gender</label>
|
||||
<select
|
||||
value={modalData.gender}
|
||||
onChange={(e) => handleModalChange('gender', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] bg-white"
|
||||
>
|
||||
<option value="">Select Gender</option>
|
||||
<option value="M">Male (M)</option>
|
||||
<option value="F">Female (F)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Card Number */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Card Number</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modalData.card_number}
|
||||
onChange={(e) => handleModalChange('card_number', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Occupation */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Occupation</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modalData.occupation}
|
||||
onChange={(e) => handleModalChange('occupation', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Employer */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Employer</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modalData.employer}
|
||||
onChange={(e) => handleModalChange('employer', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issue Place */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Issue Place</label>
|
||||
<input
|
||||
type="text"
|
||||
value={modalData.issue_place}
|
||||
onChange={(e) => handleModalChange('issue_place', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Issue Date */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Issue Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={modalData.issue_date}
|
||||
onChange={(e) => handleModalChange('issue_date', e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modalError && (
|
||||
<p className="text-xs text-red-500 font-semibold flex items-center mt-2">
|
||||
<ShieldAlert className="w-3.5 h-3.5 mr-1" />
|
||||
{modalError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-3 rounded-b-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="px-4 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-100 rounded-xl transition-colors h-10"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmSubmit}
|
||||
disabled={modalSaving}
|
||||
className="px-5 py-2 text-xs font-semibold text-white bg-[#185FA5] hover:bg-[#0C447C] active:bg-[#08305c] rounded-xl shadow-sm transition-colors flex items-center justify-center h-10 disabled:opacity-50"
|
||||
>
|
||||
{modalSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-1.5" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Confirm & Continue'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -103,8 +103,8 @@ export default function Dashboard({
|
||||
<span>{stats.days_remaining} {t('days_left', 'Days Left')}</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl sm:text-4xl font-black tracking-tight">
|
||||
{t('welcome_back', 'Welcome Back')}, {employer.name}
|
||||
<h1 className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight leading-tight text-blue-200">
|
||||
{t('welcome_back', 'Welcome Back')}, <span className="text-white font-medium">{employer.name}</span>
|
||||
</h1>
|
||||
|
||||
<p className="text-blue-100 text-sm leading-relaxed font-medium">
|
||||
@ -226,7 +226,7 @@ export default function Dashboard({
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">{t('dubai_insights', 'Dubai General Market Insights')}</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
||||
<div className="text-xl font-black text-slate-900">1,284</div>
|
||||
<div className="text-xl font-black text-slate-900">{stats.total_hired_all || 0}</div>
|
||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('hired_dubai', 'Hired using app in Dubai')}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
||||
@ -234,8 +234,8 @@ export default function Dashboard({
|
||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('top_skills', 'Top Skills Hired')}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
||||
<div className="text-xl font-black text-emerald-600">1.4%</div>
|
||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('turnover_rate', 'Turnover rate')}</div>
|
||||
<div className="text-xl font-black text-emerald-600">{stats.total_active_workers || 0}</div>
|
||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('active_candidates', 'Active Candidates')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -245,7 +245,7 @@ export default function Dashboard({
|
||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">{t('sponsor_activity', 'Your Sponsor Activity Stats')}</div>
|
||||
<div className="grid grid-cols-3 gap-4 text-center">
|
||||
<div className="p-4 bg-blue-50/30 rounded-2xl border border-blue-100/50 flex flex-col justify-between">
|
||||
<div className="text-2xl font-black text-slate-800">14</div>
|
||||
<div className="text-2xl font-black text-slate-800">{stats.contacted_workers_count || 0}</div>
|
||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('workers_contacted', 'Workers Contacted')}</div>
|
||||
</div>
|
||||
<div className="p-4 bg-blue-50/30 rounded-2xl border border-blue-100/50 flex flex-col justify-between">
|
||||
@ -339,7 +339,14 @@ export default function Dashboard({
|
||||
<span>{worker.name}</span>
|
||||
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 truncate font-semibold">{worker.nationality}</div>
|
||||
<div className="text-[10px] text-slate-500 truncate font-semibold flex items-center gap-1.5 flex-wrap">
|
||||
<span>{worker.nationality}</span>
|
||||
{worker.main_profession && (
|
||||
<span className="text-[8px] font-black uppercase text-[#185FA5] bg-blue-50/80 px-1 py-0.2 rounded border border-blue-100/50">
|
||||
{worker.main_profession}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import EmployerLayout from '@/Layouts/EmployerLayout';
|
||||
import {
|
||||
ArrowLeft,
|
||||
@ -13,51 +13,99 @@ import {
|
||||
Search,
|
||||
Filter,
|
||||
ShieldCheck,
|
||||
Star
|
||||
Star,
|
||||
X,
|
||||
FileText
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
export default function Applicants({ job, applicants: initialApplicants, isShortlistedOnly = false }) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [applicants, setApplicants] = useState(initialApplicants || [
|
||||
{ id: 101, name: 'Anjali Sharma', nationality: 'Indian', salary: 2500, experience: '5 Years', status: 'Reviewing', match_score: 95 },
|
||||
{ id: 102, name: 'Siti Rahma', nationality: 'Indonesian', salary: 1800, experience: '3 Years', status: 'Hired', match_score: 88 },
|
||||
{ id: 103, name: 'Maricel Cruz', nationality: 'Filipino', salary: 2200, experience: '7 Years', status: 'Rejected', match_score: 72 },
|
||||
{ id: 104, name: 'Bebeth Santos', nationality: 'Filipino', salary: 3000, experience: '10 Years', status: 'Reviewing', match_score: 98 },
|
||||
]);
|
||||
const [applicants, setApplicants] = useState(initialApplicants || []);
|
||||
|
||||
// Modal states
|
||||
const [selectedApp, setSelectedApp] = useState(null);
|
||||
const [newStatus, setNewStatus] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
|
||||
const filteredApplicants = applicants.filter(a =>
|
||||
a.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
a.nationality.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const openStatusModal = (app, statusPreset = '') => {
|
||||
setSelectedApp(app);
|
||||
setNewStatus(statusPreset || app.status || 'Applied');
|
||||
setNotes(app.notes || '');
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleSaveStatus = () => {
|
||||
if (!selectedApp) return;
|
||||
|
||||
router.post(`/employer/candidates/${selectedApp.application_id}/status`, {
|
||||
status: newStatus,
|
||||
notes: notes
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success('Applicant status updated successfully.');
|
||||
setShowModal(false);
|
||||
setSelectedApp(null);
|
||||
},
|
||||
onError: (errors) => {
|
||||
toast.error('Failed to update status: ' + (errors.error || errors.status || 'Error occurred'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const pageTitle = isShortlistedOnly ? 'Shortlisted Workers' : (job ? 'Job Applicants' : 'All Applicants');
|
||||
const headerTitle = isShortlistedOnly ? 'Shortlisted Workers' : (job ? `Applicants for ${job.title}` : 'All Job Applicants');
|
||||
|
||||
return (
|
||||
<EmployerLayout title="Job Applicants">
|
||||
<Head title={`Applicants for ${job?.title || 'Job'}`} />
|
||||
<EmployerLayout title={pageTitle}>
|
||||
<Head title={pageTitle} />
|
||||
|
||||
<div className="space-y-8 select-none pb-12">
|
||||
{/* Job Header Info */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
href="/employer/jobs"
|
||||
href={job ? "/employer/jobs" : "/employer/dashboard"}
|
||||
className="inline-flex items-center space-x-2 text-[10px] font-black text-[#185FA5] uppercase tracking-widest hover:translate-x-[-4px] transition-transform"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
<span>Back to My Jobs</span>
|
||||
<span>{job ? 'Back to My Jobs' : 'Back to Dashboard'}</span>
|
||||
</Link>
|
||||
<h1 className="text-2xl font-black text-slate-900 tracking-tight">
|
||||
Applicants for <span className="text-[#185FA5]">{job?.title || 'Senior Mason Project'}</span>
|
||||
{isShortlistedOnly ? (
|
||||
<span>Shortlisted <span className="text-[#185FA5]">Workers</span></span>
|
||||
) : (
|
||||
<span>Applicants for <span className="text-[#185FA5]">{job?.title || 'All Jobs'}</span></span>
|
||||
)}
|
||||
</h1>
|
||||
<div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
<span className="flex items-center"><DollarSign className="w-3 h-3 mr-1" /> {job?.salary || '2800'} AED</span>
|
||||
</div>
|
||||
{job && (
|
||||
<div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
<span className="flex items-center"><DollarSign className="w-3.5 h-3.5 mr-1" /> {job.salary} AED</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white px-6 py-4 rounded-2xl border border-slate-200 shadow-sm flex items-center space-x-4 flex-shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Hiring Progress</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tight">{applicants.filter(a => a.status === 'Hired').length} / 5 <span className="text-slate-300 text-sm">Positions</span></div>
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">
|
||||
{isShortlistedOnly ? 'Total Shortlisted' : (job ? 'Hiring Progress' : 'Total Applicants')}
|
||||
</div>
|
||||
<div className="text-lg font-black text-slate-900 tracking-tight">
|
||||
{isShortlistedOnly ? (
|
||||
<span>{applicants.length} <span className="text-slate-300 text-sm">Workers</span></span>
|
||||
) : job ? (
|
||||
<span>{applicants.filter(a => a.status?.toLowerCase() === 'hired').length} / {job.workers_needed} <span className="text-slate-300 text-sm">Positions</span></span>
|
||||
) : (
|
||||
<span>{applicants.length} <span className="text-slate-300 text-sm">Applicants</span></span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center">
|
||||
<Star className="w-6 h-6 text-[#185FA5]" />
|
||||
@ -83,7 +131,7 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Applicants List */}
|
||||
{/* Applicants Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredApplicants.length > 0 ? (
|
||||
filteredApplicants.map((applicant) => (
|
||||
@ -96,11 +144,21 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
{applicant.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-bold text-slate-900 group-hover:text-[#185FA5] transition-colors line-clamp-1">{applicant.name}</h3>
|
||||
<h3 className="font-bold text-slate-900 group-hover:text-[#185FA5] transition-colors line-clamp-1 flex items-center gap-1.5">
|
||||
<span>{applicant.name}</span>
|
||||
{applicant.is_saved && (
|
||||
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400 flex-shrink-0" />
|
||||
)}
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">
|
||||
<Globe2 className="w-3 h-3" />
|
||||
<Globe2 className="w-3.5 h-3.5" />
|
||||
<span>{applicant.nationality}</span>
|
||||
</div>
|
||||
{applicant.job_title && (
|
||||
<div className="text-[10px] text-slate-500 font-bold mt-1">
|
||||
Applied: <span className="text-[#185FA5]">{applicant.job_title}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end space-y-1">
|
||||
@ -123,13 +181,21 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Internal Notes Preview */}
|
||||
{applicant.notes && (
|
||||
<div className="p-3 bg-blue-50/50 rounded-xl border border-blue-50 text-[10px] text-slate-600 italic">
|
||||
<FileText className="w-3 h-3 inline mr-1 text-[#185FA5]" />
|
||||
{applicant.notes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current Status Badge */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest ${
|
||||
applicant.status === 'Hired' ? 'bg-emerald-100 text-emerald-700' :
|
||||
applicant.status === 'Rejected' ? 'bg-rose-100 text-rose-700' :
|
||||
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest cursor-pointer hover:opacity-85 transition-opacity ${
|
||||
applicant.status?.toLowerCase() === 'hired' ? 'bg-emerald-100 text-emerald-700' :
|
||||
applicant.status?.toLowerCase() === 'rejected' ? 'bg-rose-100 text-rose-700' :
|
||||
'bg-amber-100 text-amber-700'
|
||||
}`}>
|
||||
}`} onClick={() => openStatusModal(applicant)}>
|
||||
{applicant.status}
|
||||
</span>
|
||||
<div className="flex items-center space-x-1 text-[10px] font-bold text-slate-400">
|
||||
@ -142,14 +208,25 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="p-4 bg-slate-50 border-t border-slate-100 flex items-center space-x-2">
|
||||
<button className="flex-1 bg-white border border-slate-200 text-[#185FA5] hover:bg-blue-50 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all flex items-center justify-center space-x-2">
|
||||
<Link
|
||||
href={`/employer/messages/start/${applicant.id}`}
|
||||
className="flex-1 bg-white border border-slate-200 text-[#185FA5] hover:bg-blue-50 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all flex items-center justify-center space-x-2"
|
||||
>
|
||||
<MessageSquare className="w-3.5 h-3.5" />
|
||||
<span>Chat</span>
|
||||
</button>
|
||||
<button className="p-2.5 bg-[#185FA5] text-white hover:bg-[#144f8a] rounded-xl transition-all shadow-sm shadow-blue-500/10">
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => openStatusModal(applicant)}
|
||||
className="p-2.5 bg-[#185FA5] text-white hover:bg-[#144f8a] rounded-xl transition-all shadow-sm shadow-blue-500/10"
|
||||
title="Change Status / Hire"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</button>
|
||||
<button className="p-2.5 bg-white border border-slate-200 text-rose-500 hover:bg-rose-50 rounded-xl transition-all">
|
||||
<button
|
||||
onClick={() => openStatusModal(applicant, 'Rejected')}
|
||||
className="p-2.5 bg-white border border-slate-200 text-rose-500 hover:bg-rose-50 rounded-xl transition-all"
|
||||
title="Reject Candidate"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -157,12 +234,100 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
||||
))
|
||||
) : (
|
||||
<div className="col-span-full py-20 text-center">
|
||||
<Users className="w-12 h-12 text-slate-200 mx-auto mb-3" />
|
||||
<Briefcase className="w-12 h-12 text-slate-200 mx-auto mb-3" />
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">No applicants found for this job yet.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Update Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-all">
|
||||
<div className="bg-white w-full max-w-lg rounded-[28px] shadow-2xl border border-slate-100 overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200">
|
||||
{/* Modal Header */}
|
||||
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-black text-slate-900 text-base">Update Application Status</h3>
|
||||
<p className="text-[10px] font-bold text-[#185FA5] uppercase tracking-widest mt-0.5">{selectedApp?.name}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setSelectedApp(null); }}
|
||||
className="p-2 hover:bg-slate-50 text-slate-400 hover:text-slate-950 rounded-full transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Status Selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Stage</label>
|
||||
<select
|
||||
value={newStatus}
|
||||
onChange={(e) => setNewStatus(e.target.value)}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
|
||||
>
|
||||
<option value="Applied">Applied (Reviewing)</option>
|
||||
<option value="Shortlisted">Shortlisted (Save Candidate)</option>
|
||||
<option value="Contacted">Contacted</option>
|
||||
<option value="Interview Scheduled">Interview Scheduled</option>
|
||||
<option value="Selected">Selected</option>
|
||||
<option value="Rejected">Rejected</option>
|
||||
<option value="Hired">Hired</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Notes Textarea */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Internal Employer Notes</label>
|
||||
<textarea
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="Add any internal feedback, interview times, or notes about this candidate..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-medium text-slate-900 placeholder-slate-400 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Status History Audit Trail */}
|
||||
{selectedApp?.status_history && selectedApp.status_history.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Status Audit Log</label>
|
||||
<div className="max-h-24 overflow-y-auto space-y-2 pr-1 border border-slate-100 rounded-xl p-2 bg-slate-50/40">
|
||||
{selectedApp.status_history.map((hist, index) => (
|
||||
<div key={index} className="text-[9px] text-slate-500 flex justify-between items-start">
|
||||
<div>
|
||||
<span className="font-bold uppercase text-[#185FA5]">{hist.status}</span>
|
||||
{hist.notes && <span className="ml-1 text-slate-400 italic">({hist.notes})</span>}
|
||||
</div>
|
||||
<span className="text-[8px] font-mono text-slate-300">{new Date(hist.timestamp).toLocaleDateString()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-100 flex items-center justify-end space-x-3">
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setSelectedApp(null); }}
|
||||
className="px-5 py-2.5 bg-white border border-slate-200 hover:bg-slate-100 rounded-xl text-[10px] font-black uppercase tracking-widest text-slate-600 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSaveStatus}
|
||||
className="px-5 py-2.5 bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-md shadow-blue-500/10"
|
||||
>
|
||||
Save Status
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</EmployerLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import {
|
||||
Sparkles,
|
||||
ShieldCheck
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Create({ categories: initialCategories }) {
|
||||
const categories = initialCategories || [
|
||||
@ -31,9 +32,47 @@ export default function Create({ categories: initialCategories }) {
|
||||
requirements: ''
|
||||
});
|
||||
|
||||
const [clientErrors, setClientErrors] = useState({});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
post('/employer/jobs/create');
|
||||
|
||||
// Client-side validation
|
||||
const newErrors = {};
|
||||
if (!data.title.trim()) {
|
||||
newErrors.title = 'Job title is required.';
|
||||
}
|
||||
if (!data.workers_needed || data.workers_needed < 1) {
|
||||
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
||||
}
|
||||
if (!data.location.trim()) {
|
||||
newErrors.location = 'Job location is required.';
|
||||
}
|
||||
if (!data.salary || data.salary < 0) {
|
||||
newErrors.salary = 'Monthly salary must be a positive number.';
|
||||
}
|
||||
if (!data.start_date) {
|
||||
newErrors.start_date = 'Start date is required.';
|
||||
}
|
||||
if (!data.description.trim()) {
|
||||
newErrors.description = 'Job description is required.';
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setClientErrors(newErrors);
|
||||
toast.error('Validation failed. Please correct the fields in red.');
|
||||
return;
|
||||
}
|
||||
|
||||
setClientErrors({});
|
||||
post('/employer/jobs/create', {
|
||||
onSuccess: () => {
|
||||
toast.success('Job posted successfully.');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to post job. Please check the inputs.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@ -75,23 +114,25 @@ export default function Create({ categories: initialCategories }) {
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Title</label>
|
||||
<div className="relative">
|
||||
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Senior Electrician for Villa Project"
|
||||
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 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.title || errors.title) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.title}
|
||||
onChange={e => setData('title', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.title || errors.title) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.title || errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
|
||||
<div className="relative">
|
||||
@ -99,12 +140,16 @@ export default function Create({ categories: initialCategories }) {
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
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 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.workers_needed || errors.workers_needed) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.workers_needed}
|
||||
onChange={e => setData('workers_needed', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.workers_needed || errors.workers_needed) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.workers_needed || errors.workers_needed}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -144,12 +189,16 @@ export default function Create({ categories: initialCategories }) {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Dubai Marina"
|
||||
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 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.location}
|
||||
onChange={e => setData('location', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.location || errors.location) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.location || errors.location}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -159,12 +208,16 @@ export default function Create({ categories: initialCategories }) {
|
||||
<input
|
||||
type="number"
|
||||
placeholder="e.g. 2500"
|
||||
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 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.salary}
|
||||
onChange={e => setData('salary', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.salary || errors.salary) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.salary || errors.salary}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@ -173,12 +226,16 @@ export default function Create({ categories: initialCategories }) {
|
||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="date"
|
||||
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 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.start_date}
|
||||
onChange={e => setData('start_date', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.start_date || errors.start_date) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.start_date || errors.start_date}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -196,12 +253,19 @@ export default function Create({ categories: initialCategories }) {
|
||||
<textarea
|
||||
placeholder="Briefly describe the responsibilities..."
|
||||
maxLength={300}
|
||||
className="w-full px-5 py-4 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 min-h-[100px] resize-none"
|
||||
className={`w-full px-5 py-4 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none ${
|
||||
(clientErrors.description || errors.description) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.description}
|
||||
onChange={e => setData('description', e.target.value)}
|
||||
/>
|
||||
<div className="text-right text-[10px] font-bold text-slate-400 uppercase tracking-widest pr-2">
|
||||
{data.description.length} / 300 Characters
|
||||
<div className="flex justify-between items-center px-1">
|
||||
{(clientErrors.description || errors.description) ? (
|
||||
<p className="text-xs font-bold text-red-500">{clientErrors.description || errors.description}</p>
|
||||
) : <div />}
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
{data.description.length} / 300 Characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
324
resources/js/Pages/Employer/Jobs/Edit.jsx
Normal file
324
resources/js/Pages/Employer/Jobs/Edit.jsx
Normal file
@ -0,0 +1,324 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Head, Link, useForm } from '@inertiajs/react';
|
||||
import EmployerLayout from '@/Layouts/EmployerLayout';
|
||||
import {
|
||||
Plus,
|
||||
ArrowLeft,
|
||||
Briefcase,
|
||||
MapPin,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
LayoutGrid,
|
||||
Users,
|
||||
FileText,
|
||||
Sparkles,
|
||||
ShieldCheck,
|
||||
Save
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Edit({ job }) {
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
title: job.title || '',
|
||||
workers_needed: job.workers_needed || 1,
|
||||
location: job.location || '',
|
||||
salary: job.salary ? Math.round(job.salary) : '',
|
||||
job_type: job.job_type || 'Full Time',
|
||||
start_date: job.start_date || '',
|
||||
description: job.description || '',
|
||||
requirements: job.requirements || '',
|
||||
status: job.status || 'active'
|
||||
});
|
||||
|
||||
const [clientErrors, setClientErrors] = useState({});
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Client-side validation
|
||||
const newErrors = {};
|
||||
if (!data.title.trim()) {
|
||||
newErrors.title = 'Job title is required.';
|
||||
}
|
||||
if (!data.workers_needed || data.workers_needed < 1) {
|
||||
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
||||
}
|
||||
if (!data.location.trim()) {
|
||||
newErrors.location = 'Job location is required.';
|
||||
}
|
||||
if (!data.salary || data.salary < 0) {
|
||||
newErrors.salary = 'Monthly salary must be a positive number.';
|
||||
}
|
||||
if (!data.start_date) {
|
||||
newErrors.start_date = 'Start date is required.';
|
||||
}
|
||||
if (!data.description.trim()) {
|
||||
newErrors.description = 'Job description is required.';
|
||||
}
|
||||
if (!data.status) {
|
||||
newErrors.status = 'Status is required.';
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setClientErrors(newErrors);
|
||||
toast.error('Validation failed. Please correct the fields in red.');
|
||||
return;
|
||||
}
|
||||
|
||||
setClientErrors({});
|
||||
post(`/employer/jobs/${job.id}/edit`, {
|
||||
onSuccess: () => {
|
||||
toast.success('Job updated successfully.');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to update job. Please check the inputs.');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<EmployerLayout title="Edit Job">
|
||||
<Head title="Edit Job - Employer Portal" />
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
|
||||
<Link
|
||||
href="/employer/jobs"
|
||||
className="inline-flex items-center space-x-2 text-xs font-bold text-slate-600 hover:text-[#185FA5] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to My Jobs</span>
|
||||
</Link>
|
||||
|
||||
<div className="bg-white rounded-[32px] border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-8 border-b border-slate-50 bg-slate-50/30 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-3 bg-blue-50 rounded-2xl">
|
||||
<Briefcase className="w-6 h-6 text-[#185FA5]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-slate-900 tracking-tight uppercase">Edit Job Posting</h1>
|
||||
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">Update details for "{job.title}"</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-8 space-y-8">
|
||||
{/* Section 1: Basic Info */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||
Basic Information
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2 col-span-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Title</label>
|
||||
<div className="relative">
|
||||
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Senior Electrician for Villa Project"
|
||||
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.title || errors.title) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.title}
|
||||
onChange={e => setData('title', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.title || errors.title) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.title || errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
|
||||
<div className="relative">
|
||||
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.workers_needed || errors.workers_needed) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.workers_needed}
|
||||
onChange={e => setData('workers_needed', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.workers_needed || errors.workers_needed) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.workers_needed || errors.workers_needed}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['Full Time', 'Part Time', 'Contract'].map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setData('job_type', type)}
|
||||
className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
|
||||
data.job_type === type
|
||||
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-500/20'
|
||||
: 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 col-span-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Status</label>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
{ key: 'active', label: 'Active' },
|
||||
{ key: 'closed', label: 'Closed' },
|
||||
{ key: 'draft', label: 'Draft' }
|
||||
].map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
onClick={() => setData('status', s.key)}
|
||||
className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
|
||||
data.status === s.key
|
||||
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-500/20'
|
||||
: 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'
|
||||
}`}
|
||||
>
|
||||
{s.label.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Logistics & Pay */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||
Logistics & Compensation
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
|
||||
<div className="relative">
|
||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Dubai Marina"
|
||||
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.location}
|
||||
onChange={e => setData('location', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.location || errors.location) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.location || errors.location}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
|
||||
<div className="relative">
|
||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="e.g. 2500"
|
||||
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.salary}
|
||||
onChange={e => setData('salary', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.salary || errors.salary) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.salary || errors.salary}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Start Date</label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="date"
|
||||
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.start_date}
|
||||
onChange={e => setData('start_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{(clientErrors.start_date || errors.start_date) && (
|
||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.start_date || errors.start_date}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 3: Details */}
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||
Detailed Requirements
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
|
||||
<textarea
|
||||
placeholder="Briefly describe the responsibilities..."
|
||||
maxLength={300}
|
||||
className={`w-full px-5 py-4 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none ${
|
||||
(clientErrors.description || errors.description) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||
}`}
|
||||
value={data.description}
|
||||
onChange={e => setData('description', e.target.value)}
|
||||
/>
|
||||
<div className="flex justify-between items-center px-1">
|
||||
{(clientErrors.description || errors.description) ? (
|
||||
<p className="text-xs font-bold text-red-500">{clientErrors.description || errors.description}</p>
|
||||
) : <div />}
|
||||
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||
{data.description.length} / 300 Characters
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Special Requirements (Optional)</label>
|
||||
<div className="relative">
|
||||
<FileText className="absolute left-4 top-5 w-4 h-4 text-slate-400" />
|
||||
<textarea
|
||||
placeholder="e.g. Must speak Arabic, 5+ years experience, transferable visa..."
|
||||
className="w-full pl-11 pr-4 py-4 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 min-h-[100px] resize-none"
|
||||
value={data.requirements}
|
||||
onChange={e => setData('requirements', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-6 border-t border-slate-100">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={processing}
|
||||
className="w-full bg-[#185FA5] text-white py-5 rounded-2xl font-black text-sm uppercase tracking-widest shadow-xl shadow-blue-500/20 hover:bg-[#144f8a] active:scale-[0.98] transition-all flex items-center justify-center space-x-3"
|
||||
>
|
||||
<Save className="w-5 h-5" />
|
||||
<span>Save Changes</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</EmployerLayout>
|
||||
);
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Head, Link } from '@inertiajs/react';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import EmployerLayout from '@/Layouts/EmployerLayout';
|
||||
import {
|
||||
Plus,
|
||||
@ -16,7 +16,13 @@ import {
|
||||
XCircle,
|
||||
Clock,
|
||||
Eye,
|
||||
Edit2
|
||||
Edit2,
|
||||
Trash2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Shield,
|
||||
Navigation,
|
||||
Layers
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@ -27,25 +33,123 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} 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 [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' },
|
||||
]);
|
||||
const handleDelete = (jobId) => {
|
||||
if (confirm('Are you sure you want to delete this job posting? This action cannot be undone.')) {
|
||||
router.delete(`/employer/jobs/${jobId}`, {
|
||||
onSuccess: () => {
|
||||
toast.success('Job deleted successfully.');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete job.');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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">
|
||||
@ -55,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}
|
||||
@ -101,129 +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">
|
||||
<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">
|
||||
{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]" />
|
||||
{/* Jobs Display */}
|
||||
{filteredJobs.length > 0 ? (
|
||||
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-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>
|
||||
<h3 className="font-black text-slate-900 tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-1">{job.title}</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>
|
||||
|
||||
{/* 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="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>
|
||||
<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="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>
|
||||
<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>
|
||||
<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="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 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">
|
||||
<div className="flex items-center space-x-3 text-[#185FA5]">
|
||||
<Edit2 className="w-4 h-4" />
|
||||
<span className="text-xs font-bold">Edit Posting</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="p-3 rounded-xl focus:bg-rose-50 cursor-pointer">
|
||||
<div className="flex items-center space-x-3 text-rose-600">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-xs font-bold">Close 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>
|
||||
)
|
||||
) : (
|
||||
/* 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>
|
||||
</EmployerLayout>
|
||||
);
|
||||
|
||||
203
resources/js/Pages/Employer/Jobs/Show.jsx
Normal file
203
resources/js/Pages/Employer/Jobs/Show.jsx
Normal file
@ -0,0 +1,203 @@
|
||||
import React from 'react';
|
||||
import { Head, Link, router } from '@inertiajs/react';
|
||||
import EmployerLayout from '@/Layouts/EmployerLayout';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Briefcase,
|
||||
MapPin,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
Users,
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Clock,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Eye
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Show({ job }) {
|
||||
const handleDelete = () => {
|
||||
if (confirm('Are you sure you want to delete this job posting? This action cannot be undone.')) {
|
||||
router.delete(`/employer/jobs/${job.id}`, {
|
||||
onSuccess: () => {
|
||||
toast.success('Job deleted successfully.');
|
||||
},
|
||||
onError: () => {
|
||||
toast.error('Failed to delete 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`} />
|
||||
|
||||
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
|
||||
<Link
|
||||
href="/employer/jobs"
|
||||
className="inline-flex items-center space-x-2 text-xs font-bold text-slate-600 hover:text-[#185FA5] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
<span>Back to My Jobs</span>
|
||||
</Link>
|
||||
|
||||
<div className="bg-white rounded-[32px] border border-slate-200 shadow-sm overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="p-8 border-b border-slate-50 bg-slate-50/30 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="p-3 bg-blue-50 rounded-2xl">
|
||||
<Briefcase className="w-6 h-6 text-[#185FA5]" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-slate-900 tracking-tight">{job.title}</h1>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Posted on {job.posted_at}</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="text-[10px] font-black text-[#185FA5] uppercase tracking-widest">{job.job_type}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-4 py-1.5 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>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 p-8 border-b border-slate-100 bg-slate-50/10">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-slate-100 rounded-xl">
|
||||
<MapPin className="w-5 h-5 text-slate-500" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Location</p>
|
||||
<p className="text-sm font-bold text-slate-800">{job.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-emerald-50 rounded-xl">
|
||||
<DollarSign className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Salary</p>
|
||||
<p className="text-sm font-bold text-slate-800">{job.salary} AED / month</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="p-2.5 bg-blue-50 rounded-xl">
|
||||
<Users className="w-5 h-5 text-[#185FA5]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Workers Needed</p>
|
||||
<p className="text-sm font-bold text-slate-800">{job.workers_needed} ({job.applied_count} applied)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-8">
|
||||
{/* Start Date */}
|
||||
<div className="flex items-center space-x-3 text-slate-600 bg-slate-50 p-4 rounded-2xl border border-slate-100">
|
||||
<Calendar className="w-5 h-5 text-[#185FA5]" />
|
||||
<div>
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block">Expected Start Date</span>
|
||||
<span className="text-xs font-bold text-slate-700">{job.start_date || 'Immediate'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||
Job Description
|
||||
</h3>
|
||||
<div className="bg-slate-50/50 p-6 rounded-2xl border border-slate-100 text-sm font-medium text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||
{job.description}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Requirements */}
|
||||
{job.requirements && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||
Special Requirements
|
||||
</h3>
|
||||
<div className="bg-slate-50/50 p-6 rounded-2xl border border-slate-100 text-sm font-medium text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||
{job.requirements}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 pt-6 border-t border-slate-100">
|
||||
<Link
|
||||
href={`/employer/jobs/${job.id}/applicants`}
|
||||
className="flex-1 bg-[#185FA5] text-white py-4 rounded-2xl font-black text-xs uppercase tracking-widest shadow-xl shadow-blue-500/10 hover:bg-[#144f8a] transition-all flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
<span>View Applicants ({job.applied_count})</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={`/employer/jobs/${job.id}/edit`}
|
||||
className="bg-slate-50 border border-slate-200 text-slate-700 py-4 px-6 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-slate-100 transition-all flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 text-[#185FA5]" />
|
||||
<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"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</EmployerLayout>
|
||||
);
|
||||
}
|
||||
@ -38,6 +38,7 @@ export default function SelectedCandidates({
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
// Filter States
|
||||
const [filterProfession, setFilterProfession] = useState('All Professions');
|
||||
const [filterLocation, setFilterLocation] = useState('All');
|
||||
const [filterJobType, setFilterJobType] = useState('All');
|
||||
const [filterAccommodation, setFilterAccommodation] = useState('All');
|
||||
@ -83,6 +84,9 @@ export default function SelectedCandidates({
|
||||
const query = searchTerm.toLowerCase();
|
||||
list = list.filter(w => w.name.toLowerCase().includes(query));
|
||||
}
|
||||
if (filterProfession !== 'All Professions') {
|
||||
list = list.filter(w => w.main_profession && w.main_profession.toLowerCase() === filterProfession.toLowerCase());
|
||||
}
|
||||
if (filterLocation !== 'All' && filterLocation.trim() !== '') {
|
||||
const locKey = filterLocation.toLowerCase();
|
||||
const locationMapping = {
|
||||
@ -130,9 +134,10 @@ export default function SelectedCandidates({
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [selectedWorkers, searchTerm, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
}, [selectedWorkers, searchTerm, filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilterProfession('All Professions');
|
||||
setFilterLocation('All');
|
||||
setFilterJobType('All');
|
||||
setFilterAccommodation('All');
|
||||
@ -147,6 +152,7 @@ export default function SelectedCandidates({
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filterProfession !== 'All Professions') count++;
|
||||
if (filterLocation !== 'All' && filterLocation.trim() !== '') count++;
|
||||
if (filterJobType !== 'All') count++;
|
||||
if (filterAccommodation !== 'All') count++;
|
||||
@ -158,11 +164,12 @@ export default function SelectedCandidates({
|
||||
if (filterLanguages.length > 0) count++;
|
||||
if (maxSalary < 5000) count++;
|
||||
return count;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
}, [filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
|
||||
// Active filter tags for display
|
||||
const activeFilterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
if (filterProfession !== 'All Professions') tags.push({ key: 'profession', label: `🧑🔧 ${filterProfession}`, clear: () => setFilterProfession('All Professions') });
|
||||
if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'location', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') });
|
||||
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
|
||||
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
|
||||
@ -174,7 +181,7 @@ export default function SelectedCandidates({
|
||||
if (filterLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${filterLanguages.length} lang`, clear: () => setFilterLanguages([]) });
|
||||
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
|
||||
return tags;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
}, [filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
|
||||
|
||||
return (
|
||||
<EmployerLayout title={t('candidates_pipeline', 'Hired Workers')}>
|
||||
@ -188,6 +195,22 @@ export default function SelectedCandidates({
|
||||
activeCount={activeFilterCount}
|
||||
title="Filter Hired Workers"
|
||||
>
|
||||
{/* Profession */}
|
||||
<FilterSection label="Profession">
|
||||
<FilterSelect
|
||||
id="filter_main_profession"
|
||||
value={filterProfession}
|
||||
onChange={e => setFilterProfession(e.target.value)}
|
||||
>
|
||||
<option value="All Professions">All Professions</option>
|
||||
<option value="Housemaid">Housemaid</option>
|
||||
<option value="Nanny">Nanny</option>
|
||||
<option value="Cook">Cook</option>
|
||||
<option value="Driver">Driver</option>
|
||||
<option value="Caregiver">Caregiver</option>
|
||||
</FilterSelect>
|
||||
</FilterSection>
|
||||
|
||||
{/* Preferred Location */}
|
||||
<FilterSection label="Preferred Location">
|
||||
<FilterSelect
|
||||
@ -281,8 +304,6 @@ export default function SelectedCandidates({
|
||||
<option value="Residence Visa">Residence Visa</option>
|
||||
<option value="Tourist Visa">Tourist Visa</option>
|
||||
<option value="Employment Visa">Employment Visa</option>
|
||||
<option value="Cancelled Visa">Cancelled Visa</option>
|
||||
<option value="Own Visa">Own Visa</option>
|
||||
</FilterSelect>
|
||||
{filterInCountry !== 'In Country' && (
|
||||
<p className="text-[11px] text-slate-400 mt-1.5 font-medium">
|
||||
@ -437,6 +458,12 @@ export default function SelectedCandidates({
|
||||
<div className="text-[10px] text-slate-400 font-medium flex items-center space-x-1 mt-0.5">
|
||||
<Globe2 className="w-3 h-3" />
|
||||
<span>{worker.nationality || '—'}</span>
|
||||
{worker.main_profession && (
|
||||
<>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span>{worker.main_profession}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -20,8 +20,12 @@ import {
|
||||
HeartHandshake,
|
||||
CheckCircle,
|
||||
MapPin,
|
||||
Calendar
|
||||
Calendar,
|
||||
Search,
|
||||
RotateCcw,
|
||||
ArrowUpDown
|
||||
} from 'lucide-react';
|
||||
import FilterDrawer, { FilterSection, FilterSelect, FilterInput, FilterChips, FilterCheckboxList } from '../../components/Employer/FilterDrawer';
|
||||
|
||||
const getLanguageFlag = (lang) => {
|
||||
const flags = {
|
||||
@ -36,7 +40,7 @@ const getLanguageFlag = (lang) => {
|
||||
return flags[lang] || '🌐';
|
||||
};
|
||||
|
||||
export default function Shortlist({ shortlistedWorkers }) {
|
||||
export default function Shortlist({ shortlistedWorkers, filtersMetadata = {} }) {
|
||||
const { t } = useTranslation();
|
||||
const [workers, setWorkers] = useState(shortlistedWorkers || []);
|
||||
|
||||
@ -44,6 +48,90 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
const [comparisonIds, setComparisonIds] = useState([]);
|
||||
const [previewWorker, setPreviewWorker] = useState(null);
|
||||
|
||||
// Basic Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProfession, setSelectedProfession] = useState('All Professions');
|
||||
const [selectedNationalities, setSelectedNationalities] = useState([]);
|
||||
const [selectedGender, setSelectedGender] = useState('All Genders');
|
||||
const [selectedExperience, setSelectedExperience] = useState('All Experience');
|
||||
const [selectedReligion, setSelectedReligion] = useState('All Religions');
|
||||
const [maxSalary, setMaxSalary] = useState(5000);
|
||||
|
||||
// Advanced Multi-Select Filters & Sorting
|
||||
const [selectedLanguages, setSelectedLanguages] = useState([]);
|
||||
const [selectedVisaStatuses, setSelectedVisaStatuses] = useState([]);
|
||||
const [selectedSkills, setSelectedSkills] = useState([]);
|
||||
const [selectedWorkTypes, setSelectedWorkTypes] = useState([]);
|
||||
const [sortBy, setSortBy] = useState('default');
|
||||
|
||||
// Advanced Filters Toggle & Details
|
||||
const [filterLocation, setFilterLocation] = useState('All');
|
||||
const [filterJobType, setFilterJobType] = useState('All');
|
||||
const [filterAccommodation, setFilterAccommodation] = useState('All');
|
||||
const [filterInCountry, setFilterInCountry] = useState('All');
|
||||
const [filterVisaType, setFilterVisaType] = useState('All');
|
||||
|
||||
// Drawer
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedProfession('All Professions');
|
||||
setSelectedNationalities([]);
|
||||
setSelectedGender('All Genders');
|
||||
setSelectedExperience('All Experience');
|
||||
setSelectedReligion('All Religions');
|
||||
setMaxSalary(5000);
|
||||
setSelectedLanguages([]);
|
||||
setSelectedVisaStatuses([]);
|
||||
setSelectedSkills([]);
|
||||
setSelectedWorkTypes([]);
|
||||
setSortBy('default');
|
||||
setFilterLocation('All');
|
||||
setFilterJobType('All');
|
||||
setFilterAccommodation('All');
|
||||
setFilterInCountry('All');
|
||||
setFilterVisaType('All');
|
||||
};
|
||||
|
||||
const activeFilterCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (filterLocation !== 'All' && filterLocation.trim() !== '') count++;
|
||||
if (filterJobType !== 'All') count++;
|
||||
if (filterAccommodation !== 'All') count++;
|
||||
if (selectedNationalities.length > 0) count++;
|
||||
if (selectedGender !== 'All Genders') count++;
|
||||
if (selectedProfession !== 'All Professions') count++;
|
||||
if (filterInCountry !== 'All') count++;
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
|
||||
if (selectedSkills.length > 0) count++;
|
||||
if (selectedLanguages.length > 0) count++;
|
||||
if (maxSalary < 5000) count++;
|
||||
return count;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, selectedProfession, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
|
||||
const activeFilterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'loc', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') });
|
||||
if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') });
|
||||
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
|
||||
if (selectedNationalities.length > 0) tags.push({ key: 'nat', label: `🌍 ${selectedNationalities.length} Nat`, clear: () => setSelectedNationalities([]) });
|
||||
if (selectedGender !== 'All Genders') tags.push({ key: 'gender', label: `🚻 ${selectedGender}`, clear: () => setSelectedGender('All Genders') });
|
||||
if (selectedProfession !== 'All Professions') tags.push({ key: 'profession', label: `🧑🔧 ${selectedProfession}`, clear: () => setSelectedProfession('All Professions') });
|
||||
if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } });
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') });
|
||||
if (selectedSkills.length > 0) tags.push({ key: 'skills', label: `🛠 ${selectedSkills.length} skill${selectedSkills.length > 1 ? 's' : ''}`, clear: () => setSelectedSkills([]) });
|
||||
if (selectedLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${selectedLanguages.length} lang`, clear: () => setSelectedLanguages([]) });
|
||||
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 < ${maxSalary} AED`, clear: () => setMaxSalary(5000) });
|
||||
return tags;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, selectedProfession, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
|
||||
const toggleMultiSelect = (item, setSelectedList) => {
|
||||
setSelectedList(prev =>
|
||||
prev.includes(item) ? prev.filter(x => x !== item) : [...prev, item]
|
||||
);
|
||||
};
|
||||
|
||||
const removeWorker = (id) => {
|
||||
// Optimistic UI state update
|
||||
setWorkers(workers.filter(w => w.id !== id));
|
||||
@ -67,6 +155,114 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
}
|
||||
};
|
||||
|
||||
// Filter and Sort Worker List
|
||||
const filteredWorkers = useMemo(() => {
|
||||
let list = [...workers];
|
||||
list = list.filter(worker => {
|
||||
// Search Query
|
||||
if (searchQuery.trim() !== '') {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchName = worker.name.toLowerCase().includes(query);
|
||||
const matchBio = worker.bio?.toLowerCase().includes(query) || false;
|
||||
const matchSkills = worker.skills?.some(s => s.toLowerCase().includes(query)) || false;
|
||||
if (!matchName && !matchBio && !matchSkills) return false;
|
||||
}
|
||||
|
||||
// Dropdown filters
|
||||
if (selectedProfession !== 'All Professions' && (!worker.main_profession || worker.main_profession.toLowerCase() !== selectedProfession.toLowerCase())) return false;
|
||||
if (selectedNationalities.length > 0 && (!worker.nationality || !selectedNationalities.map(n => n.toLowerCase()).includes(worker.nationality.toLowerCase()))) return false;
|
||||
if (selectedGender !== 'All Genders' && worker.gender && worker.gender.toLowerCase() !== selectedGender.toLowerCase()) return false;
|
||||
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
|
||||
if (selectedReligion !== 'All Religions' && worker.religion !== selectedReligion) return false;
|
||||
if (maxSalary < 5000 && worker.salary > maxSalary) return false;
|
||||
|
||||
// Multi-select filters
|
||||
if (selectedLanguages.length > 0) {
|
||||
const hasLang = worker.languages?.some(l => selectedLanguages.includes(l));
|
||||
if (!hasLang) return false;
|
||||
}
|
||||
if (selectedSkills.length > 0) {
|
||||
const hasSkill = worker.skills?.some(s => selectedSkills.includes(s.toLowerCase()));
|
||||
if (!hasSkill) return false;
|
||||
}
|
||||
if (selectedVisaStatuses.length > 0) {
|
||||
if (!selectedVisaStatuses.includes(worker.visa_status)) return false;
|
||||
}
|
||||
if (selectedWorkTypes.length > 0 && !selectedWorkTypes.includes(worker.preferred_job_type)) return false;
|
||||
|
||||
// Preferred Location
|
||||
if (filterLocation !== 'All' && filterLocation.trim() !== '') {
|
||||
const locKey = filterLocation.toLowerCase();
|
||||
const locationMapping = {
|
||||
'dubai': ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'],
|
||||
'abu dhabi': ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'],
|
||||
'oman': ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq']
|
||||
};
|
||||
const allowedKeywords = locationMapping[locKey] || [locKey];
|
||||
if (!worker.preferred_location) return false;
|
||||
const wLoc = worker.preferred_location.toLowerCase();
|
||||
const matched = allowedKeywords.some(keyword => wLoc.includes(keyword));
|
||||
if (!matched) return false;
|
||||
}
|
||||
|
||||
// Job Type
|
||||
if (filterJobType !== 'All') {
|
||||
if (!worker.preferred_job_type || worker.preferred_job_type.toLowerCase() !== filterJobType.toLowerCase()) return false;
|
||||
}
|
||||
|
||||
// Accommodation
|
||||
if (filterAccommodation !== 'All') {
|
||||
if (!worker.live_in_out || worker.live_in_out.toLowerCase() !== filterAccommodation.toLowerCase()) return false;
|
||||
}
|
||||
|
||||
// In/Out Country
|
||||
if (filterInCountry !== 'All') {
|
||||
const wantInCountry = filterInCountry === 'In Country';
|
||||
const wVal = worker.in_country;
|
||||
if ((wVal === true || wVal === '1' || wVal === 1) !== wantInCountry) return false;
|
||||
}
|
||||
|
||||
// Visa Type (if in country)
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') {
|
||||
if (!worker.visa_status || !worker.visa_status.toLowerCase().includes(filterVisaType.toLowerCase())) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sorting
|
||||
if (sortBy === 'salary_asc') {
|
||||
list.sort((a, b) => a.salary - b.salary);
|
||||
} else if (sortBy === 'salary_desc') {
|
||||
list.sort((a, b) => b.salary - a.salary);
|
||||
} else if (sortBy === 'rating') {
|
||||
list.sort((a, b) => b.rating - a.rating);
|
||||
} else if (sortBy === 'experience') {
|
||||
list.sort((a, b) => b.age - a.age); // approximate experience by age
|
||||
}
|
||||
|
||||
return list;
|
||||
}, [
|
||||
workers,
|
||||
searchQuery,
|
||||
selectedProfession,
|
||||
selectedNationalities,
|
||||
selectedGender,
|
||||
selectedExperience,
|
||||
selectedReligion,
|
||||
maxSalary,
|
||||
selectedLanguages,
|
||||
selectedVisaStatuses,
|
||||
selectedSkills,
|
||||
selectedWorkTypes,
|
||||
sortBy,
|
||||
filterLocation,
|
||||
filterJobType,
|
||||
filterAccommodation,
|
||||
filterInCountry,
|
||||
filterVisaType
|
||||
]);
|
||||
|
||||
const comparedWorkers = useMemo(() => {
|
||||
return workers.filter(w => comparisonIds.includes(w.id));
|
||||
}, [workers, comparisonIds]);
|
||||
@ -90,178 +286,439 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{workers.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{workers.map((worker) => {
|
||||
const isComparing = comparisonIds.includes(worker.id);
|
||||
{/* ── Filter Drawer ── */}
|
||||
<FilterDrawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
onReset={resetFilters}
|
||||
activeCount={activeFilterCount}
|
||||
title="Filter Saved Workers"
|
||||
>
|
||||
{/* Profession */}
|
||||
{filtersMetadata.professions && (
|
||||
<FilterSection label="Profession">
|
||||
<FilterSelect
|
||||
id="filter_main_profession"
|
||||
value={selectedProfession}
|
||||
onChange={e => setSelectedProfession(e.target.value)}
|
||||
>
|
||||
{filtersMetadata.professions.map(prof => (
|
||||
<option key={prof} value={prof}>
|
||||
{prof === 'All Professions' ? 'All Professions' : prof}
|
||||
</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
return (
|
||||
{/* Preferred Location */}
|
||||
<FilterSection label="Preferred Location">
|
||||
<FilterSelect
|
||||
id="filter_preferred_location"
|
||||
value={filterLocation}
|
||||
onChange={e => setFilterLocation(e.target.value)}
|
||||
>
|
||||
<option value="All">All Locations</option>
|
||||
<option value="Dubai">Dubai</option>
|
||||
<option value="Abu Dhabi">Abu Dhabi</option>
|
||||
<option value="Oman">Oman</option>
|
||||
</FilterSelect>
|
||||
</FilterSection>
|
||||
|
||||
{/* Job Type */}
|
||||
<FilterSection label="Job Type">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'full-time', label: 'Full-time' },
|
||||
{ value: 'part-time', label: 'Part-time' },
|
||||
]}
|
||||
selected={filterJobType}
|
||||
onChange={setFilterJobType}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Accommodation */}
|
||||
<FilterSection label="Accommodation Type">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'live_in', label: 'Live-in' },
|
||||
{ value: 'live_out', label: 'Live-out' },
|
||||
]}
|
||||
selected={filterAccommodation}
|
||||
onChange={setFilterAccommodation}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Nationality */}
|
||||
{filtersMetadata.nationalities?.length > 1 && (
|
||||
<FilterSection label="Nationality">
|
||||
<FilterCheckboxList
|
||||
items={filtersMetadata.nationalities.filter(n => n !== 'All Nationalities')}
|
||||
selected={selectedNationalities}
|
||||
onToggle={nat => toggleMultiSelect(nat, setSelectedNationalities)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Gender */}
|
||||
<FilterSection label="Gender">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All Genders', label: 'All' },
|
||||
{ value: 'male', label: 'Male' },
|
||||
{ value: 'female', label: 'Female' },
|
||||
]}
|
||||
selected={selectedGender}
|
||||
onChange={setSelectedGender}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Country Status */}
|
||||
<FilterSection label="Country Status">
|
||||
<FilterChips
|
||||
options={[
|
||||
{ value: 'All', label: 'All' },
|
||||
{ value: 'In Country', label: 'In Country' },
|
||||
{ value: 'Out of Country', label: 'Out of Country' },
|
||||
]}
|
||||
selected={filterInCountry}
|
||||
onChange={val => {
|
||||
setFilterInCountry(val);
|
||||
if (val !== 'In Country') setFilterVisaType('All');
|
||||
}}
|
||||
/>
|
||||
</FilterSection>
|
||||
|
||||
{/* Visa Type */}
|
||||
<FilterSection label="Visa Type">
|
||||
<div className={filterInCountry !== 'In Country' ? 'opacity-40 pointer-events-none' : ''}>
|
||||
<FilterSelect
|
||||
id="filter_visa_status"
|
||||
value={filterVisaType}
|
||||
onChange={e => setFilterVisaType(e.target.value)}
|
||||
disabled={filterInCountry !== 'In Country'}
|
||||
>
|
||||
<option value="All">All Visa Types</option>
|
||||
<option value="Residence Visa">Residence Visa</option>
|
||||
<option value="Tourist Visa">Tourist Visa</option>
|
||||
<option value="Employment Visa">Employment Visa</option>
|
||||
</FilterSelect>
|
||||
{filterInCountry !== 'In Country' && (
|
||||
<p className="text-[11px] text-slate-400 mt-1.5 font-medium">Select "In Country" to enable this filter</p>
|
||||
)}
|
||||
</div>
|
||||
</FilterSection>
|
||||
|
||||
{/* Skills */}
|
||||
{filtersMetadata.skills?.length > 1 && (
|
||||
<FilterSection label="Skills">
|
||||
<FilterCheckboxList
|
||||
items={filtersMetadata.skills.slice(1).map(s => s.toLowerCase())}
|
||||
selected={selectedSkills}
|
||||
onToggle={skill => toggleMultiSelect(skill, setSelectedSkills)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Languages */}
|
||||
{filtersMetadata.languages?.length > 1 && (
|
||||
<FilterSection label="Languages">
|
||||
<FilterCheckboxList
|
||||
items={filtersMetadata.languages.slice(1)}
|
||||
selected={selectedLanguages}
|
||||
onToggle={lang => toggleMultiSelect(lang, setSelectedLanguages)}
|
||||
/>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Salary Range */}
|
||||
<FilterSection label={maxSalary === 5000 ? "Max Salary — Any" : `Max Salary — ${maxSalary} AED`}>
|
||||
<input
|
||||
type="range"
|
||||
min="500"
|
||||
max="5000"
|
||||
step="100"
|
||||
value={maxSalary}
|
||||
onChange={e => setMaxSalary(Number(e.target.value))}
|
||||
className="w-full accent-[#185FA5]"
|
||||
/>
|
||||
<div className="flex justify-between text-[10px] font-bold text-slate-400 mt-1">
|
||||
<span>500 AED</span>
|
||||
<span>5,000 AED</span>
|
||||
</div>
|
||||
</FilterSection>
|
||||
</FilterDrawer>
|
||||
|
||||
{/* Filter and Search Panel */}
|
||||
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('search_by_name_skills_bio', 'Search by name, skills, bio...')}
|
||||
className="w-full pl-11 pr-4 py-3 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Sort */}
|
||||
<div className="flex items-center bg-slate-50 rounded-xl border border-slate-200 px-3 py-2 text-xs font-bold text-slate-700">
|
||||
<ArrowUpDown className="w-4 h-4 text-slate-400 mr-2" />
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
className="bg-transparent focus:outline-none cursor-pointer"
|
||||
>
|
||||
<option value="default">{t('default_match', 'Default Match')}</option>
|
||||
<option value="salary_asc">{t('salary_low_to_high', 'Salary: Low to High')}</option>
|
||||
<option value="salary_desc">{t('salary_high_to_low', 'Salary: High to Low')}</option>
|
||||
<option value="rating">{t('rating_high_to_low', 'Rating: High to Low')}</option>
|
||||
<option value="experience">{t('experience_level', 'Experience Level')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Reset */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetFilters}
|
||||
className="flex items-center justify-center space-x-1.5 px-3 py-2.5 rounded-xl border border-slate-200 hover:bg-slate-50 text-xs font-semibold text-slate-600 transition-colors"
|
||||
title="Reset all filters"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
|
||||
{/* Filter Drawer button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className={`relative flex items-center space-x-2 px-4 py-2.5 rounded-xl border transition-all text-xs font-bold ${
|
||||
activeFilterCount > 0
|
||||
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-200'
|
||||
: 'border-slate-200 hover:bg-slate-50 text-slate-600 bg-white'
|
||||
}`}
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
<span>{t('filters', 'Filters')}</span>
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="bg-white/25 text-white text-[10px] font-black px-1.5 py-0.5 rounded-full leading-none">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active filter tags */}
|
||||
{activeFilterTags.length > 0 && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-100 flex flex-wrap gap-2 items-center">
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest mr-1">Active:</span>
|
||||
{activeFilterTags.map(tag => (
|
||||
<button
|
||||
key={tag.key}
|
||||
onClick={tag.clear}
|
||||
className="inline-flex items-center space-x-1.5 px-2.5 py-1 bg-[#185FA5]/10 text-[#185FA5] text-[11px] font-bold rounded-full border border-[#185FA5]/20 hover:bg-[#185FA5]/20 transition-all"
|
||||
>
|
||||
<span>{tag.label}</span>
|
||||
<span className="text-[#185FA5]/60 font-black">×</span>
|
||||
</button>
|
||||
))}
|
||||
<button onClick={resetFilters} className="text-[11px] font-bold text-rose-500 hover:text-rose-700 ml-1 transition-colors">Clear all</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Listing metadata bar */}
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<div className="text-xs font-semibold text-slate-500">
|
||||
{t('found_workers_matching', 'Found {count} workers matching your preferences')
|
||||
.split('{count}')[0]}
|
||||
<span className="text-slate-900 font-bold">{filteredWorkers.length}</span>
|
||||
{t('found_workers_matching', 'Found {count} workers matching your preferences')
|
||||
.split('{count}')[1] || ''}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-xs font-medium text-slate-600">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span>{t('direct_sponsoring_active', 'Direct Sponsorship Active')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{workers.length > 0 ? (
|
||||
filteredWorkers.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredWorkers.map((worker) => {
|
||||
const isComparing = comparisonIds.includes(worker.id);
|
||||
|
||||
return (
|
||||
<div key={worker.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all flex flex-col justify-between overflow-hidden group relative">
|
||||
|
||||
{/* Card Header with Photo Option and Availability indicator */}
|
||||
<div className="bg-slate-50/50 p-6 border-b border-slate-100 flex items-start justify-between relative">
|
||||
<div className="flex items-center space-x-3.5 min-w-0 flex-1">
|
||||
{/* Photo (optional) container */}
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-100 text-[#185FA5] flex items-center justify-center font-bold text-xl border border-blue-200 shadow-inner flex-shrink-0 overflow-hidden relative">
|
||||
{worker.photo ? (
|
||||
<img src={worker.photo} alt={worker.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User className="w-6 h-6 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
{/* Card Header with Photo Option and Availability indicator */}
|
||||
<div className="bg-slate-50/40 p-4 sm:p-5 pb-3 border-b border-slate-100 flex items-start justify-between relative">
|
||||
<div className="flex items-center space-x-3 min-w-0 flex-1">
|
||||
{/* Photo (optional) container */}
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-[#185FA5] flex items-center justify-center font-bold text-lg border border-blue-100 shadow-inner flex-shrink-0 overflow-hidden relative">
|
||||
{worker.photo ? (
|
||||
<img src={worker.photo} alt={worker.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-extrabold text-base text-slate-900 group-hover:text-[#185FA5] transition-colors truncate flex items-center space-x-1.5">
|
||||
<span>{worker.name}</span>
|
||||
{false && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider bg-emerald-500 text-white shadow-xs gap-0.5" title="OCR Verified">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
<span>{t('verified', 'Verified')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="font-extrabold text-sm sm:text-base text-slate-900 group-hover:text-[#185FA5] transition-colors truncate flex items-center gap-1.5 flex-wrap">
|
||||
<span>{worker.name}</span>
|
||||
{worker.main_profession && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[8px] font-black uppercase tracking-wider bg-blue-50 text-[#185FA5] border border-blue-100">
|
||||
{worker.main_profession}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Passport Status Verification Badge & Visa Status */}
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{/* Removed visa expiry warning badge for cleaner UI */}
|
||||
{worker.visa_status && (worker.visa_status.toLowerCase().includes('tourist') || worker.visa_status.toLowerCase().includes('visit')) ? (
|
||||
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-600 flex-shrink-0 animate-bounce" />
|
||||
<span>{worker.visa_status} ({t('warning', 'Warning')})</span>
|
||||
</div>
|
||||
) : worker.visa_status && worker.visa_status.toLowerCase().includes('cancelled') ? (
|
||||
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-rose-700 bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200 animate-pulse">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-600 flex-shrink-0" />
|
||||
<span>{worker.visa_status} ({t('warning', 'Warning')})</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100">
|
||||
<span>{worker.visa_status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Passport Status Verification Badge & Visa Status */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{worker.visa_status && (worker.visa_status.toLowerCase().includes('tourist') || worker.visa_status.toLowerCase().includes('visit')) ? (
|
||||
<div className="flex items-center space-x-0.5 text-[8px] font-black uppercase text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
|
||||
<AlertTriangle className="w-2.5 h-2.5 text-amber-600 flex-shrink-0 animate-bounce" />
|
||||
<span>{worker.visa_status}</span>
|
||||
</div>
|
||||
) : worker.visa_status && worker.visa_status.toLowerCase().includes('cancelled') ? (
|
||||
<div className="flex items-center space-x-0.5 text-[8px] font-black uppercase text-rose-700 bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200 animate-pulse">
|
||||
<AlertTriangle className="w-2.5 h-2.5 text-rose-600 flex-shrink-0" />
|
||||
<span>{worker.visa_status}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-0.5 text-[8px] font-black uppercase text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100">
|
||||
<span>{worker.visa_status}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1.5 text-xs text-slate-500 mt-1">
|
||||
<Globe2 className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="font-bold">{worker.nationality}</span>
|
||||
<span>•</span>
|
||||
<span>{worker.age} yrs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1 text-[10px] text-slate-500 font-bold">
|
||||
<Globe2 className="w-3 h-3 text-slate-400" />
|
||||
<span>{worker.nationality}</span>
|
||||
<span>•</span>
|
||||
<span>{worker.age} yrs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Right Availability status and trash remove button */}
|
||||
<div className="flex flex-col items-end space-y-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeWorker(worker.id)}
|
||||
className="p-2 bg-rose-50 text-rose-600 hover:bg-rose-100 rounded-xl transition-colors border border-rose-100"
|
||||
title={t('remove_from_shortlist', 'Remove from shortlist')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Top Right Actions & Salary */}
|
||||
<div className="flex flex-col items-end justify-between self-stretch flex-shrink-0 ml-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeWorker(worker.id)}
|
||||
className="p-1.5 bg-rose-50 hover:bg-rose-100 border border-rose-100 text-rose-600 rounded-lg transition-colors"
|
||||
title={t('remove_from_shortlist', 'Remove from shortlist')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<div className="text-right mt-auto pt-2">
|
||||
<div className="text-[#185FA5] text-base font-extrabold tracking-tight">
|
||||
{worker.salary} <span className="text-[10px] font-bold text-slate-500">AED</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-slate-400 font-bold mt-[-2px]">/mo</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-6 space-y-4 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
{/* Card Body */}
|
||||
<div className="p-4 sm:p-5 pt-3 space-y-3 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-3">
|
||||
{/* Details Table */}
|
||||
<div className="grid grid-cols-2 gap-2 p-2.5 bg-slate-50/70 border border-slate-100/50 rounded-xl text-[10px] text-slate-600 font-bold">
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Briefcase className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate">Exp: {worker.experience}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Sparkles className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate uppercase text-[9px]">{worker.preferred_job_type}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<MapPin className="w-3 h-3 text-[#185FA5] flex-shrink-0" />
|
||||
<span className="truncate text-[#185FA5]">{worker.preferred_location || 'Not Specified'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||
<span className="truncate">{worker.rating || '4.5'} ({worker.reviews_count || '12'})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ratings & Cost */}
|
||||
<div className="flex items-center justify-end text-xs">
|
||||
<div className="flex items-center space-x-1 font-bold text-slate-800">
|
||||
<DollarSign className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-sm font-black">{worker.salary} AED</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium">/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Core exact platform Skills List */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('platform_skills', 'Platform Skills')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{worker.skills?.slice(0, 3).map(skill => (
|
||||
<span key={skill} className="px-2 py-0.5 bg-blue-50 border border-blue-100 text-[#185FA5] text-[8px] font-black rounded-md uppercase tracking-wider">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{worker.skills?.length > 3 && (
|
||||
<span className="px-1.5 py-0.5 bg-slate-50 border border-slate-200 text-slate-500 text-[8px] font-black rounded-md">
|
||||
+{worker.skills.length - 3} More
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Languages Badges with Visual Flags */}
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('languages_spoken', 'Languages Spoken')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{worker.languages?.map(lang => (
|
||||
<span key={lang} className="text-[8px] bg-slate-50 text-slate-600 px-2 py-0.5 rounded font-bold uppercase flex items-center space-x-1 border border-slate-200">
|
||||
<span>{getLanguageFlag(lang)}</span>
|
||||
<span>{lang}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Table */}
|
||||
<div className="grid grid-cols-2 gap-2.5 p-3.5 bg-slate-50 rounded-xl text-[11px] text-slate-600 font-bold">
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Briefcase className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate">Exp: {worker.experience}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Sparkles className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate uppercase text-[10px]">{worker.preferred_job_type}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#185FA5] flex-shrink-0" />
|
||||
<span className="truncate text-[#185FA5]">{worker.preferred_location || 'Not Specified'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Star className="w-3.5 h-3.5 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||
<span className="truncate">{worker.rating || '4.5'} ({worker.reviews_count || '12'})</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions block */}
|
||||
<div className="pt-3 border-t border-slate-100 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewWorker(worker)}
|
||||
className="w-full bg-slate-50 border border-slate-200 hover:bg-slate-100 text-slate-700 rounded-xl h-9 font-bold text-xs flex items-center justify-center transition-colors space-x-1"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>{t('quick_preview', 'Quick Preview')}</span>
|
||||
</button>
|
||||
|
||||
{/* Core exact platform Skills List */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">{t('platform_skills', 'Platform Skills')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{worker.skills?.map(skill => (
|
||||
<span key={skill} className="px-2.5 py-0.5 bg-blue-50 border border-blue-100 text-[#185FA5] text-[9px] font-black rounded-lg uppercase tracking-wider">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleComparison(worker.id)}
|
||||
className={`w-full rounded-xl h-9 font-bold text-xs flex items-center justify-center transition-colors border ${
|
||||
isComparing
|
||||
? 'bg-purple-600 border-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-white border-slate-200 hover:bg-slate-50 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isComparing ? t('comparing_active', 'Comparing ✓') : t('compare_candidate', 'Compare')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Languages Badges with Visual Flags */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">{t('languages_spoken', 'Languages Spoken')}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{worker.languages?.map(lang => (
|
||||
<span key={lang} className="text-[9px] bg-slate-100 text-slate-700 px-2 py-0.5 rounded font-bold uppercase flex items-center space-x-1 border border-slate-200">
|
||||
<span>{getLanguageFlag(lang)}</span>
|
||||
<span>{lang}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions block */}
|
||||
<div className="pt-4 border-t border-slate-100 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewWorker(worker)}
|
||||
className="w-full bg-slate-50 border border-slate-200 hover:bg-slate-100 text-slate-700 rounded-xl h-10 font-bold text-xs flex items-center justify-center transition-colors space-x-1"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>{t('quick_preview', 'Quick Preview')}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleComparison(worker.id)}
|
||||
className={`w-full rounded-xl h-10 font-bold text-xs flex items-center justify-center transition-colors border ${
|
||||
isComparing
|
||||
? 'bg-purple-600 border-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-white border-slate-200 hover:bg-slate-50 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isComparing ? t('comparing_active', 'Comparing ✓') : t('compare_candidate', 'Compare Candidate')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Link
|
||||
href={`/employer/workers/${worker.id}`}
|
||||
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl h-10 font-bold text-xs flex items-center justify-center transition-colors"
|
||||
>
|
||||
{t('open_profile', 'Open Profile')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/employer/messages/${worker.id}`}
|
||||
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl h-10 font-bold text-xs flex items-center justify-center space-x-1.5 transition-colors shadow-xs"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>{t('message', 'Message')}</span>
|
||||
</Link>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Link
|
||||
href={`/employer/workers/${worker.id}`}
|
||||
className="w-full bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl h-9 font-bold text-xs flex items-center justify-center transition-colors"
|
||||
>
|
||||
{t('open_profile', 'Open Profile')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/employer/messages/${worker.id}`}
|
||||
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl h-9 font-bold text-xs flex items-center justify-center space-x-1.5 transition-colors shadow-xs"
|
||||
>
|
||||
<MessageSquare className="w-4 h-4" />
|
||||
<span>{t('message', 'Message')}</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -269,8 +726,23 @@ export default function Shortlist({ shortlistedWorkers }) {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white rounded-2xl border border-slate-200 shadow-sm space-y-3 w-full col-span-full">
|
||||
<SlidersHorizontal className="w-12 h-12 text-slate-300 mx-auto" />
|
||||
<div className="text-base font-bold text-slate-800">{t('no_matching_results', 'No matching saved workers')}</div>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
{t('no_matching_results_desc', 'Try clearing or modifying your filters to find your saved candidates.')}
|
||||
</p>
|
||||
<button
|
||||
onClick={resetFilters}
|
||||
className="inline-block mt-2 bg-[#185FA5] text-white px-6 py-2.5 rounded-xl text-xs font-bold shadow-sm hover:bg-[#144f8a] transition-colors"
|
||||
>
|
||||
{t('clear_all_filters', 'Clear All Filters')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="text-center py-16 bg-white rounded-2xl border border-slate-200 shadow-sm space-y-3">
|
||||
<div className="text-center py-16 bg-white rounded-2xl border border-slate-200 shadow-sm space-y-3 w-full col-span-full">
|
||||
<SlidersHorizontal className="w-12 h-12 text-slate-300 mx-auto" />
|
||||
<div className="text-base font-bold text-slate-800">{t('shortlist_empty', 'Your shortlist is empty')}</div>
|
||||
<p className="text-xs text-slate-500 max-w-sm mx-auto">
|
||||
|
||||
@ -21,8 +21,9 @@ import {
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import axios from 'axios';
|
||||
|
||||
export default function Subscription({ currentPlan, expiresAt, plans }) {
|
||||
export default function Subscription({ currentPlan, expiresAt, plans, invoices: initialInvoices }) {
|
||||
const { t } = useTranslation();
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [showPayTabsModal, setShowPayTabsModal] = useState(false);
|
||||
@ -40,11 +41,13 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
||||
const [activePlan, setActivePlan] = useState(currentPlan || 'Premium Employer Pass');
|
||||
|
||||
// Payment History List
|
||||
const [invoices, setInvoices] = useState([
|
||||
{ id: 'INV-2026-001', date: 'May 01, 2026', amount: '199 AED', status: 'Paid', plan: 'Premium Employer Pass' },
|
||||
{ id: 'INV-2026-002', date: 'Apr 01, 2026', amount: '199 AED', status: 'Paid', plan: 'Premium Employer Pass' },
|
||||
{ id: 'INV-2026-003', date: 'Mar 01, 2026', amount: '99 AED', status: 'Refunded', plan: 'Basic Search' }
|
||||
]);
|
||||
const [invoices, setInvoices] = useState(initialInvoices || []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialInvoices) {
|
||||
setInvoices(initialInvoices);
|
||||
}
|
||||
}, [initialInvoices]);
|
||||
|
||||
const handleSelectPlan = (plan) => {
|
||||
setSelectedPlan(plan);
|
||||
@ -59,32 +62,35 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowPayTabsModal(false);
|
||||
setShowSuccess(true);
|
||||
setActivePlan(selectedPlan.name);
|
||||
axios.post(route('employer.subscription.purchase'), {
|
||||
plan_id: selectedPlan.id
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.success) {
|
||||
setShowPayTabsModal(false);
|
||||
setShowSuccess(true);
|
||||
if (response.data.currentPlan) {
|
||||
setActivePlan(response.data.currentPlan);
|
||||
}
|
||||
setInvoices(response.data.invoices);
|
||||
|
||||
// Add to invoices
|
||||
const newInvoice = {
|
||||
id: `INV-2026-0${invoices.length + 1}`,
|
||||
date: 'Today',
|
||||
amount: selectedPlan.price,
|
||||
status: 'Paid',
|
||||
plan: selectedPlan.name
|
||||
};
|
||||
setInvoices([newInvoice, ...invoices]);
|
||||
toast.success(`🎉 ${t('payment_approved', 'Payment Approved by PayTabs!')}`, {
|
||||
description: t('successfully_subscribed_desc', 'Successfully subscribed to {plan}. Premium access limits updated instantly.').replace('{plan}', selectedPlan.name),
|
||||
duration: 5000,
|
||||
});
|
||||
|
||||
toast.success(`🎉 ${t('payment_approved', 'Payment Approved by PayTabs!')}`, {
|
||||
description: t('successfully_subscribed_desc', 'Successfully subscribed to {plan}. Premium access limits updated instantly.').replace('{plan}', selectedPlan.name),
|
||||
duration: 5000,
|
||||
// Reset forms
|
||||
setCardName('');
|
||||
setCardNumber('');
|
||||
setCardExpiry('');
|
||||
setCardCvv('');
|
||||
|
||||
setTimeout(() => setShowSuccess(false), 5000);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
toast.error(error.response?.data?.error || 'Failed to complete subscription upgrade');
|
||||
});
|
||||
|
||||
// Reset forms
|
||||
setCardName('');
|
||||
setCardNumber('');
|
||||
setCardExpiry('');
|
||||
setCardCvv('');
|
||||
|
||||
setTimeout(() => setShowSuccess(false), 5000);
|
||||
};
|
||||
|
||||
const triggerFailedPaymentSimulation = () => {
|
||||
@ -239,7 +245,7 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
||||
{/* Payment History & Invoice logs */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 pt-4">
|
||||
{/* Invoice Logs */}
|
||||
<div className="lg:col-span-8 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
||||
<div className="lg:col-span-12 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<FileText className="w-5 h-5 text-[#185FA5]" />
|
||||
@ -251,19 +257,24 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
||||
<div className="divide-y divide-slate-100">
|
||||
{invoices.map((inv) => (
|
||||
<div key={inv.id} className="flex items-center justify-between py-4 text-xs font-semibold text-slate-700">
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<div className="font-bold text-slate-800 flex items-center space-x-1.5">
|
||||
<span>{inv.plan}</span>
|
||||
<span>{inv.plan_name}</span>
|
||||
<span className="text-[9px] text-slate-400 font-mono">({inv.id})</span>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-medium">Billed: {inv.date}</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-1 text-[10px] text-slate-400 font-medium">
|
||||
<div>{t('purchase_date', 'Purchased')}: <span className="font-semibold text-slate-600">{inv.purchase_date}</span></div>
|
||||
<div>{t('activation_date', 'Activated')}: <span className="font-semibold text-slate-600">{inv.activation_date}</span></div>
|
||||
<div>{t('expiry_date', 'Expires')}: <span className="font-semibold text-slate-600">{inv.expiry_date}</span></div>
|
||||
<div>{t('payment_status', 'Payment')}: <span className="font-semibold text-slate-600">{inv.payment_status}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-6">
|
||||
<div className="flex items-center space-x-6 ml-4">
|
||||
<div className="text-right">
|
||||
<div className="font-extrabold text-slate-900">{inv.amount}</div>
|
||||
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded ${
|
||||
inv.status === 'Paid' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
|
||||
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded inline-block mt-0.5 ${
|
||||
inv.status === 'Active' ? 'bg-emerald-50 text-emerald-700' : (inv.status === 'Pending' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-600')
|
||||
}`}>
|
||||
{inv.status}
|
||||
</span>
|
||||
@ -281,42 +292,6 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sponsor Billing Details Form */}
|
||||
<div className="lg:col-span-4 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-extrabold text-base text-slate-900">{t('corporate_details', 'Corporate Details')}</h3>
|
||||
<p className="text-xs text-slate-500 font-medium">{t('update_tax_invoicing', 'Update tax invoicing options.')}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-xs font-bold text-slate-700">
|
||||
<div>
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px] mb-1.5">{t('sponsor_email_address', 'Sponsor Email Address')}</label>
|
||||
<input
|
||||
type="email"
|
||||
value={billingEmail}
|
||||
onChange={(e) => setBillingEmail(e.target.value)}
|
||||
className="w-full p-2.5 border border-slate-200 rounded-xl bg-slate-50/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px] mb-1.5">{t('billing_vat_id', 'Billing VAT Registry ID (Optional)')}</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. TRN 100293810200003"
|
||||
className="w-full p-2.5 border border-slate-200 rounded-xl bg-slate-50/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => toast.success(t('corporate_billing_updated', 'Corporate billing details updated.'))}
|
||||
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl py-2.5 font-bold text-xs"
|
||||
>
|
||||
{t('save_billing_profile', 'Save Billing Profile')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@ -47,6 +47,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const { t } = useTranslation();
|
||||
// Basic Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedProfession, setSelectedProfession] = useState('All Professions');
|
||||
const [selectedNationalities, setSelectedNationalities] = useState([]);
|
||||
const [selectedGender, setSelectedGender] = useState('All Genders');
|
||||
const [selectedExperience, setSelectedExperience] = useState('All Experience');
|
||||
@ -108,9 +109,13 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
const outCountryVal = params.get('out_country');
|
||||
const visa = params.get('visa_status') || params.get('next_visa_type') || params.get('visa_type');
|
||||
const gender = params.get('gender');
|
||||
const prof = params.get('main_profession') || params.get('profession');
|
||||
|
||||
let hasAdvanced = false;
|
||||
|
||||
if (prof) {
|
||||
setSelectedProfession(prof);
|
||||
}
|
||||
if (nat) {
|
||||
setSelectedNationalities(nat.split(',').map(x => x.trim()).filter(Boolean));
|
||||
}
|
||||
@ -196,6 +201,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
|
||||
const resetFilters = () => {
|
||||
setSearchQuery('');
|
||||
setSelectedProfession('All Professions');
|
||||
setSelectedNationalities([]);
|
||||
setSelectedGender('All Genders');
|
||||
setSelectedExperience('All Experience');
|
||||
@ -221,13 +227,14 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
if (filterAccommodation !== 'All') count++;
|
||||
if (selectedNationalities.length > 0) count++;
|
||||
if (selectedGender !== 'All Genders') count++;
|
||||
if (selectedProfession !== 'All Professions') count++;
|
||||
if (filterInCountry !== 'All') count++;
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
|
||||
if (selectedSkills.length > 0) count++;
|
||||
if (selectedLanguages.length > 0) count++;
|
||||
if (maxSalary < 5000) count++;
|
||||
return count;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, selectedProfession, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
|
||||
const activeFilterTags = useMemo(() => {
|
||||
const tags = [];
|
||||
@ -236,13 +243,14 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') });
|
||||
if (selectedNationalities.length > 0) tags.push({ key: 'nat', label: `🌍 ${selectedNationalities.length} Nat`, clear: () => setSelectedNationalities([]) });
|
||||
if (selectedGender !== 'All Genders') tags.push({ key: 'gender', label: `🚻 ${selectedGender}`, clear: () => setSelectedGender('All Genders') });
|
||||
if (selectedProfession !== 'All Professions') tags.push({ key: 'profession', label: `🧑🔧 ${selectedProfession}`, clear: () => setSelectedProfession('All Professions') });
|
||||
if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } });
|
||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') });
|
||||
if (selectedSkills.length > 0) tags.push({ key: 'skills', label: `🛠 ${selectedSkills.length} skill${selectedSkills.length > 1 ? 's' : ''}`, clear: () => setSelectedSkills([]) });
|
||||
if (selectedLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${selectedLanguages.length} lang`, clear: () => setSelectedLanguages([]) });
|
||||
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
|
||||
return tags;
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, selectedProfession, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||
|
||||
// Filter and Sort Worker List
|
||||
const filteredWorkers = useMemo(() => {
|
||||
@ -257,6 +265,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
}
|
||||
|
||||
// Dropdown filters
|
||||
if (selectedProfession !== 'All Professions' && (!worker.main_profession || worker.main_profession.toLowerCase() !== selectedProfession.toLowerCase())) return false;
|
||||
if (selectedNationalities.length > 0 && (!worker.nationality || !selectedNationalities.map(n => n.toLowerCase()).includes(worker.nationality.toLowerCase()))) return false;
|
||||
if (selectedGender !== 'All Genders' && worker.gender && worker.gender.toLowerCase() !== selectedGender.toLowerCase()) return false;
|
||||
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
|
||||
@ -332,6 +341,7 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
}, [
|
||||
initialWorkers,
|
||||
searchQuery,
|
||||
selectedProfession,
|
||||
selectedNationalities,
|
||||
selectedGender,
|
||||
selectedExperience,
|
||||
@ -400,6 +410,23 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
activeCount={activeFilterCount}
|
||||
title="Filter Workers"
|
||||
>
|
||||
{/* Profession */}
|
||||
{filtersMetadata.professions && (
|
||||
<FilterSection label="Profession">
|
||||
<FilterSelect
|
||||
id="filter_main_profession"
|
||||
value={selectedProfession}
|
||||
onChange={e => setSelectedProfession(e.target.value)}
|
||||
>
|
||||
{filtersMetadata.professions.map(prof => (
|
||||
<option key={prof} value={prof}>
|
||||
{prof === 'All Professions' ? 'All Professions' : prof}
|
||||
</option>
|
||||
))}
|
||||
</FilterSelect>
|
||||
</FilterSection>
|
||||
)}
|
||||
|
||||
{/* Preferred Location */}
|
||||
<FilterSection label="Preferred Location">
|
||||
<FilterSelect
|
||||
@ -493,8 +520,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
<option value="Residence Visa">Residence Visa</option>
|
||||
<option value="Tourist Visa">Tourist Visa</option>
|
||||
<option value="Employment Visa">Employment Visa</option>
|
||||
<option value="Cancelled Visa">Cancelled Visa</option>
|
||||
<option value="Own Visa">Own Visa</option>
|
||||
</FilterSelect>
|
||||
{filterInCountry !== 'In Country' && (
|
||||
<p className="text-[11px] text-slate-400 mt-1.5 font-medium">Select "In Country" to enable this filter</p>
|
||||
@ -648,142 +673,140 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
<div key={worker.id} className="bg-white rounded-2xl border border-slate-200 shadow-sm hover:shadow-md transition-all flex flex-col justify-between overflow-hidden group relative">
|
||||
|
||||
{/* Card Header with Photo Option and Availability indicator */}
|
||||
<div className="bg-slate-50/50 p-6 border-b border-slate-100 flex items-start justify-between relative">
|
||||
<div className="flex items-center space-x-3.5 min-w-0 flex-1">
|
||||
<div className="bg-slate-50/40 p-4 sm:p-5 pb-3 border-b border-slate-100 flex items-start justify-between relative">
|
||||
<div className="flex items-center space-x-3 min-w-0 flex-1">
|
||||
{/* Photo (optional) container */}
|
||||
<div className="w-14 h-14 rounded-2xl bg-blue-100 text-[#185FA5] flex items-center justify-center font-bold text-xl border border-blue-200 shadow-inner flex-shrink-0 overflow-hidden relative">
|
||||
<div className="w-12 h-12 rounded-xl bg-blue-50 text-[#185FA5] flex items-center justify-center font-bold text-lg border border-blue-100 shadow-inner flex-shrink-0 overflow-hidden relative">
|
||||
{worker.photo ? (
|
||||
<img src={worker.photo} alt={worker.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<User className="w-6 h-6 text-slate-400" />
|
||||
<User className="w-5 h-5 text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-extrabold text-base text-slate-900 group-hover:text-[#185FA5] transition-colors truncate flex items-center space-x-1.5">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="font-extrabold text-sm sm:text-base text-slate-900 group-hover:text-[#185FA5] transition-colors truncate flex items-center gap-1.5 flex-wrap">
|
||||
<span>{worker.name}</span>
|
||||
{false && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[8px] font-black uppercase tracking-wider bg-emerald-500 text-white shadow-xs gap-0.5 animate-pulse" title="OCR Verified">
|
||||
<CheckCircle2 className="w-3 h-3" />
|
||||
<span>{t('verified')}</span>
|
||||
{worker.main_profession && (
|
||||
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-[8px] font-black uppercase tracking-wider bg-blue-50 text-[#185FA5] border border-blue-100">
|
||||
{worker.main_profession}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Passport Status Verification Badge & Visa Status */}
|
||||
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||
{worker.visa_expiry_date ? (
|
||||
<div className={`flex items-center space-x-1 text-[9px] font-black uppercase px-1.5 py-0.5 rounded border ${
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{worker.visa_expiry_date ? (
|
||||
<div className={`flex items-center space-x-0.5 text-[8px] font-black uppercase px-1.5 py-0.5 rounded border ${
|
||||
worker.document_expiry_days !== null && worker.document_expiry_days <= 30
|
||||
? 'text-rose-700 bg-rose-50 border-rose-200 animate-pulse'
|
||||
: worker.document_expiry_days !== null && worker.document_expiry_days <= 90
|
||||
? 'text-amber-700 bg-amber-50 border-amber-200'
|
||||
: 'text-[#185FA5] bg-blue-50 border-blue-100'
|
||||
}`}>
|
||||
<Calendar className="w-3 h-3 flex-shrink-0" />
|
||||
<Calendar className="w-2.5 h-2.5 flex-shrink-0" />
|
||||
<span>Visa Exp: {worker.visa_expiry_date}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
|
||||
<Calendar className="w-3 h-3 flex-shrink-0 text-amber-600" />
|
||||
<div className="flex items-center space-x-0.5 text-[8px] font-black uppercase text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
|
||||
<Calendar className="w-2.5 h-2.5 flex-shrink-0 text-amber-600" />
|
||||
<span>Visa Expiry Pending</span>
|
||||
</div>
|
||||
)}
|
||||
{worker.visa_status && (worker.visa_status.toLowerCase().includes('tourist') || worker.visa_status.toLowerCase().includes('visit')) ? (
|
||||
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
|
||||
<AlertTriangle className="w-3 h-3 text-amber-600 flex-shrink-0 animate-bounce" />
|
||||
<span>{worker.visa_status} ({t('warning', 'Warning')})</span>
|
||||
<div className="flex items-center space-x-0.5 text-[8px] font-black uppercase text-amber-700 bg-amber-50 px-1.5 py-0.5 rounded border border-amber-200">
|
||||
<AlertTriangle className="w-2.5 h-2.5 text-amber-600 flex-shrink-0 animate-bounce" />
|
||||
<span>{worker.visa_status}</span>
|
||||
</div>
|
||||
) : worker.visa_status && worker.visa_status.toLowerCase().includes('cancelled') ? (
|
||||
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-rose-700 bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200 animate-pulse">
|
||||
<AlertTriangle className="w-3 h-3 text-rose-600 flex-shrink-0" />
|
||||
<span>{worker.visa_status} ({t('warning', 'Warning')})</span>
|
||||
<div className="flex items-center space-x-0.5 text-[8px] font-black uppercase text-rose-700 bg-rose-50 px-1.5 py-0.5 rounded border border-rose-200 animate-pulse">
|
||||
<AlertTriangle className="w-2.5 h-2.5 text-rose-600 flex-shrink-0" />
|
||||
<span>{worker.visa_status}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center space-x-1 text-[9px] font-black uppercase text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100">
|
||||
<div className="flex items-center space-x-0.5 text-[8px] font-black uppercase text-blue-700 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100">
|
||||
<span>{worker.visa_status}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Removed visa expiry status badge for cleaner UI */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-1.5 text-xs text-slate-500 mt-1">
|
||||
<Globe2 className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span className="font-bold">{worker.nationality}</span>
|
||||
<div className="flex items-center space-x-1 text-[10px] text-slate-500 font-bold">
|
||||
<Globe2 className="w-3 h-3 text-slate-400" />
|
||||
<span>{worker.nationality}</span>
|
||||
<span>•</span>
|
||||
<span>{worker.age} {t('yrs', 'yrs')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top Right Availability status and bookmark */}
|
||||
<div className="flex flex-col items-end space-y-2 flex-shrink-0 ml-4">
|
||||
{/* Top Right Actions & Salary */}
|
||||
<div className="flex flex-col items-end justify-between self-stretch flex-shrink-0 ml-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleShortlist(worker.id)}
|
||||
className={`p-2 rounded-xl transition-all ${
|
||||
className={`p-1.5 rounded-lg transition-all border ${
|
||||
isShortlisted
|
||||
? 'bg-blue-50 text-[#185FA5] hover:bg-blue-100'
|
||||
: 'bg-white text-slate-400 hover:text-[#185FA5] border border-slate-200'
|
||||
? 'bg-blue-50 border-blue-200 text-[#185FA5]'
|
||||
: 'bg-white hover:bg-slate-50 border-slate-200 text-slate-400 hover:text-[#185FA5] shadow-xs'
|
||||
}`}
|
||||
>
|
||||
<Bookmark className={`w-3.5 h-3.5 ${isShortlisted ? 'fill-[#185FA5]' : ''}`} />
|
||||
</button>
|
||||
<div className="text-right mt-auto pt-2">
|
||||
<div className="text-[#185FA5] text-base font-extrabold tracking-tight">
|
||||
{worker.salary} <span className="text-[10px] font-bold text-slate-500">{t('aed', 'AED')}</span>
|
||||
</div>
|
||||
<div className="text-[9px] text-slate-400 font-bold mt-[-2px]">/{t('mo', 'mo')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card Body */}
|
||||
<div className="p-6 space-y-4 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
|
||||
{/* Ratings & Cost */}
|
||||
<div className="flex items-center justify-end text-xs">
|
||||
<div className="flex items-center space-x-1 font-bold text-slate-800">
|
||||
<DollarSign className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-sm font-black">{worker.salary} {t('aed', 'AED')}</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium">/{t('mo', 'mo')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-4 sm:p-5 pt-3 space-y-3 flex-1 flex flex-col justify-between">
|
||||
<div className="space-y-3">
|
||||
{/* Details Table */}
|
||||
<div className="grid grid-cols-2 gap-2.5 p-3.5 bg-slate-50 rounded-xl text-[11px] text-slate-600 font-bold">
|
||||
<div className="grid grid-cols-2 gap-2 p-2.5 bg-slate-50/70 border border-slate-100/50 rounded-xl text-[10px] text-slate-600 font-bold">
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Briefcase className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
<Briefcase className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate">{t('experience', 'Exp')}: {worker.experience}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Sparkles className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate uppercase text-[10px]">{worker.preferred_job_type}</span>
|
||||
<Sparkles className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
||||
<span className="truncate uppercase text-[9px]">{worker.preferred_job_type}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<MapPin className="w-3.5 h-3.5 text-[#185FA5] flex-shrink-0" />
|
||||
<MapPin className="w-3 h-3 text-[#185FA5] flex-shrink-0" />
|
||||
<span className="truncate text-[#185FA5]">{worker.preferred_location || 'Not Specified'}</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1.5 truncate">
|
||||
<Star className="w-3.5 h-3.5 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||
<Star className="w-3 h-3 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||
<span className="truncate">{worker.rating} ({worker.reviews_count})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Core exact platform Skills List */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">{t('platform_skills', 'Platform Skills')}</div>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('platform_skills', 'Platform Skills')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{worker.skills?.map(skill => (
|
||||
<span key={skill} className="px-2.5 py-0.5 bg-blue-50 border border-blue-100 text-[#185FA5] text-[9px] font-black rounded-lg uppercase tracking-wider">
|
||||
{worker.skills?.slice(0, 3).map(skill => (
|
||||
<span key={skill} className="px-2 py-0.5 bg-blue-50 border border-blue-100 text-[#185FA5] text-[8px] font-black rounded-md uppercase tracking-wider">
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
{worker.skills?.length > 3 && (
|
||||
<span className="px-1.5 py-0.5 bg-slate-50 border border-slate-200 text-slate-500 text-[8px] font-black rounded-md">
|
||||
+{worker.skills.length - 3} More
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Languages Badges with Visual Flags */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">{t('languages_spoken', 'Languages Spoken')}</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('languages_spoken', 'Languages Spoken')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{worker.languages?.map(lang => (
|
||||
<span key={lang} className="text-[9px] bg-slate-100 text-slate-700 px-2 py-0.5 rounded font-bold uppercase flex items-center space-x-1 border border-slate-200">
|
||||
<span key={lang} className="text-[8px] bg-slate-50 text-slate-600 px-2 py-0.5 rounded font-bold uppercase flex items-center space-x-1 border border-slate-200">
|
||||
<span>{getLanguageFlag(lang)}</span>
|
||||
<span>{lang}</span>
|
||||
</span>
|
||||
@ -793,12 +816,12 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
</div>
|
||||
|
||||
{/* Actions block */}
|
||||
<div className="pt-4 border-t border-slate-100 space-y-2">
|
||||
<div className="pt-3 border-t border-slate-100 space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPreviewWorker(worker)}
|
||||
className="w-full bg-slate-50 border border-slate-200 hover:bg-slate-100 text-slate-700 rounded-xl h-10 font-bold text-xs flex items-center justify-center transition-colors space-x-1"
|
||||
className="w-full bg-slate-50 border border-slate-200 hover:bg-slate-100 text-slate-700 rounded-xl h-9 font-bold text-xs flex items-center justify-center transition-colors space-x-1"
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
<span>{t('quick_preview')}</span>
|
||||
@ -807,22 +830,21 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleComparison(worker.id)}
|
||||
className={`w-full rounded-xl h-10 font-bold text-xs flex items-center justify-center transition-colors border ${
|
||||
className={`w-full rounded-xl h-9 font-bold text-xs flex items-center justify-center transition-colors border ${
|
||||
isComparing
|
||||
? 'bg-purple-600 border-purple-600 text-white hover:bg-purple-700'
|
||||
: 'bg-white border-slate-200 hover:bg-slate-50 text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{isComparing ? t('comparing', 'Comparing ✓') : t('compare_candidate', 'Compare Candidate')}
|
||||
{isComparing ? t('comparing', 'Comparing ✓') : t('compare_candidate', 'Compare')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`/employer/workers/${worker.id}`}
|
||||
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl h-10 font-bold text-xs flex items-center justify-center transition-colors shadow-xs"
|
||||
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl h-9 font-bold text-xs flex items-center justify-center transition-colors shadow-xs"
|
||||
>
|
||||
View Profile
|
||||
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
@ -947,8 +969,12 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
||||
<div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<h4 className="font-extrabold text-lg text-slate-900">{previewWorker.name}</h4>
|
||||
|
||||
</div>
|
||||
{previewWorker.main_profession && (
|
||||
<div className="text-[10px] font-black text-[#185FA5] bg-blue-50 border border-blue-100 px-2 py-0.5 rounded-md inline-block w-fit mb-1">
|
||||
{previewWorker.main_profession}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{previewWorker.visa_expiry_date ? (
|
||||
<div className={`flex items-center space-x-1 font-black text-[9px] uppercase tracking-wider border px-1.5 py-0.5 rounded w-fit ${
|
||||
|
||||
@ -205,8 +205,12 @@ export default function Show({ worker }) {
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-slate-900">{worker.name}</h1>
|
||||
|
||||
</div>
|
||||
{worker.main_profession && (
|
||||
<div className="text-[11px] font-black text-[#185FA5] bg-blue-50 border border-blue-100 px-2.5 py-1 rounded-lg uppercase tracking-wider w-fit">
|
||||
{worker.main_profession}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Passport verification status bar & Visa Status */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@ -314,6 +318,16 @@ export default function Show({ worker }) {
|
||||
<span className="font-extrabold text-slate-900 text-xs">{worker.name}</span>
|
||||
</div>
|
||||
|
||||
{worker.main_profession && (
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-200">
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-600 font-bold">
|
||||
<Briefcase className="w-4 h-4 text-slate-500" />
|
||||
<span>{t('main_profession', 'Main Profession')}</span>
|
||||
</div>
|
||||
<span className="font-extrabold text-slate-900 text-xs">{worker.main_profession}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pb-3 border-b border-slate-200">
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-600 font-bold">
|
||||
<Phone className="w-4 h-4 text-slate-500" />
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
"find_workers": "Find Workers",
|
||||
"shortlist": "Shortlist",
|
||||
"candidates": "Hired Workers",
|
||||
"my_jobs": "My Jobs",
|
||||
"messages": "Messages",
|
||||
"charity_events": "Charity Events",
|
||||
"subscription": "Subscription",
|
||||
@ -425,5 +426,13 @@
|
||||
"share_experience_placeholder": "Tell other sponsors about childcare skills, cleaning, cooking style, driving safety, etc...",
|
||||
"post_trust_review_action": "Post Trust Review",
|
||||
"no_charity_events_title": "No Charity Events",
|
||||
"no_charity_events_desc": "You haven't posted any charity events yet"
|
||||
"no_charity_events_desc": "You haven't posted any charity events yet",
|
||||
"workers_group": "Workers",
|
||||
"workers_list": "Workers List",
|
||||
"hired_workers": "Hired Workers",
|
||||
"jobs_group": "Jobs",
|
||||
"applicants": "Applicants",
|
||||
"shortlisted_workers": "Shortlisted Workers",
|
||||
"help_support": "Help & Support",
|
||||
"payment_history": "Payment History"
|
||||
}
|
||||
@ -3,6 +3,7 @@
|
||||
"find_workers": "कामगार खोजें",
|
||||
"shortlist": "शॉर्टलिस्ट",
|
||||
"candidates": "नियुक्त कर्मचारी",
|
||||
"my_jobs": "मेरी नौकरियाँ",
|
||||
"messages": "संदेश",
|
||||
"charity_events": "दान कार्यक्रम",
|
||||
"subscription": "सदस्यता",
|
||||
@ -424,5 +425,13 @@
|
||||
"share_experience_placeholder": "अन्य प्रायोजकों को बाल देखभाल कौशल, सफाई, खाना पकाने की शैली, ड्राइविंग सुरक्षा आदि के बारे में बताएं...",
|
||||
"post_trust_review_action": "समीक्षा पोस्ट करें",
|
||||
"no_charity_events_title": "कोई चैरिटी कार्यक्रम नहीं",
|
||||
"no_charity_events_desc": "आपने अभी तक कोई चैरिटी कार्यक्रम पोस्ट नहीं किया है"
|
||||
"no_charity_events_desc": "आपने अभी तक कोई चैरिटी कार्यक्रम पोस्ट नहीं किया है",
|
||||
"workers_group": "कामगार",
|
||||
"workers_list": "कामगारों की सूची",
|
||||
"hired_workers": "नियुक्त कर्मचारी",
|
||||
"jobs_group": "नौकरियाँ",
|
||||
"applicants": "आवेदक",
|
||||
"shortlisted_workers": "शॉर्टलिस्ट किए गए कामगार",
|
||||
"help_support": "सहायता और समर्थन",
|
||||
"payment_history": "भुगतान इतिहास"
|
||||
}
|
||||
@ -13,6 +13,8 @@
|
||||
use App\Http\Controllers\Api\SponsorAuthController;
|
||||
use App\Http\Controllers\Api\SponsorController;
|
||||
use App\Http\Controllers\Api\GoogleVisionOcrController;
|
||||
use App\Http\Controllers\Api\WorkerNotificationController;
|
||||
use App\Http\Controllers\Api\EmployerNotificationController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -75,6 +77,8 @@
|
||||
Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']);
|
||||
Route::get('/workers/dashboard/views', [WorkerProfileController::class, 'getProfileViews']);
|
||||
Route::get('/workers/dashboard', [WorkerProfileController::class, 'getDashboard']);
|
||||
Route::get('/workers/employers', [WorkerProfileController::class, 'getEmployers']);
|
||||
Route::get('/workers/employers/{id}', [WorkerProfileController::class, 'getEmployerProfile']);
|
||||
Route::post('/workers/change-password', [WorkerProfileController::class, 'changePassword']);
|
||||
|
||||
|
||||
@ -103,6 +107,27 @@
|
||||
Route::post('/workers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromWorker']);
|
||||
Route::post('/workers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromWorker']);
|
||||
Route::get('/workers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
|
||||
|
||||
// Job Management (Worker)
|
||||
Route::get('/workers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'listJobs']);
|
||||
Route::get('/workers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'jobDetails']);
|
||||
Route::post('/workers/jobs/{id}/apply', [\App\Http\Controllers\Api\WorkerJobController::class, 'applyJob']);
|
||||
Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']);
|
||||
Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']);
|
||||
Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']);
|
||||
|
||||
// Review Management (Worker reviewing Employer)
|
||||
Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']);
|
||||
Route::post('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'addReview']);
|
||||
Route::get('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'viewReview']);
|
||||
Route::put('/workers/reviews/{id}', [\App\Http\Controllers\Api\WorkerReviewController::class, 'editReview']);
|
||||
|
||||
// Notification Management (Worker)
|
||||
Route::get('/workers/notifications', [WorkerNotificationController::class, 'index']);
|
||||
Route::put('/workers/notifications/read', [WorkerNotificationController::class, 'markAsRead']);
|
||||
Route::put('/workers/notifications/{id}/read', [WorkerNotificationController::class, 'markAsRead']);
|
||||
Route::delete('/workers/notifications', [WorkerNotificationController::class, 'destroy']);
|
||||
Route::delete('/workers/notifications/{id}', [WorkerNotificationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token)
|
||||
@ -153,6 +178,22 @@
|
||||
Route::post('/employers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromEmployer']);
|
||||
Route::post('/employers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromEmployer']);
|
||||
Route::get('/employers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
|
||||
|
||||
// Job & Applicant Management (Employer)
|
||||
Route::get('/employers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerListJobs']);
|
||||
Route::post('/employers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerCreateJob']);
|
||||
Route::get('/employers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerJobDetail']);
|
||||
Route::post('/employers/jobs/{id}/update', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerUpdateJob']);
|
||||
Route::delete('/employers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerDeleteJob']);
|
||||
Route::get('/employers/jobs/{id}/applicants', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerJobApplicants']);
|
||||
Route::post('/employers/applications/{id}/status', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerUpdateApplicationStatus']);
|
||||
|
||||
// Notification Management (Employer)
|
||||
Route::get('/employers/notifications', [EmployerNotificationController::class, 'index']);
|
||||
Route::put('/employers/notifications/read', [EmployerNotificationController::class, 'markAsRead']);
|
||||
Route::put('/employers/notifications/{id}/read', [EmployerNotificationController::class, 'markAsRead']);
|
||||
Route::delete('/employers/notifications', [EmployerNotificationController::class, 'destroy']);
|
||||
Route::delete('/employers/notifications/{id}', [EmployerNotificationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token)
|
||||
|
||||
@ -2,7 +2,10 @@
|
||||
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
|
||||
Artisan::command('inspire', function () {
|
||||
$this->comment(Inspiring::quote());
|
||||
})->purpose('Display an inspiring quote');
|
||||
|
||||
Schedule::command('app:send-reminders')->daily();
|
||||
|
||||
168
routes/web.php
168
routes/web.php
@ -76,9 +76,97 @@
|
||||
Route::delete('/charity-organizations/{id}', [\App\Http\Controllers\Admin\SponsorController::class, 'delete'])->name('admin.charity-organizations.delete');
|
||||
|
||||
Route::get('/subscriptions', function () {
|
||||
return Inertia::render('Admin/Subscriptions/Index');
|
||||
$plans = \App\Models\Plan::all()->map(function ($plan) {
|
||||
$plan->usage_count = \App\Models\Sponsor::where('subscription_plan', $plan->id)
|
||||
->where('subscription_status', 'active')
|
||||
->count();
|
||||
return $plan;
|
||||
});
|
||||
return Inertia::render('Admin/Subscriptions/Index', [
|
||||
'plans' => $plans
|
||||
]);
|
||||
})->name('admin.subscriptions');
|
||||
|
||||
Route::post('/subscriptions', function (\Illuminate\Http\Request $request) {
|
||||
$data = $request->validate([
|
||||
'id' => 'required|string|unique:plans,id',
|
||||
'name' => 'required|string|max:255',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'duration' => 'required|string',
|
||||
'features' => 'required|array',
|
||||
'max_contact_people' => 'nullable|integer',
|
||||
'unlimited_contacts' => 'required|boolean',
|
||||
'enable_job_apply' => 'required|boolean',
|
||||
'status' => 'nullable|string|in:Active,Disabled',
|
||||
]);
|
||||
|
||||
\App\Models\Plan::create([
|
||||
'id' => $data['id'],
|
||||
'name' => $data['name'],
|
||||
'price' => $data['price'],
|
||||
'duration' => $data['duration'],
|
||||
'features' => $data['features'],
|
||||
'status' => $data['status'] ?? 'Active',
|
||||
'max_contact_people' => $data['unlimited_contacts'] ? null : $data['max_contact_people'],
|
||||
'unlimited_contacts' => $data['unlimited_contacts'],
|
||||
'enable_job_apply' => $data['enable_job_apply'],
|
||||
]);
|
||||
|
||||
return redirect()->back();
|
||||
})->name('admin.subscriptions.store');
|
||||
|
||||
Route::post('/subscriptions/{id}/update', function (\Illuminate\Http\Request $request, $id) {
|
||||
$plan = \App\Models\Plan::findOrFail($id);
|
||||
$data = $request->validate([
|
||||
'name' => 'required|string|max:255',
|
||||
'price' => 'required|numeric|min:0',
|
||||
'duration' => 'required|string',
|
||||
'features' => 'required|array',
|
||||
'max_contact_people' => 'nullable|integer',
|
||||
'unlimited_contacts' => 'required|boolean',
|
||||
'enable_job_apply' => 'required|boolean',
|
||||
'status' => 'required|string|in:Active,Disabled',
|
||||
]);
|
||||
|
||||
if ($data['status'] === 'Disabled') {
|
||||
$usageCount = \App\Models\Sponsor::where('subscription_plan', $plan->id)
|
||||
->where('subscription_status', 'active')
|
||||
->count();
|
||||
if ($usageCount > 0) {
|
||||
return redirect()->back()->withErrors([
|
||||
'status' => "Cannot disable plan. {$usageCount} " . ($usageCount === 1 ? 'person is' : 'people are') . " using this plan."
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$plan->update([
|
||||
'name' => $data['name'],
|
||||
'price' => $data['price'],
|
||||
'duration' => $data['duration'],
|
||||
'features' => $data['features'],
|
||||
'status' => $data['status'],
|
||||
'max_contact_people' => $data['unlimited_contacts'] ? null : $data['max_contact_people'],
|
||||
'unlimited_contacts' => $data['unlimited_contacts'],
|
||||
'enable_job_apply' => $data['enable_job_apply'],
|
||||
]);
|
||||
|
||||
return redirect()->back();
|
||||
})->name('admin.subscriptions.update');
|
||||
|
||||
Route::delete('/subscriptions/{id}', function ($id) {
|
||||
$plan = \App\Models\Plan::findOrFail($id);
|
||||
$usageCount = \App\Models\Sponsor::where('subscription_plan', $plan->id)
|
||||
->where('subscription_status', 'active')
|
||||
->count();
|
||||
if ($usageCount > 0) {
|
||||
return redirect()->back()->withErrors([
|
||||
'status' => "Cannot delete plan. {$usageCount} " . ($usageCount === 1 ? 'person is' : 'people are') . " using this plan."
|
||||
]);
|
||||
}
|
||||
$plan->delete();
|
||||
return redirect()->back();
|
||||
})->name('admin.subscriptions.delete');
|
||||
|
||||
Route::get('/payments', function () {
|
||||
$payments = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||
->leftJoin('users', 'subscriptions.user_id', '=', 'users.id')
|
||||
@ -282,6 +370,7 @@
|
||||
Route::post('/employer/resend-otp', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resendOtp'])->name('employer.resend-otp')->middleware('throttle:3,1');
|
||||
Route::get('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showUploadEmiratesId'])->name('employer.upload-emirates-id');
|
||||
Route::post('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'uploadEmiratesId'])->name('employer.upload-emirates-id.submit');
|
||||
Route::post('/employer/confirm-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'confirmEmiratesId'])->name('employer.confirm-emirates-id.submit');
|
||||
Route::get('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegisterPayment'])->name('employer.register-payment');
|
||||
Route::post('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'storeRegisterPayment'])->name('employer.register-payment.submit');
|
||||
Route::get('/employer/create-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showCreatePassword'])->name('employer.create-password');
|
||||
@ -324,6 +413,13 @@
|
||||
Route::get('/jobs', [\App\Http\Controllers\Employer\JobController::class, 'index'])->name('employer.jobs');
|
||||
Route::get('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'create'])->name('employer.jobs.create');
|
||||
Route::post('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'store'])->name('employer.jobs.store');
|
||||
Route::get('/jobs/all-applicants', [\App\Http\Controllers\Employer\JobController::class, 'allApplicants'])->name('employer.jobs.all-applicants');
|
||||
Route::get('/jobs/shortlisted', [\App\Http\Controllers\Employer\JobController::class, 'shortlistedApplicants'])->name('employer.jobs.shortlisted');
|
||||
Route::get('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'edit'])->name('employer.jobs.edit');
|
||||
Route::post('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'update'])->name('employer.jobs.update');
|
||||
Route::delete('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'destroy'])->name('employer.jobs.destroy');
|
||||
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');
|
||||
@ -331,13 +427,10 @@
|
||||
$user = $sessId ? \App\Models\User::find($sessId) : \App\Models\User::where('role', 'employer')->first();
|
||||
|
||||
$sub = $user ? \Illuminate\Support\Facades\DB::table('subscriptions')->where('user_id', $user->id)->where('status', 'active')->latest('id')->first() : null;
|
||||
$expiresAt = $sub && $sub->expires_at ? date('Y-m-d', strtotime($sub->expires_at)) : '2026-12-31';
|
||||
$planName = $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass';
|
||||
|
||||
return Inertia::render('Employer/Subscription', [
|
||||
'currentPlan' => $planName,
|
||||
'expiresAt' => $expiresAt,
|
||||
'plans' => [
|
||||
$dbPlans = \App\Models\Plan::where('status', 'Active')->get();
|
||||
if ($dbPlans->isEmpty()) {
|
||||
$plans = [
|
||||
[
|
||||
'id' => 'basic',
|
||||
'name' => 'Basic Search',
|
||||
@ -355,17 +448,74 @@
|
||||
'popular' => true,
|
||||
],
|
||||
[
|
||||
'id' => 'enterprise',
|
||||
'id' => 'vip',
|
||||
'name' => 'VIP Concierge',
|
||||
'price' => '499 AED',
|
||||
'period' => 'month',
|
||||
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
|
||||
'popular' => false,
|
||||
],
|
||||
]
|
||||
];
|
||||
} else {
|
||||
$plans = $dbPlans->map(function ($p) {
|
||||
$features = is_string($p->features) ? json_decode($p->features, true) : $p->features;
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'name' => $p->name,
|
||||
'price' => round($p->price) . ' AED',
|
||||
'period' => strtolower($p->duration) === 'monthly' ? 'month' : (strtolower($p->duration) === 'yearly' ? 'year' : 'period'),
|
||||
'features' => $features ?: [],
|
||||
'popular' => $p->id === 'premium',
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
$planName = 'Premium Employer Pass';
|
||||
$expiresAt = '2026-12-31';
|
||||
|
||||
if ($sub) {
|
||||
$expiresAt = date('Y-m-d', strtotime($sub->expires_at));
|
||||
$associatedPlan = \App\Models\Plan::find($sub->plan_id);
|
||||
$planName = $associatedPlan ? $associatedPlan->name : (ucfirst($sub->plan_id) . ' Pass');
|
||||
}
|
||||
|
||||
$invoices = [];
|
||||
if ($user) {
|
||||
$invoices = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||
->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
|
||||
->where('subscriptions.user_id', $user->id)
|
||||
->orderBy('subscriptions.created_at', 'desc')
|
||||
->select('subscriptions.*', 'plans.name as plan_name')
|
||||
->get()
|
||||
->map(function($s) {
|
||||
$planLabel = $s->plan_name ?: (ucfirst($s->plan_id) . ' Pass');
|
||||
$subStatus = strtolower($s->status);
|
||||
if ($subStatus !== 'active' && $subStatus !== 'pending' && $subStatus !== 'expired') {
|
||||
$subStatus = 'expired';
|
||||
}
|
||||
return [
|
||||
'id' => 'INV-' . date('Y', strtotime($s->created_at)) . '-' . str_pad($s->id, 3, '0', STR_PAD_LEFT),
|
||||
'plan_name' => $planLabel,
|
||||
'purchase_date' => date('M d, Y', strtotime($s->created_at)),
|
||||
'activation_date' => $s->starts_at ? date('M d, Y', strtotime($s->starts_at)) : 'N/A',
|
||||
'expiry_date' => $s->expires_at ? date('M d, Y', strtotime($s->expires_at)) : 'N/A',
|
||||
'amount' => round($s->amount_aed) . ' AED',
|
||||
'payment_status' => 'Paid',
|
||||
'status' => ucfirst($subStatus),
|
||||
];
|
||||
})->toArray();
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/Subscription', [
|
||||
'currentPlan' => $planName,
|
||||
'expiresAt' => $expiresAt,
|
||||
'plans' => $plans,
|
||||
'invoices' => $invoices
|
||||
]);
|
||||
})->name('employer.subscription');
|
||||
|
||||
Route::post('/subscription/purchase', [\App\Http\Controllers\Employer\PaymentController::class, 'purchase'])->name('employer.subscription.purchase');
|
||||
|
||||
Route::get('/messages', [\App\Http\Controllers\Employer\MessageController::class, 'index'])->name('employer.messages');
|
||||
Route::get('/messages/{id}', [\App\Http\Controllers\Employer\MessageController::class, 'show'])->name('employer.messages.show');
|
||||
Route::post('/messages/{id}/send', [\App\Http\Controllers\Employer\MessageController::class, 'send'])->name('employer.messages.send');
|
||||
|
||||
142
tests/Feature/AdminSubscriptionPlansTest.php
Normal file
142
tests/Feature/AdminSubscriptionPlansTest.php
Normal file
@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Plan;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminSubscriptionPlansTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $admin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->admin = User::create([
|
||||
'name' => 'Admin User',
|
||||
'email' => 'admin@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'admin',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_view_subscription_plans()
|
||||
{
|
||||
// Seed default plans are loaded from the migration, let's verify
|
||||
$this->assertDatabaseHas('plans', ['id' => 'basic']);
|
||||
|
||||
$response = $this->actingAs($this->admin)->get('/admin/subscriptions');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_admin_can_create_new_plan()
|
||||
{
|
||||
$response = $this->actingAs($this->admin)->post('/admin/subscriptions', [
|
||||
'id' => 'super-vip',
|
||||
'name' => 'Super VIP Plan',
|
||||
'price' => 999.00,
|
||||
'duration' => 'Monthly',
|
||||
'features' => ['Super feature 1', 'Super feature 2'],
|
||||
'max_contact_people' => null,
|
||||
'unlimited_contacts' => true,
|
||||
'enable_job_apply' => true,
|
||||
'status' => 'Active',
|
||||
]);
|
||||
|
||||
$response->assertStatus(302);
|
||||
$this->assertDatabaseHas('plans', [
|
||||
'id' => 'super-vip',
|
||||
'name' => 'Super VIP Plan',
|
||||
'price' => 999.00,
|
||||
'duration' => 'Monthly',
|
||||
'unlimited_contacts' => true,
|
||||
'enable_job_apply' => true,
|
||||
'status' => 'Active',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_update_existing_plan()
|
||||
{
|
||||
$response = $this->actingAs($this->admin)->post('/admin/subscriptions/basic/update', [
|
||||
'name' => 'Basic Search Updated',
|
||||
'price' => 120.00,
|
||||
'duration' => 'Monthly',
|
||||
'features' => ['Browse 500+ workers', '10 Shortlists', 'New feature'],
|
||||
'max_contact_people' => 15,
|
||||
'unlimited_contacts' => false,
|
||||
'enable_job_apply' => false,
|
||||
'status' => 'Active',
|
||||
]);
|
||||
|
||||
$response->assertStatus(302);
|
||||
$this->assertDatabaseHas('plans', [
|
||||
'id' => 'basic',
|
||||
'name' => 'Basic Search Updated',
|
||||
'price' => 120.00,
|
||||
'max_contact_people' => 15,
|
||||
'unlimited_contacts' => false,
|
||||
'status' => 'Active',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_can_delete_plan()
|
||||
{
|
||||
$response = $this->actingAs($this->admin)->delete('/admin/subscriptions/basic');
|
||||
|
||||
$response->assertStatus(302);
|
||||
$this->assertDatabaseMissing('plans', [
|
||||
'id' => 'basic',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_cannot_disable_plan_if_in_use()
|
||||
{
|
||||
// Associate a sponsor with 'basic' plan
|
||||
\App\Models\Sponsor::create([
|
||||
'full_name' => 'Test Sponsor',
|
||||
'email' => 'sponsor@example.com',
|
||||
'mobile' => '+971501112233',
|
||||
'password' => bcrypt('password'),
|
||||
'subscription_status' => 'active',
|
||||
'subscription_plan' => 'basic',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->admin)->post('/admin/subscriptions/basic/update', [
|
||||
'name' => 'Basic Search',
|
||||
'price' => 99.00,
|
||||
'duration' => 'Monthly',
|
||||
'features' => ['Browse 500+ workers'],
|
||||
'max_contact_people' => 10,
|
||||
'unlimited_contacts' => false,
|
||||
'enable_job_apply' => false,
|
||||
'status' => 'Disabled',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['status']);
|
||||
$this->assertEquals('Active', Plan::find('basic')->status);
|
||||
}
|
||||
|
||||
public function test_admin_cannot_delete_plan_if_in_use()
|
||||
{
|
||||
// Associate a sponsor with 'basic' plan
|
||||
\App\Models\Sponsor::create([
|
||||
'full_name' => 'Test Sponsor',
|
||||
'email' => 'sponsor@example.com',
|
||||
'mobile' => '+971501112233',
|
||||
'password' => bcrypt('password'),
|
||||
'subscription_status' => 'active',
|
||||
'subscription_plan' => 'basic',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->admin)->delete('/admin/subscriptions/basic');
|
||||
|
||||
$response->assertSessionHasErrors(['status']);
|
||||
$this->assertDatabaseHas('plans', ['id' => 'basic']);
|
||||
}
|
||||
}
|
||||
281
tests/Feature/AutomatedReminderTest.php
Normal file
281
tests/Feature/AutomatedReminderTest.php
Normal file
@ -0,0 +1,281 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\Sponsor;
|
||||
use App\Models\WorkerDocument;
|
||||
use App\Models\EmployerProfile;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\ReminderLog;
|
||||
use App\Notifications\DocumentExpiryNotification;
|
||||
use App\Notifications\HireConfirmationNotification;
|
||||
use App\Notifications\JoiningConfirmationNotification;
|
||||
use App\Notifications\PendingConfirmationNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AutomatedReminderTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createTestWorker(array $overrides = []): Worker
|
||||
{
|
||||
return Worker::create(array_merge([
|
||||
'name' => 'John Worker',
|
||||
'email' => 'worker@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'phone' => '971500000001',
|
||||
'nationality' => 'Indian',
|
||||
'status' => 'active',
|
||||
'age' => 25,
|
||||
'salary' => 2000,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Helper info',
|
||||
'passport_status' => 'valid',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
public function test_document_expiry_reminders()
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
// 1. Create a Worker with a document expiring in exactly 7 days
|
||||
$worker = $this->createTestWorker();
|
||||
|
||||
$doc = WorkerDocument::create([
|
||||
'worker_id' => $worker->id,
|
||||
'type' => 'passport',
|
||||
'number' => 'PASS123',
|
||||
'expiry_date' => now()->addDays(7)->toDateString(),
|
||||
]);
|
||||
|
||||
// 2. Create an Employer (User) with Emirates ID expiring in exactly 3 days
|
||||
$employer = User::create([
|
||||
'name' => 'Alice Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
$employerProfile = EmployerProfile::create([
|
||||
'user_id' => $employer->id,
|
||||
'phone' => '971500000002',
|
||||
'address' => 'Dubai',
|
||||
'emirates_id_expiry' => now()->addDays(3)->toDateString(),
|
||||
]);
|
||||
|
||||
// 3. Create a Sponsor with Emirates ID expiring in exactly 1 day
|
||||
$sponsor = Sponsor::create([
|
||||
'full_name' => 'Sponsor Bob',
|
||||
'email' => 'sponsor@example.com',
|
||||
'mobile' => '971500000003',
|
||||
'password' => bcrypt('password'),
|
||||
'emirates_id_expiry' => now()->addDays(1)->toDateString(),
|
||||
]);
|
||||
|
||||
// Run the command
|
||||
Artisan::call('app:send-reminders');
|
||||
|
||||
// Assert Worker got Passport expiry 7 days notification
|
||||
Notification::assertSentTo(
|
||||
$worker,
|
||||
DocumentExpiryNotification::class,
|
||||
function ($notification) {
|
||||
return $notification->documentType === 'Passport' &&
|
||||
$notification->daysRemaining === 7 &&
|
||||
$notification->reminderLevel === '7_days';
|
||||
}
|
||||
);
|
||||
|
||||
// Assert Employer got Emirates ID expiry 3 days notification
|
||||
Notification::assertSentTo(
|
||||
$employer,
|
||||
DocumentExpiryNotification::class,
|
||||
function ($notification) {
|
||||
return $notification->documentType === 'Emirates ID' &&
|
||||
$notification->daysRemaining === 3 &&
|
||||
$notification->reminderLevel === '3_days';
|
||||
}
|
||||
);
|
||||
|
||||
// Assert Sponsor got Emirates ID expiry 1 day notification
|
||||
Notification::assertSentTo(
|
||||
$sponsor,
|
||||
DocumentExpiryNotification::class,
|
||||
function ($notification) {
|
||||
return $notification->documentType === 'Emirates ID' &&
|
||||
$notification->daysRemaining === 1 &&
|
||||
$notification->reminderLevel === '1_day';
|
||||
}
|
||||
);
|
||||
|
||||
// Assert reminder logs were created
|
||||
$this->assertDatabaseHas('reminder_logs', [
|
||||
'user_type' => get_class($worker),
|
||||
'user_id' => $worker->id,
|
||||
'event_type' => 'document_expiry',
|
||||
'reminder_level' => '7_days',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('reminder_logs', [
|
||||
'user_type' => get_class($employer),
|
||||
'user_id' => $employer->id,
|
||||
'event_type' => 'document_expiry',
|
||||
'reminder_level' => '3_days',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('reminder_logs', [
|
||||
'user_type' => get_class($sponsor),
|
||||
'user_id' => $sponsor->id,
|
||||
'event_type' => 'document_expiry_emirates',
|
||||
'reminder_level' => '1_day',
|
||||
]);
|
||||
|
||||
// Run the command again to ensure duplicate is prevented
|
||||
Notification::fake();
|
||||
Artisan::call('app:send-reminders');
|
||||
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_hire_and_joining_confirmations_reminders()
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
// Create Employer
|
||||
$employer = User::create([
|
||||
'name' => 'Alice Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
// Create Worker
|
||||
$worker = $this->createTestWorker();
|
||||
|
||||
// Create a Job Post starting in 7 days
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => 'Housekeeper',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2000,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->addDays(7)->toDateString(),
|
||||
'description' => 'Test job',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Create a Job Application with status 'selected' (needs Hire Confirmation)
|
||||
$appSelected = JobApplication::create([
|
||||
'job_id' => $job->id,
|
||||
'worker_id' => $worker->id,
|
||||
'status' => 'selected',
|
||||
]);
|
||||
|
||||
// Create another Job Post starting in 3 days
|
||||
$jobHired = JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => 'Nanny',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->addDays(3)->toDateString(),
|
||||
'description' => 'Test job hired',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Create a Job Application with status 'hired' (needs Joining Confirmation)
|
||||
$appHired = JobApplication::create([
|
||||
'job_id' => $jobHired->id,
|
||||
'worker_id' => $worker->id,
|
||||
'status' => 'hired',
|
||||
]);
|
||||
|
||||
// Run the command
|
||||
Artisan::call('app:send-reminders');
|
||||
|
||||
// Assert Employer got Hire Confirmation 7 days notification
|
||||
Notification::assertSentTo(
|
||||
$employer,
|
||||
HireConfirmationNotification::class,
|
||||
function ($notification) use ($appSelected) {
|
||||
return $notification->jobTitle === 'Housekeeper' &&
|
||||
$notification->workerName === 'John Worker' &&
|
||||
$notification->daysRemaining === 7 &&
|
||||
$notification->applicationId === $appSelected->id;
|
||||
}
|
||||
);
|
||||
|
||||
// Assert Worker got Joining Confirmation 3 days notification
|
||||
Notification::assertSentTo(
|
||||
$worker,
|
||||
JoiningConfirmationNotification::class,
|
||||
function ($notification) use ($appHired) {
|
||||
return $notification->jobTitle === 'Nanny' &&
|
||||
$notification->employerName === 'Alice Employer' &&
|
||||
$notification->daysRemaining === 3 &&
|
||||
$notification->applicationId === $appHired->id;
|
||||
}
|
||||
);
|
||||
|
||||
// Verify duplicate prevention
|
||||
Notification::fake();
|
||||
Artisan::call('app:send-reminders');
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_pending_direct_job_offer_reminders()
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
// Create Employer
|
||||
$employer = User::create([
|
||||
'name' => 'Alice Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
// Create Worker
|
||||
$worker = $this->createTestWorker();
|
||||
|
||||
// Create pending direct Job Offer starting in 1 day
|
||||
$offer = JobOffer::create([
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->addDays(1)->toDateString(),
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 3000,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
// Run the command
|
||||
Artisan::call('app:send-reminders');
|
||||
|
||||
// Assert Worker got Pending Direct Job Offer 1 day notification
|
||||
Notification::assertSentTo(
|
||||
$worker,
|
||||
PendingConfirmationNotification::class,
|
||||
function ($notification) use ($offer) {
|
||||
return $notification->actionType === 'accept_offer' &&
|
||||
$notification->daysRemaining === 1 &&
|
||||
$notification->entityId === $offer->id;
|
||||
}
|
||||
);
|
||||
|
||||
// Verify duplicate prevention
|
||||
Notification::fake();
|
||||
Artisan::call('app:send-reminders');
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
}
|
||||
295
tests/Feature/EmployerJobTest.php
Normal file
295
tests/Feature/EmployerJobTest.php
Normal file
@ -0,0 +1,295 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\Worker;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmployerJobTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $employer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->employer = User::create([
|
||||
'name' => 'Jane Sponsor',
|
||||
'email' => 'sponsor@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
// Create an active premium subscription so Jane can post/view jobs
|
||||
\Illuminate\Support\Facades\DB::table('subscriptions')->insert([
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'premium',
|
||||
'amount_aed' => 199.00,
|
||||
'starts_at' => now(),
|
||||
'expires_at' => now()->addDays(30),
|
||||
'paytabs_transaction_id' => 'TEST_TXN_ID',
|
||||
'status' => 'active',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test displaying the employer jobs list with basic plan.
|
||||
*/
|
||||
public function test_employer_with_basic_plan_cannot_view_jobs_list()
|
||||
{
|
||||
// Update subscription to basic
|
||||
\Illuminate\Support\Facades\DB::table('subscriptions')
|
||||
->where('user_id', $this->employer->id)
|
||||
->update(['plan_id' => 'basic']);
|
||||
|
||||
$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 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.
|
||||
*/
|
||||
public function test_employer_can_view_jobs_list()
|
||||
{
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Experienced Mason',
|
||||
'workers_needed' => 5,
|
||||
'job_type' => 'Full Time',
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 2800,
|
||||
'start_date' => '2026-07-01',
|
||||
'description' => 'Looking for experienced masons for a residential project.',
|
||||
'requirements' => '5+ years experience, basic English.',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get('/employer/jobs');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Experienced Mason');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test displaying the create job form.
|
||||
*/
|
||||
public function test_employer_can_view_create_job_form()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get('/employer/jobs/create');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test storing a new job posting.
|
||||
*/
|
||||
public function test_employer_can_post_job()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post('/employer/jobs/create', [
|
||||
'title' => 'Villa Cleaner',
|
||||
'workers_needed' => 2,
|
||||
'job_type' => 'Part Time',
|
||||
'location' => 'Al Barsha',
|
||||
'salary' => 1800,
|
||||
'start_date' => '2026-07-10',
|
||||
'description' => 'Need part-time villa cleaners for daily housekeeping tasks.',
|
||||
'requirements' => 'Housekeeping experience is a plus.',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/employer/jobs');
|
||||
$response->assertSessionHas('success');
|
||||
|
||||
$this->assertDatabaseHas('job_posts', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Villa Cleaner',
|
||||
'workers_needed' => 2,
|
||||
'job_type' => 'Part Time',
|
||||
'location' => 'Al Barsha',
|
||||
'salary' => 1800,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test validation during job posting.
|
||||
*/
|
||||
public function test_job_posting_requires_fields()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post('/employer/jobs/create', [
|
||||
'title' => '',
|
||||
'workers_needed' => 0,
|
||||
'salary' => -100,
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['title', 'workers_needed', 'location', 'salary', 'job_type', 'start_date', 'description']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test viewing job applicants.
|
||||
*/
|
||||
public function test_employer_can_view_applicants()
|
||||
{
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Mason for Project',
|
||||
'workers_needed' => 3,
|
||||
'job_type' => 'Contract',
|
||||
'location' => 'Sharjah',
|
||||
'salary' => 3000,
|
||||
'start_date' => '2026-07-15',
|
||||
'description' => 'Contract mason project.',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$worker = Worker::create([
|
||||
'name' => 'John Worker',
|
||||
'email' => 'worker@example.com',
|
||||
'phone' => '+971500000001',
|
||||
'nationality' => 'Indian',
|
||||
'age' => 30,
|
||||
'salary' => 2500,
|
||||
'availability' => 'Available',
|
||||
'experience' => '4 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Vetted domestic worker with great experience.',
|
||||
]);
|
||||
|
||||
$application = JobApplication::create([
|
||||
'job_id' => $job->id,
|
||||
'worker_id' => $worker->id,
|
||||
'status' => 'applied',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get("/employer/jobs/{$job->id}/applicants");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('John Worker');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test viewing all job applicants.
|
||||
*/
|
||||
public function test_employer_can_view_all_applicants()
|
||||
{
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Mason for Project',
|
||||
'workers_needed' => 3,
|
||||
'job_type' => 'Contract',
|
||||
'location' => 'Sharjah',
|
||||
'salary' => 3000,
|
||||
'start_date' => '2026-07-15',
|
||||
'description' => 'Contract mason project.',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$worker = Worker::create([
|
||||
'name' => 'John Worker',
|
||||
'email' => 'worker@example.com',
|
||||
'phone' => '+971500000001',
|
||||
'nationality' => 'Indian',
|
||||
'age' => 30,
|
||||
'salary' => 2500,
|
||||
'availability' => 'Available',
|
||||
'experience' => '4 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Vetted domestic worker with great experience.',
|
||||
]);
|
||||
|
||||
$application = JobApplication::create([
|
||||
'job_id' => $job->id,
|
||||
'worker_id' => $worker->id,
|
||||
'status' => 'applied',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get("/employer/jobs/all-applicants");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('John Worker');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test viewing shortlisted applicants.
|
||||
*/
|
||||
public function test_employer_can_view_shortlisted_applicants()
|
||||
{
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Mason for Project',
|
||||
'workers_needed' => 3,
|
||||
'job_type' => 'Contract',
|
||||
'location' => 'Sharjah',
|
||||
'salary' => 3000,
|
||||
'start_date' => '2026-07-15',
|
||||
'description' => 'Contract mason project.',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$worker = Worker::create([
|
||||
'name' => 'Shortlisted John',
|
||||
'email' => 'worker_shortlisted@example.com',
|
||||
'phone' => '+971500000002',
|
||||
'nationality' => 'Indian',
|
||||
'age' => 30,
|
||||
'salary' => 2500,
|
||||
'availability' => 'Available',
|
||||
'experience' => '4 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Vetted domestic worker with great experience.',
|
||||
]);
|
||||
|
||||
$application = JobApplication::create([
|
||||
'job_id' => $job->id,
|
||||
'worker_id' => $worker->id,
|
||||
'status' => 'shortlisted',
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get("/employer/jobs/shortlisted");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Shortlisted John');
|
||||
}
|
||||
}
|
||||
@ -152,10 +152,10 @@ public function test_employer_can_update_profile_with_emirates_id_fields()
|
||||
'emirates_id' => [
|
||||
'emirates_id_number' => '784-1987-5493842-5',
|
||||
'name' => 'Mohammad Jobaier Mohammad Abul Kalam',
|
||||
'date_of_birth' => '14/04/1987',
|
||||
'date_of_birth' => '1987-04-14',
|
||||
'nationality' => 'Bangladesh',
|
||||
'issue_date' => '12/12/2025',
|
||||
'expiry_date' => '11/12/2027',
|
||||
'issue_date' => '2025-12-12',
|
||||
'expiry_date' => '2027-12-11',
|
||||
'employer' => 'Msj International Technical Services L.L.C UAE',
|
||||
'issue_place' => 'Dubai',
|
||||
'occupation' => 'Electrician',
|
||||
@ -371,4 +371,226 @@ public function test_employer_can_change_password()
|
||||
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('NewPassword@123', $this->employer->fresh()->password));
|
||||
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('NewPassword@123', $sponsor->fresh()->password));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test registering an employer with DD/MM/YYYY dates normalizes them to Y-m-d.
|
||||
*/
|
||||
public function test_employer_registration_normalizes_dates()
|
||||
{
|
||||
$response = $this->postJson('/api/employers/register', [
|
||||
'name' => 'Sandeep Vishwakarma',
|
||||
'email' => 'sandeep@example.com',
|
||||
'phone' => '+971509990002',
|
||||
'address' => 'Dubai, UAE',
|
||||
'emirates_id' => [
|
||||
'emirates_id_number' => '784-2002-4977006-4',
|
||||
'name' => 'Sandeep Vishwakarma',
|
||||
'date_of_birth' => '05/07/2002',
|
||||
'issue_date' => '07/06/2023',
|
||||
'expiry_date' => '06/06/2025',
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('employer_profiles', [
|
||||
'phone' => '+971509990002',
|
||||
'emirates_id_dob' => '2002-07-05',
|
||||
'emirates_id_issue_date' => '2023-06-07',
|
||||
'emirates_id_expiry' => '2025-06-06',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('sponsors', [
|
||||
'mobile' => '+971509990002',
|
||||
'emirates_id_dob' => '2002-07-05',
|
||||
'emirates_id_issue_date' => '2023-06-07',
|
||||
'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']);
|
||||
}
|
||||
|
||||
// Create sponsor record for payment verification
|
||||
\App\Models\Sponsor::create([
|
||||
'email' => $this->employer->email,
|
||||
'full_name' => $this->employer->name,
|
||||
'mobile' => '+971501112222',
|
||||
'password' => $this->employer->password,
|
||||
'is_verified' => true,
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,4 +85,31 @@ public function test_employer_upload_emirates_id_ocr_and_purge()
|
||||
$this->assertNotNull($profile->emirates_id_expiry);
|
||||
$this->assertEquals('approved', $profile->verification_status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a stale session with an invalid user ID does not cause a redirect loop.
|
||||
*/
|
||||
public function test_stale_session_does_not_cause_redirect_loop()
|
||||
{
|
||||
// 1. Visit with a session user object containing a non-existent ID
|
||||
$staleUser = (object)[
|
||||
'id' => 999999, // Doesn't exist
|
||||
'name' => 'Stale User',
|
||||
'email' => 'stale@example.com',
|
||||
'role' => 'employer',
|
||||
'subscription_status' => 'active',
|
||||
'verification_status' => 'approved',
|
||||
];
|
||||
|
||||
// Accessing dashboard should fail auth, clear session, and redirect to login
|
||||
$response = $this->withSession(['user' => $staleUser])
|
||||
->get('/employer/dashboard');
|
||||
|
||||
$response->assertRedirect(route('employer.login'));
|
||||
$this->assertNull(session('user')); // Session must be cleared
|
||||
|
||||
// Subsequent access to login page should render successfully (no redirect)
|
||||
$loginResponse = $this->get('/employer/login');
|
||||
$loginResponse->assertStatus(200);
|
||||
}
|
||||
}
|
||||
|
||||
192
tests/Feature/EmployerShortlistWebFilterTest.php
Normal file
192
tests/Feature/EmployerShortlistWebFilterTest.php
Normal file
@ -0,0 +1,192 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\Shortlist;
|
||||
use App\Models\WorkerDocument;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmployerShortlistWebFilterTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $employer;
|
||||
protected $worker1;
|
||||
protected $worker2;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->employer = User::create([
|
||||
'name' => 'Test Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
$this->worker1 = Worker::create([
|
||||
'name' => 'Worker Indian Dubai',
|
||||
'email' => 'worker1@example.com',
|
||||
'phone' => '+971501111111',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Indian',
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Dubai',
|
||||
'preferred_job_type' => 'full-time',
|
||||
'live_in_out' => 'live_in',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Residence Visa',
|
||||
'main_profession' => 'Housemaid',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'experience' => '3 Years',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'EN',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'Experienced helper',
|
||||
'gender' => 'female',
|
||||
]);
|
||||
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $this->worker1->id,
|
||||
'type' => 'passport',
|
||||
'number' => 'P111111',
|
||||
'file_path' => 'passports/test1.jpg',
|
||||
]);
|
||||
|
||||
$this->worker2 = Worker::create([
|
||||
'name' => 'Worker Filipino Abu Dhabi',
|
||||
'email' => 'worker2@example.com',
|
||||
'phone' => '+971502222222',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Filipino',
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'preferred_location' => 'Abu Dhabi',
|
||||
'preferred_job_type' => 'part-time',
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Tourist Visa',
|
||||
'main_profession' => 'Nanny',
|
||||
'age' => 28,
|
||||
'salary' => 1800,
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'TL',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'Ready to work',
|
||||
'gender' => 'male',
|
||||
]);
|
||||
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $this->worker2->id,
|
||||
'type' => 'passport',
|
||||
'number' => 'P222222',
|
||||
'file_path' => 'passports/test2.jpg',
|
||||
]);
|
||||
|
||||
// Shortlist both workers for this employer
|
||||
Shortlist::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $this->worker1->id,
|
||||
]);
|
||||
|
||||
Shortlist::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $this->worker2->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test shortlist index page loads correct properties.
|
||||
*/
|
||||
public function test_employer_can_view_shortlist_with_metadata()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.shortlist'));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('shortlistedWorkers');
|
||||
$response->assertSee('filtersMetadata');
|
||||
|
||||
// Check if both workers are returned when no filters are applied
|
||||
$shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
|
||||
$this->assertCount(2, $shortlisted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test shortlist filtering by location.
|
||||
*/
|
||||
public function test_employer_shortlist_filtering_by_location()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.shortlist', ['preferred_location' => 'Abu Dhabi']));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
|
||||
|
||||
$this->assertCount(1, $shortlisted);
|
||||
$this->assertEquals('Worker Filipino Abu Dhabi', $shortlisted[0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test shortlist filtering by nationality.
|
||||
*/
|
||||
public function test_employer_shortlist_filtering_by_nationality()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.shortlist', ['nationality' => 'Indian']));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
|
||||
|
||||
$this->assertCount(1, $shortlisted);
|
||||
$this->assertEquals('Worker Indian Dubai', $shortlisted[0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test shortlist filtering by job type and accommodation.
|
||||
*/
|
||||
public function test_employer_shortlist_filtering_by_job_type_and_accommodation()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.shortlist', [
|
||||
'job_type' => 'part-time',
|
||||
'accommodation_type' => 'live_out'
|
||||
]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
|
||||
|
||||
$this->assertCount(1, $shortlisted);
|
||||
$this->assertEquals('Worker Filipino Abu Dhabi', $shortlisted[0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test shortlist filtering by visa status and gender.
|
||||
*/
|
||||
public function test_employer_shortlist_filtering_by_visa_status_and_gender()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.shortlist', [
|
||||
'visa_status' => 'Residence Visa',
|
||||
'gender' => 'female'
|
||||
]));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$shortlisted = $response->viewData('page')['props']['shortlistedWorkers'];
|
||||
|
||||
$this->assertCount(1, $shortlisted);
|
||||
$this->assertEquals('Worker Indian Dubai', $shortlisted[0]['name']);
|
||||
}
|
||||
}
|
||||
259
tests/Feature/EmployerSubscriptionUpgradeTest.php
Normal file
259
tests/Feature/EmployerSubscriptionUpgradeTest.php
Normal file
@ -0,0 +1,259 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EmployerSubscriptionUpgradeTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $employer;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create employer
|
||||
$this->employer = User::create([
|
||||
'name' => 'Employer User',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
'subscription_status' => 'expired',
|
||||
'subscription_expires_at' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_purchase_when_no_active_subscription_activates_immediately()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)->postJson(route('employer.subscription.purchase'), [
|
||||
'plan_id' => 'premium',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
|
||||
// Assert subscription is active
|
||||
$this->assertDatabaseHas('subscriptions', [
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'premium',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->employer->refresh();
|
||||
$this->assertEquals('active', $this->employer->subscription_status);
|
||||
$this->assertNotNull($this->employer->subscription_expires_at);
|
||||
}
|
||||
|
||||
public function test_purchase_when_active_subscription_exists_queues_pending()
|
||||
{
|
||||
// 1. Create active subscription
|
||||
$activeSub = Subscription::create([
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'basic',
|
||||
'amount_aed' => 99.00,
|
||||
'starts_at' => now(),
|
||||
'expires_at' => now()->addDays(30),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->employer->update([
|
||||
'subscription_status' => 'active',
|
||||
'subscription_expires_at' => $activeSub->expires_at,
|
||||
]);
|
||||
|
||||
// 2. Purchase another subscription
|
||||
$response = $this->actingAs($this->employer)->postJson(route('employer.subscription.purchase'), [
|
||||
'plan_id' => 'premium',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
|
||||
// Assert new subscription is pending and queued chronologically
|
||||
$this->assertDatabaseHas('subscriptions', [
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'premium',
|
||||
'status' => 'pending',
|
||||
'starts_at' => $activeSub->expires_at,
|
||||
]);
|
||||
|
||||
// User should still have active status with previous expiry
|
||||
$this->employer->refresh();
|
||||
$this->assertEquals('active', $this->employer->subscription_status);
|
||||
$this->assertEquals($activeSub->expires_at->toDateTimeString(), $this->employer->subscription_expires_at->toDateTimeString());
|
||||
}
|
||||
|
||||
public function test_pending_subscription_activates_when_active_expires()
|
||||
{
|
||||
// 1. Create active subscription expiring in the past
|
||||
$expiredSub = Subscription::create([
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'basic',
|
||||
'amount_aed' => 99.00,
|
||||
'starts_at' => now()->subDays(40),
|
||||
'expires_at' => now()->subDays(10), // expired 10 days ago
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// 2. Create pending subscription queued to start when expired sub ended
|
||||
$pendingSub = Subscription::create([
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'premium',
|
||||
'amount_aed' => 199.00,
|
||||
'starts_at' => $expiredSub->expires_at,
|
||||
'expires_at' => $expiredSub->expires_at->copy()->addDays(30),
|
||||
'status' => 'pending',
|
||||
]);
|
||||
|
||||
$this->employer->update([
|
||||
'subscription_status' => 'active',
|
||||
'subscription_expires_at' => $expiredSub->expires_at,
|
||||
]);
|
||||
|
||||
// 3. Trigger check and activate routine
|
||||
User::checkAndActivatePendingSubscriptions($this->employer->id);
|
||||
|
||||
// 4. Assert expired sub is set to expired
|
||||
$expiredSub->refresh();
|
||||
$this->assertEquals('expired', $expiredSub->status);
|
||||
|
||||
// 5. Assert pending sub is now active
|
||||
$pendingSub->refresh();
|
||||
$this->assertEquals('active', $pendingSub->status);
|
||||
|
||||
// 6. Assert user is active and has new expires_at set starting from now
|
||||
$this->employer->refresh();
|
||||
$this->assertEquals('active', $this->employer->subscription_status);
|
||||
$this->assertTrue($this->employer->subscription_expires_at > now());
|
||||
}
|
||||
|
||||
public function test_expired_subscription_restricts_access_and_redirects_to_subscription_plans()
|
||||
{
|
||||
// 1. Create expired subscription
|
||||
Subscription::create([
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'basic',
|
||||
'amount_aed' => 99.00,
|
||||
'starts_at' => now()->subDays(40),
|
||||
'expires_at' => now()->subDays(10), // expired 10 days ago
|
||||
'status' => 'expired',
|
||||
]);
|
||||
|
||||
$this->employer->update([
|
||||
'subscription_status' => 'expired',
|
||||
'subscription_expires_at' => now()->subDays(10),
|
||||
]);
|
||||
|
||||
// 2. Try to view dashboard - should redirect to subscription
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get('/employer/dashboard');
|
||||
|
||||
$response->assertRedirect(route('employer.subscription'));
|
||||
|
||||
// 3. Accessing subscription page itself is allowed
|
||||
$subResponse = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.subscription'));
|
||||
|
||||
$subResponse->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_contact_limit_blocks_new_conversations_but_allows_existing_ones()
|
||||
{
|
||||
// 1. Create a custom plan with max_contact_people = 2
|
||||
Plan::create([
|
||||
'id' => 'test-plan-2',
|
||||
'name' => 'Test Plan 2',
|
||||
'price' => 10.00,
|
||||
'duration' => 'Monthly',
|
||||
'features' => ['Feature 1'],
|
||||
'max_contact_people' => 2,
|
||||
'unlimited_contacts' => false,
|
||||
'enable_job_apply' => true,
|
||||
'status' => 'Active',
|
||||
]);
|
||||
|
||||
Subscription::create([
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'test-plan-2',
|
||||
'amount_aed' => 10.00,
|
||||
'starts_at' => now(),
|
||||
'expires_at' => now()->addDays(30),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$this->employer->update([
|
||||
'subscription_status' => 'active',
|
||||
'subscription_expires_at' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
// Create 3 workers
|
||||
$worker1 = \App\Models\Worker::create(['name' => 'Worker One', 'email' => 'w1@ex.com', 'phone' => '1111111', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 25, 'salary' => 1500, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
|
||||
$worker2 = \App\Models\Worker::create(['name' => 'Worker Two', 'email' => 'w2@ex.com', 'phone' => '2222222', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 26, 'salary' => 1600, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
|
||||
$worker3 = \App\Models\Worker::create(['name' => 'Worker Three', 'email' => 'w3@ex.com', 'phone' => '3333333', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 27, 'salary' => 1700, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
|
||||
|
||||
// 2. Contact worker 1 -> Allowed
|
||||
$response1 = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.messages.start', ['workerId' => $worker1->id]));
|
||||
|
||||
$response1->assertRedirect();
|
||||
$this->assertDatabaseHas('conversations', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $worker1->id,
|
||||
]);
|
||||
|
||||
// 3. Contact worker 2 -> Allowed
|
||||
$response2 = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.messages.start', ['workerId' => $worker2->id]));
|
||||
|
||||
$response2->assertRedirect();
|
||||
$this->assertDatabaseHas('conversations', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $worker2->id,
|
||||
]);
|
||||
|
||||
// 4. Contact worker 3 -> Blocked (limit reached)
|
||||
$response3 = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.messages.start', ['workerId' => $worker3->id]));
|
||||
|
||||
$response3->assertRedirect(route('employer.subscription'));
|
||||
$response3->assertSessionHas('error');
|
||||
$this->assertDatabaseMissing('conversations', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $worker3->id,
|
||||
]);
|
||||
|
||||
// 5. Contact worker 1 again -> Allowed (already contacted)
|
||||
$response4 = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.messages.start', ['workerId' => $worker1->id]));
|
||||
|
||||
$response4->assertRedirect();
|
||||
}
|
||||
|
||||
public function test_api_expired_subscription_blocks_access()
|
||||
{
|
||||
$token = 'test_api_token_123';
|
||||
$this->employer->update([
|
||||
'api_token' => $token,
|
||||
'subscription_status' => 'expired',
|
||||
]);
|
||||
|
||||
// Access API dashboard -> Should get 403 Forbidden
|
||||
$response = $this->getJson('/api/employers/dashboard', [
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
}
|
||||
}
|
||||
@ -49,6 +49,7 @@ protected function setUp(): void
|
||||
'live_in_out' => 'live_in',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Residence Visa',
|
||||
'main_profession' => 'Housemaid',
|
||||
'age' => 25,
|
||||
'gender' => 'female',
|
||||
'salary' => 1500,
|
||||
@ -80,6 +81,7 @@ protected function setUp(): void
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Tourist Visa',
|
||||
'main_profession' => 'Nanny',
|
||||
'age' => 28,
|
||||
'gender' => 'female',
|
||||
'salary' => 1800,
|
||||
@ -96,7 +98,7 @@ protected function setUp(): void
|
||||
'file_path' => 'passports/test2.jpg',
|
||||
]);
|
||||
|
||||
// Worker 3: Dubai, full-time, live-out, Kenyan, in_country=false, Cancelled Visa, male
|
||||
// Worker 3: Dubai, full-time, live-out, Kenyan, in_country=false, Residence Visa, male
|
||||
$w3 = Worker::create([
|
||||
'name' => 'Worker Kenyan Out Country',
|
||||
'email' => 'worker3@example.com',
|
||||
@ -110,7 +112,8 @@ protected function setUp(): void
|
||||
'preferred_job_type' => 'full-time',
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => false,
|
||||
'visa_status' => 'Cancelled Visa',
|
||||
'visa_status' => 'Residence Visa',
|
||||
'main_profession' => 'Cook',
|
||||
'age' => 30,
|
||||
'gender' => 'male',
|
||||
'salary' => 1200,
|
||||
@ -201,17 +204,29 @@ public function test_get_workers_visa_status_if_in_country_filter()
|
||||
$this->assertCount(1, $workers);
|
||||
$this->assertEquals('Worker Filipino In Abu Dhabi', $workers[0]['name']);
|
||||
|
||||
// Cancelled Visa is for Worker 3, but Worker 3 is out country, so it should not match
|
||||
// Residence Visa is for Worker 3 (Kenyan), but Worker 3 is out country, so it should not match
|
||||
// because of the "if in country next visa type" constraint.
|
||||
$response2 = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?visa_status=Cancelled Visa');
|
||||
])->getJson('/api/employers/workers?visa_status=Residence Visa&nationality=Kenyan');
|
||||
|
||||
$response2->assertStatus(200);
|
||||
$workers2 = $response2->json('data.workers');
|
||||
$this->assertCount(0, $workers2);
|
||||
}
|
||||
|
||||
public function test_get_workers_main_profession_filter()
|
||||
{
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $this->token,
|
||||
])->getJson('/api/employers/workers?main_profession=Housemaid');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workers = $response->json('data.workers');
|
||||
$this->assertCount(1, $workers);
|
||||
$this->assertEquals('Worker Indian In Dubai', $workers[0]['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test getCandidates filtering.
|
||||
*/
|
||||
|
||||
@ -41,6 +41,7 @@ protected function setUp(): void
|
||||
'live_in_out' => 'live_in',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Residence Visa',
|
||||
'main_profession' => 'Housemaid',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'experience' => '3 Years',
|
||||
@ -70,6 +71,7 @@ protected function setUp(): void
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Tourist Visa',
|
||||
'main_profession' => 'Nanny',
|
||||
'age' => 28,
|
||||
'salary' => 1800,
|
||||
'experience' => '2 Years',
|
||||
@ -164,7 +166,8 @@ public function test_employer_can_view_candidates_index_and_apply_filters()
|
||||
'preferred_job_type' => 'full-time',
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => false,
|
||||
'visa_status' => 'Cancelled Visa',
|
||||
'visa_status' => 'Residence Visa',
|
||||
'main_profession' => 'Cook',
|
||||
'age' => 30,
|
||||
'salary' => 1200,
|
||||
'experience' => 'None',
|
||||
@ -227,4 +230,59 @@ public function test_employer_workers_web_gender_filtering()
|
||||
$response->assertSee('Worker Indian Dubai');
|
||||
$response->assertDontSee('Worker Filipino Abu Dhabi');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test web workers index main_profession filtering.
|
||||
*/
|
||||
public function test_employer_workers_web_main_profession_filtering()
|
||||
{
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.workers', ['main_profession' => 'Housemaid']));
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertSee('Worker Indian Dubai');
|
||||
$response->assertDontSee('Worker Filipino Abu Dhabi');
|
||||
|
||||
// Test candidate filtering by main_profession
|
||||
$w = Worker::create([
|
||||
'name' => 'Worker Kenyan Hired',
|
||||
'email' => 'worker3@example.com',
|
||||
'phone' => '+971503333333',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Kenyan',
|
||||
'verified' => true,
|
||||
'status' => 'Hired',
|
||||
'preferred_location' => 'Dubai',
|
||||
'preferred_job_type' => 'full-time',
|
||||
'live_in_out' => 'live_out',
|
||||
'in_country' => false,
|
||||
'visa_status' => 'Residence Visa',
|
||||
'main_profession' => 'Cook',
|
||||
'age' => 30,
|
||||
'salary' => 1200,
|
||||
'experience' => 'None',
|
||||
'religion' => 'Christian',
|
||||
'language' => 'SW',
|
||||
'availability' => 'Immediate',
|
||||
'bio' => 'New helper',
|
||||
]);
|
||||
|
||||
JobOffer::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $w->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 1200,
|
||||
'notes' => 'Hired',
|
||||
'status' => 'accepted',
|
||||
]);
|
||||
|
||||
$response2 = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get(route('employer.candidates', ['main_profession' => 'Cook']));
|
||||
|
||||
$response2->assertStatus(200);
|
||||
$response2->assertSee('selectedWorkers');
|
||||
}
|
||||
}
|
||||
|
||||
369
tests/Feature/NewRemindersAndSettingsTest.php
Normal file
369
tests/Feature/NewRemindersAndSettingsTest.php
Normal file
@ -0,0 +1,369 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use App\Models\Review;
|
||||
use App\Models\EmployerReview;
|
||||
use App\Notifications\WorkerNoResponseNotification;
|
||||
use App\Notifications\WorkerReviewReminderNotification;
|
||||
use App\Notifications\EmployerReviewReminderNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NewRemindersAndSettingsTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createTestWorker(array $overrides = []): Worker
|
||||
{
|
||||
return Worker::create(array_merge([
|
||||
'name' => 'John Worker',
|
||||
'email' => 'worker@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'phone' => '971500000001',
|
||||
'nationality' => 'Indian',
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker_api_token',
|
||||
'age' => 25,
|
||||
'salary' => 2000,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Helper info',
|
||||
'passport_status' => 'valid',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
private function createTestEmployer(array $overrides = []): User
|
||||
{
|
||||
return User::create(array_merge([
|
||||
'name' => 'Alice Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
'api_token' => 'employer_api_token',
|
||||
'subscription_status' => 'active',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
public function test_worker_no_response_reminders()
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$worker = $this->createTestWorker();
|
||||
$employer = $this->createTestEmployer();
|
||||
|
||||
// 1. Create a pending direct Job Offer created exactly 14 days ago
|
||||
$offer = JobOffer::create([
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->addDays(5)->toDateString(),
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 3000,
|
||||
'status' => 'pending',
|
||||
]);
|
||||
// Force created_at to 14 days ago
|
||||
$offer->created_at = now()->subDays(14);
|
||||
$offer->save();
|
||||
|
||||
// 2. Create a conversation where the last message is from employer, sent 14 days ago
|
||||
$conversation = Conversation::create([
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $worker->id,
|
||||
]);
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'sender_type' => 'App\Models\User',
|
||||
'sender_id' => $employer->id,
|
||||
'text' => 'Hello worker, are you interested?',
|
||||
]);
|
||||
$message->created_at = now()->subDays(14);
|
||||
$message->save();
|
||||
|
||||
// Run scheduler
|
||||
Artisan::call('app:send-reminders');
|
||||
|
||||
// Assert Worker got WorkerNoResponseNotification for both
|
||||
Notification::assertSentTo(
|
||||
$worker,
|
||||
WorkerNoResponseNotification::class,
|
||||
function ($notification) use ($offer) {
|
||||
return $notification->type === 'worker_no_response_offer' &&
|
||||
$notification->daysElapsed === 14 &&
|
||||
$notification->entityId === $offer->id;
|
||||
}
|
||||
);
|
||||
|
||||
Notification::assertSentTo(
|
||||
$worker,
|
||||
WorkerNoResponseNotification::class,
|
||||
function ($notification) use ($message) {
|
||||
return $notification->type === 'worker_no_response_chat' &&
|
||||
$notification->daysElapsed === 14 &&
|
||||
$notification->entityId === $message->id;
|
||||
}
|
||||
);
|
||||
|
||||
// Assert reminder logs were created
|
||||
$this->assertDatabaseHas('reminder_logs', [
|
||||
'user_type' => get_class($worker),
|
||||
'user_id' => $worker->id,
|
||||
'event_type' => 'worker_no_response_offer',
|
||||
'reminder_level' => '14_days',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('reminder_logs', [
|
||||
'user_type' => get_class($worker),
|
||||
'user_id' => $worker->id,
|
||||
'event_type' => 'worker_no_response_chat',
|
||||
'reminder_level' => '14_days',
|
||||
]);
|
||||
|
||||
// Run again to verify duplicate prevention
|
||||
Notification::fake();
|
||||
Artisan::call('app:send-reminders');
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_review_reminders_after_joining_date()
|
||||
{
|
||||
Notification::fake();
|
||||
|
||||
$worker = $this->createTestWorker();
|
||||
$employer = $this->createTestEmployer();
|
||||
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => 'Nanny',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->subMonths(4)->toDateString(),
|
||||
'description' => 'Test job',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// JobApplication with joining_confirmed_at exactly 3 months ago
|
||||
$app = JobApplication::create([
|
||||
'job_id' => $job->id,
|
||||
'worker_id' => $worker->id,
|
||||
'status' => 'hired',
|
||||
'joining_confirmed_at' => now()->subMonths(3)->toDateString(),
|
||||
]);
|
||||
|
||||
// Run scheduler
|
||||
Artisan::call('app:send-reminders');
|
||||
|
||||
// Assert Worker got review reminder for employer
|
||||
Notification::assertSentTo(
|
||||
$worker,
|
||||
WorkerReviewReminderNotification::class,
|
||||
function ($notification) use ($app) {
|
||||
return $notification->employerName === 'Alice Employer' &&
|
||||
$notification->applicationId === $app->id &&
|
||||
$notification->reviewLevel === '3_months';
|
||||
}
|
||||
);
|
||||
|
||||
// Assert Employer got review reminder for worker
|
||||
Notification::assertSentTo(
|
||||
$employer,
|
||||
EmployerReviewReminderNotification::class,
|
||||
function ($notification) use ($app) {
|
||||
return $notification->workerName === 'John Worker' &&
|
||||
$notification->applicationId === $app->id &&
|
||||
$notification->reviewLevel === '3_months';
|
||||
}
|
||||
);
|
||||
|
||||
// Assert reminder logs were created
|
||||
$this->assertDatabaseHas('reminder_logs', [
|
||||
'user_type' => get_class($worker),
|
||||
'user_id' => $worker->id,
|
||||
'event_type' => 'review_employer_reminder',
|
||||
'reminder_level' => '3_months',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('reminder_logs', [
|
||||
'user_type' => get_class($employer),
|
||||
'user_id' => $employer->id,
|
||||
'event_type' => 'review_worker_reminder',
|
||||
'reminder_level' => '3_months',
|
||||
]);
|
||||
|
||||
// Run again to verify duplicate prevention
|
||||
Notification::fake();
|
||||
Artisan::call('app:send-reminders');
|
||||
Notification::assertNothingSent();
|
||||
}
|
||||
|
||||
public function test_worker_review_controller_eligibility_and_edit_window()
|
||||
{
|
||||
$worker = $this->createTestWorker();
|
||||
$employer = $this->createTestEmployer();
|
||||
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => 'Test Job',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->subMonths(4)->toDateString(),
|
||||
'description' => 'Test Description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// 1. Cannot review before eligibility period (set eligibility to 3 months)
|
||||
$app = JobApplication::create([
|
||||
'job_id' => $job->id,
|
||||
'worker_id' => $worker->id,
|
||||
'status' => 'hired',
|
||||
'joining_confirmed_at' => now()->subMonths(2)->toDateString(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/workers/reviews', [
|
||||
'employer_id' => $employer->id,
|
||||
'job_id' => $job->id,
|
||||
'rating' => 5,
|
||||
'comment' => 'Great employer!',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.');
|
||||
|
||||
// 2. Can review after eligibility period
|
||||
$app->joining_confirmed_at = now()->subMonths(3)->subDays(5)->toDateString();
|
||||
$app->save();
|
||||
|
||||
$response = $this->postJson('/api/workers/reviews', [
|
||||
'employer_id' => $employer->id,
|
||||
'job_id' => $job->id,
|
||||
'rating' => 5,
|
||||
'comment' => 'Great employer!',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('success', true);
|
||||
$reviewId = $response->json('data.review.id');
|
||||
|
||||
// 3. Edit review within 7 days
|
||||
$response = $this->putJson("/api/workers/reviews/{$reviewId}", [
|
||||
'rating' => 4,
|
||||
'comment' => 'Good employer indeed.',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
|
||||
// 4. Cannot edit review after 7 days
|
||||
$review = EmployerReview::find($reviewId);
|
||||
$review->created_at = now()->subDays(8);
|
||||
$review->save();
|
||||
|
||||
$response = $this->putJson("/api/workers/reviews/{$reviewId}", [
|
||||
'rating' => 3,
|
||||
'comment' => 'Okay employer.',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'The 7-day edit window for this review has expired.');
|
||||
}
|
||||
|
||||
public function test_employer_review_controller_eligibility_and_edit_window()
|
||||
{
|
||||
$worker = $this->createTestWorker();
|
||||
$employer = $this->createTestEmployer();
|
||||
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => 'Test Job',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->subMonths(4)->toDateString(),
|
||||
'description' => 'Test Description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// 1. Cannot review before eligibility period (set eligibility to 3 months)
|
||||
$app = JobApplication::create([
|
||||
'job_id' => $job->id,
|
||||
'worker_id' => $worker->id,
|
||||
'status' => 'hired',
|
||||
'joining_confirmed_at' => now()->subMonths(2)->toDateString(),
|
||||
]);
|
||||
|
||||
$response = $this->postJson('/api/employers/reviews', [
|
||||
'worker_id' => $worker->id,
|
||||
'rating' => 5,
|
||||
'comment' => 'Great worker!',
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.');
|
||||
|
||||
// 2. Can review after eligibility period
|
||||
$app->joining_confirmed_at = now()->subMonths(3)->subDays(5)->toDateString();
|
||||
$app->save();
|
||||
|
||||
$response = $this->postJson('/api/employers/reviews', [
|
||||
'worker_id' => $worker->id,
|
||||
'rating' => 5,
|
||||
'comment' => 'Great worker!',
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('success', true);
|
||||
$reviewId = $response->json('data.review.id');
|
||||
|
||||
// 3. Edit review within 7 days
|
||||
$response = $this->putJson("/api/employers/reviews/{$reviewId}", [
|
||||
'rating' => 4,
|
||||
'comment' => 'Good worker indeed.',
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
|
||||
// 4. Cannot edit review after 7 days
|
||||
$review = Review::find($reviewId);
|
||||
$review->created_at = now()->subDays(8);
|
||||
$review->save();
|
||||
|
||||
$response = $this->putJson("/api/employers/reviews/{$reviewId}", [
|
||||
'rating' => 3,
|
||||
'comment' => 'Okay worker.',
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'The 7-day edit window for this review has expired.');
|
||||
}
|
||||
}
|
||||
248
tests/Feature/NotificationApiTest.php
Normal file
248
tests/Feature/NotificationApiTest.php
Normal file
@ -0,0 +1,248 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Notifications\DocumentExpiryNotification;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private function createTestWorker(array $overrides = []): Worker
|
||||
{
|
||||
return Worker::create(array_merge([
|
||||
'name' => 'John Worker',
|
||||
'email' => 'worker@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'phone' => '971500000001',
|
||||
'nationality' => 'Indian',
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker_api_token',
|
||||
'age' => 25,
|
||||
'salary' => 2000,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Helper info',
|
||||
'passport_status' => 'valid',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
private function createTestEmployer(array $overrides = []): User
|
||||
{
|
||||
return User::create(array_merge([
|
||||
'name' => 'Alice Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
'api_token' => 'employer_api_token',
|
||||
'subscription_status' => 'active',
|
||||
], $overrides));
|
||||
}
|
||||
|
||||
public function test_worker_can_list_notifications_with_filters()
|
||||
{
|
||||
$worker = $this->createTestWorker();
|
||||
|
||||
// Send two notifications
|
||||
$worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days'));
|
||||
$worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days'));
|
||||
|
||||
// Mark the Passport notification as read deterministically
|
||||
$passportNotification = $worker->unreadNotifications()->where('data->document_type', 'Passport')->first();
|
||||
$passportNotification->markAsRead();
|
||||
|
||||
// 1. Fetch all notifications
|
||||
$response = $this->getJson('/api/workers/notifications', [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonCount(2, 'data.notifications');
|
||||
|
||||
// 2. Fetch unread notifications
|
||||
$response = $this->getJson('/api/workers/notifications?filter=unread', [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1, 'data.notifications');
|
||||
$response->assertJsonPath('data.notifications.0.data.document_type', 'Visa');
|
||||
|
||||
// 3. Fetch read notifications
|
||||
$response = $this->getJson('/api/workers/notifications?filter=read', [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1, 'data.notifications');
|
||||
$response->assertJsonPath('data.notifications.0.data.document_type', 'Passport');
|
||||
}
|
||||
|
||||
public function test_worker_can_mark_single_and_all_notifications_as_read()
|
||||
{
|
||||
$worker = $this->createTestWorker();
|
||||
|
||||
// Send two notifications
|
||||
$worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days'));
|
||||
$worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days'));
|
||||
|
||||
$unread = $worker->unreadNotifications;
|
||||
$this->assertEquals(2, $unread->count());
|
||||
|
||||
// 1. Mark single notification as read
|
||||
$notificationId = $unread->first()->id;
|
||||
$response = $this->putJson("/api/workers/notifications/{$notificationId}/read", [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(1, $worker->fresh()->unreadNotifications->count());
|
||||
|
||||
// 2. Mark all notifications as read
|
||||
$response = $this->putJson('/api/workers/notifications/read', [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(0, $worker->fresh()->unreadNotifications->count());
|
||||
}
|
||||
|
||||
public function test_worker_can_delete_single_and_all_notifications()
|
||||
{
|
||||
$worker = $this->createTestWorker();
|
||||
|
||||
// Send two notifications
|
||||
$worker->notify(new DocumentExpiryNotification('Passport', '2026-12-31', 30, '30_days'));
|
||||
$worker->notify(new DocumentExpiryNotification('Visa', '2026-12-31', 7, '7_days'));
|
||||
|
||||
$this->assertEquals(2, $worker->notifications()->count());
|
||||
|
||||
// 1. Delete single notification
|
||||
$notificationId = $worker->notifications->first()->id;
|
||||
$response = $this->deleteJson("/api/workers/notifications/{$notificationId}", [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(1, $worker->fresh()->notifications()->count());
|
||||
|
||||
// 2. Delete all notifications
|
||||
$response = $this->deleteJson('/api/workers/notifications', [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(0, $worker->fresh()->notifications()->count());
|
||||
}
|
||||
|
||||
public function test_employer_can_list_notifications_with_filters()
|
||||
{
|
||||
$employer = $this->createTestEmployer();
|
||||
|
||||
// Send two notifications
|
||||
$employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days'));
|
||||
$employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days'));
|
||||
|
||||
// Mark the Emirates ID notification as read deterministically
|
||||
$emiratesIdNotification = $employer->unreadNotifications()->where('data->document_type', 'Emirates ID')->first();
|
||||
$emiratesIdNotification->markAsRead();
|
||||
|
||||
// 1. Fetch all notifications
|
||||
$response = $this->getJson('/api/employers/notifications', [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonCount(2, 'data.notifications');
|
||||
|
||||
// 2. Fetch unread notifications
|
||||
$response = $this->getJson('/api/employers/notifications?filter=unread', [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1, 'data.notifications');
|
||||
$response->assertJsonPath('data.notifications.0.data.document_type', 'Trade License');
|
||||
|
||||
// 3. Fetch read notifications
|
||||
$response = $this->getJson('/api/employers/notifications?filter=read', [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonCount(1, 'data.notifications');
|
||||
$response->assertJsonPath('data.notifications.0.data.document_type', 'Emirates ID');
|
||||
}
|
||||
|
||||
public function test_employer_can_mark_single_and_all_notifications_as_read()
|
||||
{
|
||||
$employer = $this->createTestEmployer();
|
||||
|
||||
// Send two notifications
|
||||
$employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days'));
|
||||
$employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days'));
|
||||
|
||||
$unread = $employer->unreadNotifications;
|
||||
$this->assertEquals(2, $unread->count());
|
||||
|
||||
// 1. Mark single notification as read
|
||||
$notificationId = $unread->first()->id;
|
||||
$response = $this->putJson("/api/employers/notifications/{$notificationId}/read", [], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(1, $employer->fresh()->unreadNotifications->count());
|
||||
|
||||
// 2. Mark all notifications as read
|
||||
$response = $this->putJson('/api/employers/notifications/read', [], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(0, $employer->fresh()->unreadNotifications->count());
|
||||
}
|
||||
|
||||
public function test_employer_can_delete_single_and_all_notifications()
|
||||
{
|
||||
$employer = $this->createTestEmployer();
|
||||
|
||||
// Send two notifications
|
||||
$employer->notify(new DocumentExpiryNotification('Emirates ID', '2026-12-31', 30, '30_days'));
|
||||
$employer->notify(new DocumentExpiryNotification('Trade License', '2026-12-31', 7, '7_days'));
|
||||
|
||||
$this->assertEquals(2, $employer->notifications()->count());
|
||||
|
||||
// 1. Delete single notification
|
||||
$notificationId = $employer->notifications->first()->id;
|
||||
$response = $this->deleteJson("/api/employers/notifications/{$notificationId}", [], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(1, $employer->fresh()->notifications()->count());
|
||||
|
||||
// 2. Delete all notifications
|
||||
$response = $this->deleteJson('/api/employers/notifications', [], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertEquals(0, $employer->fresh()->notifications()->count());
|
||||
}
|
||||
}
|
||||
@ -217,15 +217,18 @@ public function test_sponsor_can_post_charity_event()
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer sponsor_token_xyz',
|
||||
])->postJson('/api/sponsors/charity-events', [
|
||||
'title' => 'Community Ramadan Iftar',
|
||||
'body' => 'Join us for a free community Iftar gathering at the center.',
|
||||
'type' => 'charity',
|
||||
'event_date' => '2026-06-15',
|
||||
'start_time' => '09:00 AM',
|
||||
'end_time' => '04:00 PM',
|
||||
'provided_items' => 'Free Iftar meals, Water, Dates',
|
||||
'location_details' => 'Al Quoz Community Center, Dubai',
|
||||
'location_pin' => 'https://maps.app.goo.gl/xyz',
|
||||
'title' => 'Community Ramadan Iftar',
|
||||
'body' => 'Join us for a free community Iftar gathering at the center.',
|
||||
'type' => 'charity',
|
||||
'event_date' => '2026-06-15',
|
||||
'start_time' => '09:00 AM',
|
||||
'end_time' => '04:00 PM',
|
||||
'provided_items' => 'Free Iftar meals, Water, Dates',
|
||||
'location_details' => 'Al Quoz Community Center, Dubai',
|
||||
'location_pin' => 'https://maps.app.goo.gl/xyz',
|
||||
'contact_person_name' => 'Jane Doe',
|
||||
'contact_number' => '551234567',
|
||||
'country_code' => '+971',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
@ -246,6 +249,9 @@ public function test_sponsor_can_post_charity_event()
|
||||
'location_details' => 'Al Quoz Community Center, Dubai',
|
||||
'location_pin' => 'https://maps.app.goo.gl/xyz',
|
||||
'content' => 'Join us for a free community Iftar gathering at the center.',
|
||||
'contact_person_name' => 'Jane Doe',
|
||||
'contact_number' => '551234567',
|
||||
'country_code' => '+971',
|
||||
]
|
||||
]
|
||||
])
|
||||
@ -266,6 +272,9 @@ public function test_sponsor_can_post_charity_event()
|
||||
'location_details',
|
||||
'location_pin',
|
||||
'content',
|
||||
'contact_person_name',
|
||||
'contact_number',
|
||||
'country_code',
|
||||
]
|
||||
]
|
||||
]);
|
||||
@ -277,6 +286,82 @@ public function test_sponsor_can_post_charity_event()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test validation rules for charity event posting.
|
||||
*/
|
||||
public function test_sponsor_charity_event_validation()
|
||||
{
|
||||
$sponsor = Sponsor::create([
|
||||
'full_name' => 'Test Sponsor',
|
||||
'organization_name' => 'Save the Children',
|
||||
'email' => 'tsponsor@example.com',
|
||||
'mobile' => '+971509994444',
|
||||
'password' => Hash::make('password123'),
|
||||
'api_token' => 'sponsor_token_xyz',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// 1. Missing fields
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer sponsor_token_xyz',
|
||||
])->postJson('/api/sponsors/charity-events', [
|
||||
'title' => 'Community Ramadan Iftar',
|
||||
'body' => 'Join us for a free community Iftar gathering at the center.',
|
||||
'type' => 'charity',
|
||||
'event_date' => '2026-06-15',
|
||||
'start_time' => '09:00 AM',
|
||||
'end_time' => '04:00 PM',
|
||||
'provided_items' => 'Free Iftar meals, Water, Dates',
|
||||
'location_details' => 'Al Quoz Community Center, Dubai',
|
||||
'location_pin' => 'https://maps.app.goo.gl/xyz',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['contact_person_name', 'contact_number', 'country_code']);
|
||||
|
||||
// 2. Invalid country code format (missing +)
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer sponsor_token_xyz',
|
||||
])->postJson('/api/sponsors/charity-events', [
|
||||
'title' => 'Community Ramadan Iftar',
|
||||
'body' => 'Join us for a free community Iftar gathering at the center.',
|
||||
'type' => 'charity',
|
||||
'event_date' => '2026-06-15',
|
||||
'start_time' => '09:00 AM',
|
||||
'end_time' => '04:00 PM',
|
||||
'provided_items' => 'Free Iftar meals, Water, Dates',
|
||||
'location_details' => 'Al Quoz Community Center, Dubai',
|
||||
'location_pin' => 'https://maps.app.goo.gl/xyz',
|
||||
'contact_person_name' => 'Jane Doe',
|
||||
'contact_number' => '551234567',
|
||||
'country_code' => '971', // missing plus
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['country_code']);
|
||||
|
||||
// 3. Invalid contact number format (non-digits)
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer sponsor_token_xyz',
|
||||
])->postJson('/api/sponsors/charity-events', [
|
||||
'title' => 'Community Ramadan Iftar',
|
||||
'body' => 'Join us for a free community Iftar gathering at the center.',
|
||||
'type' => 'charity',
|
||||
'event_date' => '2026-06-15',
|
||||
'start_time' => '09:00 AM',
|
||||
'end_time' => '04:00 PM',
|
||||
'provided_items' => 'Free Iftar meals, Water, Dates',
|
||||
'location_details' => 'Al Quoz Community Center, Dubai',
|
||||
'location_pin' => 'https://maps.app.goo.gl/xyz',
|
||||
'contact_person_name' => 'Jane Doe',
|
||||
'contact_number' => '55-123-abc', // invalid format
|
||||
'country_code' => '+971',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422)
|
||||
->assertJsonValidationErrors(['contact_number']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that a sponsor can list charity events showing both employer and sponsor events.
|
||||
*/
|
||||
@ -605,6 +690,40 @@ public function test_sponsor_forgot_password_user_not_found()
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test registering a sponsor with DD/MM/YYYY dates normalizes them to Y-m-d.
|
||||
*/
|
||||
public function test_sponsor_registration_normalizes_dates()
|
||||
{
|
||||
$response = $this->postJson('/api/sponsors/register', [
|
||||
'full_name' => 'Test Sponsor Date Normalization',
|
||||
'mobile' => '+971509990999',
|
||||
'password' => 'securepassword',
|
||||
'organization_name' => 'Charity Date Norm',
|
||||
'email' => 'test.sponsor.datenorm@example.com',
|
||||
'nationality' => 'Emirati',
|
||||
'city' => 'Abu Dhabi',
|
||||
'address' => 'Villa 45, Street 12',
|
||||
'country_code' => '+971',
|
||||
'emirates_id' => [
|
||||
'emirates_id_number' => '784-1988-5310327-2',
|
||||
'name' => 'Test Sponsor Date Normalization',
|
||||
'date_of_birth' => '05/07/2002',
|
||||
'issue_date' => '07/06/2023',
|
||||
'expiry_date' => '06/06/2025',
|
||||
],
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
|
||||
$this->assertDatabaseHas('sponsors', [
|
||||
'email' => 'test.sponsor.datenorm@example.com',
|
||||
'emirates_id_dob' => '2002-07-05',
|
||||
'emirates_id_issue_date' => '2023-06-07',
|
||||
'emirates_id_expiry' => '2025-06-06',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test sponsor can change password successfully.
|
||||
*/
|
||||
|
||||
407
tests/Feature/WorkerEmployersApiTest.php
Normal file
407
tests/Feature/WorkerEmployersApiTest.php
Normal file
@ -0,0 +1,407 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerDocument;
|
||||
use App\Models\User;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\EmployerProfile;
|
||||
|
||||
class WorkerEmployersApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_worker_dashboard_current_employer_detail()
|
||||
{
|
||||
// Create worker
|
||||
$worker = Worker::create([
|
||||
'name' => 'Rahul Sharma',
|
||||
'email' => 'rahul@example.com',
|
||||
'phone' => '+971501234567',
|
||||
'language' => 'HI',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Indian',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker-test-token',
|
||||
]);
|
||||
|
||||
// Create passport document to prevent pending 404
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $worker->id,
|
||||
'type' => 'passport',
|
||||
'number' => '123456',
|
||||
'file_path' => 'passports/test.pdf',
|
||||
]);
|
||||
|
||||
// Create an employer
|
||||
$employer = User::create([
|
||||
'name' => 'Employer One',
|
||||
'email' => 'emp1@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
EmployerProfile::create([
|
||||
'user_id' => $employer->id,
|
||||
'company_name' => 'Ahmad Tech Ltd',
|
||||
'nationality' => 'UAE',
|
||||
'city' => 'Dubai',
|
||||
]);
|
||||
|
||||
// Create an accepted job offer (current employer)
|
||||
JobOffer::create([
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->subDays(10),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'status' => 'accepted',
|
||||
'notes' => 'Current Active Job',
|
||||
]);
|
||||
|
||||
// Hit the worker dashboard API
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson('/api/workers/dashboard');
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJson([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'current_employer' => [
|
||||
'id' => $employer->id,
|
||||
'name' => 'Employer One',
|
||||
'company_name' => 'Ahmad Tech Ltd',
|
||||
'city' => 'Dubai',
|
||||
'nationality' => 'UAE',
|
||||
],
|
||||
'currently_working_sponsor' => [
|
||||
'id' => $employer->id,
|
||||
'name' => 'Employer One',
|
||||
'company_name' => 'Ahmad Tech Ltd',
|
||||
'city' => 'Dubai',
|
||||
'nationality' => 'UAE',
|
||||
]
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_worker_employers_list_pagination_and_sorting()
|
||||
{
|
||||
// Create worker
|
||||
$worker = Worker::create([
|
||||
'name' => 'Rahul Sharma',
|
||||
'email' => 'rahul@example.com',
|
||||
'phone' => '+971501234567',
|
||||
'language' => 'HI',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Indian',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker-test-token',
|
||||
]);
|
||||
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $worker->id,
|
||||
'type' => 'passport',
|
||||
'number' => '123456',
|
||||
'file_path' => 'passports/test.pdf',
|
||||
]);
|
||||
|
||||
// Create 3 employers
|
||||
$emp1 = User::create([
|
||||
'name' => 'Employer One',
|
||||
'email' => 'emp1@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer'
|
||||
]);
|
||||
EmployerProfile::create(['user_id' => $emp1->id, 'company_name' => 'Tech One']);
|
||||
|
||||
$emp2 = User::create([
|
||||
'name' => 'Employer Two',
|
||||
'email' => 'emp2@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer'
|
||||
]);
|
||||
EmployerProfile::create(['user_id' => $emp2->id, 'company_name' => 'Tech Two']);
|
||||
|
||||
$emp3 = User::create([
|
||||
'name' => 'Employer Three',
|
||||
'email' => 'emp3@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer'
|
||||
]);
|
||||
EmployerProfile::create(['user_id' => $emp3->id, 'company_name' => 'Tech Three']);
|
||||
|
||||
// Create JobOffers:
|
||||
// Offer 1: Completed status, joining 20 days ago
|
||||
JobOffer::create([
|
||||
'employer_id' => $emp1->id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->subDays(20),
|
||||
'location' => 'Abu Dhabi',
|
||||
'salary' => 2000,
|
||||
'status' => 'completed',
|
||||
'notes' => 'Past job',
|
||||
]);
|
||||
|
||||
// Offer 2: Cancelled status, joining 30 days ago
|
||||
JobOffer::create([
|
||||
'employer_id' => $emp2->id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->subDays(30),
|
||||
'location' => 'Sharjah',
|
||||
'salary' => 1800,
|
||||
'status' => 'cancelled',
|
||||
'notes' => 'Cancelled job',
|
||||
]);
|
||||
|
||||
// Offer 3: Accepted (Active) status, joining 10 days ago
|
||||
JobOffer::create([
|
||||
'employer_id' => $emp3->id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->subDays(10),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 3000,
|
||||
'status' => 'accepted',
|
||||
'notes' => 'Active job',
|
||||
]);
|
||||
|
||||
// List all employers (should return 3)
|
||||
// With Active employer at the top, then previous employers sorted by work_date desc:
|
||||
// 1st: Emp 3 (Active)
|
||||
// 2nd: Emp 1 (Completed, 20 days ago)
|
||||
// 3rd: Emp 2 (Cancelled, 30 days ago)
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson('/api/workers/employers');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$data = $response->json('data.employers');
|
||||
|
||||
$this->assertCount(3, $data);
|
||||
$this->assertEquals('Active', $data[0]['status']);
|
||||
$this->assertEquals('Employer Three', $data[0]['employer']['name']);
|
||||
$this->assertEquals('Completed', $data[1]['status']);
|
||||
$this->assertEquals('Employer One', $data[1]['employer']['name']);
|
||||
$this->assertEquals('Cancelled', $data[2]['status']);
|
||||
$this->assertEquals('Employer Two', $data[2]['employer']['name']);
|
||||
|
||||
// Test filtering by status Completed
|
||||
$responseFiltered = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson('/api/workers/employers?status=completed');
|
||||
|
||||
$responseFiltered->assertStatus(200);
|
||||
$filteredData = $responseFiltered->json('data.employers');
|
||||
$this->assertCount(1, $filteredData);
|
||||
$this->assertEquals('Completed', $filteredData[0]['status']);
|
||||
$this->assertEquals('Employer One', $filteredData[0]['employer']['name']);
|
||||
|
||||
// Test filtering by status Active
|
||||
$responseActive = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson('/api/workers/employers?status=active');
|
||||
|
||||
$responseActive->assertStatus(200);
|
||||
$activeData = $responseActive->json('data.employers');
|
||||
$this->assertCount(1, $activeData);
|
||||
$this->assertEquals('Active', $activeData[0]['status']);
|
||||
$this->assertEquals('Employer Three', $activeData[0]['employer']['name']);
|
||||
|
||||
// Test pagination
|
||||
$responsePaginated = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson('/api/workers/employers?per_page=2');
|
||||
|
||||
$responsePaginated->assertStatus(200);
|
||||
$paginatedData = $responsePaginated->json('data.employers');
|
||||
$this->assertCount(2, $paginatedData);
|
||||
$this->assertEquals(3, $responsePaginated->json('data.pagination.total'));
|
||||
$this->assertEquals(2, $responsePaginated->json('data.pagination.per_page'));
|
||||
}
|
||||
|
||||
public function test_worker_can_view_employer_profile_with_permissions_and_reviews()
|
||||
{
|
||||
// Create worker
|
||||
$worker = Worker::create([
|
||||
'name' => 'Rahul Sharma',
|
||||
'email' => 'rahul@example.com',
|
||||
'phone' => '+971501234567',
|
||||
'language' => 'HI',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Indian',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker-test-token',
|
||||
]);
|
||||
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $worker->id,
|
||||
'type' => 'passport',
|
||||
'number' => '123456',
|
||||
'file_path' => 'passports/test.pdf',
|
||||
]);
|
||||
|
||||
// Create an employer
|
||||
$employer = User::create([
|
||||
'name' => 'Employer One',
|
||||
'email' => 'emp1@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
EmployerProfile::create([
|
||||
'user_id' => $employer->id,
|
||||
'company_name' => 'Ahmad Tech Ltd',
|
||||
'phone' => '+971500000001',
|
||||
'nationality' => 'UAE',
|
||||
'city' => 'Dubai',
|
||||
'accommodation' => 'Provided',
|
||||
'language' => 'English',
|
||||
]);
|
||||
|
||||
// Create active job post
|
||||
\App\Models\JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => 'Housekeeper Needed',
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 2500,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->addDays(5)->toDateString(),
|
||||
'description' => 'Clean house',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Create an accepted job offer (connection exists)
|
||||
JobOffer::create([
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->subDays(10),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'status' => 'accepted',
|
||||
]);
|
||||
|
||||
// Create a review submitted by another worker
|
||||
$otherWorker = Worker::create([
|
||||
'name' => 'John Doe',
|
||||
'email' => 'john@example.com',
|
||||
'phone' => '+971501234568',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Filipino',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'passport_status' => 'valid',
|
||||
]);
|
||||
|
||||
\App\Models\EmployerReview::create([
|
||||
'worker_id' => $otherWorker->id,
|
||||
'employer_id' => $employer->id,
|
||||
'rating' => 5,
|
||||
'title' => 'Excellent sponsor',
|
||||
'comment' => 'Very polite and pays on time.',
|
||||
]);
|
||||
|
||||
// Hit the new API endpoint
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson("/api/workers/employers/{$employer->id}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.employer.name', 'Employer One');
|
||||
$response->assertJsonPath('data.employer.email', 'emp1@example.com'); // Contact info visible because of JobOffer connection
|
||||
$response->assertJsonPath('data.employer.phone', '+971500000001');
|
||||
$response->assertJsonPath('data.employer.company_name', 'Ahmad Tech Ltd');
|
||||
$response->assertJsonPath('data.employer.rating', 5);
|
||||
$response->assertJsonPath('data.employer.reviews_count', 1);
|
||||
$response->assertJsonPath('data.employer.review_summary.stars.5', 1);
|
||||
$response->assertJsonCount(1, 'data.employer.active_jobs');
|
||||
$response->assertJsonPath('data.employer.active_jobs.0.title', 'Housekeeper Needed');
|
||||
$response->assertJsonCount(1, 'data.reviews.data');
|
||||
$response->assertJsonPath('data.reviews.data.0.worker.name', 'John Doe');
|
||||
$response->assertJsonPath('data.reviews.data.0.title', 'Excellent sponsor');
|
||||
}
|
||||
|
||||
public function test_worker_can_view_employer_profile_without_permissions_masks_contact()
|
||||
{
|
||||
// Create worker
|
||||
$worker = Worker::create([
|
||||
'name' => 'Rahul Sharma',
|
||||
'email' => 'rahul@example.com',
|
||||
'phone' => '+971501234567',
|
||||
'language' => 'HI',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Indian',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker-test-token',
|
||||
]);
|
||||
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $worker->id,
|
||||
'type' => 'passport',
|
||||
'number' => '123456',
|
||||
'file_path' => 'passports/test.pdf',
|
||||
]);
|
||||
|
||||
// Create an employer
|
||||
$employer = User::create([
|
||||
'name' => 'Employer One',
|
||||
'email' => 'emp1@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
EmployerProfile::create([
|
||||
'user_id' => $employer->id,
|
||||
'company_name' => 'Ahmad Tech Ltd',
|
||||
'phone' => '+971500000001',
|
||||
]);
|
||||
|
||||
// Hit the API (no connection exists)
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson("/api/workers/employers/{$employer->id}");
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.employer.name', 'Employer One');
|
||||
$response->assertJsonPath('data.employer.email', null); // Masked/null since no connection exists
|
||||
$response->assertJsonPath('data.employer.phone', null);
|
||||
}
|
||||
}
|
||||
510
tests/Feature/WorkerJobApiTest.php
Normal file
510
tests/Feature/WorkerJobApiTest.php
Normal file
@ -0,0 +1,510 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\Plan;
|
||||
use App\Models\Subscription;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkerJobApiTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $employer;
|
||||
protected $worker;
|
||||
protected $plan;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Get standard plan
|
||||
$this->plan = Plan::find('premium');
|
||||
|
||||
// Create employer with active subscription
|
||||
$this->employer = User::create([
|
||||
'name' => 'Employer User',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
'subscription_status' => 'active',
|
||||
'subscription_expires_at' => now()->addDays(30),
|
||||
'api_token' => 'employer_api_token',
|
||||
]);
|
||||
|
||||
Subscription::create([
|
||||
'user_id' => $this->employer->id,
|
||||
'plan_id' => 'premium',
|
||||
'amount_aed' => 100.00,
|
||||
'starts_at' => now(),
|
||||
'expires_at' => now()->addDays(30),
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Create worker
|
||||
$this->worker = Worker::create([
|
||||
'name' => 'Worker User',
|
||||
'email' => 'worker@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'phone' => '971501234567',
|
||||
'nationality' => 'Indian',
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker_api_token',
|
||||
'age' => 25,
|
||||
'salary' => 2000,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Helper info',
|
||||
'passport_status' => 'valid',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_employer_job_crud_web()
|
||||
{
|
||||
// 1. Create Job (POST /employer/jobs/create)
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post('/employer/jobs/create', [
|
||||
'title' => 'Test Mason Job',
|
||||
'workers_needed' => 3,
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2000,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->addDays(5)->format('Y-m-d'),
|
||||
'description' => 'Test mason job description',
|
||||
'requirements' => 'Must have experience',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/employer/jobs');
|
||||
|
||||
$this->assertDatabaseHas('job_posts', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Test Mason Job',
|
||||
'location' => 'Dubai',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$job = JobPost::first();
|
||||
|
||||
// 2. View Job Details (GET /employer/jobs/{id})
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get("/employer/jobs/{$job->id}");
|
||||
$response->assertStatus(200);
|
||||
|
||||
// 3. Edit Job (POST /employer/jobs/{id}/edit)
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post("/employer/jobs/{$job->id}/edit", [
|
||||
'title' => 'Updated Mason Job',
|
||||
'workers_needed' => 2,
|
||||
'location' => 'Abu Dhabi',
|
||||
'salary' => 2500,
|
||||
'job_type' => 'Part Time',
|
||||
'start_date' => now()->addDays(10)->format('Y-m-d'),
|
||||
'description' => 'Updated description',
|
||||
'requirements' => 'Must speak English',
|
||||
'status' => 'closed',
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/employer/jobs');
|
||||
$this->assertDatabaseHas('job_posts', [
|
||||
'id' => $job->id,
|
||||
'title' => 'Updated Mason Job',
|
||||
'status' => 'closed',
|
||||
]);
|
||||
|
||||
// 4. Delete Job (DELETE /employer/jobs/{id})
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->delete("/employer/jobs/{$job->id}");
|
||||
|
||||
$response->assertRedirect('/employer/jobs');
|
||||
$this->assertSoftDeleted('job_posts', [
|
||||
'id' => $job->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_worker_job_apis()
|
||||
{
|
||||
// Setup an active job post
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Available Plumber Job',
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 3000,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Contract',
|
||||
'start_date' => now()->addDays(2),
|
||||
'description' => 'Plumber description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// 1. List available jobs
|
||||
$response = $this->getJson('/api/workers/jobs', [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonCount(1, 'data.jobs');
|
||||
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
|
||||
$response->assertJsonPath('data.jobs.0.posted_by', 'Employer User');
|
||||
|
||||
// 2. Retrieve job details
|
||||
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
|
||||
$response->assertJsonPath('data.job.posted_by', 'Employer User');
|
||||
|
||||
// 3. Apply for job
|
||||
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertDatabaseHas('job_applications', [
|
||||
'worker_id' => $this->worker->id,
|
||||
'job_id' => $job->id,
|
||||
'status' => 'applied',
|
||||
]);
|
||||
|
||||
// 4. Prevent duplicate job applications
|
||||
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(409);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'You have already applied for this job.');
|
||||
|
||||
// 5. View applied jobs
|
||||
$response = $this->getJson('/api/workers/applied-jobs', [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonCount(1, 'data.applications');
|
||||
$response->assertJsonPath('data.applications.0.job.title', 'Available Plumber Job');
|
||||
$response->assertJsonPath('data.applications.0.job.posted_by', 'Employer User');
|
||||
|
||||
$application = JobApplication::first();
|
||||
|
||||
// 6. Employer view applicants for job
|
||||
$response = $this->getJson("/api/employers/jobs/{$job->id}/applicants", [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonCount(1, 'data.applicants');
|
||||
$response->assertJsonPath('data.applicants.0.worker.name', 'Worker User');
|
||||
|
||||
// 7. Employer update application status
|
||||
$response = $this->postJson("/api/employers/applications/{$application->id}/status", [
|
||||
'status' => 'shortlisted'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertDatabaseHas('job_applications', [
|
||||
'id' => $application->id,
|
||||
'status' => 'shortlisted',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_employer_job_apis_and_access_restrictions()
|
||||
{
|
||||
// 1. List jobs via API (premium plan by default)
|
||||
$response = $this->getJson('/api/employers/jobs', [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonCount(0, 'data.jobs');
|
||||
|
||||
// 2. Create job via API
|
||||
$response = $this->postJson('/api/employers/jobs', [
|
||||
'title' => 'API Clean Job',
|
||||
'workers_needed' => 3,
|
||||
'location' => 'Dubai JLT',
|
||||
'salary' => 2200,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->addDays(3)->format('Y-m-d'),
|
||||
'description' => 'Clean job description',
|
||||
'requirements' => 'Requirements content'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('success', true);
|
||||
$jobId = $response->json('data.job.id');
|
||||
|
||||
$this->assertDatabaseHas('job_posts', [
|
||||
'id' => $jobId,
|
||||
'title' => 'API Clean Job',
|
||||
'location' => 'Dubai JLT',
|
||||
]);
|
||||
|
||||
// 3. Get job detail via API
|
||||
$response = $this->getJson("/api/employers/jobs/{$jobId}", [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.job.title', 'API Clean Job');
|
||||
|
||||
// 4. Update job via API
|
||||
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
|
||||
'title' => 'Updated API Clean Job',
|
||||
'workers_needed' => 4,
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 2500,
|
||||
'job_type' => 'Contract',
|
||||
'start_date' => now()->addDays(4)->format('Y-m-d'),
|
||||
'description' => 'Updated clean job description',
|
||||
'requirements' => 'Updated requirements',
|
||||
'status' => 'draft'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('data.job.title', 'Updated API Clean Job');
|
||||
$response->assertJsonPath('data.job.status', 'draft');
|
||||
|
||||
// 5. Delete job via API
|
||||
$response = $this->deleteJson("/api/employers/jobs/{$jobId}", [], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$this->assertSoftDeleted('job_posts', [
|
||||
'id' => $jobId
|
||||
]);
|
||||
|
||||
// 6. Test basic plan restrictions (no job access)
|
||||
Subscription::where('user_id', $this->employer->id)->update(['plan_id' => 'basic']);
|
||||
|
||||
// Check List Jobs block
|
||||
$response = $this->getJson('/api/employers/jobs', [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
|
||||
// Check Create Job block
|
||||
$response = $this->postJson('/api/employers/jobs', [
|
||||
'title' => 'Blocked Job'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
// Check Get Detail block
|
||||
$response = $this->getJson("/api/employers/jobs/{$jobId}", [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
// Check Update block
|
||||
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
|
||||
'title' => 'Blocked Job'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
// Check Delete block
|
||||
$response = $this->deleteJson("/api/employers/jobs/{$jobId}", [], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
// Check Applicants block
|
||||
$response = $this->getJson("/api/employers/jobs/{$jobId}/applicants", [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
// Check Update status block
|
||||
$response = $this->postJson("/api/employers/applications/1/status", [
|
||||
'status' => 'shortlisted'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
|
||||
// 7. Test contact limits enforcement (both Web and API)
|
||||
// Set plan limits to max 1 contacted worker
|
||||
Subscription::where('user_id', $this->employer->id)->update(['plan_id' => 'premium']);
|
||||
$premiumPlan = Plan::find('premium');
|
||||
$premiumPlan->update([
|
||||
'unlimited_contacts' => false,
|
||||
'max_contact_people' => 1
|
||||
]);
|
||||
|
||||
// Create 2 workers
|
||||
$workerA = Worker::create([
|
||||
'name' => 'Worker A',
|
||||
'email' => 'workera@example.com',
|
||||
'phone' => '971501234568',
|
||||
'nationality' => 'Indian',
|
||||
'status' => 'active',
|
||||
'age' => 25,
|
||||
'salary' => 2000,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Helper A',
|
||||
]);
|
||||
|
||||
$workerB = Worker::create([
|
||||
'name' => 'Worker B',
|
||||
'email' => 'workerb@example.com',
|
||||
'phone' => '971501234569',
|
||||
'nationality' => 'Indian',
|
||||
'status' => 'active',
|
||||
'age' => 25,
|
||||
'salary' => 2000,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Helper B',
|
||||
]);
|
||||
|
||||
// Create an active job for testing applications status updates
|
||||
$activeJob = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Active Job Limit Test',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2000,
|
||||
'workers_needed' => 5,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->addDays(5),
|
||||
'description' => 'Test description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
$appA = JobApplication::create([
|
||||
'job_id' => $activeJob->id,
|
||||
'worker_id' => $workerA->id,
|
||||
'status' => 'applied'
|
||||
]);
|
||||
|
||||
$appB = JobApplication::create([
|
||||
'job_id' => $activeJob->id,
|
||||
'worker_id' => $workerB->id,
|
||||
'status' => 'applied'
|
||||
]);
|
||||
|
||||
// First contact (Worker A) -> Should succeed
|
||||
$response = $this->postJson("/api/employers/applications/{$appA->id}/status", [
|
||||
'status' => 'shortlisted'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$this->assertEquals(1, User::contactedWorkersCount($this->employer->id));
|
||||
|
||||
// Second contact (Worker B) -> Should fail with 403 (contact limit reached)
|
||||
$response = $this->postJson("/api/employers/applications/{$appB->id}/status", [
|
||||
'status' => 'shortlisted'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonFragment([
|
||||
'message' => 'You have reached your contacted workers limit of 1 under your active plan. Please upgrade or renew your subscription to contact more workers.'
|
||||
]);
|
||||
|
||||
// Web status update: first check web login and update status
|
||||
// Web: contact limit block redirect
|
||||
$response = $this->actingAs($this->employer)
|
||||
->withSession(['user' => $this->employer])
|
||||
->post("/employer/candidates/{$appB->id}/status", [
|
||||
'status' => 'Offer Sent'
|
||||
]);
|
||||
|
||||
$response->assertRedirect('/employer/subscription');
|
||||
$response->assertSessionHas('error', 'You have reached your contacted workers limit of 1 under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||
}
|
||||
|
||||
public function test_job_hiring_workflow_enhancements()
|
||||
{
|
||||
// 1. Create a job
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Workflow Developer Job',
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 5000,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->addDays(2),
|
||||
'description' => 'Developer description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// 2. Worker applies
|
||||
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(201);
|
||||
$app = JobApplication::first();
|
||||
|
||||
// 3. Worker views details of application
|
||||
$response = $this->getJson("/api/workers/applied-jobs/{$app->id}", [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.application.status', 'applied');
|
||||
$response->assertJsonPath('data.application.job.posted_by', 'Employer User');
|
||||
$response->assertJsonCount(0, 'data.application.status_history');
|
||||
|
||||
// 4. Employer updates status to shortlisted with notes
|
||||
$response = $this->postJson("/api/employers/applications/{$app->id}/status", [
|
||||
'status' => 'shortlisted',
|
||||
'notes' => 'Very qualified candidate.'
|
||||
], [
|
||||
'Authorization' => 'Bearer employer_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.application.status', 'shortlisted');
|
||||
$response->assertJsonPath('data.application.notes', 'Very qualified candidate.');
|
||||
$response->assertJsonCount(1, 'data.application.status_history');
|
||||
$response->assertJsonPath('data.application.status_history.0.status', 'shortlisted');
|
||||
$response->assertJsonPath('data.application.status_history.0.notes', 'Very qualified candidate.');
|
||||
|
||||
// 5. Verify saved workers sync: worker is now saved (shortlisted)
|
||||
$this->assertDatabaseHas('shortlists', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
]);
|
||||
|
||||
// 6. Worker withdraws application -> should fail because status is shortlisted (not applied)
|
||||
$response = $this->postJson("/api/workers/applied-jobs/{$app->id}/withdraw", [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(400);
|
||||
|
||||
// 7. Reset application status to applied to test withdraw
|
||||
$app->refresh();
|
||||
$app->update(['status' => 'applied']);
|
||||
$response = $this->postJson("/api/workers/applied-jobs/{$app->id}/withdraw", [], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
$response->assertStatus(200);
|
||||
$this->assertDatabaseMissing('job_applications', [
|
||||
'id' => $app->id
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -154,6 +154,7 @@ public function test_s1_complete_basic_profile_setup()
|
||||
'nationality' => 'Indian',
|
||||
'language' => 'HI',
|
||||
'gender' => 'male',
|
||||
'main_profession' => 'Nanny',
|
||||
'skills' => [],
|
||||
]);
|
||||
|
||||
@ -180,6 +181,7 @@ public function test_s1_complete_basic_profile_setup()
|
||||
'phone' => $phone,
|
||||
'language' => 'HI',
|
||||
'gender' => 'male',
|
||||
'main_profession' => 'Nanny',
|
||||
'verified' => false,
|
||||
'status' => 'active',
|
||||
]);
|
||||
@ -386,6 +388,7 @@ public function test_register_with_new_api_fields()
|
||||
'in_country' => 'true',
|
||||
'visa_status' => 'Tourist Visa',
|
||||
'preferred_job_type' => 'full-time',
|
||||
'main_profession' => 'Housemaid',
|
||||
'gender' => 'male',
|
||||
'live_in_out' => 'live_out',
|
||||
'preferred_location' => 'Dubai Marina',
|
||||
@ -399,6 +402,7 @@ public function test_register_with_new_api_fields()
|
||||
'in_country' => true,
|
||||
'visa_status' => 'Tourist Visa',
|
||||
'preferred_job_type' => 'full-time',
|
||||
'main_profession' => 'Housemaid',
|
||||
'gender' => 'male',
|
||||
'live_in_out' => 'live_out',
|
||||
'preferred_location' => 'Dubai Marina',
|
||||
@ -503,7 +507,7 @@ public function test_register_passport_and_visa_with_direct_json_payload()
|
||||
'nationality' => 'Filipino',
|
||||
'age' => $expectedAge,
|
||||
'verified' => true,
|
||||
'visa_status' => 'CLEANER',
|
||||
'visa_status' => 'Tourist Visa',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('worker_documents', [
|
||||
@ -632,7 +636,7 @@ public function test_register_visa_with_full_ocr_fields()
|
||||
$visaDoc = \App\Models\WorkerDocument::where('worker_id', $workerId)->where('type', 'visa')->first();
|
||||
$this->assertNotNull($visaDoc->ocr_data);
|
||||
$this->assertEquals('uae_visa', $visaDoc->ocr_data['document_type']);
|
||||
$this->assertEquals('ENTRY PERMIT', $visaDoc->ocr_data['visa_type']);
|
||||
$this->assertEquals('Tourist Visa', $visaDoc->ocr_data['visa_type']);
|
||||
$this->assertEquals('Mr.MUHAMMAD NADEEM RASHEED', $visaDoc->ocr_data['full_name']);
|
||||
$this->assertEquals(97.4, $visaDoc->ocr_accuracy);
|
||||
}
|
||||
@ -685,7 +689,7 @@ public function test_register_passport_and_visa_with_json_encoded_strings()
|
||||
'name' => 'Johnny Test',
|
||||
'phone' => '+971509999999',
|
||||
'verified' => true,
|
||||
'visa_status' => 'HELPER',
|
||||
'visa_status' => 'Tourist Visa',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('worker_documents', [
|
||||
|
||||
211
tests/Feature/WorkerLoginReviewDataTest.php
Normal file
211
tests/Feature/WorkerLoginReviewDataTest.php
Normal file
@ -0,0 +1,211 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerDocument;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\Review;
|
||||
use App\Models\EmployerReview;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\EmployerProfile;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkerLoginReviewDataTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $worker;
|
||||
protected $employer;
|
||||
protected $job;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// 1. Create worker
|
||||
$this->worker = Worker::create([
|
||||
'name' => 'Rahul Sharma',
|
||||
'email' => 'rahul@example.com',
|
||||
'phone' => '+971501234567',
|
||||
'password' => bcrypt('password'),
|
||||
'nationality' => 'Indian',
|
||||
'age' => 25,
|
||||
'salary' => 1500,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => 'Not Specified',
|
||||
'religion' => 'Not Specified',
|
||||
'bio' => 'Test',
|
||||
'verified' => true,
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker-test-token',
|
||||
]);
|
||||
|
||||
// Create passport document to prevent pending 404
|
||||
WorkerDocument::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'type' => 'passport',
|
||||
'number' => '123456',
|
||||
'file_path' => 'passports/test.pdf',
|
||||
]);
|
||||
|
||||
// 2. Create an employer
|
||||
$this->employer = User::create([
|
||||
'name' => 'Employer One',
|
||||
'email' => 'emp1@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
'api_token' => 'employer-test-token',
|
||||
]);
|
||||
|
||||
EmployerProfile::create([
|
||||
'user_id' => $this->employer->id,
|
||||
'company_name' => 'Ahmad Tech Ltd',
|
||||
'nationality' => 'UAE',
|
||||
'city' => 'Dubai',
|
||||
'phone' => '+971 50 123 4567',
|
||||
'address' => 'Dubai, UAE',
|
||||
]);
|
||||
|
||||
// 3. Create job post
|
||||
$this->job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Test Job',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->subMonths(4)->toDateString(),
|
||||
'description' => 'Test Job Description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_worker_login_and_profile_returns_review_data()
|
||||
{
|
||||
// Create an Employer review of this Worker
|
||||
Review::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
'rating' => 4,
|
||||
'comment' => 'Good worker',
|
||||
]);
|
||||
|
||||
// Create a Worker review of this Employer
|
||||
EmployerReview::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 5,
|
||||
'title' => 'Nice place',
|
||||
'comment' => 'Great experience',
|
||||
]);
|
||||
|
||||
// Act: Worker login
|
||||
$response = $this->postJson('/api/workers/login', [
|
||||
'phone' => '+971501234567',
|
||||
'password' => 'password'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$workerData = $response->json('data.worker');
|
||||
|
||||
$this->assertEquals(4.0, $workerData['rating']);
|
||||
$this->assertEquals(1, $workerData['reviews_count']);
|
||||
$this->assertCount(1, $workerData['reviews']);
|
||||
$this->assertEquals('Good worker', $workerData['reviews'][0]['comment']);
|
||||
$this->assertEquals('Employer One', $workerData['reviews'][0]['employer']['name']);
|
||||
|
||||
$this->assertCount(1, $workerData['employer_reviews']);
|
||||
$this->assertEquals('Great experience', $workerData['employer_reviews'][0]['comment']);
|
||||
$this->assertEquals('Employer One', $workerData['employer_reviews'][0]['employer']['name']);
|
||||
|
||||
// Act: Get worker profile
|
||||
$token = $response->json('data.token');
|
||||
$profileResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer ' . $token,
|
||||
])->getJson('/api/workers/profile');
|
||||
|
||||
$profileResponse->assertStatus(200);
|
||||
$profileWorker = $profileResponse->json('data.worker');
|
||||
$this->assertEquals(4.0, $profileWorker['rating']);
|
||||
$this->assertEquals(1, $profileWorker['reviews_count']);
|
||||
}
|
||||
|
||||
public function test_worker_dashboard_and_employers_list_includes_employer_review_stats()
|
||||
{
|
||||
// Hired job offer to establish relationship
|
||||
JobOffer::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
'work_date' => now()->subDays(10),
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'status' => 'accepted',
|
||||
'notes' => 'Current Active Job',
|
||||
]);
|
||||
|
||||
// Submit worker review of employer
|
||||
EmployerReview::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 5,
|
||||
'title' => 'Fantastic',
|
||||
'comment' => 'Very good',
|
||||
]);
|
||||
|
||||
// Act: Get dashboard
|
||||
$dashboardResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson('/api/workers/dashboard');
|
||||
|
||||
$dashboardResponse->assertStatus(200);
|
||||
$currentEmp = $dashboardResponse->json('data.current_employer');
|
||||
$this->assertEquals(5.0, $currentEmp['rating']);
|
||||
$this->assertEquals(1, $currentEmp['reviews_count']);
|
||||
$this->assertCount(1, $currentEmp['reviews']);
|
||||
$this->assertEquals('Very good', $currentEmp['reviews'][0]['comment']);
|
||||
|
||||
// Act: Get employers list
|
||||
$employersResponse = $this->withHeaders([
|
||||
'Authorization' => 'Bearer worker-test-token',
|
||||
])->getJson('/api/workers/employers');
|
||||
|
||||
$employersResponse->assertStatus(200);
|
||||
$listEmp = $employersResponse->json('data.employers.0.employer');
|
||||
$this->assertEquals(5.0, $listEmp['rating']);
|
||||
$this->assertEquals(1, $listEmp['reviews_count']);
|
||||
$this->assertCount(1, $listEmp['reviews']);
|
||||
}
|
||||
|
||||
public function test_employer_profile_returns_review_data()
|
||||
{
|
||||
// Submit review of employer
|
||||
EmployerReview::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 5,
|
||||
'title' => 'Nice place',
|
||||
'comment' => 'Great experience',
|
||||
]);
|
||||
|
||||
// Act: Get employer profile
|
||||
$response = $this->withHeaders([
|
||||
'Authorization' => 'Bearer employer-test-token',
|
||||
])->getJson('/api/employers/profile');
|
||||
|
||||
$response->assertStatus(200);
|
||||
$profile = $response->json('data.profile');
|
||||
|
||||
$this->assertEquals(5.0, $profile['rating']);
|
||||
$this->assertEquals(1, $profile['reviews_count']);
|
||||
$this->assertCount(1, $profile['reviews']);
|
||||
$this->assertEquals('Great experience', $profile['reviews'][0]['comment']);
|
||||
$this->assertEquals('Rahul Sharma', $profile['reviews'][0]['worker']['name']);
|
||||
}
|
||||
}
|
||||
304
tests/Feature/WorkerReviewTest.php
Normal file
304
tests/Feature/WorkerReviewTest.php
Normal file
@ -0,0 +1,304 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\Worker;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\EmployerReview;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WorkerReviewTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected $employer;
|
||||
protected $worker;
|
||||
protected $job;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Create employer
|
||||
$this->employer = User::create([
|
||||
'name' => 'Test Employer',
|
||||
'email' => 'employer@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'role' => 'employer',
|
||||
]);
|
||||
|
||||
// Create worker
|
||||
$this->worker = Worker::create([
|
||||
'name' => 'Worker User',
|
||||
'email' => 'worker@example.com',
|
||||
'password' => bcrypt('password'),
|
||||
'phone' => '971501234567',
|
||||
'nationality' => 'Indian',
|
||||
'status' => 'active',
|
||||
'api_token' => 'worker_api_token',
|
||||
'age' => 25,
|
||||
'salary' => 2000,
|
||||
'availability' => 'Immediate',
|
||||
'experience' => '2 Years',
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Helper info',
|
||||
'passport_status' => 'valid',
|
||||
]);
|
||||
|
||||
// Create job post
|
||||
$this->job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Test Job',
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2500,
|
||||
'workers_needed' => 1,
|
||||
'job_type' => 'Full Time',
|
||||
'start_date' => now()->subMonths(4)->toDateString(),
|
||||
'description' => 'Test Job Description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cannot_review_without_confirmed_hire_and_joining()
|
||||
{
|
||||
// Act: Try to review before any job application is made
|
||||
$response = $this->postJson('/api/workers/reviews', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 5,
|
||||
'comment' => 'Great employer!',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'You can only review employers with a confirmed hire and completed joining.');
|
||||
}
|
||||
|
||||
public function test_cannot_review_before_3_months_since_joining()
|
||||
{
|
||||
// Setup job application with hired status but joining_confirmed_at is only 2 months ago
|
||||
JobApplication::create([
|
||||
'job_id' => $this->job->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
'status' => 'hired',
|
||||
'joining_confirmed_at' => now()->subMonths(2)->toDateString(),
|
||||
]);
|
||||
|
||||
// Act: Try to review
|
||||
$response = $this->postJson('/api/workers/reviews', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 5,
|
||||
'comment' => 'Great employer!',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'You can write a review only after 3 months from the joining date.');
|
||||
}
|
||||
|
||||
public function test_can_review_after_3_months_since_joining()
|
||||
{
|
||||
// Setup job application with hired status and joining_confirmed_at 3.5 months ago
|
||||
JobApplication::create([
|
||||
'job_id' => $this->job->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
'status' => 'hired',
|
||||
'joining_confirmed_at' => now()->subMonths(3)->subDays(15)->toDateString(),
|
||||
]);
|
||||
|
||||
// Act: Post review
|
||||
$response = $this->postJson('/api/workers/reviews', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 4,
|
||||
'title' => 'Fair Employer',
|
||||
'comment' => 'Very good experience overall.',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.review.rating', 4);
|
||||
$response->assertJsonPath('data.review.title', 'Fair Employer');
|
||||
$response->assertJsonPath('data.review.comment', 'Very good experience overall.');
|
||||
|
||||
$this->assertDatabaseHas('employer_reviews', [
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 4,
|
||||
'title' => 'Fair Employer',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cannot_duplicate_reviews()
|
||||
{
|
||||
// Setup job application
|
||||
JobApplication::create([
|
||||
'job_id' => $this->job->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
'status' => 'hired',
|
||||
'joining_confirmed_at' => now()->subMonths(4)->toDateString(),
|
||||
]);
|
||||
|
||||
// Submit first review
|
||||
EmployerReview::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 5,
|
||||
'comment' => 'Nice',
|
||||
]);
|
||||
|
||||
// Act: Try to submit duplicate review
|
||||
$response = $this->postJson('/api/workers/reviews', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 4,
|
||||
'comment' => 'Another review',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'You have already submitted a review for this job/employer.');
|
||||
}
|
||||
|
||||
public function test_edit_review_within_7_days()
|
||||
{
|
||||
// Setup existing review created now
|
||||
$review = EmployerReview::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 3,
|
||||
'title' => 'Initial Title',
|
||||
'comment' => 'Initial Comment',
|
||||
]);
|
||||
|
||||
// Act: Edit the review
|
||||
$response = $this->putJson("/api/workers/reviews/{$review->id}", [
|
||||
'rating' => 5,
|
||||
'title' => 'Updated Title',
|
||||
'comment' => 'Updated Comment Details',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.review.rating', 5);
|
||||
$response->assertJsonPath('data.review.title', 'Updated Title');
|
||||
$response->assertJsonPath('data.review.comment', 'Updated Comment Details');
|
||||
|
||||
$this->assertDatabaseHas('employer_reviews', [
|
||||
'id' => $review->id,
|
||||
'rating' => 5,
|
||||
'title' => 'Updated Title',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_cannot_edit_review_after_7_days()
|
||||
{
|
||||
// Setup existing review created 8 days ago
|
||||
$review = EmployerReview::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 3,
|
||||
'comment' => 'Initial Comment',
|
||||
]);
|
||||
|
||||
// Force creation date to be 8 days ago
|
||||
$review->created_at = now()->subDays(8);
|
||||
$review->save();
|
||||
|
||||
// Act: Attempt to edit
|
||||
$response = $this->putJson("/api/workers/reviews/{$review->id}", [
|
||||
'rating' => 5,
|
||||
'comment' => 'Attempted Edit',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
$response->assertJsonPath('success', false);
|
||||
$response->assertJsonPath('message', 'The 7-day edit window for this review has expired.');
|
||||
}
|
||||
|
||||
public function test_view_and_list_reviews()
|
||||
{
|
||||
// Create a review
|
||||
$review = EmployerReview::create([
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => $this->job->id,
|
||||
'rating' => 5,
|
||||
'title' => 'Awesome place',
|
||||
'comment' => 'Had a wonderful time.',
|
||||
]);
|
||||
|
||||
// 1. Get Single Review
|
||||
$response = $this->getJson("/api/workers/reviews/{$review->id}", [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.review.title', 'Awesome place');
|
||||
$response->assertJsonPath('data.review.rating', 5);
|
||||
|
||||
// 2. List Reviews
|
||||
$response = $this->getJson('/api/workers/reviews', [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(200);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonCount(1, 'data.reviews');
|
||||
$response->assertJsonPath('data.reviews.0.title', 'Awesome place');
|
||||
}
|
||||
|
||||
public function test_review_with_invalid_or_missing_job_id_passes_and_saves_null()
|
||||
{
|
||||
// Setup job application with hired status and joining_confirmed_at 3.5 months ago
|
||||
JobApplication::create([
|
||||
'job_id' => $this->job->id,
|
||||
'worker_id' => $this->worker->id,
|
||||
'status' => 'hired',
|
||||
'joining_confirmed_at' => now()->subMonths(3)->subDays(15)->toDateString(),
|
||||
]);
|
||||
|
||||
// Act: Post review with an invalid job_id (e.g. 'invalid-id-or-empty')
|
||||
$response = $this->postJson('/api/workers/reviews', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => 'invalid-id-or-empty',
|
||||
'rating' => 4,
|
||||
'title' => 'Fair Employer',
|
||||
'comment' => 'Very good experience overall.',
|
||||
], [
|
||||
'Authorization' => 'Bearer worker_api_token'
|
||||
]);
|
||||
|
||||
$response->assertStatus(201);
|
||||
$response->assertJsonPath('success', true);
|
||||
$response->assertJsonPath('data.review.job_id', null); // Resolved to null
|
||||
|
||||
$this->assertDatabaseHas('employer_reviews', [
|
||||
'worker_id' => $this->worker->id,
|
||||
'employer_id' => $this->employer->id,
|
||||
'job_id' => null,
|
||||
'rating' => 4,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user