Compare commits
No commits in common. "a95a33829c93c47cb0b8060d4cbf96cac7c07e62" and "6bb00f21533f886f0846e2f407411824f5aa3bb4" have entirely different histories.
a95a33829c
...
6bb00f2153
@ -1,291 +0,0 @@
|
|||||||
<?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('Automated reminder system run complete!');
|
|
||||||
}
|
|
||||||
|
|
||||||
private function getReminderLevel(int $days): ?string
|
|
||||||
{
|
|
||||||
if ($days === 7) {
|
|
||||||
return '7_days';
|
|
||||||
} elseif ($days === 3) {
|
|
||||||
return '3_days';
|
|
||||||
} elseif ($days === 1) {
|
|
||||||
return '1_day';
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
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);
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -57,7 +57,6 @@ private function formatWorker(Worker $w)
|
|||||||
'deleted_at' => $w->deleted_at?->toISOString(),
|
'deleted_at' => $w->deleted_at?->toISOString(),
|
||||||
'language' => $w->language,
|
'language' => $w->language,
|
||||||
'languages' => $langs,
|
'languages' => $langs,
|
||||||
'main_profession' => $w->main_profession,
|
|
||||||
'preferred_location' => $w->preferred_location,
|
'preferred_location' => $w->preferred_location,
|
||||||
'country' => $w->country,
|
'country' => $w->country,
|
||||||
'city' => $w->city,
|
'city' => $w->city,
|
||||||
@ -155,13 +154,6 @@ public function getWorkers(Request $request)
|
|||||||
$workersArray = $formattedWorkers->toArray();
|
$workersArray = $formattedWorkers->toArray();
|
||||||
|
|
||||||
// Apply filters: gender, skills, language/languages, nationality, preferred_location, job_type, live_in_out, in_country, visa_status
|
// 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')) {
|
if ($request->filled('gender')) {
|
||||||
$gender = strtolower($request->gender);
|
$gender = strtolower($request->gender);
|
||||||
$workersArray = array_values(array_filter($workersArray, function ($c) use ($gender) {
|
$workersArray = array_values(array_filter($workersArray, function ($c) use ($gender) {
|
||||||
@ -489,13 +481,6 @@ public function getCandidates(Request $request)
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status
|
// 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')) {
|
if ($request->filled('gender')) {
|
||||||
$gender = strtolower($request->gender);
|
$gender = strtolower($request->gender);
|
||||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) {
|
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($gender) {
|
||||||
|
|||||||
@ -181,7 +181,6 @@ public function setupProfile(Request $request)
|
|||||||
'gender' => 'nullable|string|in:male,female,other',
|
'gender' => 'nullable|string|in:male,female,other',
|
||||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||||
'preferred_location' => 'nullable|string|max:255',
|
'preferred_location' => 'nullable|string|max:255',
|
||||||
'main_profession' => 'nullable|string|max:100',
|
|
||||||
'skills' => 'nullable|array',
|
'skills' => 'nullable|array',
|
||||||
'skills.*' => 'integer|exists:skills,id',
|
'skills.*' => 'integer|exists:skills,id',
|
||||||
'fcm_token' => 'nullable|string|max:255',
|
'fcm_token' => 'nullable|string|max:255',
|
||||||
@ -242,7 +241,6 @@ public function setupProfile(Request $request)
|
|||||||
'gender' => $request->gender,
|
'gender' => $request->gender,
|
||||||
'live_in_out' => $request->live_in_out,
|
'live_in_out' => $request->live_in_out,
|
||||||
'preferred_location' => $request->preferred_location,
|
'preferred_location' => $request->preferred_location,
|
||||||
'main_profession' => $request->main_profession,
|
|
||||||
'age' => 25, // Default — updated later in full profile
|
'age' => 25, // Default — updated later in full profile
|
||||||
'salary' => 1500, // Default AED — updated later
|
'salary' => 1500, // Default AED — updated later
|
||||||
'availability' => 'Immediate',
|
'availability' => 'Immediate',
|
||||||
@ -320,7 +318,6 @@ public function register(Request $request)
|
|||||||
'in_country' => 'nullable',
|
'in_country' => 'nullable',
|
||||||
'visa_status' => 'nullable|string|max:100',
|
'visa_status' => 'nullable|string|max:100',
|
||||||
'preferred_job_type' => 'nullable|string|max:100',
|
'preferred_job_type' => 'nullable|string|max:100',
|
||||||
'main_profession' => 'nullable|string|max:100',
|
|
||||||
'gender' => 'nullable|string|in:male,female,other',
|
'gender' => 'nullable|string|in:male,female,other',
|
||||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||||
'preferred_location' => 'nullable|string|max:255',
|
'preferred_location' => 'nullable|string|max:255',
|
||||||
@ -456,7 +453,6 @@ public function register(Request $request)
|
|||||||
'gender' => $request->gender,
|
'gender' => $request->gender,
|
||||||
'live_in_out' => $request->live_in_out,
|
'live_in_out' => $request->live_in_out,
|
||||||
'preferred_location' => $request->preferred_location,
|
'preferred_location' => $request->preferred_location,
|
||||||
'main_profession' => $request->main_profession,
|
|
||||||
'age' => $age,
|
'age' => $age,
|
||||||
'salary' => $request->salary,
|
'salary' => $request->salary,
|
||||||
'availability' => 'Immediate',
|
'availability' => 'Immediate',
|
||||||
|
|||||||
@ -1,321 +0,0 @@
|
|||||||
<?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');
|
|
||||||
|
|
||||||
$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 3 months from joining date
|
|
||||||
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < 3) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'You can write a review only after 3 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 7-day edit window has expired
|
|
||||||
if ($review->created_at->addDays(7)->isPast()) {
|
|
||||||
return response()->json([
|
|
||||||
'success' => false,
|
|
||||||
'message' => 'The 7-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);
|
|
||||||
}
|
|
||||||
|
|
||||||
$editable = !$review->created_at->addDays(7)->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) {
|
|
||||||
$editable = !$rev->created_at->addDays(7)->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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -91,7 +91,6 @@ public function index(Request $request)
|
|||||||
'emirates_id_status' => $emiratesIdStatus,
|
'emirates_id_status' => $emiratesIdStatus,
|
||||||
'passport_status' => $w->passport_status,
|
'passport_status' => $w->passport_status,
|
||||||
'category' => 'Domestic Worker',
|
'category' => 'Domestic Worker',
|
||||||
'main_profession' => $w->main_profession,
|
|
||||||
'skills' => $mappedSkills,
|
'skills' => $mappedSkills,
|
||||||
'visa_status' => $visaStatus,
|
'visa_status' => $visaStatus,
|
||||||
'experience' => $w->experience,
|
'experience' => $w->experience,
|
||||||
@ -193,12 +192,6 @@ public function index(Request $request)
|
|||||||
return false;
|
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')) {
|
if ($request->filled('gender')) {
|
||||||
$gender = strtolower($request->gender);
|
$gender = strtolower($request->gender);
|
||||||
$workers = array_values(array_filter($workers, function ($c) use ($gender) {
|
$workers = array_values(array_filter($workers, function ($c) use ($gender) {
|
||||||
@ -275,7 +268,6 @@ public function index(Request $request)
|
|||||||
|
|
||||||
$filtersMetadata = [
|
$filtersMetadata = [
|
||||||
'nationalities' => array_merge(['All Nationalities'], $dbNationalities),
|
'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'],
|
'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'],
|
||||||
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
|
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
|
||||||
'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'],
|
'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'],
|
||||||
@ -375,7 +367,6 @@ public function show($id)
|
|||||||
'emirates_id_status' => $emiratesIdStatus,
|
'emirates_id_status' => $emiratesIdStatus,
|
||||||
'passport_status' => $w->passport_status,
|
'passport_status' => $w->passport_status,
|
||||||
'category' => 'Domestic Worker',
|
'category' => 'Domestic Worker',
|
||||||
'main_profession' => $w->main_profession,
|
|
||||||
'skills' => $mappedSkills,
|
'skills' => $mappedSkills,
|
||||||
'visa_status' => $visaStatus,
|
'visa_status' => $visaStatus,
|
||||||
'experience' => $w->experience,
|
'experience' => $w->experience,
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,7 +15,6 @@ class JobApplication extends Model
|
|||||||
'status',
|
'status',
|
||||||
'notes',
|
'notes',
|
||||||
'status_history',
|
'status_history',
|
||||||
'joining_confirmed_at',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
<?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',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
@ -5,11 +5,10 @@
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Notifications\Notifiable;
|
|
||||||
|
|
||||||
class Worker extends Model
|
class Worker extends Model
|
||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes, Notifiable;
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
@ -36,7 +35,6 @@ class Worker extends Model
|
|||||||
'in_country',
|
'in_country',
|
||||||
'visa_status',
|
'visa_status',
|
||||||
'preferred_job_type',
|
'preferred_job_type',
|
||||||
'main_profession',
|
|
||||||
'fcm_token',
|
'fcm_token',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,35 +0,0 @@
|
|||||||
<?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'] ?? []
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,106 +0,0 @@
|
|||||||
<?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,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
<?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,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,101 +0,0 @@
|
|||||||
<?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,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,114 +0,0 @@
|
|||||||
<?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,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
<?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');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@ -63,79 +63,28 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"description": "Optional JSON object containing extracted organization/trade license details.",
|
"description": "Optional JSON object containing extracted organization/trade license details.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"document_type": {
|
"document_type": { "type": "string", "example": "dubai_commercial_license" },
|
||||||
"type": "string",
|
"country": { "type": "string", "example": "United Arab Emirates" },
|
||||||
"example": "dubai_commercial_license"
|
"authority": { "type": "string", "example": "Dubai Economy and Tourism" },
|
||||||
},
|
"license_no": { "type": "string", "example": "1426961" },
|
||||||
"country": {
|
"company_name": { "type": "string", "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" },
|
||||||
"type": "string",
|
"business_name": { "type": "string", "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" },
|
||||||
"example": "United Arab Emirates"
|
"license_category": { "type": "string", "example": "Dep. of Economic Development" },
|
||||||
},
|
"legal_type": { "type": "string", "example": "Limited Liability Company(LLC)" },
|
||||||
"authority": {
|
"issue_date": { "type": "string", "example": "22/10/2024" },
|
||||||
"type": "string",
|
"expiry_date": { "type": "string", "example": "21/10/2026" },
|
||||||
"example": "Dubai Economy and Tourism"
|
"main_license_no": { "type": "string", "example": "1426961" },
|
||||||
},
|
"register_no": { "type": "string", "example": "2436760" },
|
||||||
"license_no": {
|
"dcci_no": { "type": "string", "example": "570232" },
|
||||||
"type": "string",
|
|
||||||
"example": "1426961"
|
|
||||||
},
|
|
||||||
"company_name": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "NEST CLIMATE TECHNICAL SERVICES L.L.C"
|
|
||||||
},
|
|
||||||
"business_name": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "NEST CLIMATE TECHNICAL SERVICES L.L.C"
|
|
||||||
},
|
|
||||||
"license_category": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Dep. of Economic Development"
|
|
||||||
},
|
|
||||||
"legal_type": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Limited Liability Company(LLC)"
|
|
||||||
},
|
|
||||||
"issue_date": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "22/10/2024"
|
|
||||||
},
|
|
||||||
"expiry_date": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "21/10/2026"
|
|
||||||
},
|
|
||||||
"main_license_no": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "1426961"
|
|
||||||
},
|
|
||||||
"register_no": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2436760"
|
|
||||||
},
|
|
||||||
"dcci_no": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "570232"
|
|
||||||
},
|
|
||||||
"members": {
|
"members": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"person_no": {
|
"person_no": { "type": "string", "example": "1709285" },
|
||||||
"type": "string",
|
"name": { "type": "string", "example": "SEYED ABDUL RAHMAN SHERIF NISAR" },
|
||||||
"example": "1709285"
|
"nationality": { "type": "string", "example": "India" },
|
||||||
},
|
"role": { "type": "string", "example": "Manager" }
|
||||||
"name": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "SEYED ABDUL RAHMAN SHERIF NISAR"
|
|
||||||
},
|
|
||||||
"nationality": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "India"
|
|
||||||
},
|
|
||||||
"role": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Manager"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -189,6 +138,7 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "151023946"
|
"example": "151023946"
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"organization_name": {
|
"organization_name": {
|
||||||
@ -1026,18 +976,6 @@
|
|||||||
"example": "full-time",
|
"example": "full-time",
|
||||||
"description": "Preferred job type."
|
"description": "Preferred job type."
|
||||||
},
|
},
|
||||||
"main_profession": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Housemaid",
|
|
||||||
"description": "Main profession of the worker selected from the master dropdown.",
|
|
||||||
"enum": [
|
|
||||||
"Housemaid",
|
|
||||||
"Nanny",
|
|
||||||
"Cook",
|
|
||||||
"Driver",
|
|
||||||
"Caregiver"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"live_in_out": {
|
"live_in_out": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
@ -2395,18 +2333,6 @@
|
|||||||
"example": "Dubai Marina",
|
"example": "Dubai Marina",
|
||||||
"description": "Preferred work location/area."
|
"description": "Preferred work location/area."
|
||||||
},
|
},
|
||||||
"main_profession": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Nanny",
|
|
||||||
"description": "Main profession of the worker selected from the master dropdown.",
|
|
||||||
"enum": [
|
|
||||||
"Housemaid",
|
|
||||||
"Nanny",
|
|
||||||
"Cook",
|
|
||||||
"Driver",
|
|
||||||
"Caregiver"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"fcm_token": {
|
"fcm_token": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "fcm_token_example",
|
"example": "fcm_token_example",
|
||||||
@ -3312,185 +3238,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/workers/reviews": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Worker/Reviews"
|
|
||||||
],
|
|
||||||
"summary": "List Worker Reviews",
|
|
||||||
"description": "Retrieves a paginated list of all reviews submitted by the authenticated worker.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "page",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "per_page",
|
|
||||||
"in": "query",
|
|
||||||
"schema": {
|
|
||||||
"type": "integer",
|
|
||||||
"default": 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Reviews retrieved successfully."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"post": {
|
|
||||||
"tags": [
|
|
||||||
"Worker/Reviews"
|
|
||||||
],
|
|
||||||
"summary": "Submit Review for Employer",
|
|
||||||
"description": "Submits a review for an employer. Eligible only after 3 months from joining, for a confirmed hire.",
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"employer_id",
|
|
||||||
"rating",
|
|
||||||
"comment"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"employer_id": {
|
|
||||||
"type": "integer",
|
|
||||||
"example": 5,
|
|
||||||
"description": "The ID of the employer (User ID)."
|
|
||||||
},
|
|
||||||
"job_id": {
|
|
||||||
"type": "integer",
|
|
||||||
"example": 2,
|
|
||||||
"description": "Optional Job ID associated with the review."
|
|
||||||
},
|
|
||||||
"rating": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 5,
|
|
||||||
"example": 5,
|
|
||||||
"description": "Rating out of 5 stars."
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Great workplace",
|
|
||||||
"description": "Optional review title."
|
|
||||||
},
|
|
||||||
"comment": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Highly supportive team and on-time salary payments.",
|
|
||||||
"description": "Review comment."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"201": {
|
|
||||||
"description": "Review submitted successfully."
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Eligibility check failed (no confirmed hire or under 3 months since joining)."
|
|
||||||
},
|
|
||||||
"422": {
|
|
||||||
"description": "Validation error or duplicate review."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/workers/reviews/{id}": {
|
|
||||||
"get": {
|
|
||||||
"tags": [
|
|
||||||
"Worker/Reviews"
|
|
||||||
],
|
|
||||||
"summary": "Get Review Details",
|
|
||||||
"description": "Retrieves the details of a specific review submitted by the worker.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Review details retrieved successfully."
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Review not found."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"put": {
|
|
||||||
"tags": [
|
|
||||||
"Worker/Reviews"
|
|
||||||
],
|
|
||||||
"summary": "Edit Submitted Review",
|
|
||||||
"description": "Updates a review. Permitted only within a 7-day edit window from the initial submission date.",
|
|
||||||
"parameters": [
|
|
||||||
{
|
|
||||||
"name": "id",
|
|
||||||
"in": "path",
|
|
||||||
"required": true,
|
|
||||||
"schema": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"requestBody": {
|
|
||||||
"required": true,
|
|
||||||
"content": {
|
|
||||||
"application/json": {
|
|
||||||
"schema": {
|
|
||||||
"type": "object",
|
|
||||||
"required": [
|
|
||||||
"rating",
|
|
||||||
"comment"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"rating": {
|
|
||||||
"type": "integer",
|
|
||||||
"minimum": 1,
|
|
||||||
"maximum": 5,
|
|
||||||
"example": 4
|
|
||||||
},
|
|
||||||
"title": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Good workplace"
|
|
||||||
},
|
|
||||||
"comment": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Updating my comment with details."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "Review edited successfully."
|
|
||||||
},
|
|
||||||
"403": {
|
|
||||||
"description": "Edit option disabled because the 7-day window expired."
|
|
||||||
},
|
|
||||||
"404": {
|
|
||||||
"description": "Review not found."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/employers/register": {
|
"/employers/register": {
|
||||||
"post": {
|
"post": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@ -5307,15 +5054,6 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "main_profession",
|
|
||||||
"in": "query",
|
|
||||||
"required": false,
|
|
||||||
"description": "Filter by main profession (e.g. Housemaid, Nanny, Cook, Driver, Caregiver).",
|
|
||||||
"schema": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "preferred_location",
|
"name": "preferred_location",
|
||||||
"in": "query",
|
"in": "query",
|
||||||
@ -6507,44 +6245,14 @@
|
|||||||
"description"
|
"description"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": { "type": "string", "example": "Housekeeper Needed" },
|
||||||
"type": "string",
|
"workers_needed": { "type": "integer", "example": 2 },
|
||||||
"example": "Housekeeper Needed"
|
"location": { "type": "string", "example": "Dubai Marina" },
|
||||||
},
|
"salary": { "type": "number", "example": 1800 },
|
||||||
"workers_needed": {
|
"job_type": { "type": "string", "enum": ["Full Time", "Part Time", "Contract"], "example": "Full Time" },
|
||||||
"type": "integer",
|
"start_date": { "type": "string", "format": "date", "example": "2026-07-15" },
|
||||||
"example": 2
|
"description": { "type": "string", "example": "Looking for an experienced housekeeper for a family in Dubai Marina." },
|
||||||
},
|
"requirements": { "type": "string", "example": "Must speak fluent English. Minimum 3 years experience." }
|
||||||
"location": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Dubai Marina"
|
|
||||||
},
|
|
||||||
"salary": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 1800
|
|
||||||
},
|
|
||||||
"job_type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"Full Time",
|
|
||||||
"Part Time",
|
|
||||||
"Contract"
|
|
||||||
],
|
|
||||||
"example": "Full Time"
|
|
||||||
},
|
|
||||||
"start_date": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date",
|
|
||||||
"example": "2026-07-15"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Looking for an experienced housekeeper for a family in Dubai Marina."
|
|
||||||
},
|
|
||||||
"requirements": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Must speak fluent English. Minimum 3 years experience."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6580,9 +6288,7 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": { "type": "integer" }
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -6613,9 +6319,7 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": { "type": "integer" }
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"responses": {
|
"responses": {
|
||||||
@ -6648,9 +6352,7 @@
|
|||||||
"name": "id",
|
"name": "id",
|
||||||
"in": "path",
|
"in": "path",
|
||||||
"required": true,
|
"required": true,
|
||||||
"schema": {
|
"schema": { "type": "integer" }
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
@ -6670,53 +6372,15 @@
|
|||||||
"status"
|
"status"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": { "type": "string", "example": "Housekeeper Needed" },
|
||||||
"type": "string",
|
"workers_needed": { "type": "integer", "example": 2 },
|
||||||
"example": "Housekeeper Needed"
|
"location": { "type": "string", "example": "Dubai Marina" },
|
||||||
},
|
"salary": { "type": "number", "example": 1800 },
|
||||||
"workers_needed": {
|
"job_type": { "type": "string", "enum": ["Full Time", "Part Time", "Contract"], "example": "Full Time" },
|
||||||
"type": "integer",
|
"start_date": { "type": "string", "format": "date", "example": "2026-07-15" },
|
||||||
"example": 2
|
"description": { "type": "string", "example": "Looking for an experienced housekeeper for a family in Dubai Marina." },
|
||||||
},
|
"requirements": { "type": "string", "example": "Must speak fluent English. Minimum 3 years experience." },
|
||||||
"location": {
|
"status": { "type": "string", "enum": ["active", "closed", "draft"], "example": "active" }
|
||||||
"type": "string",
|
|
||||||
"example": "Dubai Marina"
|
|
||||||
},
|
|
||||||
"salary": {
|
|
||||||
"type": "number",
|
|
||||||
"example": 1800
|
|
||||||
},
|
|
||||||
"job_type": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"Full Time",
|
|
||||||
"Part Time",
|
|
||||||
"Contract"
|
|
||||||
],
|
|
||||||
"example": "Full Time"
|
|
||||||
},
|
|
||||||
"start_date": {
|
|
||||||
"type": "string",
|
|
||||||
"format": "date",
|
|
||||||
"example": "2026-07-15"
|
|
||||||
},
|
|
||||||
"description": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Looking for an experienced housekeeper for a family in Dubai Marina."
|
|
||||||
},
|
|
||||||
"requirements": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Must speak fluent English. Minimum 3 years experience."
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"active",
|
|
||||||
"closed",
|
|
||||||
"draft"
|
|
||||||
],
|
|
||||||
"example": "active"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -6795,8 +6459,8 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"application_id": {
|
"application_id": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"example": 10
|
"example": 10
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -6848,7 +6512,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"401": {
|
"401": {
|
||||||
@ -7816,39 +7480,23 @@
|
|||||||
"schema": {
|
"schema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"success": {
|
"success": { "type": "boolean", "example": true },
|
||||||
"type": "boolean",
|
|
||||||
"example": true
|
|
||||||
},
|
|
||||||
"data": {
|
"data": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"application": {
|
"application": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": {
|
"id": { "type": "integer", "example": 12 },
|
||||||
"type": "integer",
|
"status": { "type": "string", "example": "shortlisted" },
|
||||||
"example": 12
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "shortlisted"
|
|
||||||
},
|
|
||||||
"status_history": {
|
"status_history": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"status": {
|
"status": { "type": "string" },
|
||||||
"type": "string"
|
"timestamp": { "type": "string" },
|
||||||
},
|
"notes": { "type": "string", "nullable": true }
|
||||||
"timestamp": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"notes": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7977,103 +7625,34 @@
|
|||||||
"license": {
|
"license": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"document_type": {
|
"document_type": { "type": "string", "example": "dubai_commercial_license" },
|
||||||
"type": "string",
|
"country": { "type": "string", "example": "United Arab Emirates" },
|
||||||
"example": "dubai_commercial_license"
|
"authority": { "type": "string", "example": "Dubai Economy and Tourism" },
|
||||||
},
|
"license_no": { "type": "string", "example": "1426961" },
|
||||||
"country": {
|
"company_name": { "type": "string", "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" },
|
||||||
"type": "string",
|
"business_name": { "type": "string", "example": "NEST CLIMATE TECHNICAL SERVICES L.L.C" },
|
||||||
"example": "United Arab Emirates"
|
"license_category": { "type": "string", "example": "Dep. of Economic Development" },
|
||||||
},
|
"legal_type": { "type": "string", "example": "Limited Liability Company(LLC)" },
|
||||||
"authority": {
|
"issue_date": { "type": "string", "example": "22/10/2024" },
|
||||||
"type": "string",
|
"expiry_date": { "type": "string", "example": "21/10/2026" },
|
||||||
"example": "Dubai Economy and Tourism"
|
"main_license_no": { "type": "string", "example": "1426961" },
|
||||||
},
|
"register_no": { "type": "string", "example": "2436760" },
|
||||||
"license_no": {
|
"dcci_no": { "type": "string", "example": "570232" }
|
||||||
"type": "string",
|
|
||||||
"example": "1426961"
|
|
||||||
},
|
|
||||||
"company_name": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "NEST CLIMATE TECHNICAL SERVICES L.L.C"
|
|
||||||
},
|
|
||||||
"business_name": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "NEST CLIMATE TECHNICAL SERVICES L.L.C"
|
|
||||||
},
|
|
||||||
"license_category": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Dep. of Economic Development"
|
|
||||||
},
|
|
||||||
"legal_type": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Limited Liability Company(LLC)"
|
|
||||||
},
|
|
||||||
"issue_date": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "22/10/2024"
|
|
||||||
},
|
|
||||||
"expiry_date": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "21/10/2026"
|
|
||||||
},
|
|
||||||
"main_license_no": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "1426961"
|
|
||||||
},
|
|
||||||
"register_no": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2436760"
|
|
||||||
},
|
|
||||||
"dcci_no": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "570232"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"emirates_id": {
|
"emirates_id": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"emirates_id_number": {
|
"emirates_id_number": { "type": "string", "example": "784-1988-5310327-2" },
|
||||||
"type": "string",
|
"name": { "type": "string", "example": "KRISHNA PRASAD" },
|
||||||
"example": "784-1988-5310327-2"
|
"date_of_birth": { "type": "string", "example": "1988-03-22" },
|
||||||
},
|
"issue_date": { "type": "string", "example": "2023-04-11" },
|
||||||
"name": {
|
"expiry_date": { "type": "string", "example": "2028-04-11" },
|
||||||
"type": "string",
|
"employer": { "type": "string", "example": "Federal Authority" },
|
||||||
"example": "KRISHNA PRASAD"
|
"issue_place": { "type": "string", "example": "Abu Dhabi" },
|
||||||
},
|
"occupation": { "type": "string", "example": "Manager" },
|
||||||
"date_of_birth": {
|
"nationality": { "type": "string", "example": "NPL" },
|
||||||
"type": "string",
|
"gender": { "type": "string", "example": "Male" }
|
||||||
"example": "1988-03-22"
|
|
||||||
},
|
|
||||||
"issue_date": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2023-04-11"
|
|
||||||
},
|
|
||||||
"expiry_date": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "2028-04-11"
|
|
||||||
},
|
|
||||||
"employer": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Federal Authority"
|
|
||||||
},
|
|
||||||
"issue_place": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Abu Dhabi"
|
|
||||||
},
|
|
||||||
"occupation": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Manager"
|
|
||||||
},
|
|
||||||
"nationality": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "NPL"
|
|
||||||
},
|
|
||||||
"gender": {
|
|
||||||
"type": "string",
|
|
||||||
"example": "Male"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"created_at": {
|
"created_at": {
|
||||||
@ -8255,12 +7834,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"main_profession": {
|
|
||||||
"type": "string",
|
|
||||||
"nullable": true,
|
|
||||||
"example": "Nanny",
|
|
||||||
"description": "The main profession of the worker"
|
|
||||||
},
|
|
||||||
"skills": {
|
"skills": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
|||||||
@ -373,9 +373,9 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button className="flex items-center space-x-3 pl-6 border-l border-slate-200 group outline-none">
|
<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 max-w-[180px]">
|
<div className="text-right hidden sm:block">
|
||||||
<div className="text-[11px] font-black text-slate-900 group-hover:text-[#185FA5] transition-colors truncate">{user.name}</div>
|
<div className="text-xs font-black text-slate-900 group-hover:text-[#185FA5] transition-colors">{user.name}</div>
|
||||||
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{t('employer_account', 'Employer Account')}</div>
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-tighter">{t('employer_account', 'Employer Account')}</div>
|
||||||
</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">
|
<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)}
|
{user.name.charAt(0)}
|
||||||
|
|||||||
@ -103,8 +103,8 @@ export default function Dashboard({
|
|||||||
<span>{stats.days_remaining} {t('days_left', 'Days Left')}</span>
|
<span>{stats.days_remaining} {t('days_left', 'Days Left')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1 className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight leading-tight text-blue-200">
|
<h1 className="text-3xl sm:text-4xl font-black tracking-tight">
|
||||||
{t('welcome_back', 'Welcome Back')}, <span className="text-white font-medium">{employer.name}</span>
|
{t('welcome_back', 'Welcome Back')}, {employer.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="text-blue-100 text-sm leading-relaxed font-medium">
|
<p className="text-blue-100 text-sm leading-relaxed font-medium">
|
||||||
@ -339,14 +339,7 @@ export default function Dashboard({
|
|||||||
<span>{worker.name}</span>
|
<span>{worker.name}</span>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-500 truncate font-semibold flex items-center gap-1.5 flex-wrap">
|
<div className="text-[10px] text-slate-500 truncate font-semibold">{worker.nationality}</div>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -97,169 +97,171 @@ export default function Shortlist({ shortlistedWorkers }) {
|
|||||||
|
|
||||||
return (
|
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">
|
<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 */}
|
{/* 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="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 min-w-0 flex-1">
|
<div className="flex items-center space-x-3.5 min-w-0 flex-1">
|
||||||
{/* Photo (optional) container */}
|
{/* 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">
|
<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 ? (
|
{worker.photo ? (
|
||||||
<img src={worker.photo} alt={worker.name} className="w-full h-full object-cover" />
|
<img src={worker.photo} alt={worker.name} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<User className="w-5 h-5 text-slate-400" />
|
<User className="w-6 h-6 text-slate-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-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">
|
<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>
|
<span>{worker.name}</span>
|
||||||
{worker.main_profession && (
|
{false && (
|
||||||
<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">
|
<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">
|
||||||
{worker.main_profession}
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
</span>
|
<span>{t('verified', 'Verified')}</span>
|
||||||
)}
|
</span>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
{/* Passport Status Verification Badge & Visa Status */}
|
|
||||||
<div className="flex flex-wrap gap-1">
|
{/* Passport Status Verification Badge & Visa Status */}
|
||||||
{worker.visa_status && (worker.visa_status.toLowerCase().includes('tourist') || worker.visa_status.toLowerCase().includes('visit')) ? (
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
<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">
|
{/* Removed visa expiry warning badge for cleaner UI */}
|
||||||
<AlertTriangle className="w-2.5 h-2.5 text-amber-600 flex-shrink-0 animate-bounce" />
|
{worker.visa_status && (worker.visa_status.toLowerCase().includes('tourist') || worker.visa_status.toLowerCase().includes('visit')) ? (
|
||||||
<span>{worker.visa_status}</span>
|
<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">
|
||||||
</div>
|
<AlertTriangle className="w-3 h-3 text-amber-600 flex-shrink-0 animate-bounce" />
|
||||||
) : worker.visa_status && worker.visa_status.toLowerCase().includes('cancelled') ? (
|
<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">
|
</div>
|
||||||
<AlertTriangle className="w-2.5 h-2.5 text-rose-600 flex-shrink-0" />
|
) : worker.visa_status && worker.visa_status.toLowerCase().includes('cancelled') ? (
|
||||||
<span>{worker.visa_status}</span>
|
<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">
|
||||||
</div>
|
<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-blue-700 bg-blue-50 px-1.5 py-0.5 rounded border border-blue-100">
|
</div>
|
||||||
<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">
|
||||||
)}
|
<span>{worker.visa_status}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex items-center space-x-1 text-[10px] text-slate-500 font-bold">
|
</div>
|
||||||
<Globe2 className="w-3 h-3 text-slate-400" />
|
|
||||||
<span>{worker.nationality}</span>
|
<div className="flex items-center space-x-1.5 text-xs text-slate-500 mt-1">
|
||||||
<span>•</span>
|
<Globe2 className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<span>{worker.age} yrs</span>
|
<span className="font-bold">{worker.nationality}</span>
|
||||||
</div>
|
<span>•</span>
|
||||||
</div>
|
<span>{worker.age} yrs</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Top Right Actions & Salary */}
|
{/* Top Right Availability status and trash remove button */}
|
||||||
<div className="flex flex-col items-end justify-between self-stretch flex-shrink-0 ml-3">
|
<div className="flex flex-col items-end space-y-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeWorker(worker.id)}
|
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"
|
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')}
|
title={t('remove_from_shortlist', 'Remove from shortlist')}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<div className="text-right mt-auto pt-2">
|
</div>
|
||||||
<div className="text-[#185FA5] text-base font-extrabold tracking-tight">
|
</div>
|
||||||
{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 */}
|
{/* Card Body */}
|
||||||
<div className="p-4 sm:p-5 pt-3 space-y-3 flex-1 flex flex-col justify-between">
|
<div className="p-6 space-y-4 flex-1 flex flex-col justify-between">
|
||||||
<div className="space-y-3">
|
<div className="space-y-4">
|
||||||
{/* 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">
|
{/* Ratings & Cost */}
|
||||||
<div className="flex items-center space-x-1.5 truncate">
|
<div className="flex items-center justify-end text-xs">
|
||||||
<Briefcase className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
<div className="flex items-center space-x-1 font-bold text-slate-800">
|
||||||
<span className="truncate">Exp: {worker.experience}</span>
|
<DollarSign className="w-3.5 h-3.5 text-emerald-600" />
|
||||||
</div>
|
<span className="text-sm font-black">{worker.salary} AED</span>
|
||||||
<div className="flex items-center space-x-1.5 truncate">
|
<span className="text-[10px] text-slate-400 font-medium">/mo</span>
|
||||||
<Sparkles className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
</div>
|
||||||
<span className="truncate uppercase text-[9px]">{worker.preferred_job_type}</span>
|
</div>
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* 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 */}
|
{/* Details Table */}
|
||||||
<div className="space-y-0.5">
|
<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="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('languages_spoken', 'Languages Spoken')}</div>
|
<div className="flex items-center space-x-1.5 truncate">
|
||||||
<div className="flex flex-wrap gap-1">
|
<Briefcase className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||||
{worker.languages?.map(lang => (
|
<span className="truncate">Exp: {worker.experience}</span>
|
||||||
<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">
|
</div>
|
||||||
<span>{getLanguageFlag(lang)}</span>
|
<div className="flex items-center space-x-1.5 truncate">
|
||||||
<span>{lang}</span>
|
<Sparkles className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||||
</span>
|
<span className="truncate uppercase text-[10px]">{worker.preferred_job_type}</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
<div className="flex items-center space-x-1.5 truncate">
|
||||||
</div>
|
<MapPin className="w-3.5 h-3.5 text-[#185FA5] flex-shrink-0" />
|
||||||
</div>
|
<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 */}
|
{/* Core exact platform Skills List */}
|
||||||
<div className="pt-3 border-t border-slate-100 space-y-2">
|
<div className="space-y-1">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">{t('platform_skills', 'Platform Skills')}</div>
|
||||||
<button
|
<div className="flex flex-wrap gap-1">
|
||||||
type="button"
|
{worker.skills?.map(skill => (
|
||||||
onClick={() => setPreviewWorker(worker)}
|
<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">
|
||||||
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"
|
{skill}
|
||||||
>
|
</span>
|
||||||
<Eye className="w-3.5 h-3.5" />
|
))}
|
||||||
<span>{t('quick_preview', 'Quick Preview')}</span>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
<button
|
{/* Languages Badges with Visual Flags */}
|
||||||
type="button"
|
<div className="space-y-1">
|
||||||
onClick={() => handleToggleComparison(worker.id)}
|
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">{t('languages_spoken', 'Languages Spoken')}</div>
|
||||||
className={`w-full rounded-xl h-9 font-bold text-xs flex items-center justify-center transition-colors border ${
|
<div className="flex flex-wrap gap-1.5">
|
||||||
isComparing
|
{worker.languages?.map(lang => (
|
||||||
? 'bg-purple-600 border-purple-600 text-white hover:bg-purple-700'
|
<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">
|
||||||
: 'bg-white border-slate-200 hover:bg-slate-50 text-slate-700'
|
<span>{getLanguageFlag(lang)}</span>
|
||||||
}`}
|
<span>{lang}</span>
|
||||||
>
|
</span>
|
||||||
{isComparing ? t('comparing_active', 'Comparing ✓') : t('compare_candidate', 'Compare')}
|
))}
|
||||||
</button>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
{/* Actions block */}
|
||||||
<Link
|
<div className="pt-4 border-t border-slate-100 space-y-2">
|
||||||
href={`/employer/workers/${worker.id}`}
|
<div className="grid grid-cols-2 gap-2">
|
||||||
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"
|
<button
|
||||||
>
|
type="button"
|
||||||
{t('open_profile', 'Open Profile')}
|
onClick={() => setPreviewWorker(worker)}
|
||||||
</Link>
|
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"
|
||||||
<Link
|
>
|
||||||
href={`/employer/messages/${worker.id}`}
|
<Eye className="w-3.5 h-3.5" />
|
||||||
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"
|
<span>{t('quick_preview', 'Quick Preview')}</span>
|
||||||
>
|
</button>
|
||||||
<MessageSquare className="w-4 h-4" />
|
|
||||||
<span>{t('message', 'Message')}</span>
|
<button
|
||||||
</Link>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -47,7 +47,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
// Basic Filters
|
// Basic Filters
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [selectedProfession, setSelectedProfession] = useState('All Professions');
|
|
||||||
const [selectedNationalities, setSelectedNationalities] = useState([]);
|
const [selectedNationalities, setSelectedNationalities] = useState([]);
|
||||||
const [selectedGender, setSelectedGender] = useState('All Genders');
|
const [selectedGender, setSelectedGender] = useState('All Genders');
|
||||||
const [selectedExperience, setSelectedExperience] = useState('All Experience');
|
const [selectedExperience, setSelectedExperience] = useState('All Experience');
|
||||||
@ -109,13 +108,9 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
const outCountryVal = params.get('out_country');
|
const outCountryVal = params.get('out_country');
|
||||||
const visa = params.get('visa_status') || params.get('next_visa_type') || params.get('visa_type');
|
const visa = params.get('visa_status') || params.get('next_visa_type') || params.get('visa_type');
|
||||||
const gender = params.get('gender');
|
const gender = params.get('gender');
|
||||||
const prof = params.get('main_profession') || params.get('profession');
|
|
||||||
|
|
||||||
let hasAdvanced = false;
|
let hasAdvanced = false;
|
||||||
|
|
||||||
if (prof) {
|
|
||||||
setSelectedProfession(prof);
|
|
||||||
}
|
|
||||||
if (nat) {
|
if (nat) {
|
||||||
setSelectedNationalities(nat.split(',').map(x => x.trim()).filter(Boolean));
|
setSelectedNationalities(nat.split(',').map(x => x.trim()).filter(Boolean));
|
||||||
}
|
}
|
||||||
@ -201,7 +196,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
|
|
||||||
const resetFilters = () => {
|
const resetFilters = () => {
|
||||||
setSearchQuery('');
|
setSearchQuery('');
|
||||||
setSelectedProfession('All Professions');
|
|
||||||
setSelectedNationalities([]);
|
setSelectedNationalities([]);
|
||||||
setSelectedGender('All Genders');
|
setSelectedGender('All Genders');
|
||||||
setSelectedExperience('All Experience');
|
setSelectedExperience('All Experience');
|
||||||
@ -227,14 +221,13 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
if (filterAccommodation !== 'All') count++;
|
if (filterAccommodation !== 'All') count++;
|
||||||
if (selectedNationalities.length > 0) count++;
|
if (selectedNationalities.length > 0) count++;
|
||||||
if (selectedGender !== 'All Genders') count++;
|
if (selectedGender !== 'All Genders') count++;
|
||||||
if (selectedProfession !== 'All Professions') count++;
|
|
||||||
if (filterInCountry !== 'All') count++;
|
if (filterInCountry !== 'All') count++;
|
||||||
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
|
if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++;
|
||||||
if (selectedSkills.length > 0) count++;
|
if (selectedSkills.length > 0) count++;
|
||||||
if (selectedLanguages.length > 0) count++;
|
if (selectedLanguages.length > 0) count++;
|
||||||
if (maxSalary < 5000) count++;
|
if (maxSalary < 5000) count++;
|
||||||
return count;
|
return count;
|
||||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, selectedProfession, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||||
|
|
||||||
const activeFilterTags = useMemo(() => {
|
const activeFilterTags = useMemo(() => {
|
||||||
const tags = [];
|
const tags = [];
|
||||||
@ -243,14 +236,13 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('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 (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 (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 !== '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 (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 (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 (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) });
|
if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 ≤${maxSalary} AED`, clear: () => setMaxSalary(5000) });
|
||||||
return tags;
|
return tags;
|
||||||
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, selectedProfession, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
}, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]);
|
||||||
|
|
||||||
// Filter and Sort Worker List
|
// Filter and Sort Worker List
|
||||||
const filteredWorkers = useMemo(() => {
|
const filteredWorkers = useMemo(() => {
|
||||||
@ -265,7 +257,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown filters
|
// 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 (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 (selectedGender !== 'All Genders' && worker.gender && worker.gender.toLowerCase() !== selectedGender.toLowerCase()) return false;
|
||||||
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
|
if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false;
|
||||||
@ -341,7 +332,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
}, [
|
}, [
|
||||||
initialWorkers,
|
initialWorkers,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
selectedProfession,
|
|
||||||
selectedNationalities,
|
selectedNationalities,
|
||||||
selectedGender,
|
selectedGender,
|
||||||
selectedExperience,
|
selectedExperience,
|
||||||
@ -410,23 +400,6 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
activeCount={activeFilterCount}
|
activeCount={activeFilterCount}
|
||||||
title="Filter Workers"
|
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 */}
|
{/* Preferred Location */}
|
||||||
<FilterSection label="Preferred Location">
|
<FilterSection label="Preferred Location">
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
@ -675,140 +648,142 @@ 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">
|
<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 */}
|
{/* 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="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 min-w-0 flex-1">
|
<div className="flex items-center space-x-3.5 min-w-0 flex-1">
|
||||||
{/* Photo (optional) container */}
|
{/* 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">
|
<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 ? (
|
{worker.photo ? (
|
||||||
<img src={worker.photo} alt={worker.name} className="w-full h-full object-cover" />
|
<img src={worker.photo} alt={worker.name} className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<User className="w-5 h-5 text-slate-400" />
|
<User className="w-6 h-6 text-slate-400" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="min-w-0 flex-1 space-y-1">
|
<div className="min-w-0 flex-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">
|
<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>
|
<span>{worker.name}</span>
|
||||||
{worker.main_profession && (
|
{false && (
|
||||||
<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">
|
<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">
|
||||||
{worker.main_profession}
|
<CheckCircle2 className="w-3 h-3" />
|
||||||
|
<span>{t('verified')}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Passport Status Verification Badge & Visa Status */}
|
{/* Passport Status Verification Badge & Visa Status */}
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1 mt-0.5">
|
||||||
{worker.visa_expiry_date ? (
|
{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 ${
|
<div className={`flex items-center space-x-1 text-[9px] font-black uppercase px-1.5 py-0.5 rounded border ${
|
||||||
worker.document_expiry_days !== null && worker.document_expiry_days <= 30
|
worker.document_expiry_days !== null && worker.document_expiry_days <= 30
|
||||||
? 'text-rose-700 bg-rose-50 border-rose-200 animate-pulse'
|
? 'text-rose-700 bg-rose-50 border-rose-200 animate-pulse'
|
||||||
: worker.document_expiry_days !== null && worker.document_expiry_days <= 90
|
: worker.document_expiry_days !== null && worker.document_expiry_days <= 90
|
||||||
? 'text-amber-700 bg-amber-50 border-amber-200'
|
? 'text-amber-700 bg-amber-50 border-amber-200'
|
||||||
: 'text-[#185FA5] bg-blue-50 border-blue-100'
|
: 'text-[#185FA5] bg-blue-50 border-blue-100'
|
||||||
}`}>
|
}`}>
|
||||||
<Calendar className="w-2.5 h-2.5 flex-shrink-0" />
|
<Calendar className="w-3 h-3 flex-shrink-0" />
|
||||||
<span>Visa Exp: {worker.visa_expiry_date}</span>
|
<span>Visa Exp: {worker.visa_expiry_date}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<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">
|
<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-2.5 h-2.5 flex-shrink-0 text-amber-600" />
|
<Calendar className="w-3 h-3 flex-shrink-0 text-amber-600" />
|
||||||
<span>Visa Expiry Pending</span>
|
<span>Visa Expiry Pending</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{worker.visa_status && (worker.visa_status.toLowerCase().includes('tourist') || worker.visa_status.toLowerCase().includes('visit')) ? (
|
{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">
|
<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-2.5 h-2.5 text-amber-600 flex-shrink-0 animate-bounce" />
|
<AlertTriangle className="w-3 h-3 text-amber-600 flex-shrink-0 animate-bounce" />
|
||||||
<span>{worker.visa_status}</span>
|
<span>{worker.visa_status} ({t('warning', 'Warning')})</span>
|
||||||
</div>
|
</div>
|
||||||
) : worker.visa_status && worker.visa_status.toLowerCase().includes('cancelled') ? (
|
) : 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">
|
<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-2.5 h-2.5 text-rose-600 flex-shrink-0" />
|
<AlertTriangle className="w-3 h-3 text-rose-600 flex-shrink-0" />
|
||||||
<span>{worker.visa_status}</span>
|
<span>{worker.visa_status} ({t('warning', 'Warning')})</span>
|
||||||
</div>
|
</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">
|
<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>
|
<span>{worker.visa_status}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* Removed visa expiry status badge for cleaner UI */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-1 text-[10px] text-slate-500 font-bold">
|
<div className="flex items-center space-x-1.5 text-xs text-slate-500 mt-1">
|
||||||
<Globe2 className="w-3 h-3 text-slate-400" />
|
<Globe2 className="w-3.5 h-3.5 text-slate-400" />
|
||||||
<span>{worker.nationality}</span>
|
<span className="font-bold">{worker.nationality}</span>
|
||||||
<span>•</span>
|
<span>•</span>
|
||||||
<span>{worker.age} {t('yrs', 'yrs')}</span>
|
<span>{worker.age} {t('yrs', 'yrs')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top Right Actions & Salary */}
|
{/* Top Right Availability status and bookmark */}
|
||||||
<div className="flex flex-col items-end justify-between self-stretch flex-shrink-0 ml-3">
|
<div className="flex flex-col items-end space-y-2 flex-shrink-0 ml-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggleShortlist(worker.id)}
|
onClick={() => toggleShortlist(worker.id)}
|
||||||
className={`p-1.5 rounded-lg transition-all border ${
|
className={`p-2 rounded-xl transition-all ${
|
||||||
isShortlisted
|
isShortlisted
|
||||||
? 'bg-blue-50 border-blue-200 text-[#185FA5]'
|
? 'bg-blue-50 text-[#185FA5] hover:bg-blue-100'
|
||||||
: 'bg-white hover:bg-slate-50 border-slate-200 text-slate-400 hover:text-[#185FA5] shadow-xs'
|
: 'bg-white text-slate-400 hover:text-[#185FA5] border border-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Bookmark className={`w-3.5 h-3.5 ${isShortlisted ? 'fill-[#185FA5]' : ''}`} />
|
<Bookmark className={`w-3.5 h-3.5 ${isShortlisted ? 'fill-[#185FA5]' : ''}`} />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Card Body */}
|
{/* Card Body */}
|
||||||
<div className="p-4 sm:p-5 pt-3 space-y-3 flex-1 flex flex-col justify-between">
|
<div className="p-6 space-y-4 flex-1 flex flex-col justify-between">
|
||||||
<div className="space-y-3">
|
<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>
|
||||||
|
|
||||||
|
|
||||||
{/* Details Table */}
|
{/* 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="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">
|
<div className="flex items-center space-x-1.5 truncate">
|
||||||
<Briefcase className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
<Briefcase className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||||
<span className="truncate">{t('experience', 'Exp')}: {worker.experience}</span>
|
<span className="truncate">{t('experience', 'Exp')}: {worker.experience}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1.5 truncate">
|
<div className="flex items-center space-x-1.5 truncate">
|
||||||
<Sparkles className="w-3 h-3 text-slate-400 flex-shrink-0" />
|
<Sparkles className="w-3.5 h-3.5 text-slate-400 flex-shrink-0" />
|
||||||
<span className="truncate uppercase text-[9px]">{worker.preferred_job_type}</span>
|
<span className="truncate uppercase text-[10px]">{worker.preferred_job_type}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1.5 truncate">
|
<div className="flex items-center space-x-1.5 truncate">
|
||||||
<MapPin className="w-3 h-3 text-[#185FA5] flex-shrink-0" />
|
<MapPin className="w-3.5 h-3.5 text-[#185FA5] flex-shrink-0" />
|
||||||
<span className="truncate text-[#185FA5]">{worker.preferred_location || 'Not Specified'}</span>
|
<span className="truncate text-[#185FA5]">{worker.preferred_location || 'Not Specified'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-1.5 truncate">
|
<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" />
|
<Star className="w-3.5 h-3.5 text-amber-500 flex-shrink-0 fill-amber-500" />
|
||||||
<span className="truncate">{worker.rating} ({worker.reviews_count})</span>
|
<span className="truncate">{worker.rating} ({worker.reviews_count})</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Core exact platform Skills List */}
|
{/* Core exact platform Skills List */}
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-1">
|
||||||
<div className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('platform_skills', 'Platform Skills')}</div>
|
<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">
|
<div className="flex flex-wrap gap-1">
|
||||||
{worker.skills?.slice(0, 3).map(skill => (
|
{worker.skills?.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">
|
<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}
|
{skill}
|
||||||
</span>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Languages Badges with Visual Flags */}
|
{/* Languages Badges with Visual Flags */}
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-1">
|
||||||
<div className="text-[8px] font-black text-slate-400 uppercase tracking-widest">{t('languages_spoken', 'Languages Spoken')}</div>
|
<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">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{worker.languages?.map(lang => (
|
{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 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>{getLanguageFlag(lang)}</span>
|
||||||
<span>{lang}</span>
|
<span>{lang}</span>
|
||||||
</span>
|
</span>
|
||||||
@ -818,12 +793,12 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Actions block */}
|
{/* Actions block */}
|
||||||
<div className="pt-3 border-t border-slate-100 space-y-2">
|
<div className="pt-4 border-t border-slate-100 space-y-2">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPreviewWorker(worker)}
|
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"
|
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" />
|
<Eye className="w-3.5 h-3.5" />
|
||||||
<span>{t('quick_preview')}</span>
|
<span>{t('quick_preview')}</span>
|
||||||
@ -832,21 +807,22 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleToggleComparison(worker.id)}
|
onClick={() => handleToggleComparison(worker.id)}
|
||||||
className={`w-full rounded-xl h-9 font-bold text-xs flex items-center justify-center transition-colors border ${
|
className={`w-full rounded-xl h-10 font-bold text-xs flex items-center justify-center transition-colors border ${
|
||||||
isComparing
|
isComparing
|
||||||
? 'bg-purple-600 border-purple-600 text-white hover:bg-purple-700'
|
? 'bg-purple-600 border-purple-600 text-white hover:bg-purple-700'
|
||||||
: 'bg-white border-slate-200 hover:bg-slate-50 text-slate-700'
|
: 'bg-white border-slate-200 hover:bg-slate-50 text-slate-700'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{isComparing ? t('comparing', 'Comparing ✓') : t('compare_candidate', 'Compare')}
|
{isComparing ? t('comparing', 'Comparing ✓') : t('compare_candidate', 'Compare Candidate')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/employer/workers/${worker.id}`}
|
href={`/employer/workers/${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 transition-colors shadow-xs"
|
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"
|
||||||
>
|
>
|
||||||
View Profile
|
View Profile
|
||||||
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -971,12 +947,8 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
<h4 className="font-extrabold text-lg text-slate-900">{previewWorker.name}</h4>
|
<h4 className="font-extrabold text-lg text-slate-900">{previewWorker.name}</h4>
|
||||||
|
|
||||||
</div>
|
</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 ? (
|
{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 ${
|
<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,12 +205,8 @@ export default function Show({ worker }) {
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center space-x-2">
|
<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>
|
<h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight text-slate-900">{worker.name}</h1>
|
||||||
|
|
||||||
</div>
|
</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 */}
|
{/* Passport verification status bar & Visa Status */}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
@ -318,16 +314,6 @@ export default function Show({ worker }) {
|
|||||||
<span className="font-extrabold text-slate-900 text-xs">{worker.name}</span>
|
<span className="font-extrabold text-slate-900 text-xs">{worker.name}</span>
|
||||||
</div>
|
</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 justify-between pb-3 border-b border-slate-200">
|
||||||
<div className="flex items-center space-x-2 text-xs text-slate-600 font-bold">
|
<div className="flex items-center space-x-2 text-xs text-slate-600 font-bold">
|
||||||
<Phone className="w-4 h-4 text-slate-500" />
|
<Phone className="w-4 h-4 text-slate-500" />
|
||||||
|
|||||||
@ -111,12 +111,6 @@
|
|||||||
Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']);
|
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::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']);
|
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']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token)
|
// Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token)
|
||||||
|
|||||||
@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
|
||||||
|
|
||||||
Artisan::command('inspire', function () {
|
Artisan::command('inspire', function () {
|
||||||
$this->comment(Inspiring::quote());
|
$this->comment(Inspiring::quote());
|
||||||
})->purpose('Display an inspiring quote');
|
})->purpose('Display an inspiring quote');
|
||||||
|
|
||||||
Schedule::command('app:send-reminders')->daily();
|
|
||||||
|
|||||||
@ -1,281 +0,0 @@
|
|||||||
<?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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -561,16 +561,6 @@ public function test_employer_can_get_plans_and_pay_with_numeric_id()
|
|||||||
$this->assertIsInt($plan['id']);
|
$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)
|
// 2. Submit payment with a numeric plan ID (e.g. 3)
|
||||||
$responsePayment = $this->postJson('/api/employers/payment', [
|
$responsePayment = $this->postJson('/api/employers/payment', [
|
||||||
'email' => $this->employer->email,
|
'email' => $this->employer->email,
|
||||||
|
|||||||
@ -154,7 +154,6 @@ public function test_s1_complete_basic_profile_setup()
|
|||||||
'nationality' => 'Indian',
|
'nationality' => 'Indian',
|
||||||
'language' => 'HI',
|
'language' => 'HI',
|
||||||
'gender' => 'male',
|
'gender' => 'male',
|
||||||
'main_profession' => 'Nanny',
|
|
||||||
'skills' => [],
|
'skills' => [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -181,7 +180,6 @@ public function test_s1_complete_basic_profile_setup()
|
|||||||
'phone' => $phone,
|
'phone' => $phone,
|
||||||
'language' => 'HI',
|
'language' => 'HI',
|
||||||
'gender' => 'male',
|
'gender' => 'male',
|
||||||
'main_profession' => 'Nanny',
|
|
||||||
'verified' => false,
|
'verified' => false,
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
@ -388,7 +386,6 @@ public function test_register_with_new_api_fields()
|
|||||||
'in_country' => 'true',
|
'in_country' => 'true',
|
||||||
'visa_status' => 'Tourist Visa',
|
'visa_status' => 'Tourist Visa',
|
||||||
'preferred_job_type' => 'full-time',
|
'preferred_job_type' => 'full-time',
|
||||||
'main_profession' => 'Housemaid',
|
|
||||||
'gender' => 'male',
|
'gender' => 'male',
|
||||||
'live_in_out' => 'live_out',
|
'live_in_out' => 'live_out',
|
||||||
'preferred_location' => 'Dubai Marina',
|
'preferred_location' => 'Dubai Marina',
|
||||||
@ -402,7 +399,6 @@ public function test_register_with_new_api_fields()
|
|||||||
'in_country' => true,
|
'in_country' => true,
|
||||||
'visa_status' => 'Tourist Visa',
|
'visa_status' => 'Tourist Visa',
|
||||||
'preferred_job_type' => 'full-time',
|
'preferred_job_type' => 'full-time',
|
||||||
'main_profession' => 'Housemaid',
|
|
||||||
'gender' => 'male',
|
'gender' => 'male',
|
||||||
'live_in_out' => 'live_out',
|
'live_in_out' => 'live_out',
|
||||||
'preferred_location' => 'Dubai Marina',
|
'preferred_location' => 'Dubai Marina',
|
||||||
|
|||||||
@ -1,271 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user