Compare commits

..

13 Commits

78 changed files with 4939 additions and 1065 deletions

View File

@ -73,6 +73,7 @@ VITE_APP_NAME="${APP_NAME}"
# Notification Reminder Settings
WORKER_NO_RESPONSE_REMINDER_DAYS=14
REVIEW_ELIGIBILITY_MONTHS=3
REVIEW_ELIGIBILITY_MINUTES=10
REVIEW_EDIT_PERIOD_DAYS=7
EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3
WORKER_JOIN_CONFIRM_REMINDER_DAYS=3

View File

@ -374,7 +374,9 @@ private function checkWorkerNoResponse()
private function checkReviewReminders()
{
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
$level = "{$eligibilityMonths}_months";
$eligibilityMinutes = config('reminders.review_eligibility_minutes');
$isMinutes = ($eligibilityMinutes !== null && $eligibilityMinutes > 0);
$level = $isMinutes ? "{$eligibilityMinutes}_minutes" : "{$eligibilityMonths}_months";
// 1. Standard Job Applications with status 'hired'
$applications = \App\Models\JobApplication::where('status', 'hired')
@ -390,9 +392,16 @@ private function checkReviewReminders()
if (!$employer || !$worker) continue;
$joiningDate = Carbon::parse($app->joining_confirmed_at);
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
if ($months >= $eligibilityMonths) {
$eligible = false;
if ($isMinutes) {
$eligible = $joiningDate->diffInMinutes(now(), false) >= $eligibilityMinutes;
} else {
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
$eligible = $months >= $eligibilityMonths;
}
if ($eligible) {
// Remind worker to review employer
if (!$this->alreadySent($worker, 'review_employer_reminder', $app, $level)) {
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name}.");
@ -428,9 +437,16 @@ private function checkReviewReminders()
if (!$worker || !$employer) continue;
$joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at;
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
if ($months >= $eligibilityMonths) {
$eligible = false;
if ($isMinutes) {
$eligible = $joiningDate->diffInMinutes(now(), false) >= $eligibilityMinutes;
} else {
$months = $joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false);
$eligible = $months >= $eligibilityMonths;
}
if ($eligible) {
// Remind worker to review employer
if (!$this->alreadySent($worker, 'review_employer_reminder', $offer, $level)) {
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name} (direct offer).");

View File

@ -734,6 +734,9 @@ public function storeAnnouncement(Request $request)
'event_time' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
'contact_person_name' => 'required|string|max:255',
'contact_number' => 'required|string|max:50',
'country_code' => 'nullable|string|max:10',
]);
$body = json_encode([
@ -744,6 +747,9 @@ public function storeAnnouncement(Request $request)
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->content,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code ?? '+971',
]);
$ann = \App\Models\Announcement::create([
@ -801,7 +807,7 @@ public function deleteAnnouncement(Request $request, $id)
}
/**
* Broadcast FCM Push Notification for new Event/Announcement.
* Broadcast FCM Push Notification and save to Database for new Event/Announcement.
*/
protected function notifyUsersOfEvent($ann)
{
@ -816,31 +822,29 @@ protected function notifyUsersOfEvent($ann)
}
// Notify all workers
$workers = \App\Models\Worker::whereNotNull('fcm_token')->get();
$workers = \App\Models\Worker::all();
foreach ($workers as $worker) {
\App\Services\FCMService::sendPushNotification(
$worker->fcm_token,
$worker->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => 'announcement',
'announcement_id' => $ann->id,
]
);
));
}
// Notify all employers
$employers = \App\Models\User::where('role', 'employer')->whereNotNull('fcm_token')->get();
$employers = \App\Models\User::where('role', 'employer')->get();
foreach ($employers as $employer) {
\App\Services\FCMService::sendPushNotification(
$employer->fcm_token,
$employer->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => 'announcement',
'announcement_id' => $ann->id,
]
);
));
}
}
}

View File

@ -132,8 +132,22 @@ public function updateProfile(Request $request, $id)
'experience' => 'required|string',
'salary' => 'nullable|numeric',
'nationality' => 'nullable|string',
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
'age' => 'nullable|integer',
'visa_status' => 'nullable|string',
'age' => [
'nullable',
function ($attribute, $value, $fail) {
if (is_numeric($value)) {
$intValue = (int)$value;
if ($intValue < 18 || $intValue > 100) {
$fail('The age must be between 18 and 100.');
}
} else {
if (!in_array($value, ['18-25', '26-35', '36-45', '46-55', '56-60'])) {
$fail('The age must be a valid number or one of the ranges: 18-25, 26-35, 36-45, 46-55, 56-60.');
}
}
}
],
'in_country' => 'nullable',
'preferred_job_type' => 'nullable|string',
'skills' => 'nullable|string',

View File

@ -108,15 +108,26 @@ public function getMyAnnouncements(Request $request)
$announcements = $query->skip($offset)->take($perPage)->get()
->map(function ($announcement) {
$charityDetails = null;
$content = $announcement->body;
if (strpos($announcement->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($announcement->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $announcement->body;
}
}
return [
'id' => $announcement->id,
'title' => $announcement->title,
'body' => $announcement->body,
'body' => $content,
'type' => $announcement->type,
'status' => $announcement->status ?? 'pending',
'remarks' => $announcement->remarks,
'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(),
'charity_details' => $charityDetails,
];
});
@ -155,12 +166,33 @@ public function createAnnouncement(Request $request)
/** @var User $employer */
$employer = $request->attributes->get('employer');
$validator = Validator::make($request->all(), [
$isCharity = $request->type === 'charity' ||
$request->has('event_date') ||
$request->has('location_details') ||
$request->has('provided_items') ||
$request->has('contact_person_name');
$rules = [
'title' => 'required|string|max:255',
'body' => 'required_without:content|string|max:5000',
'content' => 'required_without:body|string|max:5000',
'type' => 'nullable|string|in:info,warning,success',
]);
'type' => 'nullable|string|in:info,warning,success,charity',
];
if ($isCharity) {
$rules['event_date'] = 'required|string';
$rules['event_time'] = 'required_without_all:start_time,end_time|nullable|string';
$rules['start_time'] = 'required_without:event_time|nullable|string';
$rules['end_time'] = 'required_without:event_time|nullable|string';
$rules['provided_items'] = 'required|string';
$rules['location_details'] = 'required|string';
$rules['location_pin'] = 'required|string|url';
$rules['contact_person_name'] = 'required|string|max:255';
$rules['contact_number'] = 'required|string|regex:/^\d{7,15}$/';
$rules['country_code'] = 'required|string|regex:/^\+\d{1,4}$/';
}
$validator = Validator::make($request->all(), $rules);
if ($validator->fails()) {
return response()->json([
@ -173,26 +205,44 @@ public function createAnnouncement(Request $request)
try {
$bodyText = $request->body ?? $request->content;
// Append extra event details if they are provided
$extras = [];
if ($request->event_date) $extras[] = "Date: " . $request->event_date;
if ($request->event_time) $extras[] = "Time: " . $request->event_time;
if ($request->location_details) $extras[] = "Location: " . $request->location_details;
if ($request->location_pin) $extras[] = "Map Pin: " . $request->location_pin;
if ($request->provided_items) $extras[] = "Provided: " . $request->provided_items;
if ($isCharity) {
$eventTime = $request->event_time;
if (empty($eventTime)) {
$eventTime = $request->start_time . ' - ' . $request->end_time;
}
if (!empty($extras)) {
$bodyText .= "\n\n" . implode("\n", $extras);
$bodyText = json_encode([
'type' => 'Charity',
'provided_items' => $request->provided_items,
'event_date' => $request->event_date,
'event_time' => $eventTime,
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->body ?? $request->content,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code,
]);
}
$announcement = Announcement::create([
'title' => $request->title,
'body' => $bodyText,
'type' => $request->type ?? 'info',
'type' => $request->type ?? ($isCharity ? 'charity' : 'info'),
'employer_id' => $employer->id,
'status' => 'pending',
]);
$charityDetails = null;
$content = $announcement->body;
if (strpos($announcement->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($announcement->body, true);
if ($decoded) {
$charityDetails = $decoded;
$content = $decoded['content'] ?? $announcement->body;
}
}
return response()->json([
'success' => true,
'message' => 'Announcement posted successfully.',
@ -200,12 +250,13 @@ public function createAnnouncement(Request $request)
'announcement' => [
'id' => $announcement->id,
'title' => $announcement->title,
'body' => $announcement->body,
'body' => $content,
'type' => $announcement->type,
'status' => $announcement->status ?? 'pending',
'remarks' => $announcement->remarks,
'created_at' => $announcement->created_at->toISOString(),
'time_ago' => $announcement->created_at->diffForHumans(),
'charity_details' => $charityDetails,
]
]
], 201);

View File

@ -324,14 +324,6 @@ public function register(Request $request)
'address' => $request->address,
]);
if ($request->filled('fcm_token')) {
\App\Services\FCMService::sendPushNotification(
$request->fcm_token,
'Registration Initiated',
'Your verification code has been sent to your email.'
);
}
// Try sending email
try {
\Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail(
@ -419,14 +411,6 @@ public function verify(Request $request)
$user->update(['fcm_token' => $fcmToken]);
}
$tokenToSend = $fcmToken ?? ($sponsor ? $sponsor->fcm_token : null) ?? ($user ? $user->fcm_token : null);
if ($tokenToSend) {
\App\Services\FCMService::sendPushNotification(
$tokenToSend,
'Email Verified',
'Proceed to payment selection.'
);
}
// Clear Cache
\Illuminate\Support\Facades\Cache::forget('employer_otp_' . $request->email);

View File

@ -400,12 +400,7 @@ public function getDashboard(Request $request)
// Recent Announcements / Events
$dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(2)->get();
$recentAnnouncements = $dbAnnouncements->map(function ($ann) {
$body = $ann->body;
$eventDate = null;
$eventTime = null;
$locationDetails = null;
$locationPin = null;
$providedItems = null;
$charityDetails = null;
if (strpos($ann->body, '{"type":"Charity"') === 0) {
$decoded = json_decode($ann->body, true);
@ -416,6 +411,7 @@ public function getDashboard(Request $request)
$locationDetails = $decoded['location_details'] ?? null;
$locationPin = $decoded['location_pin'] ?? null;
$providedItems = $decoded['provided_items'] ?? null;
$charityDetails = $decoded;
}
} else {
$body = $ann->body;
@ -438,6 +434,7 @@ public function getDashboard(Request $request)
'provided_items' => $providedItems,
'created_at' => $ann->created_at->toISOString(),
'time_ago' => $ann->created_at->diffForHumans(),
'charity_details' => $charityDetails,
];
});

View File

@ -71,12 +71,23 @@ public function addReview(Request $request)
], 403);
}
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
], 403);
// Verify if completed configured period from joining date
$eligibilityMinutes = config('reminders.review_eligibility_minutes');
if ($eligibilityMinutes !== null && $eligibilityMinutes > 0) {
if ($joiningDate->diffInMinutes(now(), false) < $eligibilityMinutes) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMinutes} minutes from the joining date."
], 403);
}
} else {
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
], 403);
}
}
// Check if employer has already reviewed this worker

View File

@ -20,7 +20,7 @@ class EmployerWorkerController extends Controller
/**
* Helper to map worker DB models to API presentation format.
*/
private function formatWorker(Worker $w)
public function formatWorker(Worker $w)
{
// Map languages from database comma-separated string
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English'];
@ -90,30 +90,6 @@ private function formatWorker(Worker $w)
]
];
})->toArray(),
'documents' => $w->documents->map(function ($doc) {
$ocrData = $doc->ocr_data;
if ($doc->type === 'visa' && is_array($ocrData)) {
unset($ocrData['file_number']);
unset($ocrData['name']);
unset($ocrData['passport_number']);
unset($ocrData['accompanied_by']);
unset($ocrData['valid_until']);
unset($ocrData['sponsor']);
}
return [
'id' => $doc->id,
'worker_id' => $doc->worker_id,
'type' => $doc->type,
'number' => $doc->number,
'issue_date' => $doc->issue_date,
'expiry_date' => $doc->expiry_date,
'ocr_accuracy' => $doc->ocr_accuracy,
'file_path' => $doc->file_path ? url($doc->file_path) : null,
'ocr_data' => $ocrData,
'created_at' => $doc->created_at?->toISOString(),
'updated_at' => $doc->updated_at?->toISOString(),
];
})->toArray(),
'category' => [
'id' => 7,
'name' => 'General Helper',
@ -440,10 +416,11 @@ public function getCandidates(Request $request)
if (!$w) return null;
$status = 'Reviewing';
if ($app->status === 'hired') $status = 'Hired';
elseif ($app->status === 'rejected') $status = 'Rejected';
elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent';
elseif ($app->status === 'applied') $status = 'Reviewing';
$dbStatusLower = strtolower($app->status ?? '');
if ($dbStatusLower === 'hired') $status = 'Hired';
elseif ($dbStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Offer Sent';
elseif ($dbStatusLower === 'applied') $status = 'Reviewing';
else $status = ucfirst($app->status);
$formattedWorker = $this->formatWorker($w);
@ -465,9 +442,10 @@ public function getCandidates(Request $request)
if (!$w) return null;
$status = 'Offer Sent';
if ($offer->status === 'accepted') $status = 'Hired';
elseif ($offer->status === 'rejected') $status = 'Rejected';
elseif ($offer->status === 'pending') $status = 'Offer Sent';
$dbOfferStatusLower = strtolower($offer->status ?? '');
if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired';
elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent';
$formattedWorker = $this->formatWorker($w);
@ -480,12 +458,21 @@ public function getCandidates(Request $request)
]);
})->filter()->values()->toArray();
// Merge and sort
$mergedCandidates = array_merge($selectedWorkers, $directWorkers);
// Merge and sort (filtering out duplicates for direct offers where already hired via application)
$hiredAppWorkerIds = collect($selectedWorkers)
->filter(fn($w) => strtolower($w['status'] ?? '') === 'hired')
->pluck('worker_id')
->toArray();
$filteredDirectWorkers = array_filter($directWorkers, function($dw) use ($hiredAppWorkerIds) {
return $dw && !in_array($dw['worker_id'], $hiredAppWorkerIds);
});
$mergedCandidates = array_merge($selectedWorkers, $filteredDirectWorkers);
// Only display Hired candidates in the candidates pipeline (exclude active states)
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) {
return $c && $c['status'] === 'Hired';
return $c && strtolower($c['status'] ?? '') === 'hired';
}));
// Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status
@ -790,11 +777,8 @@ public function hireCandidate(Request $request, $id = null)
$offerId = substr($targetId, 6);
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail();
$offer->update(['status' => 'accepted']);
if ($offer->worker) {
$offer->worker->update(['status' => 'Hired']);
$worker = $offer->worker;
}
$offer->update(['status' => 'pending']);
$worker = $offer->worker;
$updatedOffer = $offer;
} else {
// Check if targetId is an application
@ -804,11 +788,8 @@ public function hireCandidate(Request $request, $id = null)
})->first();
if ($application) {
$application->update(['status' => 'hired']);
if ($application->worker) {
$application->worker->update(['status' => 'Hired']);
$worker = $application->worker;
}
$application->update(['status' => 'hire_requested']);
$worker = $application->worker;
$updatedApplication = $application;
} else {
// Try targeting by Worker ID directly
@ -821,15 +802,13 @@ public function hireCandidate(Request $request, $id = null)
], 404);
}
$worker->update(['status' => 'Hired']);
// Find existing direct offer
// Find existing direct offer or create a new one with status 'pending'
$offer = JobOffer::where('employer_id', $employerId)
->where('worker_id', $worker->id)
->first();
if ($offer) {
$offer->update(['status' => 'accepted']);
$offer->update(['status' => 'pending']);
$updatedOffer = $offer;
} else {
// Find existing application
@ -839,43 +818,57 @@ public function hireCandidate(Request $request, $id = null)
->first();
if ($application) {
$application->update(['status' => 'hired']);
$application->update(['status' => 'hire_requested']);
$updatedApplication = $application;
} else {
// Create a new direct job offer and accept it
// Create a new direct job offer in 'pending' status
$updatedOffer = JobOffer::create([
'employer_id' => $employerId,
'worker_id' => $worker->id,
'work_date' => now()->format('Y-m-d'),
'location' => 'Dubai',
'salary' => $worker->salary,
'notes' => 'Direct sponsoring matching finalized via mobile API.',
'status' => 'accepted',
'notes' => 'Direct sponsoring matching initiated via API.',
'status' => 'pending',
]);
}
}
}
}
// Dispatch push notification to worker
if ($worker && $worker->fcm_token) {
\App\Services\FCMService::sendPushNotification(
$worker->fcm_token,
"Congratulations! You've been Hired",
"Employer " . ($employer->name ?? "Employer") . " has hired you.",
[
'type' => 'hired',
'employer_id' => $employer->id,
]
);
// Dispatch push & database notification to worker for confirmation request
if ($worker) {
if ($updatedApplication) {
$job = $updatedApplication->jobPost;
$worker->notify(new \App\Notifications\GenericNotification(
"Action Required: Confirm Hiring",
"Employer " . ($employer->name ?? "Employer") . " wants to hire you for '" . ($job->title ?? 'Job Post') . "'. Please confirm.",
[
'type' => 'hire_request',
'job_id' => $job->id ?? '',
'application_id' => $updatedApplication->id,
'status' => 'hire_requested',
]
));
} else if ($updatedOffer) {
$worker->notify(new \App\Notifications\GenericNotification(
"Direct Hiring Offer",
($employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
[
'type' => 'direct_hire_request',
'offer_id' => $updatedOffer->id,
'status' => 'pending',
]
));
}
}
return response()->json([
'success' => true,
'message' => 'Candidate successfully marked as Hired!',
'message' => 'Hire request initiated successfully. Awaiting candidate confirmation.',
'data' => [
'worker_id' => $worker ? $worker->id : null,
'worker_status' => $worker ? $worker->status : 'Hired',
'worker_status' => $worker ? $worker->status : 'Active',
'application' => $updatedApplication,
'offer' => $updatedOffer,
]
@ -1049,30 +1042,6 @@ public function getWorkerDetail(Request $request, $id)
]
];
})->toArray(),
'documents' => $w->documents->map(function ($doc) {
$ocrData = $doc->ocr_data;
if ($doc->type === 'visa' && is_array($ocrData)) {
unset($ocrData['file_number']);
unset($ocrData['name']);
unset($ocrData['passport_number']);
unset($ocrData['accompanied_by']);
unset($ocrData['valid_until']);
unset($ocrData['sponsor']);
}
return [
'id' => $doc->id,
'worker_id' => $doc->worker_id,
'type' => $doc->type,
'number' => $doc->number,
'issue_date' => $doc->issue_date,
'expiry_date' => $doc->expiry_date,
'ocr_accuracy' => $doc->ocr_accuracy,
'file_path' => $doc->file_path ? url($doc->file_path) : null,
'ocr_data' => $ocrData,
'created_at' => $doc->created_at?->toISOString(),
'updated_at' => $doc->updated_at?->toISOString(),
];
})->toArray(),
];
return response()->json([

View File

@ -58,11 +58,10 @@ public function skills()
public function sendOtp(Request $request)
{
$validator = Validator::make($request->all(), [
'phone' => 'required_without:email|nullable|string|max:50|unique:workers,phone',
'phone' => 'required_without:email|nullable|string|max:50',
'email' => 'required_without:phone|nullable|email|max:255|unique:workers,email',
], [
'phone.required_without' => 'Please provide a mobile number or email address.',
'phone.unique' => 'This mobile number is already registered.',
'email.required_without' => 'Please provide a mobile number or email address.',
'email.unique' => 'This email address is already registered.',
]);
@ -75,6 +74,17 @@ public function sendOtp(Request $request)
], 422);
}
if ($request->phone) {
$exists = Worker::findByPhone($request->phone);
if ($exists) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => ['phone' => ['This mobile number is already registered.']]
], 422);
}
}
// Determine identifier (phone takes priority)
$identifier = $request->phone ?? $request->email;
$isPhone = !empty($request->phone);
@ -205,7 +215,7 @@ public function setupProfile(Request $request)
}
// Check if already registered
if ($request->phone && Worker::where('phone', $request->phone)->exists()) {
if ($request->phone && Worker::findByPhone($request->phone)) {
return response()->json([
'success' => false,
'message' => 'This mobile number is already registered.'
@ -243,7 +253,7 @@ public function setupProfile(Request $request)
'live_in_out' => $request->live_in_out,
'preferred_location' => $request->preferred_location,
'main_profession' => $request->main_profession,
'age' => 25, // Default — updated later in full profile
'age' => '18-25', // Default — updated later in full profile
'salary' => 1500, // Default AED — updated later
'availability' => 'Immediate',
'experience' => 'Not Specified',
@ -309,16 +319,30 @@ public function register(Request $request)
$validator = Validator::make($request->all(), [
'name' => 'required|string|max:255',
'phone' => 'required|string|max:50|unique:workers,phone',
'phone' => 'required|string|max:50',
'salary' => 'required|numeric|min:0',
'password' => 'required|string|min:6',
'skills' => 'nullable',
'language' => 'nullable|string',
'nationality' => 'nullable|string|max:100',
'age' => 'nullable|integer',
'age' => [
'nullable',
function ($attribute, $value, $fail) {
if (is_numeric($value)) {
$intValue = (int)$value;
if ($intValue < 18 || $intValue > 100) {
$fail('The age must be between 18 and 100.');
}
} else {
if (!in_array($value, ['18-25', '26-35', '36-45', '46-55', '56-60'])) {
$fail('The age must be a valid number or one of the ranges: 18-25, 26-35, 36-45, 46-55, 56-60.');
}
}
}
],
'experience' => 'nullable|string|max:100',
'in_country' => 'nullable',
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
'visa_status' => 'nullable|string',
'preferred_job_type' => 'nullable|string|max:100',
'main_profession' => 'nullable|string|max:100',
'gender' => 'nullable|string|in:male,female,other',
@ -362,7 +386,6 @@ public function register(Request $request)
'visa.sponsor_name' => 'nullable|string|max:255',
'visa.ocr_accuracy' => 'nullable|numeric',
], [
'phone.unique' => 'This mobile number is already registered.',
'passport.passport_number.unique' => 'This passport number is already registered.',
]);
@ -374,6 +397,17 @@ public function register(Request $request)
], 422);
}
if ($request->phone) {
$exists = Worker::findByPhone($request->phone);
if ($exists) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => ['phone' => ['This mobile number is already registered.']]
], 422);
}
}
try {
$phoneClean = preg_replace('/[^0-9]/', '', $request->phone);
$email = "worker.{$phoneClean}@migrant.ae";
@ -401,7 +435,7 @@ public function register(Request $request)
// Initialize fields
$name = $request->name;
$nationality = $request->nationality ?? 'Not Specified';
$age = $request->age ?? 25;
$age = $request->age ?? '18-25';
$visaStatus = $inCountry ? $request->visa_status : null;
$passportDataInput = $request->input('passport');
@ -430,7 +464,20 @@ public function register(Request $request)
if (!empty($passportDataInput['date_of_birth'])) {
try {
$dob = new \DateTime($this->normaliseDateForController($passportDataInput['date_of_birth']));
$age = (new \DateTime())->diff($dob)->y;
$ageVal = (new \DateTime())->diff($dob)->y;
if ($ageVal >= 18 && $ageVal <= 25) {
$age = '18-25';
} elseif ($ageVal >= 26 && $ageVal <= 35) {
$age = '26-35';
} elseif ($ageVal >= 36 && $ageVal <= 45) {
$age = '36-45';
} elseif ($ageVal >= 46 && $ageVal <= 55) {
$age = '46-55';
} elseif ($ageVal >= 56 && $ageVal <= 60) {
$age = '56-60';
} else {
$age = $ageVal < 18 ? '18-25' : '56-60';
}
} catch (\Exception $dateEx) {
logger()->error('Passport DOB parse error in register: ' . $dateEx->getMessage());
}
@ -562,7 +609,7 @@ public function register(Request $request)
try {
$worker = $request->phone
? Worker::where('phone', $request->phone)->first()
? Worker::findByPhone($request->phone)
: Worker::where('email', $request->email)->first();
if (!$worker || !Hash::check($request->password, $worker->password)) {
@ -1333,7 +1380,7 @@ public function forgotPassword(Request $request)
], 422);
}
$worker = Worker::where('phone', $request->phone)->first();
$worker = Worker::findByPhone($request->phone);
if (!$worker) {
return response()->json([
@ -1409,7 +1456,7 @@ public function resetPassword(Request $request)
], 422);
}
$worker = Worker::where('phone', $request->phone)->first();
$worker = Worker::findByPhone($request->phone);
if (!$worker) {
return response()->json([

View File

@ -18,14 +18,114 @@ class WorkerJobController extends Controller
*/
public function listJobs(Request $request)
{
$jobs = JobPost::where('status', 'active')
->with(['employer.employerProfile'])
->latest()
->get()
->map(function ($job) {
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
$query = JobPost::whereIn('status', ['active', 'closed'])
->with(['employer.employerProfile', 'skills']);
if ($worker) {
$workerProfession = trim($worker->main_profession);
$workerSkillIds = $worker->skills->pluck('id')->toArray();
if ($workerProfession !== '' || !empty($workerSkillIds)) {
$query->where(function($q) use ($workerProfession, $workerSkillIds) {
if ($workerProfession !== '') {
$q->where(\Illuminate\Support\Facades\DB::raw('LOWER(main_profession)'), strtolower($workerProfession));
}
if (!empty($workerSkillIds)) {
$q->orWhereHas('skills', function ($q2) use ($workerSkillIds) {
$q2->whereIn('skills.id', $workerSkillIds);
});
}
});
}
}
$workerApplications = [];
if ($worker) {
$workerApplications = JobApplication::where('worker_id', $worker->id)
->pluck('status', 'job_id')
->toArray();
}
// Apply filters
// Filter by job_type / jobType
if ($request->has('job_type') && $request->input('job_type') !== '') {
$query->where('job_type', $request->input('job_type'));
}
if ($request->has('jobType') && $request->input('jobType') !== '') {
$query->where('job_type', $request->input('jobType'));
}
// Filter by skills
if ($request->has('skills') && $request->input('skills') !== '') {
$skills = $request->input('skills');
if (is_string($skills)) {
$skills = array_filter(array_map('trim', explode(',', $skills)));
}
if (!empty($skills)) {
$query->whereHas('skills', function ($q) use ($skills) {
$q->where(function($q2) use ($skills) {
$q2->whereIn('skills.id', $skills)
->orWhereIn('skills.name', $skills);
});
});
}
}
// Filter by salary
if ($request->has('salary_min') && $request->input('salary_min') !== '') {
$query->where('salary', '>=', (float) $request->input('salary_min'));
}
if ($request->has('salary_max') && $request->input('salary_max') !== '') {
$query->where('salary', '<=', (float) $request->input('salary_max'));
}
if ($request->has('salary') && $request->input('salary') !== '') {
$query->where('salary', '>=', (float) $request->input('salary'));
}
if ($request->has('search') && $request->input('search') !== '') {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%")
->orWhere('location', 'like', "%{$search}%");
});
}
// Apply Sorting
if ($request->has('sortBy') && $request->input('sortBy') !== '') {
$sortBy = strtoupper($request->input('sortBy'));
if ($sortBy === 'LATEST FIRST') {
$query->latest();
} elseif ($sortBy === 'OLDEST FIRST') {
$query->oldest();
} elseif ($sortBy === 'PRICE: LOW TO HIGH') {
$query->orderBy('salary', 'asc');
} elseif ($sortBy === 'PRICE: HIGH TO LOW') {
$query->orderBy('salary', 'desc');
} else {
$query->latest();
}
} else {
$query->latest();
}
$jobs = $query->get()
->map(function ($job) use ($worker, $workerApplications) {
$status = 'new';
if ($worker && isset($workerApplications[$job->id])) {
$status = $workerApplications[$job->id];
} elseif ($job->status === 'closed') {
$status = 'closed';
}
return [
'id' => $job->id,
'title' => $job->title,
'main_profession' => $job->main_profession,
'skills' => $job->skills->pluck('name')->toArray(),
'location' => $job->location,
'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed,
@ -36,6 +136,7 @@ public function listJobs(Request $request)
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
'posted_by' => $job->employer->name ?? 'Private Sponsor',
'posted_at' => $job->created_at->toIso8601String(),
'status' => $status,
];
});
@ -53,8 +154,8 @@ public function listJobs(Request $request)
*/
public function jobDetails($id)
{
$job = JobPost::where('status', 'active')
->with(['employer.employerProfile'])
$job = JobPost::whereIn('status', ['active', 'closed'])
->with(['employer.employerProfile', 'skills'])
->find($id);
if (!$job) {
@ -64,12 +165,29 @@ public function jobDetails($id)
], 404);
}
$worker = request()->attributes->get('worker');
$status = 'new';
if ($worker) {
$application = JobApplication::where('worker_id', $worker->id)
->where('job_id', $job->id)
->first();
if ($application) {
$status = $application->status;
} elseif ($job->status === 'closed') {
$status = 'closed';
}
} elseif ($job->status === 'closed') {
$status = 'closed';
}
return response()->json([
'success' => true,
'data' => [
'job' => [
'id' => $job->id,
'title' => $job->title,
'main_profession' => $job->main_profession,
'skills' => $job->skills->pluck('name')->toArray(),
'location' => $job->location,
'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed,
@ -80,6 +198,7 @@ public function jobDetails($id)
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
'posted_by' => $job->employer->name ?? 'Private Sponsor',
'posted_at' => $job->created_at->toIso8601String(),
'status' => $status,
]
]
]);
@ -121,6 +240,19 @@ public function applyJob(Request $request, $id)
'status' => 'applied'
]);
$employer = $job->employer;
if ($employer) {
$employer->notify(new \App\Notifications\GenericNotification(
"New Job Application",
"A worker (" . ($worker->name ?? 'Candidate') . ") has applied for your job: " . ($job->title ?? 'Job Post'),
[
'type' => 'job_application_submitted',
'job_id' => $job->id,
'application_id' => $application->id,
]
));
}
return response()->json([
'success' => true,
'message' => 'Successfully applied for the job.',
@ -148,6 +280,7 @@ public function appliedJobs(Request $request)
return [
'application_id' => $app->id,
'status' => $app->status,
'employer_status' => $app->employer_status,
'applied_at' => $app->created_at->toIso8601String(),
'job' => $job ? [
'id' => $job->id,
@ -244,6 +377,8 @@ public function employerCreateJob(Request $request)
$validator = Validator::make($request->all(), [
'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0',
@ -264,6 +399,7 @@ public function employerCreateJob(Request $request)
$job = JobPost::create([
'employer_id' => $employer->id,
'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type,
'location' => $request->location,
@ -274,6 +410,27 @@ public function employerCreateJob(Request $request)
'status' => 'active',
]);
// Sync skills
$skillsInput = $request->skills;
$skillIds = [];
if (is_array($skillsInput)) {
foreach ($skillsInput as $skillNameOrId) {
if (is_numeric($skillNameOrId)) {
$skillIds[] = (int)$skillNameOrId;
} else {
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
$skillIds[] = $skill->id;
}
}
} else if (is_string($skillsInput)) {
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
foreach ($skillsArray as $name) {
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
$skillIds[] = $skill->id;
}
}
$job->skills()->sync($skillIds);
return response()->json([
'success' => true,
'message' => 'Job posted successfully.',
@ -281,6 +438,8 @@ public function employerCreateJob(Request $request)
'job' => [
'id' => $job->id,
'title' => $job->title,
'main_profession' => $job->main_profession,
'skills' => $job->skills()->pluck('name')->toArray(),
'location' => $job->location,
'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed,
@ -368,8 +527,17 @@ public function employerUpdateJob(Request $request, $id)
], 404);
}
if ($job->applications()->exists()) {
return response()->json([
'success' => false,
'message' => 'This job cannot be edited because workers have already applied for it.'
], 422);
}
$validator = Validator::make($request->all(), [
'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0',
@ -390,6 +558,7 @@ public function employerUpdateJob(Request $request, $id)
$job->update([
'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type,
'location' => $request->location,
@ -400,6 +569,27 @@ public function employerUpdateJob(Request $request, $id)
'status' => strtolower($request->status),
]);
// Sync skills
$skillsInput = $request->skills;
$skillIds = [];
if (is_array($skillsInput)) {
foreach ($skillsInput as $skillNameOrId) {
if (is_numeric($skillNameOrId)) {
$skillIds[] = (int)$skillNameOrId;
} else {
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
$skillIds[] = $skill->id;
}
}
} else if (is_string($skillsInput)) {
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
foreach ($skillsArray as $name) {
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
$skillIds[] = $skill->id;
}
}
$job->skills()->sync($skillIds);
return response()->json([
'success' => true,
'message' => 'Job updated successfully.',
@ -407,6 +597,8 @@ public function employerUpdateJob(Request $request, $id)
'job' => [
'id' => $job->id,
'title' => $job->title,
'main_profession' => $job->main_profession,
'skills' => $job->skills()->pluck('name')->toArray(),
'location' => $job->location,
'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed,
@ -496,6 +688,140 @@ public function employerJobApplicants(Request $request, $id)
$query->where('status', strtolower($request->status));
}
// Apply filters based on worker attributes
$query->whereHas('worker', function($q) use ($request) {
// Gender filter
if ($request->filled('gender')) {
$q->where('gender', strtolower($request->gender));
}
// Nationality filter
if ($request->filled('nationality')) {
$natInput = $request->nationality;
$natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput)));
if (!empty($natsArray)) {
$q->where(function($subQ) use ($natsArray) {
foreach ($natsArray as $n) {
$subQ->orWhere('nationality', 'like', "%{$n}%");
}
});
}
}
// Main Profession filter
if ($request->filled('main_profession')) {
$q->where('main_profession', strtolower($request->main_profession));
}
// Age filter (integer or range e.g. "18-25")
if ($request->filled('age')) {
$ageVal = $request->age;
if (str_contains($ageVal, '-')) {
[$minAge, $maxAge] = array_map('intval', explode('-', $ageVal));
$q->whereBetween('age', [$minAge, $maxAge]);
} else {
$q->where('age', $ageVal);
}
}
// Experience filter
if ($request->filled('experience')) {
$q->where('experience', 'like', "%{$request->experience}%");
}
// Salary range filter
if ($request->filled('salary_min')) {
$q->where('salary', '>=', (float)$request->salary_min);
}
if ($request->filled('salary_max')) {
$q->where('salary', '<=', (float)$request->salary_max);
}
// Preferred location filter
if ($request->filled('preferred_location')) {
$prefLoc = $request->preferred_location;
$locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc)));
if (!empty($locsArray)) {
$q->where(function($subQ) use ($locsArray) {
foreach ($locsArray as $l) {
$subQ->orWhere('preferred_location', 'like', "%{$l}%");
}
});
}
}
// Job type filter (preferred_job_type)
$jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type');
if ($jobTypeParam) {
$jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam)));
if (!empty($jobTypesArray)) {
$q->where(function($subQ) use ($jobTypesArray) {
foreach ($jobTypesArray as $jt) {
$subQ->orWhere('preferred_job_type', 'like', "%{$jt}%");
}
});
}
}
// Accommodation filter (live_in_out)
$accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type');
if ($accParam) {
$accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam)));
if (!empty($accsArray)) {
$q->where(function($subQ) use ($accsArray) {
foreach ($accsArray as $a) {
$normA = str_replace(['_', '-'], ' ', $a);
$subQ->orWhere('live_in_out', 'like', "%{$normA}%");
}
});
}
}
// In country filter
if ($request->has('in_country')) {
$inCountryVal = $request->input('in_country');
if ($inCountryVal !== null && $inCountryVal !== '') {
$isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
if ($isInCountry === null) {
$strVal = strtolower(trim($inCountryVal));
if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') {
$isInCountry = true;
} elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') {
$isInCountry = false;
}
}
if ($isInCountry !== null) {
$q->where('in_country', $isInCountry);
}
}
}
// Visa status filter
$visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type');
if ($visaParam) {
$visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam)));
if (!empty($visasArray)) {
$q->where(function($subQ) use ($visasArray) {
foreach ($visasArray as $v) {
$subQ->orWhere('visa_status', 'like', "%{$v}%");
}
});
}
}
// Skills filter
if ($request->filled('skills')) {
$skills = $request->skills;
$skillsArray = is_array($skills) ? $skills : array_filter(array_map('trim', explode(',', $skills)));
if (!empty($skillsArray)) {
$q->whereHas('skills', function($skillQ) use ($skillsArray) {
$skillQ->whereIn('name', $skillsArray)
->orWhereIn('id', array_filter($skillsArray, 'is_numeric'));
});
}
}
});
$sortBy = $request->input('sort_by', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
@ -562,7 +888,7 @@ public function employerUpdateApplicationStatus(Request $request, $id)
}
$validator = Validator::make($request->all(), [
'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired',
'status' => 'required|string|in:applied,review,reviewing,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,hire_requested',
'notes' => 'nullable|string',
]);
@ -587,9 +913,14 @@ public function employerUpdateApplicationStatus(Request $request, $id)
}
$dbStatus = strtolower($request->status);
if ($dbStatus === 'hired') {
$dbStatus = 'hire_requested';
} elseif ($dbStatus === 'review' || $dbStatus === 'reviewing') {
$dbStatus = 'applied';
}
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' for the first time
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
if (in_array($dbStatus, $contactStatuses)) {
if (!User::canContactWorker($employer->id, $application->worker_id)) {
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first();
@ -614,8 +945,16 @@ public function employerUpdateApplicationStatus(Request $request, $id)
'notes' => $notes,
];
$empStatus = strtolower($request->status);
if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') {
$empStatus = 'review';
} elseif ($empStatus === 'hire_requested') {
$empStatus = 'hired';
}
$updateData = [
'status' => $dbStatus,
'employer_status' => $empStatus,
'status_history' => $history,
];
@ -638,12 +977,7 @@ public function employerUpdateApplicationStatus(Request $request, $id)
}
}
// If hired, also update worker status
if ($dbStatus === 'hired') {
if ($application->worker) {
$application->worker->update(['status' => 'Hired']);
}
} elseif ($dbStatus === 'rejected') {
if ($dbStatus === 'rejected') {
if ($application->worker) {
$application->worker->update(['status' => 'active']);
}
@ -689,6 +1023,7 @@ public function appliedJobDetail(Request $request, $id)
'application' => [
'id' => $application->id,
'status' => $application->status,
'employer_status' => $application->employer_status,
'status_history' => $application->status_history ?: [],
'applied_at' => $application->created_at->toIso8601String(),
'job' => $job ? [
@ -742,7 +1077,164 @@ public function withdrawApplication(Request $request, $id)
}
/**
* Dispatch FCM Push Notification.
* API for workers to confirm a hire request.
* POST /api/workers/applied-jobs/{id}/confirm-hire
*/
public function confirmHire(Request $request, $id)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
$application = JobApplication::where('worker_id', $worker->id)->find($id);
if (!$application) {
return response()->json([
'success' => false,
'message' => 'Application not found.'
], 404);
}
if (strtolower($application->status) !== 'hire_requested') {
return response()->json([
'success' => false,
'message' => 'This application is not pending confirmation.'
], 400);
}
// Update application status
$history = $application->status_history ?: [];
$history[] = [
'status' => 'hired',
'timestamp' => now()->toIso8601String(),
'notes' => 'Hiring confirmed by worker.',
];
$application->update([
'status' => 'hired',
'status_history' => $history,
'joining_confirmed_at' => now(),
]);
// Update worker status
$worker->update([
'status' => 'Hired',
'availability' => 'Hired'
]);
// Create/update the JobOffer record to satisfy the Worker Employer List API
$job = $application->jobPost;
$employer = $job ? $job->employer : null;
if ($employer) {
\App\Models\JobOffer::updateOrCreate(
[
'employer_id' => $employer->id,
'worker_id' => $worker->id,
],
[
'work_date' => $job->start_date ?? now(),
'location' => $job->location ?? 'Dubai',
'salary' => $job->salary ?? 0,
'status' => 'accepted',
'notes' => $application->notes ?? 'Hired through Job Application',
]
);
// Notify Employer: Hire confirmation
$employer->notify(new \App\Notifications\GenericNotification(
"Hire Confirmation",
"Worker " . ($worker->name ?? "Candidate") . " has confirmed the hiring for '" . ($job->title ?? 'Job Post') . "'.",
[
'type' => 'hire_confirmation',
'job_id' => $job->id,
'application_id' => $application->id,
]
));
}
// Notify Worker: Hired
$worker->notify(new \App\Notifications\GenericNotification(
"Congratulations! You've been Hired",
"You are now officially hired for: '" . ($job->title ?? 'Job Post') . "'.",
[
'type' => 'hired',
'job_id' => $job->id,
'application_id' => $application->id,
]
));
return response()->json([
'success' => true,
'message' => 'Hiring confirmed successfully.',
'data' => [
'application' => $application->fresh()
]
]);
}
/**
* API for workers to decline a hire request.
* POST /api/workers/applied-jobs/{id}/decline-hire
*/
public function declineHire(Request $request, $id)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
$application = JobApplication::where('worker_id', $worker->id)->find($id);
if (!$application) {
return response()->json([
'success' => false,
'message' => 'Application not found.'
], 404);
}
if (strtolower($application->status) !== 'hire_requested') {
return response()->json([
'success' => false,
'message' => 'This application is not pending confirmation.'
], 400);
}
// Update application status back to declined
$history = $application->status_history ?: [];
$history[] = [
'status' => 'declined',
'timestamp' => now()->toIso8601String(),
'notes' => 'Hiring declined by worker.',
];
$application->update([
'status' => 'declined',
'status_history' => $history,
]);
$job = $application->jobPost;
$employer = $job ? $job->employer : null;
if ($employer) {
// Notify Employer: Hire declined
$employer->notify(new \App\Notifications\GenericNotification(
"Hire Request Declined",
"Worker " . ($worker->name ?? "Candidate") . " has declined the hiring request for '" . ($job->title ?? 'Job Post') . "'.",
[
'type' => 'hire_declined',
'job_id' => $job->id,
'application_id' => $application->id,
]
));
}
return response()->json([
'success' => true,
'message' => 'Hiring request declined successfully.',
'data' => [
'application' => $application->fresh()
]
]);
}
/**
* Dispatch FCM Push Notification & In-app database notifications.
*/
private function triggerStatusNotification($application, $status)
{
@ -750,63 +1242,63 @@ private function triggerStatusNotification($application, $status)
$job = $application->jobPost;
$employer = $job ? $job->employer : null;
// When a new application is submitted: notify employer
if ($status === 'applied') {
if ($employer && $employer->fcm_token) {
\App\Services\FCMService::sendPushNotification(
$employer->fcm_token,
"New Job Application",
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
[
'type' => 'new_job_application',
'job_id' => $job->id,
'application_id' => $application->id,
]
);
}
return;
}
// When status is updated: notify worker
if ($worker && $worker->fcm_token) {
if ($worker) {
$title = "Job Application Update";
$body = "";
$type = 'job_application_status_update';
switch (strtolower($status)) {
case 'shortlisted':
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
$type = 'shortlisted';
break;
case 'applied':
case 'review':
case 'reviewing':
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
$type = 'review';
break;
case 'contacted':
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
$type = 'contacted';
break;
case 'interview scheduled':
case 'interview_scheduled':
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
$type = 'interview_scheduled';
break;
case 'selected':
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
$type = 'selected';
break;
case 'rejected':
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
$type = 'rejected';
break;
case 'hire_requested':
$title = "Action Required: Confirm Hiring";
$body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm.";
$type = 'hire_request';
break;
case 'hired':
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
$type = 'hired';
break;
default:
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
break;
}
\App\Services\FCMService::sendPushNotification(
$worker->fcm_token,
$worker->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => 'job_application_status_update',
'type' => $type,
'job_id' => $job->id ?? '',
'application_id' => $application->id,
'status' => $status,
]
);
));
}
}
}

View File

@ -4,6 +4,7 @@
use App\Http\Controllers\Controller;
use App\Models\JobOffer;
use App\Models\JobApplication;
use App\Models\Worker;
use App\Models\WorkerDocument;
use App\Models\ProfileView;
@ -68,8 +69,23 @@ public function updateProfile(Request $request)
$validator = Validator::make($request->all(), [
'name' => 'nullable|string|max:255',
'phone' => 'nullable|string|max:50|unique:workers,phone,' . $worker->id,
'age' => 'nullable|integer|min:18|max:70',
'phone' => 'nullable|string|max:50',
'main_profession' => 'nullable|string|max:100',
'age' => [
'nullable',
function ($attribute, $value, $fail) {
if (is_numeric($value)) {
$intValue = (int)$value;
if ($intValue < 18 || $intValue > 100) {
$fail('The age must be between 18 and 100.');
}
} else {
if (!in_array($value, ['18-25', '26-35', '36-45', '46-55', '56-60'])) {
$fail('The age must be a valid number or one of the ranges: 18-25, 26-35, 36-45, 46-55, 56-60.');
}
}
}
],
'nationality' => 'nullable|string|max:100',
'language' => 'nullable|string',
'salary' => 'nullable|numeric|min:0',
@ -77,7 +93,7 @@ public function updateProfile(Request $request)
'skills' => 'nullable|array',
'skills.*' => 'exists:skills,id',
'in_country' => 'nullable',
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
'visa_status' => 'nullable|string',
'preferred_job_type' => 'nullable|string|max:100',
'gender' => 'nullable|string|in:male,female,other',
'live_in_out' => 'nullable|string|in:live_in,live_out',
@ -93,7 +109,6 @@ public function updateProfile(Request $request)
],
'visa' => 'nullable|array',
], [
'phone.unique' => 'This mobile number is already registered.',
'passport.passport_number.unique' => 'This passport number is already registered.',
]);
@ -105,6 +120,17 @@ public function updateProfile(Request $request)
], 422);
}
if ($request->phone) {
$exists = Worker::findByPhone($request->phone);
if ($exists && $exists->id !== $worker->id) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => ['phone' => ['This mobile number is already registered.']]
], 422);
}
}
// Document Update Check: Verify passport number matches existing passport number if already verified/saved
$existingPassport = $worker->documents()->where('type', 'passport')->first();
if ($existingPassport && $request->has('passport')) {
@ -139,7 +165,8 @@ public function updateProfile(Request $request)
'gender',
'live_in_out',
'preferred_location',
'fcm_token'
'fcm_token',
'main_profession'
]);
// Filter out null inputs if we want to support partial updates
@ -471,10 +498,9 @@ public function respondToOffer(Request $request, $id)
// Dispatch push notification to employer
$employer = $offer->employer;
if ($employer && $employer->fcm_token) {
if ($employer) {
$statusMessage = $responseStatus === 'accepted' ? 'accepted' : 'rejected';
\App\Services\FCMService::sendPushNotification(
$employer->fcm_token,
$employer->notify(new \App\Notifications\GenericNotification(
"Job Offer " . ucfirst($statusMessage),
"Worker " . ($worker->name ?? "Candidate") . " has " . $statusMessage . " your job offer.",
[
@ -482,7 +508,7 @@ public function respondToOffer(Request $request, $id)
'offer_id' => $offer->id,
'status' => $responseStatus,
]
);
));
}
return response()->json([
@ -587,13 +613,29 @@ public function getDashboard(Request $request)
// 3. Currently working sponsor/employer
$currentSponsor = null;
$emp = null;
$hiredAt = null;
$acceptedOffer = JobOffer::where('worker_id', $worker->id)
->where('status', 'accepted')
->whereIn(DB::raw('LOWER(status)'), ['accepted', 'hired'])
->with(['employer.employerProfile', 'employer.employerReviews.worker'])
->first();
if ($acceptedOffer && $acceptedOffer->employer) {
if ($acceptedOffer) {
$emp = $acceptedOffer->employer;
$hiredAt = $acceptedOffer->updated_at;
} else {
$hiredApplication = \App\Models\JobApplication::where('worker_id', $worker->id)
->whereIn(DB::raw('LOWER(status)'), ['hired'])
->with(['jobPost.employer.employerProfile', 'jobPost.employer.employerReviews.worker'])
->first();
if ($hiredApplication && $hiredApplication->jobPost && $hiredApplication->jobPost->employer) {
$emp = $hiredApplication->jobPost->employer;
$hiredAt = $hiredApplication->updated_at;
}
}
if ($emp) {
$empProfile = $emp->employerProfile;
$currentSponsor = [
'id' => $emp->id,
@ -602,9 +644,9 @@ public function getDashboard(Request $request)
'nationality' => $empProfile->nationality ?? 'UAE',
'city' => $empProfile->city ?? 'Dubai',
'email' => $emp->email,
'phone' => $emp->phone,
'hired_at' => $acceptedOffer->updated_at->toIso8601String(),
'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'),
'phone' => $empProfile->phone ?? $emp->phone,
'hired_at' => $hiredAt ? $hiredAt->toIso8601String() : null,
'hired_at_formatted' => $hiredAt ? $hiredAt->format('M d, Y') : null,
'rating' => $emp->rating,
'reviews_count' => $emp->reviews_count,
'reviews' => $emp->employerReviews,
@ -687,45 +729,22 @@ public function getEmployers(Request $request)
$worker = $request->attributes->get('worker');
try {
$query = JobOffer::where('worker_id', $worker->id)
->with(['employer.employerProfile', 'employer.employerReviews.worker']);
// Fetch JobOffers
$offers = JobOffer::where('worker_id', $worker->id)
->with(['employer.employerProfile', 'employer.employerReviews.worker'])
->get();
// Filtering by status
if ($request->filled('status')) {
$statusInput = strtolower($request->status);
if ($statusInput === 'active') {
$query->where('status', 'accepted');
} else {
$query->where('status', $statusInput);
}
}
// Fetch hired JobApplications
$applications = JobApplication::where('worker_id', $worker->id)
->where('status', 'hired')
->with(['jobPost.employer.employerProfile', 'jobPost.employer.employerReviews.worker'])
->get();
// Sorting
$sortBy = $request->input('sort_by', 'joining_date');
$sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc';
$sortColumn = 'work_date';
if ($sortBy === 'salary') {
$sortColumn = 'salary';
} elseif ($sortBy === 'status') {
$sortColumn = 'status';
} elseif ($sortBy === 'created_at') {
$sortColumn = 'created_at';
}
// Always display the current active employer (status = accepted) at the top of the list,
// followed by other employers ordered by the chosen sort column.
$query->orderByRaw("CASE WHEN status = 'accepted' THEN 0 ELSE 1 END ASC")
->orderBy($sortColumn, $sortOrder);
$perPage = (int) $request->input('per_page', 15);
$paginator = $query->paginate($perPage);
$employers = collect($paginator->items())->map(function ($offer) {
// Map JobOffers
$mappedOffers = $offers->map(function ($offer) {
$emp = $offer->employer;
$empProfile = $emp ? $emp->employerProfile : null;
// Map 'accepted' -> 'Active' (and others to Title Case for nice presentation)
$statusFormatted = $offer->status;
if (strtolower($offer->status) === 'accepted') {
$statusFormatted = 'Active';
@ -736,10 +755,13 @@ public function getEmployers(Request $request)
return [
'offer_id' => $offer->id,
'joining_date' => $offer->work_date ? $offer->work_date->format('Y-m-d') : null,
'joining_date_raw' => $offer->work_date,
'location' => $offer->location,
'salary' => (int) $offer->salary,
'status' => $statusFormatted,
'status_raw' => $offer->status,
'notes' => $offer->notes,
'created_at' => $offer->created_at,
'employer' => $emp ? [
'id' => $emp->id,
'name' => $emp->name,
@ -755,15 +777,120 @@ public function getEmployers(Request $request)
];
});
// Map JobApplications
$mappedApps = $applications->map(function ($app) {
$job = $app->jobPost;
$emp = $job ? $job->employer : null;
$empProfile = $emp ? $emp->employerProfile : null;
return [
'offer_id' => $app->id,
'joining_date' => $app->updated_at ? $app->updated_at->format('Y-m-d') : null,
'joining_date_raw' => $app->updated_at,
'location' => $job->location ?? null,
'salary' => $job ? (int) $job->salary : 0,
'status' => 'Active',
'status_raw' => 'accepted',
'notes' => $app->notes,
'created_at' => $app->created_at,
'employer' => $emp ? [
'id' => $emp->id,
'name' => $emp->name,
'email' => $emp->email,
'phone' => $emp->phone,
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
'city' => $empProfile->city ?? null,
'nationality' => $empProfile->nationality ?? null,
'rating' => $emp->rating,
'reviews_count' => $emp->reviews_count,
'reviews' => $emp->employerReviews,
] : null
];
});
// Merge and de-duplicate by employer id
$merged = $mappedOffers->concat($mappedApps)->unique(function ($item) {
return $item['employer']['id'] ?? null;
})->values();
// Filtering by status
if ($request->filled('status')) {
$statusInput = strtolower($request->status);
$merged = $merged->filter(function ($item) use ($statusInput) {
$rawStatus = strtolower($item['status_raw'] ?? '');
if ($statusInput === 'active') {
return $rawStatus === 'accepted' || $rawStatus === 'hired';
}
return $rawStatus === $statusInput;
});
}
// Sorting
$sortBy = $request->input('sort_by', 'joining_date');
$sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc';
$merged = $merged->sort(function ($a, $b) use ($sortBy, $sortOrder) {
// Priority: status = 'Active' (accepted/hired) comes first
$aActive = $a['status'] === 'Active' ? 0 : 1;
$bActive = $b['status'] === 'Active' ? 0 : 1;
if ($aActive !== $bActive) {
return $aActive <=> $bActive;
}
$valA = null;
$valB = null;
if ($sortBy === 'salary') {
$valA = $a['salary'];
$valB = $b['salary'];
} elseif ($sortBy === 'status') {
$valA = $a['status'];
$valB = $b['status'];
} elseif ($sortBy === 'created_at') {
$valA = $a['created_at'];
$valB = $b['created_at'];
} else {
$valA = $a['joining_date_raw'];
$valB = $b['joining_date_raw'];
}
if ($valA == $valB) {
return 0;
}
if ($sortOrder === 'asc') {
return $valA > $valB ? 1 : -1;
} else {
return $valA < $valB ? 1 : -1;
}
});
// Pagination
$total = $merged->count();
$perPage = (int) $request->input('per_page', 15);
$currentPage = (int) $request->input('page', 1);
$lastPage = max(1, (int) ceil($total / $perPage));
$pageItems = $merged->slice(($currentPage - 1) * $perPage, $perPage)->values();
// Clean up temporary sorting/filtering keys
$employers = $pageItems->map(function ($item) {
unset($item['joining_date_raw']);
unset($item['status_raw']);
unset($item['created_at']);
return $item;
});
return response()->json([
'success' => true,
'data' => [
'employers' => $employers,
'pagination' => [
'total' => $paginator->total(),
'per_page' => $paginator->perPage(),
'current_page' => $paginator->currentPage(),
'last_page' => $paginator->lastPage(),
'total' => $total,
'per_page' => $perPage,
'current_page' => $currentPage,
'last_page' => $lastPage,
]
]
], 200);
@ -976,7 +1103,22 @@ public function getEmployerProfile(Request $request, $id)
->where('worker_id', $worker->id)
->exists();
if ($hasOffer || $hasApplication || $hasConversation) {
// If the worker has 'Hired' status (case-insensitive) and is hired by this employer
$isHiredByThisEmployer = false;
if (strtolower($worker->status ?? '') === 'hired') {
$isHiredByThisEmployer = \App\Models\JobOffer::where('employer_id', $employer->id)
->where('worker_id', $worker->id)
->whereIn(DB::raw('LOWER(status)'), ['accepted', 'hired'])
->exists() ||
\App\Models\JobApplication::where('worker_id', $worker->id)
->whereHas('jobPost', function ($query) use ($employer) {
$query->where('employer_id', $employer->id);
})
->where(DB::raw('LOWER(status)'), 'hired')
->exists();
}
if ($hasOffer || $hasApplication || $hasConversation || $isHiredByThisEmployer) {
$hasAccess = true;
}

View File

@ -90,13 +90,23 @@ public function addReview(Request $request)
], 403);
}
// Verify if completed configured months from joining date
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
], 403);
// Verify if completed configured period from joining date
$eligibilityMinutes = config('reminders.review_eligibility_minutes');
if ($eligibilityMinutes !== null && $eligibilityMinutes > 0) {
if ($joiningDate->diffInMinutes(now(), false) < $eligibilityMinutes) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMinutes} minutes from the joining date."
], 403);
}
} else {
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
return response()->json([
'success' => false,
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
], 403);
}
}
// 2. Prevent duplicate reviews for the same job/employer combination

View File

@ -72,6 +72,9 @@ public function store(Request $request)
'event_time' => 'required|string',
'location_details' => 'required|string',
'location_pin' => 'required|string|url',
'contact_person_name' => 'required|string|max:255',
'contact_number' => 'required|string|max:50',
'country_code' => 'nullable|string|max:10',
]);
$body = json_encode([
@ -82,6 +85,9 @@ public function store(Request $request)
'location_details' => $request->location_details,
'location_pin' => $request->location_pin,
'content' => $request->content,
'contact_person_name' => $request->contact_person_name,
'contact_number' => $request->contact_number,
'country_code' => $request->country_code ?? '+971',
]);
$sess = session('user');

View File

@ -54,11 +54,12 @@ public function index(Request $request)
if (!$w) return null;
// Map DB status to UI friendly status
$status = 'Reviewing';
if ($app->status === 'hired') $status = 'Hired';
elseif ($app->status === 'rejected') $status = 'Rejected';
elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent';
elseif ($app->status === 'applied') $status = 'Reviewing';
$status = 'Review';
$dbStatusLower = strtolower($app->status ?? '');
if ($dbStatusLower === 'hired') $status = 'Hired';
elseif ($dbStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Shortlisted';
elseif ($dbStatusLower === 'applied' || $dbStatusLower === 'review' || $dbStatusLower === 'reviewing') $status = 'Review';
else $status = ucfirst($app->status);
return [
@ -83,15 +84,27 @@ public function index(Request $request)
->with('worker')
->get();
$directWorkers = $directOffers->map(function ($offer) {
// Get worker IDs of all hired job applications for this employer to prevent duplication
$hiredAppWorkerIds = collect($applications)
->filter(fn($app) => strtolower($app->status ?? '') === 'hired')
->pluck('worker_id')
->toArray();
$directWorkers = $directOffers->map(function ($offer) use ($hiredAppWorkerIds) {
$w = $offer->worker;
if (!$w) return null;
// Avoid duplicate hired entries if worker was hired via standard application
$dbOfferStatusLower = strtolower($offer->status ?? '');
if (($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') && in_array($w->id, $hiredAppWorkerIds)) {
return null;
}
// Map DB offer status to UI friendly status
$status = 'Offer Sent';
if ($offer->status === 'accepted') $status = 'Hired';
elseif ($offer->status === 'rejected') $status = 'Rejected';
elseif ($offer->status === 'pending') $status = 'Offer Sent';
if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired';
elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected';
elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent';
return [
'id' => 'offer_' . $offer->id, // Unique string key prefix to prevent clashes
@ -115,7 +128,7 @@ public function index(Request $request)
// Only display Hired candidates in the candidates pipeline
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($w) {
return $w && $w['status'] === 'Hired';
return $w && strtolower($w['status'] ?? '') === 'hired';
}));
// Apply filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status
@ -303,7 +316,7 @@ private function checkJobAccess($user)
public function updateStatus(Request $request, $id)
{
$request->validate([
'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent',
'status' => 'required|string|in:Applied,Review,review,Reviewing,reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent,hire_requested',
'notes' => 'nullable|string',
]);
@ -314,18 +327,31 @@ public function updateStatus(Request $request, $id)
$dbStatus = 'pending';
if (in_array(strtolower($request->status), ['hired', 'accepted'])) {
$dbStatus = 'accepted';
$offer->worker->update(['status' => 'Hired']);
// Under two-way confirmation, it must NOT immediately set status to accepted/Hired.
// It should set offer status to 'pending' (waiting for worker confirmation).
$dbStatus = 'pending';
} elseif (in_array(strtolower($request->status), ['rejected'])) {
$dbStatus = 'rejected';
$offer->worker->update(['status' => 'active']);
} elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) {
$dbStatus = 'pending';
}
$offer->update(['status' => $dbStatus]);
return back()->with('success', 'Direct candidate hiring offer status updated successfully to ' . $request->status);
// Notify worker about the hire offer if it is set to pending
if ($dbStatus === 'pending') {
$offer->worker->notify(new \App\Notifications\GenericNotification(
"Direct Hiring Offer",
($offer->employer->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
[
'type' => 'direct_hire_request',
'offer_id' => $offer->id,
'status' => 'pending',
]
));
}
return back()->with('success', 'Hire request initiated successfully. Awaiting candidate confirmation.');
}
// Standard job application status update
@ -343,8 +369,8 @@ public function updateStatus(Request $request, $id)
$dbStatus = 'applied';
$statusLower = strtolower($request->status);
if ($statusLower === 'hired') {
$dbStatus = 'hired';
if ($statusLower === 'hired' || $statusLower === 'hire_requested') {
$dbStatus = 'hire_requested';
} elseif ($statusLower === 'rejected') {
$dbStatus = 'rejected';
} elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') {
@ -355,12 +381,12 @@ public function updateStatus(Request $request, $id)
$dbStatus = 'interview_scheduled';
} elseif ($statusLower === 'selected') {
$dbStatus = 'selected';
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied') {
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied' || $statusLower === 'review') {
$dbStatus = 'applied';
}
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
if (in_array($dbStatus, $contactStatuses)) {
if (!User::canContactWorker($user->id, $app->worker_id)) {
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
@ -382,8 +408,16 @@ public function updateStatus(Request $request, $id)
'notes' => $request->input('notes'),
];
$empStatus = strtolower($request->status);
if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') {
$empStatus = 'review';
} elseif ($empStatus === 'hire_requested') {
$empStatus = 'hired';
}
$updateData = [
'status' => $dbStatus,
'employer_status' => $empStatus,
'status_history' => $history,
];
@ -406,12 +440,7 @@ public function updateStatus(Request $request, $id)
}
}
// If hired, also update worker status
if ($dbStatus === 'hired') {
if ($app->worker) {
$app->worker->update(['status' => 'Hired']);
}
} elseif ($dbStatus === 'rejected') {
if ($dbStatus === 'rejected') {
if ($app->worker) {
$app->worker->update(['status' => 'active']);
}
@ -429,63 +458,63 @@ private function triggerStatusNotification($application, $status)
$job = $application->jobPost;
$employer = $job ? $job->employer : null;
// When a new application is submitted: notify employer
if ($status === 'applied') {
if ($employer && $employer->fcm_token) {
\App\Services\FCMService::sendPushNotification(
$employer->fcm_token,
"New Job Application",
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
[
'type' => 'new_job_application',
'job_id' => $job->id,
'application_id' => $application->id,
]
);
}
return;
}
// When status is updated: notify worker
if ($worker && $worker->fcm_token) {
if ($worker) {
$title = "Job Application Update";
$body = "";
$type = 'job_application_status_update';
switch (strtolower($status)) {
case 'shortlisted':
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
$type = 'shortlisted';
break;
case 'applied':
case 'review':
case 'reviewing':
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
$type = 'review';
break;
case 'contacted':
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
$type = 'contacted';
break;
case 'interview scheduled':
case 'interview_scheduled':
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
$type = 'interview_scheduled';
break;
case 'selected':
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
$type = 'selected';
break;
case 'rejected':
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
$type = 'rejected';
break;
case 'hire_requested':
$title = "Action Required: Confirm Hiring";
$body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm.";
$type = 'hire_request';
break;
case 'hired':
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
$type = 'hired';
break;
default:
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
break;
}
\App\Services\FCMService::sendPushNotification(
$worker->fcm_token,
$worker->notify(new \App\Notifications\GenericNotification(
$title,
$body,
[
'type' => 'job_application_status_update',
'type' => $type,
'job_id' => $job->id ?? '',
'application_id' => $application->id,
'status' => $status,
]
);
));
}
}
}

View File

@ -62,6 +62,33 @@ public function index(Request $request)
$totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() +
\App\Models\JobApplication::where('status', 'hired')->count();
$totalActiveWorkers = \App\Models\Worker::where('status', 'active')->count();
$totalWorkerProfiles = \App\Models\Worker::count();
// Calculate dynamic avg monthly hires based on actual database entries if available, otherwise fallback to 550
$driver = \Illuminate\Support\Facades\DB::getDriverName();
$formatExpr = $driver === 'sqlite' ? "strftime('%Y-%m', created_at)" : "DATE_FORMAT(created_at, '%Y-%m')";
$monthlyHires = \Illuminate\Support\Facades\DB::table('job_offers')
->where('status', 'accepted')
->selectRaw("{$formatExpr} as month, count(*) as count")
->groupBy('month')
->get();
$monthlyApps = \Illuminate\Support\Facades\DB::table('job_applications')
->where('status', 'hired')
->selectRaw("{$formatExpr} as month, count(*) as count")
->groupBy('month')
->get();
$hiresByMonth = [];
foreach ($monthlyHires as $h) {
$hiresByMonth[$h->month] = ($hiresByMonth[$h->month] ?? 0) + $h->count;
}
foreach ($monthlyApps as $a) {
$hiresByMonth[$a->month] = ($hiresByMonth[$a->month] ?? 0) + $a->count;
}
$avgMonthlyHires = count($hiresByMonth) > 0 ? (array_sum($hiresByMonth) / count($hiresByMonth)) : 0;
$avgMonthlyHires = max(550, round($avgMonthlyHires));
$stats = [
'shortlisted_count' => $shortlistedCount,
@ -71,6 +98,8 @@ public function index(Request $request)
'hired_count' => $hiredCount,
'total_hired_all' => $totalHiredAll,
'total_active_workers' => $totalActiveWorkers,
'total_worker_profiles' => $totalWorkerProfiles,
'avg_monthly_hires' => $avgMonthlyHires,
'recent_failed_payment' => false
];

View File

@ -139,7 +139,32 @@ public function create()
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
return Inertia::render('Employer/Jobs/Create');
$professions = \App\Models\Skill::orderBy('name')
->pluck('name')
->map(function ($name) {
return ucwords($name);
})
->unique()
->values()
->toArray();
if (empty($professions)) {
$professions = [
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
];
}
$skills = \App\Models\Skill::all()->map(function ($skill) {
return [
'id' => $skill->id,
'name' => $skill->name,
];
});
return Inertia::render('Employer/Jobs/Create', [
'professions' => $professions,
'availableSkills' => $skills,
]);
}
public function store(Request $request)
@ -156,6 +181,8 @@ public function store(Request $request)
$request->validate([
'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0',
@ -165,9 +192,10 @@ public function store(Request $request)
'requirements' => 'nullable|string',
]);
JobPost::create([
$job = JobPost::create([
'employer_id' => $user->id,
'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type,
'location' => $request->location,
@ -178,6 +206,27 @@ public function store(Request $request)
'status' => 'active',
]);
// Sync skills
$skillsInput = $request->skills;
$skillIds = [];
if (is_array($skillsInput)) {
foreach ($skillsInput as $skillNameOrId) {
if (is_numeric($skillNameOrId)) {
$skillIds[] = (int)$skillNameOrId;
} else {
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
$skillIds[] = $skill->id;
}
}
} else if (is_string($skillsInput)) {
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
foreach ($skillsArray as $name) {
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
$skillIds[] = $skill->id;
}
}
$job->skills()->sync($skillIds);
return redirect()->route('employer.jobs')->with('success', 'Job posted successfully.');
}
@ -242,7 +291,7 @@ public function edit($id)
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
}
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->with('skills')->firstOrFail();
// Convert start_date format to Y-m-d for date input
$jobData = $job->toArray();
@ -250,8 +299,40 @@ public function edit($id)
$jobData['start_date'] = $job->start_date->format('Y-m-d');
}
// Add skills list
$jobData['skills'] = $job->skills->map(function ($s) {
return [
'id' => $s->id,
'name' => $s->name,
];
})->toArray();
$professions = \App\Models\Skill::orderBy('name')
->pluck('name')
->map(function ($name) {
return ucwords($name);
})
->unique()
->values()
->toArray();
if (empty($professions)) {
$professions = [
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
];
}
$skills = \App\Models\Skill::all()->map(function ($skill) {
return [
'id' => $skill->id,
'name' => $skill->name,
];
});
return Inertia::render('Employer/Jobs/Edit', [
'job' => $jobData,
'professions' => $professions,
'availableSkills' => $skills,
]);
}
@ -269,8 +350,14 @@ public function update(Request $request, $id)
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
if ($job->applications()->exists()) {
return back()->withErrors(['general' => 'This job cannot be edited because workers have already applied for it.']);
}
$request->validate([
'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0',
@ -283,6 +370,7 @@ public function update(Request $request, $id)
$job->update([
'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type,
'location' => $request->location,
@ -293,6 +381,27 @@ public function update(Request $request, $id)
'status' => strtolower($request->status),
]);
// Sync skills
$skillsInput = $request->skills;
$skillIds = [];
if (is_array($skillsInput)) {
foreach ($skillsInput as $skillNameOrId) {
if (is_numeric($skillNameOrId)) {
$skillIds[] = (int)$skillNameOrId;
} else {
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
$skillIds[] = $skill->id;
}
}
} else if (is_string($skillsInput)) {
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
foreach ($skillsArray as $name) {
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
$skillIds[] = $skill->id;
}
}
$job->skills()->sync($skillIds);
return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.');
}

View File

@ -37,6 +37,21 @@ private function resolveCurrentUser()
return null;
}
private function getWorkerStatus($employerId, $workerId, $defaultStatus)
{
$pendingOfferOrApp = JobOffer::where('employer_id', $employerId)
->where('worker_id', $workerId)
->where('status', 'pending')
->exists() ||
\App\Models\JobApplication::where('worker_id', $workerId)
->whereHas('jobPost', function($q) use ($employerId) {
$q->where('employer_id', $employerId);
})->where('status', 'hire_requested')
->exists();
return $pendingOfferOrApp ? 'pending_confirmation' : strtolower($defaultStatus ?? 'active');
}
public static function processWorkerResponse($conv, $worker, $text)
{
$replyText = strtolower(trim($text));
@ -105,13 +120,13 @@ public function index(Request $request)
->latest('updated_at')
->get();
$conversations = $dbConversations->map(function ($conv) {
$conversations = $dbConversations->map(function ($conv) use ($user) {
$lastMsg = $conv->messages->last();
return [
'id' => $conv->id,
'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status),
'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
'online' => true,
@ -136,13 +151,13 @@ public function show($id)
->latest('updated_at')
->get();
$conversations = $dbConversations->map(function ($conv) {
$conversations = $dbConversations->map(function ($conv) use ($user) {
$lastMsg = $conv->messages->last();
return [
'id' => $conv->id,
'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate',
'worker_status' => strtolower($conv->worker->status ?? 'active'),
'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status),
'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
'online' => true,
@ -165,7 +180,7 @@ public function show($id)
'id' => $activeConv->id,
'worker_id' => $activeConv->worker_id,
'worker_name' => $activeConv->worker->name ?? 'Candidate',
'worker_status' => strtolower($activeConv->worker->status ?? 'active'),
'worker_status' => $this->getWorkerStatus($user->id, $activeConv->worker_id, $activeConv->worker->status),
'online' => true,
'salary' => ($activeConv->worker->salary ?? 2000) . ' AED',
'nationality' => $activeConv->worker->nationality ?? 'Unknown',
@ -182,10 +197,59 @@ public function show($id)
];
})->toArray();
$application = \App\Models\JobApplication::where('worker_id', $activeConv->worker_id)
->whereHas('jobPost', function($q) use ($user) {
$q->where('employer_id', $user->id);
})
->latest('id')
->first();
if (!$application) {
$jobPost = \App\Models\JobPost::where('employer_id', $user->id)->latest('id')->first();
if (!$jobPost) {
$jobPost = \App\Models\JobPost::create([
'employer_id' => $user->id,
'title' => 'General Helper',
'location' => 'Dubai',
'salary' => 2000,
'job_type' => 'full-time',
'status' => 'open',
'start_date' => now()->format('Y-m-d'),
'description' => 'General domestic helper position.',
'requirements' => 'General helper requirements.',
]);
}
$application = \App\Models\JobApplication::create([
'job_id' => $jobPost->id,
'worker_id' => $activeConv->worker_id,
'status' => 'applied',
'employer_status' => 'review',
'status_history' => [
[
'status' => 'applied',
'timestamp' => now()->toIso8601String(),
'notes' => 'Application created via chat conversation.',
]
],
]);
}
$applicationData = null;
if ($application) {
$applicationData = [
'application_id' => $application->id,
'status' => $application->status,
'notes' => $application->notes,
'status_history' => $application->status_history ?: [],
'name' => $activeConv->worker->name ?? 'Candidate',
];
}
return Inertia::render('Employer/Messages/Show', [
'conversations' => $conversations,
'conversation' => $conversationData,
'initialMessages' => $initialMessages,
'application' => $applicationData,
]);
}

View File

@ -365,6 +365,16 @@ public function show($id)
$visaStatus = $w->visa_status;
$pendingOfferOrApp = JobOffer::where('employer_id', $user ? $user->id : 0)
->where('worker_id', $w->id)
->where('status', 'pending')
->exists() ||
\App\Models\JobApplication::where('worker_id', $w->id)
->whereHas('jobPost', function($q) use ($user) {
$q->where('employer_id', $user ? $user->id : 0);
})->where('status', 'hire_requested')
->exists();
$worker = [
'id' => $w->id,
'name' => $w->name,
@ -392,21 +402,13 @@ public function show($id)
'reviews' => $reviews,
'similar_workers' => $similarWorkers,
'status' => $w->status,
'availability_status' => $w->status,
'availability_status' => $pendingOfferOrApp ? 'Pending Confirmation' : ($w->status === 'Hired' ? 'Hired' : $w->status),
'document_expiry_status' => $w->document_expiry_status,
'document_expiry_days' => $w->document_expiry_days,
'visa_expiry_date' => $w->visa_expiry_date,
'in_country' => (bool) $w->in_country,
'preferred_location' => $w->preferred_location,
'documents' => $w->documents->map(function ($doc) {
$ocrData = $doc->ocr_data;
if ($doc->type === 'visa' && is_array($ocrData)) {
unset($ocrData['file_number']);
unset($ocrData['name']);
unset($ocrData['passport_number']);
unset($ocrData['accompanied_by']);
unset($ocrData['valid_until']);
}
return [
'id' => $doc->id,
'type' => $doc->type,
@ -415,7 +417,7 @@ public function show($id)
'expiry_date' => $doc->expiry_date,
'ocr_accuracy' => $doc->ocr_accuracy,
'file_path' => $doc->file_path ? url($doc->file_path) : null,
'ocr_data' => $ocrData,
'ocr_data' => $doc->ocr_data,
];
})->toArray(),
];
@ -479,17 +481,18 @@ public function markHired(Request $request, $id)
}
$worker = Worker::findOrFail($id);
$worker->update([
'status' => 'Hired',
]);
// Do NOT update worker status to 'Hired' directly.
// Check if there is an existing job offer for this employer and worker
$offer = JobOffer::where('employer_id', $employerId)
->where('worker_id', $worker->id)
->first();
$application = null;
if ($offer) {
$offer->update(['status' => 'accepted']);
$offer->update(['status' => 'pending']);
} else {
// Also check if there's an existing job application
$jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id');
@ -498,21 +501,53 @@ public function markHired(Request $request, $id)
->first();
if ($application) {
$application->update(['status' => 'hired']);
$application->update(['status' => 'hire_requested']);
$history = $application->status_history ?: [];
$history[] = [
'status' => 'hire_requested',
'timestamp' => now()->toIso8601String(),
'notes' => 'Hire request initiated by employer.',
];
$application->update(['status_history' => $history]);
} else {
// Create a new direct job offer with status accepted
JobOffer::create([
// Create a new direct job offer with status pending
$offer = JobOffer::create([
'employer_id' => $employerId,
'worker_id' => $worker->id,
'work_date' => now()->format('Y-m-d'),
'location' => 'Dubai',
'salary' => $worker->salary,
'notes' => 'Direct sponsoring matching finalized.',
'status' => 'accepted',
'notes' => 'Direct sponsoring matching initiated.',
'status' => 'pending',
]);
}
}
return back()->with('success', 'Worker successfully marked as Hired!');
// Notify the worker
if ($offer) {
$worker->notify(new \App\Notifications\GenericNotification(
"Direct Hiring Offer",
($user->name ?? "Employer") . " wants to hire you. Do you want to accept this job offer?",
[
'type' => 'direct_hire_request',
'offer_id' => $offer->id,
'status' => 'pending',
]
));
} else {
$worker->notify(new \App\Notifications\GenericNotification(
"Action Required: Confirm Hiring",
"Employer " . ($user->name ?? "Employer") . " wants to hire you for '" . ($application->jobPost->title ?? 'Job Post') . "'. Please confirm.",
[
'type' => 'hire_request',
'job_id' => $application->jobPost->id ?? '',
'application_id' => $application->id,
'status' => 'hire_requested',
]
));
}
return back()->with('success', 'Hire request initiated successfully! Awaiting candidate confirmation.');
}
}

View File

@ -13,6 +13,7 @@ class JobApplication extends Model
'job_id',
'worker_id',
'status',
'employer_status',
'notes',
'status_history',
'joining_confirmed_at',

View File

@ -10,9 +10,38 @@ class JobPost extends Model
{
use HasFactory, SoftDeletes;
protected static function booted()
{
static::updated(function ($jobPost) {
if ($jobPost->wasChanged('status')) {
$oldStatus = $jobPost->getOriginal('status');
$newStatus = $jobPost->status;
// Send push notification to all workers who applied to this job
$applications = $jobPost->applications()->with('worker')->get();
foreach ($applications as $app) {
$worker = $app->worker;
if ($worker) {
$worker->notify(new \App\Notifications\GenericNotification(
"Job Status Changed",
"The status of the job '" . $jobPost->title . "' has been changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.",
[
'type' => 'job_status_change',
'job_id' => $jobPost->id,
'old_status' => $oldStatus,
'new_status' => $newStatus,
]
));
}
}
}
});
}
protected $fillable = [
'employer_id',
'title',
'main_profession',
'workers_needed',
'job_type',
'location',
@ -34,6 +63,10 @@ public function employer()
return $this->belongsTo(User::class, 'employer_id');
}
public function skills()
{
return $this->belongsToMany(Skill::class, 'job_post_skills', 'job_post_id', 'skill_id');
}
public function applications()
{

View File

@ -11,6 +11,27 @@ class Worker extends Model
{
use HasFactory, SoftDeletes, Notifiable;
protected static function booted()
{
static::updated(function ($worker) {
if ($worker->wasChanged('status')) {
$oldStatus = $worker->getOriginal('status');
$newStatus = $worker->status;
$worker->notify(new \App\Notifications\GenericNotification(
"Profile Status Update",
"Your profile status has changed from '" . ucfirst($oldStatus) . "' to '" . ucfirst($newStatus) . "'.",
[
'type' => 'worker_status_change',
'worker_id' => $worker->id,
'old_status' => $oldStatus,
'new_status' => $newStatus,
]
));
}
});
}
protected $fillable = [
'name',
'email',
@ -215,4 +236,36 @@ public function profileViews()
{
return $this->hasMany(ProfileView::class);
}
public static function findByPhone($phone)
{
if (empty($phone)) {
return null;
}
$cleanInput = ltrim(preg_replace('/\D/', '', $phone), '0');
if (empty($cleanInput)) {
return null;
}
// If input is short, fall back to direct query
if (strlen($cleanInput) < 7) {
return self::where('phone', $phone)->first();
}
$suffix = substr($cleanInput, -7);
$candidates = self::whereRaw("REPLACE(REPLACE(phone, ' ', ''), '+', '') LIKE ?", ['%' . $suffix])->get();
foreach ($candidates as $candidate) {
$cleanCandidate = ltrim(preg_replace('/\D/', '', $candidate->phone), '0');
if ($cleanInput === $cleanCandidate ||
str_ends_with($cleanInput, $cleanCandidate) ||
str_ends_with($cleanCandidate, $cleanInput)) {
return $candidate;
}
}
return null;
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class GenericNotification extends Notification
{
use Queueable;
public $title;
public $body;
public $data;
/**
* Create a new notification instance.
*/
public function __construct($title, $body, array $data = [])
{
$this->title = $title;
$this->body = $body;
$this->data = $data;
}
/**
* Get the notification's delivery channels.
*/
public function via($notifiable): array
{
$channels = ['database'];
if (!empty($notifiable->fcm_token)) {
$channels[] = FcmChannel::class;
}
return $channels;
}
/**
* Get the array representation of the notification for in-app database storage.
*/
public function toArray($notifiable): array
{
return array_merge([
'title' => $this->title,
'body' => $this->body,
], $this->data);
}
/**
* Get the push notification representation.
*/
public function toFcm($notifiable): array
{
return [
'token' => $notifiable->fcm_token,
'title' => $this->title,
'body' => $this->body,
'data' => array_merge([
'title' => $this->title,
'body' => $this->body,
], $this->data),
];
}
}

View File

@ -59,7 +59,7 @@ public static function getAccessToken()
$base64UrlSignature = self::base64UrlEncode($signature);
$jwtToken = $signatureInput . '.' . $base64UrlSignature;
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
$response = Http::withoutVerifying()->asForm()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
'assertion' => $jwtToken,
]);
@ -140,7 +140,7 @@ public static function sendPushNotification($token, $title, $body, array $data =
$payload['message']['data'] = $formattedData;
}
$response = Http::withToken($accessToken)
$response = Http::withoutVerifying()->withToken($accessToken)
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload);
if ($response->failed()) {

12
check_doc.php Normal file
View File

@ -0,0 +1,12 @@
<?php
require 'vendor/autoload.php';
$app = require_once 'bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
$doc = \App\Models\WorkerDocument::where('number', 'P666382')->first();
if ($doc) {
print_r($doc->getAttributes());
} else {
echo "NOT FOUND\n";
}

View File

@ -3,6 +3,7 @@
return [
'worker_no_response_reminder_days' => env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14),
'review_eligibility_months' => env('REVIEW_ELIGIBILITY_MONTHS', 3),
'review_eligibility_minutes' => env('REVIEW_ELIGIBILITY_MINUTES'),
'review_edit_period_days' => env('REVIEW_EDIT_PERIOD_DAYS', 7),
'employer_hire_confirm_reminder_days' => env('EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS', '3,7'),
'worker_join_confirm_reminder_days' => env('WORKER_JOIN_CONFIRM_REMINDER_DAYS', '3,7,1'),

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('job_posts', function (Blueprint $table) {
$table->string('main_profession')->nullable()->after('title');
});
Schema::create('job_post_skills', function (Blueprint $table) {
$table->id();
$table->foreignId('job_post_id')->constrained('job_posts')->onDelete('cascade');
$table->foreignId('skill_id')->constrained('skills')->onDelete('cascade');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('job_post_skills');
Schema::table('job_posts', function (Blueprint $table) {
$table->dropColumn('main_profession');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('job_applications', function (Blueprint $table) {
$table->string('employer_status')->nullable()->after('status');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('job_applications', function (Blueprint $table) {
$table->dropColumn('employer_status');
});
}
};

View File

@ -0,0 +1,25 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->string('age')->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->integer('age')->nullable(false)->change();
});
}
};

View File

@ -32,6 +32,10 @@ public function run(): void
SkillSeeder::class,
AdminSeeder::class,
NationalitySeeder::class,
WorkerSeeder::class,
EmployerProfileSeeder::class,
JobPostSeeder::class,
ConversationSeeder::class,
]);
}
}

View File

@ -36,6 +36,7 @@
<!-- Reminder Configuration for Tests -->
<env name="WORKER_NO_RESPONSE_REMINDER_DAYS" value="14"/>
<env name="REVIEW_ELIGIBILITY_MONTHS" value="3"/>
<env name="REVIEW_ELIGIBILITY_MINUTES" value="0"/>
<env name="REVIEW_EDIT_PERIOD_DAYS" value="7"/>
<env name="EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS" value="3,7"/>
<env name="WORKER_JOIN_CONFIRM_REMINDER_DAYS" value="3,7,1"/>

View File

@ -1017,9 +1017,16 @@
"description": "Nationality of the worker."
},
"age": {
"type": "integer",
"example": 28,
"description": "Age of the worker."
"type": "string",
"enum": [
"18-25",
"26-35",
"36-45",
"46-55",
"56-60"
],
"example": "26-35",
"description": "Age range of the worker."
},
"experience": {
"type": "string",
@ -4138,6 +4145,56 @@
],
"summary": "List Available Jobs",
"description": "Returns a list of all active job postings available for workers to discover and apply.",
"parameters": [
{
"name": "job_type",
"in": "query",
"schema": {
"type": "string"
},
"description": "Direct filter by exact job type (e.g., 'Full Time', 'Part Time', 'Contract')."
},
{
"name": "skills",
"in": "query",
"schema": {
"type": "string"
},
"description": "Comma-separated list of skill names or IDs to filter by (e.g. 'Cooking,Cleaning')."
},
{
"name": "salary_min",
"in": "query",
"schema": {
"type": "number"
},
"description": "Minimum salary filter."
},
{
"name": "salary_max",
"in": "query",
"schema": {
"type": "number"
},
"description": "Maximum salary filter."
},
{
"name": "search",
"in": "query",
"schema": {
"type": "string"
},
"description": "General search query for title, description, or location."
},
{
"name": "sortBy",
"in": "query",
"schema": {
"type": "string"
},
"description": "Sorting order (e.g. 'LATEST FIRST', 'OLDEST FIRST', 'PRICE: LOW TO HIGH', 'PRICE: HIGH TO LOW')."
}
],
"security": [
{
"bearerAuth": []
@ -4680,7 +4737,10 @@
"event_date",
"event_time",
"location_details",
"location_pin"
"location_pin",
"contact_person_name",
"contact_number",
"country_code"
],
"properties": {
"title": {
@ -4712,6 +4772,18 @@
"type": "string",
"format": "uri",
"example": "https://maps.app.goo.gl/xyz"
},
"contact_person_name": {
"type": "string",
"example": "Jane Doe"
},
"contact_number": {
"type": "string",
"example": "551234567"
},
"country_code": {
"type": "string",
"example": "+971"
}
}
}
@ -7030,6 +7102,141 @@
"schema": {
"type": "integer"
}
},
{
"name": "search",
"in": "query",
"description": "Search term matching worker name or nationality.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "status",
"in": "query",
"description": "Filter by job application status (applied, shortlisted, hired, etc.).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "gender",
"in": "query",
"description": "Filter by worker gender (male, female, other).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "nationality",
"in": "query",
"description": "Filter by worker nationality (comma-separated list or array).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "main_profession",
"in": "query",
"description": "Filter by worker main profession.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "age",
"in": "query",
"description": "Filter by worker age (exact number or range, e.g. 18-25).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "experience",
"in": "query",
"description": "Filter by worker experience.",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "salary_min",
"in": "query",
"description": "Filter by worker minimum expected monthly salary.",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "salary_max",
"in": "query",
"description": "Filter by worker maximum expected monthly salary.",
"required": false,
"schema": {
"type": "number"
}
},
{
"name": "preferred_location",
"in": "query",
"description": "Filter by worker preferred location (comma-separated list or array).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "job_type",
"in": "query",
"description": "Filter by worker preferred job type (full-time, part-time, contract, etc.).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "live_in_out",
"in": "query",
"description": "Filter by worker accommodation type (live_in, live_out).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "in_country",
"in": "query",
"description": "Filter by worker presence in country (true/false, yes/no).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "visa_status",
"in": "query",
"description": "Filter by worker visa status (Tourist Visa, Residence Visa, etc.).",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "skills",
"in": "query",
"description": "Filter by worker skills (comma-separated names or IDs).",
"required": false,
"schema": {
"type": "string"
}
}
],
"responses": {
@ -7362,33 +7569,106 @@
"employer": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"name": { "type": "string", "example": "Jane Sponsor" },
"email": { "type": "string", "nullable": true, "example": "sponsor@example.com" },
"phone": { "type": "string", "nullable": true, "example": "+971501112222" },
"company_name": { "type": "string", "example": "Sponsor Co" },
"city": { "type": "string", "nullable": true, "example": "Dubai" },
"district": { "type": "string", "nullable": true, "example": "Dubai Marina" },
"nationality": { "type": "string", "nullable": true, "example": "Emirati" },
"address": { "type": "string", "nullable": true, "example": "123 Street" },
"accommodation": { "type": "string", "nullable": true, "example": "Provided" },
"language": { "type": "string", "nullable": true, "example": "English" },
"profile_photo_url": { "type": "string", "nullable": true, "example": "https://example.com/photo.jpg" },
"rating": { "type": "number", "format": "float", "example": 4.5 },
"reviews_count": { "type": "integer", "example": 12 },
"id": {
"type": "integer",
"example": 1
},
"name": {
"type": "string",
"example": "Jane Sponsor"
},
"email": {
"type": "string",
"nullable": true,
"example": "sponsor@example.com"
},
"phone": {
"type": "string",
"nullable": true,
"example": "+971501112222"
},
"company_name": {
"type": "string",
"example": "Sponsor Co"
},
"city": {
"type": "string",
"nullable": true,
"example": "Dubai"
},
"district": {
"type": "string",
"nullable": true,
"example": "Dubai Marina"
},
"nationality": {
"type": "string",
"nullable": true,
"example": "Emirati"
},
"address": {
"type": "string",
"nullable": true,
"example": "123 Street"
},
"accommodation": {
"type": "string",
"nullable": true,
"example": "Provided"
},
"language": {
"type": "string",
"nullable": true,
"example": "English"
},
"profile_photo_url": {
"type": "string",
"nullable": true,
"example": "https://example.com/photo.jpg"
},
"rating": {
"type": "number",
"format": "float",
"example": 4.5
},
"reviews_count": {
"type": "integer",
"example": 12
},
"review_summary": {
"type": "object",
"properties": {
"total": { "type": "integer", "example": 12 },
"average": { "type": "number", "example": 4.5 },
"total": {
"type": "integer",
"example": 12
},
"average": {
"type": "number",
"example": 4.5
},
"stars": {
"type": "object",
"properties": {
"5": { "type": "integer", "example": 8 },
"4": { "type": "integer", "example": 2 },
"3": { "type": "integer", "example": 1 },
"2": { "type": "integer", "example": 1 },
"1": { "type": "integer", "example": 0 }
"5": {
"type": "integer",
"example": 8
},
"4": {
"type": "integer",
"example": 2
},
"3": {
"type": "integer",
"example": 1
},
"2": {
"type": "integer",
"example": 1
},
"1": {
"type": "integer",
"example": 0
}
}
}
}
@ -7398,15 +7678,45 @@
"items": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 5 },
"title": { "type": "string", "example": "Housekeeper Needed" },
"location": { "type": "string", "example": "Dubai Marina" },
"salary": { "type": "integer", "example": 2500 },
"job_type": { "type": "string", "example": "Full Time" },
"start_date": { "type": "string", "format": "date", "example": "2026-08-01" },
"description": { "type": "string", "example": "Clean house" },
"requirements": { "type": "string", "nullable": true, "example": "Experience required" },
"posted_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" }
"id": {
"type": "integer",
"example": 5
},
"title": {
"type": "string",
"example": "Housekeeper Needed"
},
"location": {
"type": "string",
"example": "Dubai Marina"
},
"salary": {
"type": "integer",
"example": 2500
},
"job_type": {
"type": "string",
"example": "Full Time"
},
"start_date": {
"type": "string",
"format": "date",
"example": "2026-08-01"
},
"description": {
"type": "string",
"example": "Clean house"
},
"requirements": {
"type": "string",
"nullable": true,
"example": "Experience required"
},
"posted_at": {
"type": "string",
"format": "date-time",
"example": "2026-07-04T12:00:00Z"
}
}
}
}
@ -7420,17 +7730,42 @@
"items": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 1 },
"rating": { "type": "integer", "example": 5 },
"title": { "type": "string", "example": "Great employer" },
"comment": { "type": "string", "example": "Highly recommended!" },
"created_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" },
"id": {
"type": "integer",
"example": 1
},
"rating": {
"type": "integer",
"example": 5
},
"title": {
"type": "string",
"example": "Great employer"
},
"comment": {
"type": "string",
"example": "Highly recommended!"
},
"created_at": {
"type": "string",
"format": "date-time",
"example": "2026-07-04T12:00:00Z"
},
"worker": {
"type": "object",
"properties": {
"id": { "type": "integer", "example": 2 },
"name": { "type": "string", "example": "Worker Name" },
"nationality": { "type": "string", "example": "Filipino" }
"id": {
"type": "integer",
"example": 2
},
"name": {
"type": "string",
"example": "Worker Name"
},
"nationality": {
"type": "string",
"example": "Filipino"
}
}
}
}
@ -7439,10 +7774,22 @@
"pagination": {
"type": "object",
"properties": {
"total": { "type": "integer", "example": 1 },
"per_page": { "type": "integer", "example": 10 },
"current_page": { "type": "integer", "example": 1 },
"last_page": { "type": "integer", "example": 1 }
"total": {
"type": "integer",
"example": 1
},
"per_page": {
"type": "integer",
"example": 10
},
"current_page": {
"type": "integer",
"example": 1
},
"last_page": {
"type": "integer",
"example": 1
}
}
}
}
@ -9068,8 +9415,16 @@
"example": "Egypt"
},
"age": {
"type": "integer",
"example": 28
"type": "string",
"enum": [
"18-25",
"26-35",
"36-45",
"46-55",
"56-60"
],
"example": "26-35",
"description": "Age range of the worker."
},
"salary": {
"type": "string",

View File

@ -173,7 +173,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
<div className="w-8 h-8 bg-[#185FA5] text-white rounded-lg flex items-center justify-center font-bold text-base shadow-sm">
M
</div>
<span className="font-bold text-lg text-slate-800 tracking-tight truncate">{t('employer_portal', 'Employer Portal')}</span>
<span className="font-bold text-lg text-slate-800 tracking-tight truncate">{t('employer_portal', 'Sponsor Portal')}</span>
</div>
<div className="flex items-center space-x-3">
@ -201,7 +201,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
<div className="w-10 h-10 bg-[#185FA5] text-white rounded-xl flex items-center justify-center font-bold text-xl shadow-md">
M
</div>
<span className="font-bold text-xl text-slate-800 tracking-tight">{t('employer_portal', 'Employer Portal')}</span>
<span className="font-bold text-xl text-slate-800 tracking-tight">{t('employer_portal', 'Sponsor Portal')}</span>
</div>
{/* Subscription Status Pill */}
@ -375,7 +375,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
<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-[11px] font-black text-slate-900 group-hover:text-[#185FA5] transition-colors truncate">{user.name}</div>
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{t('employer_account', 'Employer Account')}</div>
<div className="text-[9px] font-bold text-slate-400 uppercase tracking-tighter">{t('employer_account', 'Sponsor Account')}</div>
</div>
<div className="w-10 h-10 rounded-xl bg-blue-100 text-[#185FA5] flex items-center justify-center font-black text-xs border border-blue-200 shadow-sm transition-transform group-hover:scale-105">
{user.name.charAt(0)}

View File

@ -6,6 +6,15 @@ import {
Calendar, MapPin, Clock, Gift, Sparkles, Search,
ChevronDown, ChevronUp, Building, User, XCircle, Eye, BellRing
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogFooter
} from '@/components/ui/dialog';
const hoursList = [
'12:00', '12:30', '01:00', '01:30', '02:00', '02:30',
@ -17,6 +26,7 @@ const hoursList = [
export default function Announcements({ initialAnnouncements }) {
const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
const [isFormOpen, setIsFormOpen] = useState(false);
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('All');
const [expandedCards, setExpandedCards] = useState({});
@ -36,7 +46,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '',
event_time: '09:00 AM - 04:00 PM',
location_details: '',
location_pin: ''
location_pin: '',
contact_person_name: '',
contact_number: '',
country_code: '+971'
});
const [errors, setErrors] = useState({});
const [toastMessage, setToastMessage] = useState(null);
@ -62,6 +75,8 @@ export default function Announcements({ initialAnnouncements }) {
} else if (!newAnnouncement.location_pin.startsWith('http://') && !newAnnouncement.location_pin.startsWith('https://')) {
newErrors.location_pin = 'Must be a valid URL';
}
if (!newAnnouncement.contact_person_name.trim()) newErrors.contact_person_name = 'Contact person name is required';
if (!newAnnouncement.contact_number.trim()) newErrors.contact_number = 'Contact number is required';
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
@ -75,7 +90,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: newAnnouncement.event_date,
event_time: newAnnouncement.event_time,
location_details: newAnnouncement.location_details,
location_pin: newAnnouncement.location_pin
location_pin: newAnnouncement.location_pin,
contact_person_name: newAnnouncement.contact_person_name,
contact_number: newAnnouncement.contact_number,
country_code: newAnnouncement.country_code
}, {
onSuccess: () => {
setNewAnnouncement({
@ -85,7 +103,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '',
event_time: '09:00 AM - 04:00 PM',
location_details: '',
location_pin: ''
location_pin: '',
contact_person_name: '',
contact_number: '',
country_code: '+971'
});
setStartTimeHour('09:00');
setStartTimeAmpm('AM');
@ -509,6 +530,39 @@ export default function Announcements({ initialAnnouncements }) {
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Contact Person Name</label>
<input
type="text"
value={newAnnouncement.contact_person_name}
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, contact_person_name: e.target.value })}
className={`w-full px-4 py-2.5 rounded-xl border ${errors.contact_person_name ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
placeholder="e.g. Jane Doe"
/>
{errors.contact_person_name && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.contact_person_name}</p>}
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Contact Mobile Number</label>
<div className="flex space-x-1.5">
<input
type="text"
value={newAnnouncement.country_code}
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, country_code: e.target.value })}
className="w-16 px-2 py-2.5 bg-white border border-slate-300 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none text-center"
/>
<input
type="text"
value={newAnnouncement.contact_number}
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, contact_number: e.target.value })}
className={`flex-1 px-4 py-2.5 rounded-xl border ${errors.contact_number ? 'border-red-500' : 'border-slate-300'} text-sm focus:ring-2 focus:ring-[#0F6E56]/20 focus:border-[#0F6E56] outline-none transition-all`}
placeholder="551234567"
/>
</div>
{errors.contact_number && <p className="text-red-500 text-xs mt-1 font-semibold">{errors.contact_number}</p>}
</div>
</div>
<div>
<label className="block text-xs font-bold text-slate-600 mb-1">Description & Instructions</label>
<textarea
@ -613,10 +667,11 @@ export default function Announcements({ initialAnnouncements }) {
ann.status === 'rejected' ? 'border-l-rose-500' :
'border-l-amber-500';
return (
return (
<div
key={ann.id}
className="group bg-white rounded-2xl border border-slate-200/80 hover:border-slate-300 shadow-sm hover:shadow-md transition-all duration-300 flex flex-col justify-between overflow-hidden relative"
onClick={() => setSelectedAnnouncement(ann)}
className="group bg-white rounded-2xl border border-slate-200/80 hover:border-slate-300 shadow-sm hover:shadow-md transition-all duration-300 flex flex-col justify-between overflow-hidden relative cursor-pointer"
>
{/* Top colored accent line */}
<div className={`h-1.5 w-full ${
@ -655,7 +710,7 @@ export default function Announcements({ initialAnnouncements }) {
</div>
{/* Action Control Buttons */}
<div className="flex items-center space-x-1 shrink-0">
<div className="flex items-center space-x-1 shrink-0" onClick={(e) => e.stopPropagation()}>
{ann.status === 'pending' && (
<div className="flex items-center space-x-0.5 bg-slate-50 p-0.5 rounded-lg border border-slate-200">
<button
@ -691,6 +746,7 @@ export default function Announcements({ initialAnnouncements }) {
{ann.posted_by_email ? (
<Link
href={`/admin/employers?search=${encodeURIComponent(ann.posted_by_email)}`}
onClick={(e) => e.stopPropagation()}
className="flex items-center text-[10px] text-slate-500 font-bold bg-slate-50/60 hover:bg-slate-100/80 hover:border-slate-300 py-1 px-2.5 rounded-lg border border-slate-100 transition-all w-fit cursor-pointer no-underline"
title="Click to view Employer Profile"
>
@ -720,7 +776,7 @@ export default function Announcements({ initialAnnouncements }) {
{ann.content.length > 90 && (
<button
type="button"
onClick={() => toggleExpand(ann.id)}
onClick={(e) => { e.stopPropagation(); toggleExpand(ann.id); }}
className="text-[#0F6E56] hover:text-[#0b523f] hover:underline font-bold flex items-center space-x-0.5 border-none bg-transparent cursor-pointer p-0 text-[10px] transition-colors"
>
<span>{isExpanded ? 'Show Less' : 'Read Full Description'}</span>
@ -729,7 +785,7 @@ export default function Announcements({ initialAnnouncements }) {
)}
{ann.status === 'rejected' && ann.remarks && (
<div className="bg-rose-50/80 border border-rose-100 rounded-xl p-2.5 text-rose-800 text-[10px] font-semibold mt-1.5 leading-relaxed">
<div className="bg-rose-50/80 border border-rose-100 rounded-xl p-2.5 text-rose-800 text-[10px] font-semibold mt-1.5 leading-relaxed" onClick={(e) => e.stopPropagation()}>
<span className="font-extrabold text-rose-900 block mb-0.5">Rejection Remarks:</span>
{ann.remarks}
</div>
@ -777,6 +833,21 @@ export default function Announcements({ initialAnnouncements }) {
</div>
</div>
</div>
{/* Contact Details */}
{details.contact_person_name && (
<div className="flex items-center space-x-2.5 bg-emerald-50/40 border border-emerald-100/50 p-2 rounded-xl">
<div className="w-7 h-7 rounded-lg bg-emerald-50 flex items-center justify-center text-emerald-500 shrink-0 border border-emerald-100">
<User className="w-3.5 h-3.5 text-emerald-600" />
</div>
<div className="min-w-0 leading-tight">
<div className="text-[8px] text-emerald-500 font-extrabold uppercase tracking-wider">CONTACT PERSON</div>
<div className="truncate text-slate-700 font-bold text-xs" title={`${details.contact_person_name} (${details.country_code || '+971'} ${details.contact_number})`}>
{details.contact_person_name} ({details.country_code || '+971'} {details.contact_number})
</div>
</div>
</div>
)}
</div>
)}
</div>
@ -789,6 +860,7 @@ export default function Announcements({ initialAnnouncements }) {
href={details.location_pin}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-[#0F6E56] hover:text-[#0b523f] hover:underline flex items-center space-x-0.5 bg-white border border-slate-200/80 hover:border-slate-300 py-1 px-2.5 rounded-lg shadow-sm font-extrabold transition-all hover:scale-[1.02] active:scale-[0.98]"
>
<span>View on Map</span>
@ -802,6 +874,122 @@ export default function Announcements({ initialAnnouncements }) {
</div>
)}
</div>
{/* Event Detail Popup Modal */}
<Dialog open={!!selectedAnnouncement} onOpenChange={(open) => !open && setSelectedAnnouncement(null)}>
<DialogContent className="sm:max-w-[650px] rounded-3xl p-0 overflow-hidden border-none shadow-2xl">
{selectedAnnouncement && (
<>
<div className="bg-gradient-to-r from-[#0F6E56] to-[#128a6c] p-8 text-white relative">
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -mr-16 -mt-16 blur-2xl" />
<div className="flex items-center space-x-2 mb-2 relative z-10">
<span className="px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider shrink-0 bg-white/20 text-white">
{selectedAnnouncement.status}
</span>
<span className="bg-white/20 text-white px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider flex items-center gap-0.5">
<Heart className="w-2.5 h-2.5 fill-white text-white" />
<span>Charity</span>
</span>
</div>
<DialogTitle className="text-2xl font-black relative z-10 leading-tight">
{selectedAnnouncement.title}
</DialogTitle>
<div className="flex flex-col space-y-0.5 text-white/80 font-bold text-xs mt-2 relative z-10">
<span>Sponsor: {selectedAnnouncement.organization} ({selectedAnnouncement.posted_by})</span>
<span>Published: {selectedAnnouncement.created_at}</span>
</div>
</div>
<div className="p-8 space-y-6 bg-white max-h-[70vh] overflow-y-auto font-sans">
<div className="space-y-2">
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Description & Instructions</h4>
<p className="text-slate-600 text-xs font-bold leading-relaxed whitespace-pre-wrap">
{selectedAnnouncement.content}
</p>
</div>
{selectedAnnouncement.charityDetails && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-slate-100">
<div className="space-y-1 bg-rose-50/40 border border-rose-100/50 p-3 rounded-2xl">
<div className="text-[9px] text-rose-500 font-extrabold uppercase tracking-wider flex items-center gap-1">
<Gift className="w-3.5 h-3.5 fill-rose-500 text-rose-500" />
<span>Items Provided</span>
</div>
<div className="text-slate-700 font-extrabold text-xs">
{selectedAnnouncement.charityDetails.provided_items}
</div>
</div>
<div className="space-y-1 bg-amber-50/40 border border-amber-100/50 p-3 rounded-2xl">
<div className="text-[9px] text-amber-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-amber-600" />
<span>Event Date</span>
</div>
<div className="text-slate-700 font-extrabold text-xs">
{selectedAnnouncement.charityDetails.event_date}
</div>
</div>
<div className="space-y-1 bg-indigo-50/40 border border-indigo-100/50 p-3 rounded-2xl">
<div className="text-[9px] text-indigo-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-indigo-600" />
<span>Event Time</span>
</div>
<div className="text-slate-700 font-extrabold text-xs">
{selectedAnnouncement.charityDetails.event_time}
</div>
</div>
<div className="space-y-1 bg-blue-50/40 border border-blue-100/50 p-3 rounded-2xl">
<div className="text-[9px] text-blue-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-blue-600" />
<span>Location</span>
</div>
<div className="text-slate-700 font-extrabold text-xs">
{selectedAnnouncement.charityDetails.location_details}
</div>
</div>
{selectedAnnouncement.charityDetails.contact_person_name && (
<div className="space-y-1 bg-emerald-50/40 border border-emerald-100/50 p-3 rounded-2xl md:col-span-2">
<div className="text-[9px] text-emerald-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
<User className="w-3.5 h-3.5 text-emerald-600" />
<span>Contact Person Details</span>
</div>
<div className="text-slate-700 font-extrabold text-xs flex flex-col sm:flex-row sm:justify-between gap-1 mt-1">
<span>Name: <strong className="text-slate-900">{selectedAnnouncement.charityDetails.contact_person_name}</strong></span>
<span>Mobile: <strong className="text-slate-900">{selectedAnnouncement.charityDetails.country_code || '+971'} {selectedAnnouncement.charityDetails.contact_number}</strong></span>
</div>
</div>
)}
</div>
)}
<div className="flex space-x-3 pt-4 border-t border-slate-100">
{selectedAnnouncement.charityDetails?.location_pin && (
<a
href={selectedAnnouncement.charityDetails.location_pin}
target="_blank"
rel="noreferrer"
className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-700 py-3 rounded-2xl text-xs font-black uppercase tracking-widest text-center no-underline flex items-center justify-center space-x-1.5 transition-all"
>
<MapPin className="w-4 h-4" />
<span>View on Map</span>
</a>
)}
<button
type="button"
onClick={() => setSelectedAnnouncement(null)}
className="flex-1 bg-[#0F6E56] hover:bg-[#0b523f] text-white py-3 rounded-2xl text-xs font-black uppercase tracking-widest cursor-pointer border-none shadow-md shadow-[#0F6E56]/25"
>
Close
</button>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</AdminLayout>
);
}

View File

@ -22,7 +22,8 @@ import {
ChevronDown,
ChevronUp,
Gift,
Plus
Plus,
User
} from 'lucide-react';
import {
Dialog,
@ -44,6 +45,7 @@ const hoursList = [
export default function Announcements({ initialAnnouncements }) {
const { t } = useTranslation();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [toastMessage, setToastMessage] = useState(null);
const [toastSub, setToastSub] = useState(null);
@ -72,7 +74,10 @@ export default function Announcements({ initialAnnouncements }) {
event_date: '',
event_time: '09:00 AM - 04:00 PM',
location_details: '',
location_pin: ''
location_pin: '',
contact_person_name: '',
contact_number: '',
country_code: '+971'
});
const updateEventTime = (sh, sa, eh, ea) => {
@ -165,7 +170,7 @@ export default function Announcements({ initialAnnouncements }) {
<span>{t('post_charity_event', 'Post Charity Event')}</span>
</button>
</DialogTrigger>
<DialogContent className="sm:max-w-[550px] rounded-3xl p-0 overflow-hidden border-none shadow-2xl">
<DialogContent className="sm:max-w-[650px] rounded-3xl p-0 overflow-hidden border-none shadow-2xl">
<div className="bg-gradient-to-r from-[#185FA5] to-[#2573c2] p-8 text-white relative">
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -mr-16 -mt-16 blur-2xl" />
<DialogTitle className="text-2xl font-black relative z-10">{t('post_charity_event', 'Post Charity Event')}</DialogTitle>
@ -366,6 +371,41 @@ export default function Announcements({ initialAnnouncements }) {
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mt-3">
<div className="space-y-1">
<label className="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1">{t('contact_person_name', 'Contact Person Name')}</label>
<input
type="text"
required
placeholder={t('contact_person_placeholder', 'e.g. Jane Doe')}
value={newAnnouncement.contact_person_name}
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, contact_person_name: e.target.value })}
className="w-full px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none"
/>
</div>
<div className="space-y-1 min-w-0">
<label className="text-[9px] font-black text-slate-500 uppercase tracking-widest ml-1">{t('contact_mobile_number', 'Contact Mobile Number')}</label>
<div className="flex space-x-1.5 w-full min-w-0">
<input
type="text"
required
placeholder="+971"
value={newAnnouncement.country_code}
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, country_code: e.target.value })}
className="w-16 px-2 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none text-center shrink-0"
/>
<input
type="text"
required
placeholder="551234567"
value={newAnnouncement.contact_number}
onChange={(e) => setNewAnnouncement({ ...newAnnouncement, contact_number: e.target.value })}
className="flex-1 min-w-0 px-3.5 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold focus:ring-2 focus:ring-[#185FA5]/20 outline-none"
/>
</div>
</div>
</div>
</div>
<div className="space-y-2">
@ -462,7 +502,8 @@ export default function Announcements({ initialAnnouncements }) {
return (
<div
key={ann.id}
className={`bg-white rounded-2xl border border-slate-200 border-l-4 ${statusColorClass} shadow-sm hover:shadow-md transition-all flex flex-col justify-between overflow-hidden`}
onClick={() => setSelectedAnnouncement(ann)}
className={`bg-white rounded-2xl border border-slate-200 border-l-4 ${statusColorClass} shadow-sm hover:shadow-md transition-all flex flex-col justify-between overflow-hidden cursor-pointer`}
>
{/* Top Card Area */}
<div className="p-4 space-y-3">
@ -494,7 +535,7 @@ export default function Announcements({ initialAnnouncements }) {
{ann.content.length > 90 && (
<button
type="button"
onClick={() => toggleExpand(ann.id)}
onClick={(e) => { e.stopPropagation(); toggleExpand(ann.id); }}
className="text-[#185FA5] hover:underline font-extrabold flex items-center space-x-0.5 border-none bg-transparent cursor-pointer p-0 text-[10px]"
>
<span>{isExpanded ? t('show_less', 'Show Less') : t('read_full_description', 'Read Full Description')}</span>
@ -502,7 +543,7 @@ export default function Announcements({ initialAnnouncements }) {
</button>
)}
{ann.status === 'rejected' && ann.remarks && (
<div className="bg-rose-50 border border-rose-100 rounded-xl p-2.5 text-rose-800 text-[11px] font-semibold mt-1.5 leading-relaxed">
<div className="bg-rose-50 border border-rose-100 rounded-xl p-2.5 text-rose-800 text-[11px] font-semibold mt-1.5 leading-relaxed" onClick={(e) => e.stopPropagation()}>
<strong>{t('rejection_remarks_label', 'Remarks')}:</strong> {ann.remarks}
</div>
)}
@ -531,6 +572,16 @@ export default function Announcements({ initialAnnouncements }) {
</div>
</div>
)}
{/* Contact details row */}
{details && details.contact_person_name && (
<div className="pt-2 border-t border-slate-100 flex items-center space-x-1.5 text-[10px] text-slate-500 font-black uppercase tracking-wider">
<User className="w-3.5 h-3.5 text-indigo-500 shrink-0" />
<span className="truncate text-slate-700 normal-case" title={`${details.contact_person_name} (${details.country_code || '+971'} ${details.contact_number})`}>
<strong>{details.contact_person_name}</strong>: {details.country_code || '+971'} {details.contact_number}
</span>
</div>
)}
</div>
{/* Bottom Card Footer */}
@ -541,7 +592,8 @@ export default function Announcements({ initialAnnouncements }) {
href={details.location_pin}
target="_blank"
rel="noreferrer"
className="text-[#185FA5] hover:underline flex items-center space-x-0.5 normal-case"
onClick={(e) => e.stopPropagation()}
className="text-[#185FA5] hover:underline flex items-center space-x-0.5 normal-case animate-none"
>
<span>{t('view_on_map', 'View on Map')}</span>
<MapPin className="w-2.5 h-2.5" />
@ -553,6 +605,121 @@ export default function Announcements({ initialAnnouncements }) {
})
)}
</div>
{/* Event Detail Popup Modal */}
<Dialog open={!!selectedAnnouncement} onOpenChange={(open) => !open && setSelectedAnnouncement(null)}>
<DialogContent className="sm:max-w-[650px] rounded-3xl p-0 overflow-hidden border-none shadow-2xl">
{selectedAnnouncement && (
<>
<div className="bg-gradient-to-r from-[#185FA5] to-[#2573c2] p-8 text-white relative">
<div className="absolute top-0 right-0 w-32 h-32 bg-white/10 rounded-full -mr-16 -mt-16 blur-2xl" />
<div className="flex items-center space-x-2 mb-2 relative z-10">
<span className="px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider shrink-0 bg-white/20 text-white">
{selectedAnnouncement.status}
</span>
<span className="bg-white/20 text-white px-2 py-0.5 rounded text-[8px] font-extrabold uppercase tracking-wider flex items-center gap-0.5">
<Heart className="w-2.5 h-2.5 fill-white text-white" />
<span>Charity</span>
</span>
</div>
<DialogTitle className="text-2xl font-black relative z-10 leading-tight">
{selectedAnnouncement.title}
</DialogTitle>
<p className="text-white/80 font-bold text-xs mt-2 relative z-10">
{t('posted_label', 'Posted')}: {selectedAnnouncement.created_at}
</p>
</div>
<div className="p-8 space-y-6 bg-white max-h-[70vh] overflow-y-auto font-sans">
<div className="space-y-2">
<h4 className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('description', 'Description')}</h4>
<p className="text-slate-600 text-xs font-bold leading-relaxed whitespace-pre-wrap">
{selectedAnnouncement.content}
</p>
</div>
{selectedAnnouncement.charityDetails && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-4 border-t border-slate-100">
<div className="space-y-1 bg-rose-50/40 border border-rose-100/50 p-3 rounded-2xl">
<div className="text-[9px] text-rose-500 font-extrabold uppercase tracking-wider flex items-center gap-1">
<Gift className="w-3.5 h-3.5 fill-rose-500 text-rose-500" />
<span>{t('items_provided', 'Items Provided')}</span>
</div>
<div className="text-slate-700 font-extrabold text-xs">
{selectedAnnouncement.charityDetails.provided_items}
</div>
</div>
<div className="space-y-1 bg-amber-50/40 border border-amber-100/50 p-3 rounded-2xl">
<div className="text-[9px] text-amber-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-amber-600" />
<span>{t('event_date', 'Event Date')}</span>
</div>
<div className="text-slate-700 font-extrabold text-xs">
{selectedAnnouncement.charityDetails.event_date}
</div>
</div>
<div className="space-y-1 bg-indigo-50/40 border border-indigo-100/50 p-3 rounded-2xl">
<div className="text-[9px] text-indigo-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-indigo-600" />
<span>{t('event_time', 'Event Time')}</span>
</div>
<div className="text-slate-700 font-extrabold text-xs">
{selectedAnnouncement.charityDetails.event_time}
</div>
</div>
<div className="space-y-1 bg-blue-50/40 border border-blue-100/50 p-3 rounded-2xl">
<div className="text-[9px] text-blue-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
<MapPin className="w-3.5 h-3.5 text-blue-600" />
<span>{t('location', 'Location')}</span>
</div>
<div className="text-slate-700 font-extrabold text-xs">
{selectedAnnouncement.charityDetails.location_details}
</div>
</div>
{selectedAnnouncement.charityDetails.contact_person_name && (
<div className="space-y-1 bg-emerald-50/40 border border-emerald-100/50 p-3 rounded-2xl md:col-span-2">
<div className="text-[9px] text-emerald-600 font-extrabold uppercase tracking-wider flex items-center gap-1">
<User className="w-3.5 h-3.5 text-emerald-600" />
<span>{t('contact_person_details', 'Contact Person Details')}</span>
</div>
<div className="text-slate-700 font-extrabold text-xs flex flex-col sm:flex-row sm:justify-between gap-1 mt-1">
<span>Name: <strong className="text-slate-900">{selectedAnnouncement.charityDetails.contact_person_name}</strong></span>
<span>Mobile: <strong className="text-slate-900">{selectedAnnouncement.charityDetails.country_code || '+971'} {selectedAnnouncement.charityDetails.contact_number}</strong></span>
</div>
</div>
)}
</div>
)}
<div className="flex space-x-3 pt-4 border-t border-slate-100">
{selectedAnnouncement.charityDetails?.location_pin && (
<a
href={selectedAnnouncement.charityDetails.location_pin}
target="_blank"
rel="noreferrer"
className="flex-1 bg-slate-100 hover:bg-slate-200 text-slate-700 py-3 rounded-2xl text-xs font-black uppercase tracking-widest text-center no-underline flex items-center justify-center space-x-1.5 transition-all"
>
<MapPin className="w-4 h-4" />
<span>{t('view_on_map', 'View on Map')}</span>
</a>
)}
<button
type="button"
onClick={() => setSelectedAnnouncement(null)}
className="flex-1 bg-[#185FA5] hover:bg-[#14508c] text-white py-3 rounded-2xl text-xs font-black uppercase tracking-widest cursor-pointer border-none shadow-md shadow-[#185FA5]/25"
>
{t('close', 'Close')}
</button>
</div>
</div>
</>
)}
</DialogContent>
</Dialog>
</div>
</EmployerLayout>
);

View File

@ -10,7 +10,7 @@ import {
Check,
X,
Users,
DollarSign,
TrendingUp,
MessageSquare
} from 'lucide-react';
@ -161,7 +161,7 @@ export default function CreatePassword() {
<div className="space-y-3 pt-6">
<span className="inline-block px-3 py-1 bg-white/10 border border-white/20 rounded-full text-xs font-semibold uppercase tracking-wider">
Employer Registration
Sponsor Registration
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers directly.
@ -175,14 +175,14 @@ export default function CreatePassword() {
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">500+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
<div className="font-bold text-lg">3000+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">550+</div>
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">

View File

@ -107,7 +107,7 @@ export default function ForgotPassword() {
return (
<div className="min-h-screen flex flex-col justify-center items-center px-4 bg-slate-50 font-sans">
<Head title="Reset Password - Employer Portal" />
<Head title="Reset Password - Sponsor Portal" />
<div className="w-full max-w-md bg-white rounded-2xl shadow-sm border border-slate-200 p-8 space-y-6">

View File

@ -8,7 +8,7 @@ import {
Info,
XCircle,
Users,
DollarSign,
TrendingUp,
MessageSquare
} from 'lucide-react';
@ -53,7 +53,7 @@ export default function Login({ flash }) {
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-50 font-sans">
<Head title="Employer Login - Migrant Portal" />
<Head title="Sponsor Login - Migrant Portal" />
{/* Left Brand Panel (Desktop Only) */}
<div className="hidden lg:flex lg:col-span-5 bg-[#185FA5] p-12 flex-col justify-between text-white relative overflow-hidden select-none">
@ -69,7 +69,7 @@ export default function Login({ flash }) {
<div className="space-y-3 pt-6">
<span className="inline-block px-3 py-1 bg-white/10 border border-white/20 rounded-full text-xs font-semibold uppercase tracking-wider">
Employer Login
Sponsor Login
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers directly.
@ -83,14 +83,14 @@ export default function Login({ flash }) {
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">500+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
<div className="font-bold text-lg">3000+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">550+</div>
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
@ -111,7 +111,7 @@ export default function Login({ flash }) {
<div className="w-full max-w-md bg-white rounded-2xl shadow-sm border border-slate-200 p-8 space-y-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">Welcome Back</h2>
<p className="text-xs text-gray-500 mt-1">Sign in to your employer portal to continue searching</p>
<p className="text-xs text-gray-500 mt-1">Sign in to your sponsor portal to continue searching</p>
</div>
{/* Status Flash Alerts */}
@ -201,13 +201,13 @@ export default function Login({ flash }) {
disabled={processing}
className="w-full bg-[#185FA5] hover:bg-[#0C447C] active:bg-[#08305c] text-white rounded-xl h-11 font-semibold text-sm transition-colors shadow-sm flex items-center justify-center disabled:opacity-70 disabled:cursor-not-allowed"
>
{processing ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : 'Sign in to Employer Portal'}
{processing ? <Loader2 className="w-5 h-5 animate-spin mr-2" /> : 'Sign in to Sponsor Portal'}
</button>
</form>
<div className="border-t border-slate-100 pt-6 text-center">
<p className="text-xs text-gray-600 font-medium">
New employer?{' '}
New sponsor?{' '}
<Link href="/employer/register" className="text-[#185FA5] font-bold hover:underline">
Create account
</Link>

View File

@ -5,7 +5,7 @@ import { Clock, CheckCircle } from 'lucide-react';
export default function PendingVerification() {
return (
<div className="min-h-screen flex flex-col justify-center items-center px-4 bg-slate-50 font-sans">
<Head title="Verification Pending - Employer Portal" />
<Head title="Verification Pending - Sponsor Portal" />
<div className="w-full max-w-md bg-white rounded-2xl shadow-sm border border-slate-200 p-8 text-center space-y-6">
<div className="w-16 h-16 bg-amber-50 rounded-full flex items-center justify-center mx-auto border border-amber-200">

View File

@ -5,7 +5,7 @@ import { toast } from 'sonner';
import {
Loader2,
Users,
DollarSign,
TrendingUp,
MessageSquare,
ChevronDown,
Search
@ -266,7 +266,7 @@ export default function Register({ prefillData }) {
return (
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-50 font-sans">
<Head title="Employer Registration - Migrant Portal" />
<Head title="Sponsor Registration - Migrant Portal" />
{/* Left Brand Panel (Desktop Only) */}
<div className="hidden lg:flex lg:col-span-5 bg-[#185FA5] p-12 flex-col justify-between text-white relative overflow-hidden select-none">
@ -282,7 +282,7 @@ export default function Register({ prefillData }) {
<div className="space-y-3 pt-6">
<span className="inline-block px-3 py-1 bg-white/10 border border-white/20 rounded-full text-xs font-semibold uppercase tracking-wider">
Employer Registration
Sponsor Registration
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers directly.
@ -296,14 +296,14 @@ export default function Register({ prefillData }) {
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">500+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
<div className="font-bold text-lg">3000+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">550+</div>
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
@ -326,7 +326,7 @@ export default function Register({ prefillData }) {
<div>
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">Create Account</h2>
<p className="text-xs text-gray-500 mt-1">Register your employer portal to start searching</p>
<p className="text-xs text-gray-500 mt-1">Register your sponsor portal to start searching</p>
</div>
<form onSubmit={handleSubmit} noValidate className="space-y-4">
@ -359,7 +359,7 @@ export default function Register({ prefillData }) {
type="email"
value={data.email}
onChange={(e) => handleInputChange('email', e.target.value)}
placeholder="employer@domain.com"
placeholder="sponsor@domain.com"
className={`w-full px-3.5 py-3 rounded-xl border text-sm focus:outline-none focus:ring-2 transition-all ${
errors.email
? 'border-red-500 focus:ring-red-500/20 focus:border-red-500'

View File

@ -9,7 +9,7 @@ import {
Lock,
ArrowRight,
Users,
DollarSign,
TrendingUp,
MessageSquare,
Loader2,
Sparkles,
@ -133,14 +133,14 @@ export default function RegisterPayment({ email, plans }) {
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">500+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
<div className="font-bold text-lg">3000+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Sponsor Access</div>
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">550+</div>
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">

View File

@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Head, Link, router } from '@inertiajs/react';
import axios from 'axios';
import { toast } from 'sonner';
import { compressImage } from '../../../lib/utils';
import {
Loader2,
ShieldAlert,
@ -9,7 +10,7 @@ import {
UploadCloud,
FileText,
Users,
DollarSign,
TrendingUp,
MessageSquare,
ArrowLeft
} from 'lucide-react';
@ -70,7 +71,7 @@ export default function UploadEmiratesId({ email }) {
}
};
const handleFrontFileChange = (e) => {
const handleFrontFileChange = async (e) => {
const selectedFile = e.target.files[0];
if (selectedFile) {
if (selectedFile.size > 10240 * 1024) {
@ -78,12 +79,17 @@ export default function UploadEmiratesId({ email }) {
toast.error('The document must not exceed 10MB.');
return;
}
setFrontFile(selectedFile);
try {
const compressedFile = await compressImage(selectedFile);
setFrontFile(compressedFile);
} catch (err) {
setFrontFile(selectedFile);
}
setError(null);
}
};
const handleBackFileChange = (e) => {
const handleBackFileChange = async (e) => {
const selectedFile = e.target.files[0];
if (selectedFile) {
if (selectedFile.size > 10240 * 1024) {
@ -91,7 +97,12 @@ export default function UploadEmiratesId({ email }) {
toast.error('The document must not exceed 10MB.');
return;
}
setBackFile(selectedFile);
try {
const compressedFile = await compressImage(selectedFile);
setBackFile(compressedFile);
} catch (err) {
setBackFile(selectedFile);
}
setError(null);
}
};
@ -203,7 +214,7 @@ export default function UploadEmiratesId({ email }) {
<div className="space-y-3 pt-6">
<span className="inline-block px-3 py-1 bg-white/10 border border-white/20 rounded-full text-xs font-semibold uppercase tracking-wider">
Employer Registration
Sponsor Registration
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers directly.
@ -217,14 +228,14 @@ export default function UploadEmiratesId({ email }) {
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">500+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
<div className="font-bold text-lg">3000+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">550+</div>
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
@ -486,9 +497,9 @@ export default function UploadEmiratesId({ email }) {
/>
</div>
{/* Employer */}
{/* Sponsor */}
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Employer</label>
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Sponsor</label>
<input
type="text"
value={modalData.employer}

View File

@ -9,7 +9,7 @@ import {
RefreshCw,
KeyRound,
Users,
DollarSign,
TrendingUp,
MessageSquare
} from 'lucide-react';
@ -139,7 +139,7 @@ export default function VerifyEmail({ email }) {
<div className="space-y-3 pt-6">
<span className="inline-block px-3 py-1 bg-white/10 border border-white/20 rounded-full text-xs font-semibold uppercase tracking-wider">
Employer Registration
Sponsor Registration
</span>
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
Find verified domestic workers directly.
@ -153,14 +153,14 @@ export default function VerifyEmail({ email }) {
<div className="grid grid-cols-3 gap-4 pt-8 border-t border-white/10">
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">500+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
<div className="font-bold text-lg">3000+</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
<DollarSign className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">Premium</div>
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
<div className="font-bold text-lg">550+</div>
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
</div>
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">

View File

@ -24,7 +24,8 @@ import {
Send,
HelpCircle,
UserCheck,
Lock
Lock,
Users
} from 'lucide-react';
export default function Dashboard({
@ -147,27 +148,6 @@ export default function Dashboard({
</Link>
</div>
{/* Contacted Workers Stat Card */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs flex flex-col justify-between space-y-4 relative group hover:border-[#185FA5] transition-all">
<div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-xl bg-amber-50 text-amber-600 flex items-center justify-center flex-shrink-0 border border-amber-100 group-hover:scale-105 transition-transform">
<MessageSquare className="w-5 h-5" />
</div>
<span className="text-[10px] font-black text-amber-600 bg-amber-50 px-2 py-0.5 rounded-md border border-amber-100">
ENGAGED
</span>
</div>
<div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-widest">{t('contacted_workers_title', 'Contacted Workers')}</div>
<div className="text-3xl font-black text-slate-800 mt-1">{stats.contacted_workers_count}</div>
<div className="text-[11px] text-slate-500 font-medium mt-1">{t('active_conversations_sub', 'Active candidate chat channels')}</div>
</div>
<Link href="/employer/messages" className="text-xs font-bold text-[#185FA5] hover:underline flex items-center space-x-1 pt-2 border-t border-slate-100">
<span>{t('view_messages', 'Open message center')}</span>
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</div>
{/* Hired Count Stat Card */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs flex flex-col justify-between space-y-4 relative group hover:border-[#185FA5] transition-all">
<div className="flex items-center justify-between">
@ -188,22 +168,46 @@ export default function Dashboard({
</Link>
</div>
{/* Shortlist Counter */}
{/* Total Worker Profiles Card */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs flex flex-col justify-between space-y-4 relative group hover:border-[#185FA5] transition-all">
<div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-xl bg-purple-50 text-purple-600 flex items-center justify-center flex-shrink-0 border border-purple-100 group-hover:scale-105 transition-transform">
<Bookmark className="w-5 h-5" />
<div className="w-12 h-12 rounded-xl bg-blue-50 text-[#185FA5] flex items-center justify-center flex-shrink-0 border border-blue-100 group-hover:scale-105 transition-transform">
<Users className="w-5 h-5" />
</div>
<span className="text-[10px] font-black text-purple-600 bg-purple-50 px-2 py-0.5 rounded-md border border-purple-100">
SAVED LIST
<span className="text-[10px] font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded-md border border-blue-100">
PLATFORM SIZE
</span>
</div>
<div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-widest">{t('saved_workers', 'Saved Workers')}</div>
<div className="text-3xl font-black text-slate-800 mt-1">{stats.shortlisted_count}</div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-widest">{t('total_worker_profiles', 'Total Worker Profiles')}</div>
<div className="text-3xl font-black text-slate-800 mt-1">
{Math.max(3000, stats.total_worker_profiles).toLocaleString()}+
</div>
</div>
<Link href="/employer/shortlist" className="text-xs font-bold text-[#185FA5] hover:underline flex items-center space-x-1 pt-2 border-t border-slate-100">
<span>{t('open_shortlist', 'Open shortlist book')}</span>
<Link href="/employer/workers" className="text-xs font-bold text-[#185FA5] hover:underline flex items-center space-x-1 pt-2 border-t border-slate-100">
<span>{t('browse_all_workers', 'Browse all workers')}</span>
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</div>
{/* Avg Monthly Hires Card */}
<div className="bg-white p-6 rounded-2xl border border-slate-200 shadow-xs flex flex-col justify-between space-y-4 relative group hover:border-[#185FA5] transition-all">
<div className="flex items-center justify-between">
<div className="w-12 h-12 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center flex-shrink-0 border border-emerald-100 group-hover:scale-105 transition-transform">
<TrendingUp className="w-5 h-5" />
</div>
<span className="text-[10px] font-black text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-md border border-emerald-100">
PERFORMANCE
</span>
</div>
<div>
<div className="text-xs font-semibold text-slate-400 uppercase tracking-widest">{t('avg_monthly_hires', 'Avg Monthly Hires')}</div>
<div className="text-3xl font-black text-slate-800 mt-1">
{Math.max(550, stats.avg_monthly_hires).toLocaleString()}+
</div>
</div>
<Link href="/employer/candidates" className="text-xs font-bold text-[#185FA5] hover:underline flex items-center space-x-1 pt-2 border-t border-slate-100">
<span>{t('view_hired_list', 'View hired list')}</span>
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</div>
@ -211,8 +215,8 @@ export default function Dashboard({
{/* 3. Analytics & Quick Action Section */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Worker Activity Analytics Module (Col 8) */}
<div className="lg:col-span-8 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
{/* Worker Activity Analytics Module (Col 12) */}
<div className="lg:col-span-12 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
<div className="pb-3 border-b border-slate-100">
<div className="flex items-center space-x-2">
<Activity className="w-5 h-5 text-[#185FA5]" />
@ -260,52 +264,6 @@ export default function Dashboard({
</div>
</div>
</div>
{/* Quick Actions Panel (Col 4) */}
<div className="lg:col-span-4 bg-white p-6 rounded-3xl border border-slate-200 shadow-xs flex flex-col justify-between space-y-6">
<div className="space-y-1">
<h3 className="font-bold text-base text-slate-900">{t('quick_actions', 'Quick Actions')}</h3>
<p className="text-xs text-slate-500 font-medium">{t('speed_up', 'Speed up your sponsorship process.')}</p>
</div>
<div className="space-y-2.5 flex-1 py-2">
<Link
href="/employer/workers"
className="w-full p-3.5 bg-slate-50 hover:bg-blue-50/50 border border-slate-200 hover:border-[#185FA5]/40 rounded-2xl transition-all flex items-center space-x-3 text-left group"
>
<div className="w-9 h-9 rounded-xl bg-purple-100/50 text-purple-600 flex items-center justify-center flex-shrink-0 group-hover:scale-105 transition-transform">
<Star className="w-4 h-4" />
</div>
<div className="truncate">
<div className="text-xs font-bold text-slate-800 group-hover:text-[#185FA5] transition-colors">{t('find_nannies', 'Find Baby Care & Nannies')}</div>
<div className="text-[10px] text-slate-500 truncate">{t('browse_nannies', 'Browse premium verified nannies')}</div>
</div>
</Link>
<a
href="https://wa.me/971501112222"
target="_blank"
rel="noreferrer"
className="w-full p-3.5 bg-slate-50 hover:bg-emerald-50/30 border border-slate-200 hover:border-emerald-500/40 rounded-2xl transition-all flex items-center space-x-3 text-left group"
>
<div className="w-9 h-9 rounded-xl bg-emerald-100/50 text-emerald-600 flex items-center justify-center flex-shrink-0 group-hover:scale-105 transition-transform">
<MessageSquare className="w-4 h-4" />
</div>
<div className="truncate">
<div className="text-xs font-bold text-slate-800 group-hover:text-emerald-700 transition-colors flex items-center space-x-1">
<span>{t('whatsapp_support', 'Direct WhatsApp Support')}</span>
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse" />
</div>
<div className="text-[10px] text-slate-500 truncate">{t('chat_manager', 'Chat with a recruitment manager')}</div>
</div>
</a>
</div>
<div className="p-3 bg-emerald-50 border border-emerald-100 rounded-2xl text-[10px] text-emerald-800 font-bold flex items-center space-x-2">
<ShieldCheck className="w-4 h-4 text-emerald-600 flex-shrink-0" />
<span>{t('legal_platform', '100% Legal UAE Agency-Free Platform')}</span>
</div>
</div>
</div>
{/* 4. Detailed Workspace Rows (Col 12 Grid) */}

View File

@ -37,7 +37,18 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
const openStatusModal = (app, statusPreset = '') => {
setSelectedApp(app);
setNewStatus(statusPreset || app.status || 'Applied');
let resolvedStatus = statusPreset || app.status || 'Applied';
const lowerStatus = resolvedStatus.toLowerCase();
if (lowerStatus === 'hire_requested' || lowerStatus === 'hired') {
resolvedStatus = 'Hired';
} else if (lowerStatus === 'rejected') {
resolvedStatus = 'Rejected';
} else if (lowerStatus === 'shortlisted') {
resolvedStatus = 'Shortlisted';
} else {
resolvedStatus = 'Applied';
}
setNewStatus(resolvedStatus);
setNotes(app.notes || '');
setShowModal(true);
};
@ -193,10 +204,12 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
<div className="flex items-center justify-between">
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest cursor-pointer hover:opacity-85 transition-opacity ${
applicant.status?.toLowerCase() === 'hired' ? 'bg-emerald-100 text-emerald-700' :
applicant.status?.toLowerCase() === 'hire_requested' ? 'bg-amber-50 text-amber-600 border border-amber-200' :
applicant.status?.toLowerCase() === 'rejected' ? 'bg-rose-100 text-rose-700' :
'bg-amber-100 text-amber-700'
'bg-blue-50 text-[#185FA5]'
}`} onClick={() => openStatusModal(applicant)}>
{applicant.status}
{applicant.status?.toLowerCase() === 'hire_requested' ? 'Pending Confirmation' :
applicant.status?.toLowerCase() === 'applied' ? 'Review' : applicant.status}
</span>
<div className="flex items-center space-x-1 text-[10px] font-bold text-slate-400">
<Link href={`/employer/workers/${applicant.id}`} className="hover:text-[#185FA5] flex items-center underline underline-offset-2">
@ -261,6 +274,11 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
{/* Modal Body */}
<div className="p-6 space-y-5">
{selectedApp?.status?.toLowerCase() === 'hire_requested' && (
<div className="p-3 bg-amber-50 rounded-xl border border-amber-100 text-[10px] text-amber-700 font-bold">
A hiring offer is pending. Waiting for worker confirmation.
</div>
)}
{/* Status Selector */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Stage</label>
@ -269,11 +287,8 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
onChange={(e) => setNewStatus(e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
>
<option value="Applied">Applied (Reviewing)</option>
<option value="Shortlisted">Shortlisted (Save Candidate)</option>
<option value="Contacted">Contacted</option>
<option value="Interview Scheduled">Interview Scheduled</option>
<option value="Selected">Selected</option>
<option value="Applied">Review</option>
<option value="Shortlisted">Shortlisted</option>
<option value="Rejected">Rejected</option>
<option value="Hired">Hired</option>
</select>

View File

@ -7,29 +7,31 @@ import {
Briefcase,
MapPin,
DollarSign,
Calendar,
LayoutGrid,
Users,
FileText,
Sparkles,
ShieldCheck
ShieldCheck,
X
} from 'lucide-react';
import { toast } from 'sonner';
export default function Create({ categories: initialCategories }) {
const categories = initialCategories || [
'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper'
export default function Create({ professions, availableSkills }) {
const categoryList = professions || [
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
];
const skillList = availableSkills || [];
const { data, setData, post, processing, errors } = useForm({
title: '',
main_profession: '',
skills: [],
workers_needed: 1,
location: '',
salary: '',
job_type: 'Full Time',
start_date: '',
start_date: new Date().toISOString().split('T')[0],
description: '',
requirements: ''
});
const [clientErrors, setClientErrors] = useState({});
@ -42,6 +44,12 @@ export default function Create({ categories: initialCategories }) {
if (!data.title.trim()) {
newErrors.title = 'Job title is required.';
}
if (!data.main_profession) {
newErrors.main_profession = 'Main profession is required.';
}
if (!data.skills || data.skills.length === 0) {
newErrors.skills = 'At least one skill is required.';
}
if (!data.workers_needed || data.workers_needed < 1) {
newErrors.workers_needed = 'Workers needed must be at least 1.';
}
@ -77,7 +85,7 @@ export default function Create({ categories: initialCategories }) {
return (
<EmployerLayout title="Post a Job">
<Head title="Post a New Job - Employer Portal" />
<Head title="Post a New Job - Sponsor Portal" />
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
<Link
@ -133,7 +141,29 @@ export default function Create({ categories: initialCategories }) {
)}
</div>
<div className="space-y-2">
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Main Profession</label>
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
className={`w-full pl-11 pr-10 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none cursor-pointer ${
(clientErrors.main_profession || errors.main_profession) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.main_profession}
onChange={e => setData('main_profession', e.target.value)}
>
<option value="">Select main profession...</option>
{categoryList.map(prof => (
<option key={prof} value={prof}>{prof}</option>
))}
</select>
</div>
{(clientErrors.main_profession || errors.main_profession) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.main_profession || errors.main_profession}</p>
)}
</div>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
<div className="relative">
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
@ -152,37 +182,7 @@ export default function Create({ categories: initialCategories }) {
)}
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
<div className="grid grid-cols-3 gap-2">
{['Full Time', 'Part Time', 'Contract'].map(type => (
<button
key={type}
type="button"
onClick={() => setData('job_type', type)}
className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
data.job_type === type
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-500/20'
: 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'
}`}
>
{type.toUpperCase()}
</button>
))}
</div>
</div>
</div>
</div>
{/* Section 2: Logistics & Pay */}
<div className="space-y-6">
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
Logistics & Compensation
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
<div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
@ -201,7 +201,7 @@ export default function Create({ categories: initialCategories }) {
)}
</div>
<div className="space-y-2">
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
<div className="relative">
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
@ -220,27 +220,29 @@ export default function Create({ categories: initialCategories }) {
)}
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Start Date</label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="date"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.start_date}
onChange={e => setData('start_date', e.target.value)}
/>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
<div className="grid grid-cols-2 gap-2">
{['Full Time', 'Part Time'].map(type => (
<button
key={type}
type="button"
onClick={() => setData('job_type', type)}
className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
data.job_type === type
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-500/20'
: 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'
}`}
>
{type.toUpperCase()}
</button>
))}
</div>
{(clientErrors.start_date || errors.start_date) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.start_date || errors.start_date}</p>
)}
</div>
</div>
</div>
{/* Section 3: Details */}
{/* Section 2: Detailed Requirements */}
<div className="space-y-6">
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
@ -248,6 +250,56 @@ export default function Create({ categories: initialCategories }) {
</h3>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Skills (Select from list)</label>
<div className="space-y-3">
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
className={`w-full pl-11 pr-10 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none cursor-pointer ${
(clientErrors.skills || errors.skills) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value=""
onChange={e => {
const val = e.target.value;
if (val && !data.skills.includes(val)) {
setData('skills', [...data.skills, val]);
}
}}
>
<option value="">Choose skills to add...</option>
{skillList.filter(s => !data.skills.includes(s.name)).map(skill => (
<option key={skill.id} value={skill.name}>{skill.name}</option>
))}
</select>
</div>
{/* Selected skills pills */}
{data.skills.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-slate-50 rounded-2xl border border-slate-100">
{data.skills.map(skill => (
<span
key={skill}
className="inline-flex items-center space-x-1.5 px-3 py-1.5 bg-[#185FA5] text-white rounded-xl text-xs font-black uppercase tracking-wider shadow-xs"
>
<span>{skill}</span>
<button
type="button"
onClick={() => setData('skills', data.skills.filter(s => s !== skill))}
className="hover:bg-blue-700/50 p-0.5 rounded-full transition-colors"
>
<X className="w-3.5 h-3.5 text-white" />
</button>
</span>
))}
</div>
)}
</div>
{(clientErrors.skills || errors.skills) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.skills || errors.skills}</p>
)}
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
<textarea
@ -268,19 +320,6 @@ export default function Create({ categories: initialCategories }) {
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Special Requirements (Optional)</label>
<div className="relative">
<FileText className="absolute left-4 top-5 w-4 h-4 text-slate-400" />
<textarea
placeholder="e.g. Must speak Arabic, 5+ years experience, transferable visa..."
className="w-full pl-11 pr-4 py-4 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none"
value={data.requirements}
onChange={e => setData('requirements', e.target.value)}
/>
</div>
</div>
</div>
</div>

View File

@ -7,26 +7,32 @@ import {
Briefcase,
MapPin,
DollarSign,
Calendar,
LayoutGrid,
Users,
FileText,
Sparkles,
ShieldCheck,
Save
Save,
X
} from 'lucide-react';
import { toast } from 'sonner';
export default function Edit({ job }) {
export default function Edit({ job, professions, availableSkills }) {
const categoryList = professions || [
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
];
const skillList = availableSkills || [];
const { data, setData, post, processing, errors } = useForm({
title: job.title || '',
main_profession: job.main_profession || '',
skills: job.skills ? job.skills.map(s => s.name) : [],
workers_needed: job.workers_needed || 1,
location: job.location || '',
salary: job.salary ? Math.round(job.salary) : '',
job_type: job.job_type || 'Full Time',
start_date: job.start_date || '',
start_date: job.start_date || new Date().toISOString().split('T')[0],
description: job.description || '',
requirements: job.requirements || '',
status: job.status || 'active'
});
@ -40,6 +46,12 @@ export default function Edit({ job }) {
if (!data.title.trim()) {
newErrors.title = 'Job title is required.';
}
if (!data.main_profession) {
newErrors.main_profession = 'Main profession is required.';
}
if (!data.skills || data.skills.length === 0) {
newErrors.skills = 'At least one skill is required.';
}
if (!data.workers_needed || data.workers_needed < 1) {
newErrors.workers_needed = 'Workers needed must be at least 1.';
}
@ -78,7 +90,7 @@ export default function Edit({ job }) {
return (
<EmployerLayout title="Edit Job">
<Head title="Edit Job - Employer Portal" />
<Head title="Edit Job - Sponsor Portal" />
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
<Link
@ -130,7 +142,29 @@ export default function Edit({ job }) {
)}
</div>
<div className="space-y-2">
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Main Profession</label>
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
className={`w-full pl-11 pr-10 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none cursor-pointer ${
(clientErrors.main_profession || errors.main_profession) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.main_profession}
onChange={e => setData('main_profession', e.target.value)}
>
<option value="">Select main profession...</option>
{categoryList.map(prof => (
<option key={prof} value={prof}>{prof}</option>
))}
</select>
</div>
{(clientErrors.main_profession || errors.main_profession) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.main_profession || errors.main_profession}</p>
)}
</div>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
<div className="relative">
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
@ -149,10 +183,48 @@ export default function Edit({ job }) {
)}
</div>
<div className="space-y-2">
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
<div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="e.g. Dubai Marina"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.location}
onChange={e => setData('location', e.target.value)}
/>
</div>
{(clientErrors.location || errors.location) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.location || errors.location}</p>
)}
</div>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
<div className="relative">
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="number"
placeholder="e.g. 2500"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.salary}
onChange={e => setData('salary', e.target.value)}
/>
</div>
{(clientErrors.salary || errors.salary) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.salary || errors.salary}</p>
)}
</div>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
<div className="grid grid-cols-3 gap-2">
{['Full Time', 'Part Time', 'Contract'].map(type => (
<div className="grid grid-cols-2 gap-2">
{['Full Time', 'Part Time'].map(type => (
<button
key={type}
type="button"
@ -169,7 +241,7 @@ export default function Edit({ job }) {
</div>
</div>
<div className="space-y-2 col-span-2">
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Status</label>
<div className="grid grid-cols-3 gap-2">
{[
@ -195,73 +267,7 @@ export default function Edit({ job }) {
</div>
</div>
{/* Section 2: Logistics & Pay */}
<div className="space-y-6">
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
Logistics & Compensation
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
<div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="e.g. Dubai Marina"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.location}
onChange={e => setData('location', e.target.value)}
/>
</div>
{(clientErrors.location || errors.location) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.location || errors.location}</p>
)}
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
<div className="relative">
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="number"
placeholder="e.g. 2500"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.salary}
onChange={e => setData('salary', e.target.value)}
/>
</div>
{(clientErrors.salary || errors.salary) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.salary || errors.salary}</p>
)}
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Start Date</label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="date"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.start_date}
onChange={e => setData('start_date', e.target.value)}
/>
</div>
{(clientErrors.start_date || errors.start_date) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.start_date || errors.start_date}</p>
)}
</div>
</div>
</div>
{/* Section 3: Details */}
{/* Section 2: Detailed Requirements */}
<div className="space-y-6">
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
@ -269,6 +275,56 @@ export default function Edit({ job }) {
</h3>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Skills (Select from list)</label>
<div className="space-y-3">
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
className={`w-full pl-11 pr-10 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none cursor-pointer ${
(clientErrors.skills || errors.skills) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value=""
onChange={e => {
const val = e.target.value;
if (val && !data.skills.includes(val)) {
setData('skills', [...data.skills, val]);
}
}}
>
<option value="">Choose skills to add...</option>
{skillList.filter(s => !data.skills.includes(s.name)).map(skill => (
<option key={skill.id} value={skill.name}>{skill.name}</option>
))}
</select>
</div>
{/* Selected skills pills */}
{data.skills.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-slate-50 rounded-2xl border border-slate-100">
{data.skills.map(skill => (
<span
key={skill}
className="inline-flex items-center space-x-1.5 px-3 py-1.5 bg-[#185FA5] text-white rounded-xl text-xs font-black uppercase tracking-wider shadow-xs"
>
<span>{skill}</span>
<button
type="button"
onClick={() => setData('skills', data.skills.filter(s => s !== skill))}
className="hover:bg-blue-700/50 p-0.5 rounded-full transition-colors"
>
<X className="w-3.5 h-3.5 text-white" />
</button>
</span>
))}
</div>
)}
</div>
{(clientErrors.skills || errors.skills) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.skills || errors.skills}</p>
)}
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
<textarea
@ -289,19 +345,6 @@ export default function Edit({ job }) {
</div>
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Special Requirements (Optional)</label>
<div className="relative">
<FileText className="absolute left-4 top-5 w-4 h-4 text-slate-400" />
<textarea
placeholder="e.g. Must speak Arabic, 5+ years experience, transferable visa..."
className="w-full pl-11 pr-4 py-4 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none"
value={data.requirements}
onChange={e => setData('requirements', e.target.value)}
/>
</div>
</div>
</div>
</div>

View File

@ -153,7 +153,7 @@ export default function Index({ initialJobs }) {
return (
<EmployerLayout title="My Jobs">
<Head title="My Jobs - Employer Portal" />
<Head title="My Jobs - Sponsor Portal" />
<div className="space-y-8 select-none">
{/* Header Actions */}

View File

@ -48,7 +48,7 @@ export default function Show({ job }) {
return (
<EmployerLayout title="Job Details">
<Head title={`Job Details: ${job.title} - Employer Portal`} />
<Head title={`Job Details: ${job.title} - Sponsor Portal`} />
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
<Link

View File

@ -9,7 +9,7 @@ export default function Index({ conversations }) {
return (
<EmployerLayout title={t('candidate_messages', 'Candidate Messages')}>
<Head title={t('messages_employer_portal', 'Messages - Employer Portal')} />
<Head title={t('messages_employer_portal', 'Messages - Sponsor Portal')} />
<div className="max-w-5xl mx-auto space-y-6 select-none">
{/* Header Actions */}

View File

@ -27,17 +27,21 @@ import {
UploadCloud,
FileText,
Mic,
Square
Square,
Clock
} from 'lucide-react';
import { toast } from 'sonner';
export default function Show({ conversation, initialMessages, conversations = [] }) {
export default function Show({ conversation, initialMessages, conversations = [], application = null }) {
const { t } = useTranslation();
const [messages, setMessages] = useState(initialMessages || []);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [showHireConfirmModal, setShowHireConfirmModal] = useState(false);
const [selectedApp, setSelectedApp] = useState(null);
const [newStatus, setNewStatus] = useState('');
const [notes, setNotes] = useState('');
const [showModal, setShowModal] = useState(false);
const [showAttachmentModal, setShowAttachmentModal] = useState(false);
const [uploadedFiles, setUploadedFiles] = useState([]);
const [isRecording, setIsRecording] = useState(false);
@ -188,12 +192,39 @@ export default function Show({ conversation, initialMessages, conversations = []
}
};
const handleMarkHired = () => {
router.post(`/employer/workers/${conversation.worker_id}/mark-hired`, {}, {
const openStatusModal = (app, statusPreset = '') => {
setSelectedApp(app);
let resolvedStatus = statusPreset || app.status || 'Applied';
const lowerStatus = resolvedStatus.toLowerCase();
if (lowerStatus === 'hire_requested' || lowerStatus === 'hired') {
resolvedStatus = 'Hired';
} else if (lowerStatus === 'rejected') {
resolvedStatus = 'Rejected';
} else if (lowerStatus === 'shortlisted') {
resolvedStatus = 'Shortlisted';
} else {
resolvedStatus = 'Applied';
}
setNewStatus(resolvedStatus);
setNotes(app.notes || '');
setShowModal(true);
};
const handleSaveStatus = () => {
if (!selectedApp) return;
router.post(`/employer/candidates/${selectedApp.application_id}/status`, {
status: newStatus,
notes: notes
}, {
preserveScroll: true,
onSuccess: () => {
toast.success(t('marked_hired', 'Worker successfully marked as Hired!'));
router.reload();
toast.success(t('status_updated_success', 'Applicant status updated successfully.'));
setShowModal(false);
setSelectedApp(null);
},
onError: (errors) => {
toast.error(t('status_updated_error', 'Failed to update status: ' + (errors.error || errors.status || 'Error occurred')));
}
});
};
@ -312,20 +343,15 @@ export default function Show({ conversation, initialMessages, conversations = []
</div>
<div className="flex items-center space-x-2">
{conversation.worker_status !== 'hired' && conversation.worker_status !== 'hidden' ? (
{application ? (
<button
type="button"
onClick={() => setShowHireConfirmModal(true)}
onClick={() => openStatusModal(application)}
className="px-3.5 py-2 bg-emerald-600 text-white hover:bg-emerald-700 rounded-xl transition-all shadow-md flex items-center space-x-1.5 text-xs font-black uppercase tracking-wider border border-emerald-700 cursor-pointer"
>
<CheckCircle2 className="w-4 h-4 text-white" />
<span>{t('mark_hired_action', 'Mark Hired')}</span>
<span>{t('update_status', 'Update Status')}</span>
</button>
) : conversation.worker_status === 'hired' ? (
<span className="px-3.5 py-2 bg-slate-100 text-slate-500 rounded-xl text-xs font-black uppercase tracking-wider border border-slate-200 flex items-center space-x-1.5">
<CheckCircle2 className="w-4 h-4 text-emerald-600" />
<span>{t('hired', 'Hired')}</span>
</span>
) : null}
</div>
</div>
@ -529,38 +555,90 @@ export default function Show({ conversation, initialMessages, conversations = []
</div>
)}
{/* Hired Confirmation Modal */}
{showHireConfirmModal && (
{/* Status Update Modal */}
{showModal && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="bg-white rounded-3xl w-full max-w-md p-6 relative animate-zoom-in text-center space-y-4 border border-slate-200 shadow-2xl">
<div className="mx-auto w-16 h-16 rounded-full bg-blue-50 border border-blue-100 flex items-center justify-center text-blue-600">
<CheckCircle2 className="w-8 h-8" />
</div>
<div className="space-y-2">
<h4 className="font-extrabold text-lg text-slate-900">{t('confirm_hire_title', 'Confirm Hiring')}</h4>
<p className="text-xs text-slate-600 leading-normal px-2">
{t('confirm_hire_desc', 'Verify this worker with your needs and make sure to hire this worker. If hired, you can view this worker in the Hired Workers page.')}
</p>
</div>
<div className="flex space-x-3 pt-2 text-xs font-bold">
<div className="bg-white w-full max-w-lg rounded-[28px] shadow-2xl border border-slate-200 overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200 text-left">
{/* Modal Header */}
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
<div>
<h3 className="font-black text-slate-900 text-base">Update Application Status</h3>
<p className="text-[10px] font-bold text-[#185FA5] uppercase tracking-widest mt-0.5">{selectedApp?.name}</p>
</div>
<button
type="button"
onClick={() => setShowHireConfirmModal(false)}
className="flex-1 py-3 border border-slate-200 hover:bg-slate-50 rounded-xl transition-colors"
onClick={() => { setShowModal(false); setSelectedApp(null); }}
className="p-2 hover:bg-slate-50 text-slate-400 hover:text-slate-950 rounded-full transition-colors cursor-pointer"
>
{t('cancel', 'Cancel')}
<X className="w-5 h-5" />
</button>
</div>
{/* Modal Body */}
<div className="p-6 space-y-5">
{selectedApp?.status?.toLowerCase() === 'hire_requested' && (
<div className="p-3 bg-amber-50 rounded-xl border border-amber-100 text-[10px] text-amber-700 font-bold">
A hiring offer is pending. Waiting for worker confirmation.
</div>
)}
{/* Status Selector */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Stage</label>
<select
value={newStatus}
onChange={(e) => setNewStatus(e.target.value)}
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all cursor-pointer"
>
<option value="Applied">Review</option>
<option value="Shortlisted">Shortlisted</option>
<option value="Rejected">Rejected</option>
<option value="Hired">Hired</option>
</select>
</div>
{/* Notes Textarea */}
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Internal Sponsor Notes</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Add any internal feedback, interview times, or notes about this candidate..."
rows={4}
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-medium text-slate-900 placeholder-slate-400 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all resize-none"
/>
</div>
{/* Status History Audit Trail */}
{selectedApp?.status_history && selectedApp.status_history.length > 0 && (
<div className="space-y-2 pt-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Status Audit Log</label>
<div className="max-h-24 overflow-y-auto space-y-2 pr-1 border border-slate-100 rounded-xl p-2 bg-slate-50/40">
{selectedApp.status_history.map((hist, index) => (
<div key={index} className="text-[9px] text-slate-500 flex justify-between items-start">
<div>
<span className="font-bold uppercase text-[#185FA5]">{hist.status}</span>
{hist.notes && <span className="ml-1 text-slate-400 italic">({hist.notes})</span>}
</div>
<span className="text-[8px] font-mono text-slate-300">{new Date(hist.timestamp).toLocaleDateString()}</span>
</div>
))}
</div>
</div>
)}
</div>
{/* Modal Footer */}
<div className="p-6 bg-slate-50 border-t border-slate-100 flex items-center justify-end space-x-3">
<button
onClick={() => { setShowModal(false); setSelectedApp(null); }}
className="px-5 py-2.5 bg-white border border-slate-200 hover:bg-slate-100 rounded-xl text-[10px] font-black uppercase tracking-widest text-slate-600 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="button"
onClick={() => {
setShowHireConfirmModal(false);
handleMarkHired();
}}
className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white py-3 rounded-xl shadow-sm transition-colors"
onClick={handleSaveStatus}
className="px-5 py-2.5 bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-md shadow-blue-500/10 cursor-pointer"
>
{t('confirm_hire_action', 'Confirm Hire')}
Save Status
</button>
</div>
</div>

View File

@ -312,7 +312,7 @@ export default function PaymentHistory({ payments = [], currentPlan, expiresAt,
</div>
<div class="address-block">
<h3>Invoice To</h3>
<p><strong>Employer Account</strong></p>
<p><strong>Sponsor Account</strong></p>
<p>Domestic Sponsor Household</p>
<p>sponsor@marketplace.ae</p>
<p>Dubai, UAE</p>
@ -591,7 +591,7 @@ export default function PaymentHistory({ payments = [], currentPlan, expiresAt,
<div class="header">
<div>
<div class="title">PAYMENT HISTORY STATEMENT</div>
<div style="font-size: 12px; margin-top: 5px; font-weight: 500;">Employer Account: ${currentPlan}</div>
<div style="font-size: 12px; margin-top: 5px; font-weight: 500;">Sponsor Account: ${currentPlan}</div>
</div>
<div class="meta-info">
<div><strong>Statement Date:</strong> ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</div>
@ -657,7 +657,7 @@ export default function PaymentHistory({ payments = [], currentPlan, expiresAt,
return (
<EmployerLayout title={t('payment_history', 'Payment History')}>
<Head title={`${t('payment_history', 'Payment History')} - ${t('employer_portal', 'Employer Portal')}`} />
<Head title={`${t('payment_history', 'Payment History')} - ${t('employer_portal', 'Sponsor Portal')}`} />
<div className="space-y-8 select-none pb-16">

View File

@ -29,6 +29,7 @@ import {
XCircle
} from 'lucide-react';
import { toast } from 'sonner';
import { compressImage } from '../../lib/utils';
export default function Profile({ employerProfile }) {
const { t } = useTranslation();
@ -109,7 +110,7 @@ export default function Profile({ employerProfile }) {
return (
<EmployerLayout title={t('employer_profile_details', 'Profile Details')}>
<Head title={`${t('profile', 'Profile')} - ${t('employer_portal', 'Employer Portal')}`} />
<Head title={`${t('profile', 'Profile')} - ${t('employer_portal', 'Sponsor Portal')}`} />
<div className="max-w-5xl mx-auto pb-16 space-y-8 select-none animate-in fade-in duration-500">
{isExpired && (
@ -398,7 +399,7 @@ export default function Profile({ employerProfile }) {
<span className="font-extrabold text-slate-700">{employerProfile?.emirates_id?.date_of_birth}</span>
</div>
<div className="flex justify-between py-2">
<span className="font-bold text-slate-400 uppercase tracking-wider text-[9px]">{t('employer_agency', 'Employer / Sponsor')}</span>
<span className="font-bold text-slate-400 uppercase tracking-wider text-[9px]">{t('employer_agency', 'Sponsor')}</span>
<span className="font-extrabold text-[#185FA5] truncate max-w-[200px]" title={employerProfile?.emirates_id?.employer}>
{employerProfile?.emirates_id?.employer}
</span>
@ -484,11 +485,17 @@ export default function Profile({ employerProfile }) {
<input
type="file"
accept="image/*"
onChange={(e) => {
onChange={async (e) => {
const file = e.target.files[0];
if (file) {
setFrontFile(file);
setFrontPreview(URL.createObjectURL(file));
try {
const compressed = await compressImage(file);
setFrontFile(compressed);
setFrontPreview(URL.createObjectURL(compressed));
} catch (err) {
setFrontFile(file);
setFrontPreview(URL.createObjectURL(file));
}
}
}}
className="absolute inset-0 opacity-0 cursor-pointer"
@ -517,11 +524,17 @@ export default function Profile({ employerProfile }) {
<input
type="file"
accept="image/*"
onChange={(e) => {
onChange={async (e) => {
const file = e.target.files[0];
if (file) {
setBackFile(file);
setBackPreview(URL.createObjectURL(file));
try {
const compressed = await compressImage(file);
setBackFile(compressed);
setBackPreview(URL.createObjectURL(compressed));
} catch (err) {
setBackFile(file);
setBackPreview(URL.createObjectURL(file));
}
}
}}
className="absolute inset-0 opacity-0 cursor-pointer"

View File

@ -56,7 +56,7 @@ export default function ProfileEdit({ employerProfile }) {
return (
<EmployerLayout title={t('edit_profile', 'Edit Profile')}>
<Head title={`${t('edit_profile', 'Edit Profile')} - ${t('employer_portal', 'Employer Portal')}`} />
<Head title={`${t('edit_profile', 'Edit Profile')} - ${t('employer_portal', 'Sponsor Portal')}`} />
<div className="max-w-3xl mx-auto pb-16 space-y-8 select-none animate-in fade-in duration-500">
{/* Back Link */}

View File

@ -0,0 +1,173 @@
import React from 'react';
import { Head, Link } from '@inertiajs/react';
import {
TrendingUp,
Users,
CheckCircle2,
ArrowRight,
Shield,
Lock,
Globe,
Zap
} from 'lucide-react';
export default function PublicStats({ total_workers, avg_hires, total_hires_all }) {
return (
<div className="min-h-screen bg-slate-900 text-white flex flex-col justify-between selection:bg-emerald-500 selection:text-slate-900">
<Head title="Platform Performance & Trust Metrics - Direct Hire Marketplace" />
{/* Navigation Header */}
<header className="border-b border-white/10 bg-slate-950/50 backdrop-blur-md sticky top-0 z-50">
<div className="max-w-7xl mx-auto px-6 h-20 flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center font-bold text-lg text-white shadow-lg shadow-blue-500/30">
M
</div>
<span className="font-extrabold tracking-tight text-lg text-white">
Direct<span className="text-blue-400">Market</span>
</span>
</div>
<div className="flex items-center space-x-4">
<Link
href="/employer/login"
className="text-sm font-semibold text-slate-300 hover:text-white transition-colors px-4 py-2"
>
Sign In
</Link>
<Link
href="/employer/register"
className="bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold px-5 py-2.5 rounded-xl transition-all shadow-md shadow-blue-600/20"
>
Register
</Link>
</div>
</div>
</header>
{/* Hero Section */}
<main className="flex-grow py-20 px-6 max-w-5xl mx-auto w-full flex flex-col justify-center space-y-16">
<div className="text-center space-y-6 max-w-3xl mx-auto">
<div className="inline-flex items-center space-x-2 px-3 py-1 bg-emerald-500/10 rounded-full text-xs font-semibold text-emerald-400 border border-emerald-500/20">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
<span>Live Platform Growth Metrics</span>
</div>
<h1 className="text-4xl sm:text-5xl font-black tracking-tight leading-none text-slate-100">
Transparency First. <br className="hidden sm:block"/>
<span className="bg-gradient-to-r from-blue-400 via-indigo-400 to-emerald-400 bg-clip-text text-transparent">Direct & Verified Hiring.</span>
</h1>
<p className="text-slate-400 text-base sm:text-lg max-w-2xl mx-auto font-medium">
Real-time stats from the region's premier zero-middlemen domestic helper platform. No recruiters, no hidden commissions, just honest data.
</p>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Worker Profiles Card */}
<div className="bg-slate-950/40 border border-white/10 p-8 rounded-3xl backdrop-blur-md relative overflow-hidden group hover:border-blue-500/30 transition-all flex flex-col justify-between h-64 shadow-xl">
<div className="absolute top-0 right-0 w-32 h-32 bg-blue-500/5 rounded-full blur-2xl group-hover:bg-blue-500/10 transition-colors" />
<div className="w-12 h-12 rounded-2xl bg-blue-500/10 border border-blue-500/20 flex items-center justify-center text-blue-400">
<Users className="w-6 h-6" />
</div>
<div className="space-y-2">
<div className="text-4xl font-extrabold tracking-tight text-white">
{total_workers.toLocaleString()}+
</div>
<div className="text-sm font-semibold text-slate-400 uppercase tracking-wider">
Worker Profiles
</div>
<p className="text-xs text-slate-500 font-medium">
Active, background-checked domestic workers ready for sponsorship.
</p>
</div>
</div>
{/* Avg Monthly Hires Card */}
<div className="bg-slate-950/40 border border-white/10 p-8 rounded-3xl backdrop-blur-md relative overflow-hidden group hover:border-emerald-500/30 transition-all flex flex-col justify-between h-64 shadow-xl">
<div className="absolute top-0 right-0 w-32 h-32 bg-emerald-500/5 rounded-full blur-2xl group-hover:bg-emerald-500/10 transition-colors" />
<div className="w-12 h-12 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
<TrendingUp className="w-6 h-6" />
</div>
<div className="space-y-2">
<div className="text-4xl font-extrabold tracking-tight text-white">
{avg_hires.toLocaleString()}+
</div>
<div className="text-sm font-semibold text-slate-400 uppercase tracking-wider">
Avg Monthly Hires
</div>
<p className="text-xs text-slate-500 font-medium">
Average number of successful matches completed directly on our app.
</p>
</div>
</div>
{/* Total Successful Hires */}
<div className="bg-slate-950/40 border border-white/10 p-8 rounded-3xl backdrop-blur-md relative overflow-hidden group hover:border-purple-500/30 transition-all flex flex-col justify-between h-64 shadow-xl">
<div className="absolute top-0 right-0 w-32 h-32 bg-purple-500/5 rounded-full blur-2xl group-hover:bg-purple-500/10 transition-colors" />
<div className="w-12 h-12 rounded-2xl bg-purple-500/10 border border-purple-500/20 flex items-center justify-center text-purple-400">
<CheckCircle2 className="w-6 h-6" />
</div>
<div className="space-y-2">
<div className="text-4xl font-extrabold tracking-tight text-white">
{total_hires_all.toLocaleString()}+
</div>
<div className="text-sm font-semibold text-slate-400 uppercase tracking-wider">
Total Hires Done
</div>
<p className="text-xs text-slate-500 font-medium">
Direct employment contracts signed by registered sponsors in Dubai.
</p>
</div>
</div>
</div>
{/* Additional Trust Points */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-8 border-t border-white/10">
<div className="flex items-start space-x-3">
<Shield className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />
<div>
<h4 className="font-bold text-sm text-slate-200">MOHRE Compliant</h4>
<p className="text-xs text-slate-400 font-medium mt-1">Every direct hire follows official UAE regulations seamlessly.</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Lock className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<h4 className="font-bold text-sm text-slate-200">OCR Identity Checks</h4>
<p className="text-xs text-slate-400 font-medium mt-1">Passports and Emirates IDs are checked using secure AI technology.</p>
</div>
</div>
<div className="flex items-start space-x-3">
<Zap className="w-5 h-5 text-purple-400 shrink-0 mt-0.5" />
<div>
<h4 className="font-bold text-sm text-slate-200">Zero Agency Fees</h4>
<p className="text-xs text-slate-400 font-medium mt-1">Hire direct and save up to AED 15,000 on middleman charges.</p>
</div>
</div>
</div>
{/* CTA Box */}
<div className="bg-gradient-to-br from-[#185FA5] to-blue-950 rounded-3xl p-8 sm:p-10 border border-blue-800 text-center relative overflow-hidden shadow-2xl space-y-6">
<div className="absolute -left-10 -bottom-10 w-64 h-64 bg-white/5 rounded-full blur-3xl pointer-events-none" />
<h2 className="text-2xl sm:text-3xl font-extrabold text-white">Start Direct Hiring Today</h2>
<p className="text-blue-100 text-sm max-w-xl mx-auto font-medium">
Join thousands of UAE sponsors who hire nannies, maids, cooks, and drivers directly and securely.
</p>
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
<Link
href="/employer/register"
className="w-full sm:w-auto bg-white text-blue-900 hover:bg-slate-100 px-8 py-4 rounded-2xl font-black text-sm transition-all shadow-lg flex items-center justify-center space-x-2 group"
>
<span>Get Started Now</span>
<ArrowRight className="w-4 h-4 text-blue-900 group-hover:translate-x-1 transition-transform" />
</Link>
</div>
</div>
</main>
{/* Footer */}
<footer className="border-t border-white/5 bg-slate-950 py-8 text-center text-xs text-slate-500 font-medium">
&copy; {new Date().getFullYear()} DirectMarket. All rights reserved. Compliant with UAE MOHRE guidelines.
</footer>
</div>
);
}

View File

@ -78,7 +78,7 @@ export default function SelectedCandidates({
// Apply Client-Side Filters
const filteredWorkers = useMemo(() => {
let list = (selectedWorkers || []).filter(w => w.status !== 'Searching');
let list = (workers || []).filter(w => w.status !== 'Searching');
if (searchTerm.trim() !== '') {
const query = searchTerm.toLowerCase();
@ -134,7 +134,7 @@ export default function SelectedCandidates({
}
return list;
}, [selectedWorkers, searchTerm, filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
}, [workers, searchTerm, filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]);
const resetFilters = () => {
setFilterProfession('All Professions');
@ -185,7 +185,7 @@ export default function SelectedCandidates({
return (
<EmployerLayout title={t('candidates_pipeline', 'Hired Workers')}>
<Head title={`${t('candidates_pipeline', 'Hired Workers')} - ${t('employer_portal', 'Employer Portal')}`} />
<Head title={`${t('candidates_pipeline', 'Hired Workers')} - ${t('employer_portal', 'Sponsor Portal')}`} />
{/* ── Filter Drawer ── */}
<FilterDrawer

View File

@ -238,7 +238,15 @@ export default function Shortlist({ shortlistedWorkers, filtersMetadata = {} })
} else if (sortBy === 'rating') {
list.sort((a, b) => b.rating - a.rating);
} else if (sortBy === 'experience') {
list.sort((a, b) => b.age - a.age); // approximate experience by age
list.sort((a, b) => {
const parseAge = (val) => {
if (!val) return 0;
if (typeof val === 'number') return val;
const num = parseInt(val, 10);
return isNaN(num) ? 0 : num;
};
return parseAge(b.age) - parseAge(a.age);
});
}
return list;
@ -269,7 +277,7 @@ export default function Shortlist({ shortlistedWorkers, filtersMetadata = {} })
return (
<EmployerLayout title={t('my_shortlist', 'My Shortlist')}>
<Head title={`${t('shortlist', 'Shortlist')} - ${t('employer_portal', 'Employer Portal')}`} />
<Head title={`${t('shortlist', 'Shortlist')} - ${t('employer_portal', 'Sponsor Portal')}`} />
<div className="space-y-6 select-none pb-16">
<div className="flex items-center justify-between">

View File

@ -38,7 +38,7 @@ export default function Subscription({ currentPlan, expiresAt, plans, invoices:
// Failed payment simulation
const [simulatedFailedState, setSimulatedFailedState] = useState(false);
const [activePlan, setActivePlan] = useState(currentPlan || 'Premium Employer Pass');
const [activePlan, setActivePlan] = useState(currentPlan || 'Premium Sponsor Pass');
// Payment History List
const [invoices, setInvoices] = useState(initialInvoices || []);
@ -108,6 +108,8 @@ export default function Subscription({ currentPlan, expiresAt, plans, invoices:
};
const downloadReceipt = (invoiceId) => {
window.location.href = route('employer.subscription.invoice.download', { id: invoiceId });
toast.success(`📄 ${t('invoice_generated', 'Invoice {id} generated').replace('{id}', invoiceId)}`, {
description: t('invoice_download_desc', 'Direct MOHRE Sponsor receipt downloaded in PDF format successfully.')
});
@ -115,7 +117,7 @@ export default function Subscription({ currentPlan, expiresAt, plans, invoices:
return (
<EmployerLayout title={t('sponsor_pass_billing', 'Sponsor Pass & Billing')}>
<Head title={t('subscription_billing_title', 'Subscription Billing & Invoices - Employer Portal')} />
<Head title={t('subscription_billing_title', 'Subscription Billing & Invoices - Sponsor Portal')} />
<div className="space-y-8 select-none w-full pb-16">

View File

@ -30,7 +30,7 @@ export default function Create({ reasons = [] }) {
return (
<EmployerLayout title={t('create_new_ticket', 'Create New Ticket')}>
<Head title={t('create_support_ticket_title', 'Create Support Ticket - Employer Portal')} />
<Head title={t('create_support_ticket_title', 'Create Support Ticket - Sponsor Portal')} />
<div className="max-w-2xl select-none w-full pb-16 space-y-6">

View File

@ -92,7 +92,7 @@ export default function Index({ tickets, faqs = [] }) {
return (
<EmployerLayout title={t('help_support', 'Help & Support')}>
<Head title={t('support_tickets_title', 'Help & Support Tickets - Employer Portal')} />
<Head title={t('support_tickets_title', 'Help & Support Tickets - Sponsor Portal')} />
<div className="space-y-8 select-none w-full pb-16">

View File

@ -98,7 +98,7 @@ export default function Show({ ticket, replies }) {
return (
<EmployerLayout title={t('ticket_details', 'Ticket Details')}>
<Head title={`${ticket.ticket_number} - Support Ticket - Employer Portal`} />
<Head title={`${ticket.ticket_number} - Support Ticket - Sponsor Portal`} />
<div className="max-w-4xl select-none w-full pb-24 space-y-6">

View File

@ -334,7 +334,15 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
} else if (sortBy === 'rating') {
workers.sort((a, b) => b.rating - a.rating);
} else if (sortBy === 'experience') {
workers.sort((a, b) => b.age - a.age); // approximate experience by age
workers.sort((a, b) => {
const parseAge = (val) => {
if (!val) return 0;
if (typeof val === 'number') return val;
const num = parseInt(val, 10);
return isNaN(num) ? 0 : num;
};
return parseAge(b.age) - parseAge(a.age);
});
}
return workers;

View File

@ -25,7 +25,8 @@ import {
AlertTriangle,
User,
Phone,
MapPin
MapPin,
Clock
} from 'lucide-react';
import { toast } from 'sonner';
@ -70,8 +71,8 @@ const getExpiryStatus = (expiryDate) => {
}
};
const safeRender = (value) => {
if (value === null || value === undefined) return 'N/A';
const safeRender = (value, fallback = 'Not Extracted') => {
if (value === null || value === undefined || String(value).trim() === '') return fallback;
if (typeof value === 'object') {
if (value.name) return String(value.name);
if (value.full_name) return String(value.full_name);
@ -86,6 +87,52 @@ export default function Show({ worker }) {
const emiratesIdDoc = worker.documents?.find(d => d.type === 'emirates_id' || d.type === 'eid');
const visaDoc = worker.documents?.find(d => d.type === 'visa');
// Robust passport document fallbacks
const passportOcr = passportDoc?.ocr_data || {};
const passportGivenNames = passportOcr.given_names || worker.name || 'Not Extracted';
const passportSurname = passportOcr.surname || '';
const passportNumber = passportOcr.passport_number || passportDoc?.number || 'Not Extracted';
const passportSex = passportOcr.sex || worker.gender || 'Not Extracted';
const passportDob = passportOcr.date_of_birth || (worker.age ? `Age ${worker.age}` : 'Not Extracted');
const passportPlaceOfBirth = passportOcr.place_of_birth || 'Not Extracted';
const passportNationality = passportOcr.nationality || worker.nationality || 'Not Extracted';
const passportIssuingCountry = passportOcr.issuing_country || 'Not Extracted';
const passportIssueDate = passportOcr.date_of_issue || passportDoc?.issue_date || 'Not Extracted';
const passportExpiryDate = passportOcr.date_of_expiry || passportDoc?.expiry_date || 'Not Extracted';
const passportAuthority = passportOcr.authority || 'Not Extracted';
const passportDocType = passportOcr.document_type || passportDoc?.type || 'Passport';
// Robust visa document fallbacks
const visaOcr = visaDoc?.ocr_data || {};
const sponsorObj = visaOcr.sponsor;
const isSponsorObj = sponsorObj && typeof sponsorObj === 'object' && !Array.isArray(sponsorObj);
const sponsorName = isSponsorObj
? (sponsorObj.name || sponsorObj.sponsor_name)
: (visaOcr.sponsor_name || (typeof sponsorObj === 'string' ? sponsorObj : ''));
const sponsorPhone = isSponsorObj
? (sponsorObj.phone || sponsorObj.mobile || sponsorObj.mobile_number)
: '';
const sponsorAddress = isSponsorObj
? (sponsorObj.address || '')
: '';
const visaFullName = visaOcr.full_name || worker.name || 'Not Extracted';
const visaEntryPermitNo = visaOcr.entry_permit_no || visaDoc?.number || 'Not Extracted';
const visaUidNo = visaOcr.uid_no || 'Not Extracted';
const visaType = visaOcr.visa_type || worker.visa_status || 'Entry Permit';
const visaPassportNo = visaOcr.passport_no || visaOcr.passport_number || passportDoc?.number || 'Not Extracted';
const visaProfession = visaOcr.profession || worker.main_profession || 'Domestic Worker';
const visaSponsorName = sponsorName || visaOcr.sponsor_name || 'Not Extracted';
const visaSponsorPhone = sponsorPhone || 'Not Extracted';
const visaSponsorAddress = sponsorAddress || 'Not Extracted';
const visaNationality = visaOcr.nationality || worker.nationality || 'Not Extracted';
const visaCountry = visaOcr.country || 'United Arab Emirates';
const visaDob = visaOcr.date_of_birth || visaOcr.dob || 'Not Extracted';
const visaPlaceOfBirth = visaOcr.place_of_birth || 'Not Extracted';
const visaIssueDate = visaOcr.issue_date || visaDoc?.issue_date || 'Not Extracted';
const visaValidUntil = visaOcr.valid_until || visaOcr.expiry_date || visaDoc?.expiry_date || 'Not Extracted';
const visaDocType = visaOcr.document_type || visaDoc?.type || 'uae_visa';
const [showReportModal, setShowReportModal] = useState(false);
const [reportReason, setReportReason] = useState('');
const [reportDetails, setReportDetails] = useState('');
@ -161,10 +208,10 @@ export default function Show({ worker }) {
router.post(`/employer/workers/${worker.id}/mark-hired`, {}, {
preserveScroll: true,
onSuccess: () => {
setAvailabilityStatus('Hired');
setAvailabilityStatus('Pending Confirmation');
setShowHiredModal(true);
toast.success(t('marked_hired', 'Worker successfully marked as Hired!'), {
description: t('marked_hired_desc', 'Sponsorship direct match finalized. Please leave an anonymous trust review!')
toast.success(t('hire_request_sent', 'Hiring request sent successfully!'), {
description: t('hire_request_sent_desc', 'Awaiting worker confirmation of the hiring offer.')
});
}
});
@ -289,16 +336,12 @@ export default function Show({ worker }) {
<span>{t('start_direct_chat', 'Start Direct Chat')}</span>
</Link>
{/* Stage 4 Hire: Mark Hired (Finalize Hiring Direct Flow) */}
{availabilityStatus !== 'Hired' && (
<button
type="button"
onClick={() => setShowHireConfirmModal(true)}
className="bg-emerald-600 hover:bg-emerald-700 text-white border border-emerald-700 px-8 py-3 rounded-xl font-black text-sm shadow-md transition-all flex items-center justify-center space-x-2 flex-1 sm:flex-initial scale-105 active:scale-100"
>
<CheckCircle2 className="w-4 h-4 text-white" />
<span>{t('mark_hired_action', 'Mark Hired')}</span>
</button>
{/* Stage 4 Hire: Pending Confirmation Indicator */}
{availabilityStatus === 'Pending Confirmation' && (
<div className="bg-amber-50 text-amber-700 border border-amber-200 px-6 py-3 rounded-xl font-bold text-sm flex items-center justify-center space-x-2">
<Clock className="w-4 h-4 text-amber-600 animate-pulse" />
<span>{t('pending_confirmation', 'Pending Confirmation')}</span>
</div>
)}
</div>
</div>
@ -463,73 +506,56 @@ export default function Show({ worker }) {
</div>
<div className="p-6">
{passportDoc.ocr_data ? (
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('given_names', 'Given Names')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.given_names)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('surname', 'Surname')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.surname)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('passport_number', 'Passport Number')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.passport_number || passportDoc.number)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('sex', 'Sex')}</div>
<div className="text-xs font-bold text-slate-800 uppercase">{safeRender(passportDoc.ocr_data.sex)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('date_of_birth', 'Date of Birth')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.date_of_birth)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('place_of_birth', 'Place of Birth')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.place_of_birth)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('nationality', 'Nationality')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.nationality || worker.nationality)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('issuing_country', 'Issuing Country')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.issuing_country)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('issue_date', 'Date of Issue')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.date_of_issue || passportDoc.issue_date)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('expiry_date', 'Date of Expiry')}</div>
<div className="text-xs font-bold text-emerald-600">{safeRender(passportDoc.ocr_data.date_of_expiry || passportDoc.expiry_date)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('authority', 'Authority')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.authority)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('document_type', 'Document Type')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDoc.ocr_data.document_type || passportDoc.type || 'passport')}</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('given_names', 'Given Names')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportGivenNames)}</div>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('document_id', 'Document ID')}</div>
<div className="text-xs font-bold text-slate-800">{passportDoc.number}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('issue_date', 'Issue')}</div>
<div className="text-xs font-bold text-slate-800">{passportDoc.issue_date || 'N/A'}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('expiry_date', 'Expiry')}</div>
<div className="text-xs font-bold text-emerald-600">{passportDoc.expiry_date || 'N/A'}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('surname', 'Surname')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportSurname)}</div>
</div>
)}
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('passport_number', 'Passport Number')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportNumber)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('sex', 'Sex')}</div>
<div className="text-xs font-bold text-slate-800 uppercase">{safeRender(passportSex)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('date_of_birth', 'Date of Birth')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDob)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('place_of_birth', 'Place of Birth')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportPlaceOfBirth)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('nationality', 'Nationality')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportNationality)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('issuing_country', 'Issuing Country')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportIssuingCountry)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('issue_date', 'Date of Issue')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportIssueDate)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('expiry_date', 'Date of Expiry')}</div>
<div className="text-xs font-bold text-emerald-600">{safeRender(passportExpiryDate)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('authority', 'Authority')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportAuthority)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('document_type', 'Document Type')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(passportDocType)}</div>
</div>
</div>
<div className="mt-6 pt-4 border-t border-slate-100 flex items-center justify-between text-xs text-slate-500 font-medium">
<span className="flex items-center space-x-1.5">
@ -689,109 +715,77 @@ export default function Show({ worker }) {
</span>
)}
</div>
<div className="p-6">
{visaDoc.ocr_data ? (() => {
const sponsorObj = visaDoc.ocr_data?.sponsor;
const isSponsorObj = sponsorObj && typeof sponsorObj === 'object' && !Array.isArray(sponsorObj);
const sponsorName = isSponsorObj
? (sponsorObj.name || sponsorObj.sponsor_name)
: (visaDoc.ocr_data?.sponsor_name || (typeof sponsorObj === 'string' ? sponsorObj : ''));
const sponsorPhone = isSponsorObj
? (sponsorObj.phone || sponsorObj.mobile || sponsorObj.mobile_number)
: '';
const sponsorAddress = isSponsorObj
? (sponsorObj.address || '')
: '';
return (
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('full_name', 'Full Name')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.full_name || worker.name)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('entry_permit_no', 'Entry Permit No')}</div>
<div className="text-xs font-mono font-bold text-slate-800">{safeRender(visaDoc.ocr_data.entry_permit_no || visaDoc.number)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('uid_no', 'UID No')}</div>
<div className="text-xs font-mono font-bold text-slate-800">{safeRender(visaDoc.ocr_data.uid_no)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('visa_type', 'Visa Type')}</div>
<div className="text-xs font-bold text-slate-800 uppercase">{safeRender(visaDoc.ocr_data.visa_type || 'ENTRY PERMIT')}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('passport_no', 'Passport No')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.passport_no || visaDoc.ocr_data.passport_number)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('profession', 'Profession')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.profession || worker.visa_status)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('sponsor_name', 'Sponsor Name')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(sponsorName || visaDoc.ocr_data.sponsor_name)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('sponsor_phone', 'Sponsor Phone')}</div>
<div className="text-xs font-mono font-bold text-slate-800">{safeRender(sponsorPhone)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('sponsor_address', 'Sponsor Address')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(sponsorAddress)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('nationality', 'Nationality')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.nationality || worker.nationality)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('country', 'Country')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.country || 'United Arab Emirates')}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('date_of_birth', 'Date of Birth')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.date_of_birth || visaDoc.ocr_data.dob)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('place_of_birth', 'Place of Birth')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.place_of_birth)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('issue_date', 'Issue Date')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.issue_date || visaDoc.issue_date)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('valid_until', 'Valid Until')}</div>
<div className="text-xs font-bold text-emerald-600">{safeRender(visaDoc.ocr_data.valid_until || visaDoc.ocr_data.expiry_date || visaDoc.expiry_date)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('document_type', 'Document Type')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDoc.ocr_data.document_type || visaDoc.type || 'uae_visa')}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('ocr_accuracy', 'OCR Accuracy')}</div>
<div className="text-xs font-bold text-emerald-600">{(visaDoc.ocr_accuracy || visaDoc.ocr_data?.ocr_accuracy || 99)}%</div>
</div>
</div>
);
})() : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('document_id', 'Document ID')}</div>
<div className="text-xs font-bold text-slate-800">{visaDoc.number}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('issue_date', 'Issue')}</div>
<div className="text-xs font-bold text-slate-800">{visaDoc.issue_date || 'N/A'}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('expiry_date', 'Expiry')}</div>
<div className="text-xs font-bold text-emerald-600">{visaDoc.expiry_date || 'N/A'}</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('full_name', 'Full Name')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaFullName)}</div>
</div>
)}
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('entry_permit_no', 'Entry Permit No')}</div>
<div className="text-xs font-mono font-bold text-slate-800">{safeRender(visaEntryPermitNo)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('uid_no', 'UID No')}</div>
<div className="text-xs font-mono font-bold text-slate-800">{safeRender(visaUidNo)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('visa_type', 'Visa Type')}</div>
<div className="text-xs font-bold text-slate-800 uppercase">{safeRender(visaType)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('passport_no', 'Passport No')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaPassportNo)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('profession', 'Profession')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaProfession)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('sponsor_name', 'Sponsor Name')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaSponsorName)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('sponsor_phone', 'Sponsor Phone')}</div>
<div className="text-xs font-mono font-bold text-slate-800">{safeRender(visaSponsorPhone)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('sponsor_address', 'Sponsor Address')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaSponsorAddress)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('nationality', 'Nationality')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaNationality)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('country', 'Country')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaCountry)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('date_of_birth', 'Date of Birth')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDob)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('place_of_birth', 'Place of Birth')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaPlaceOfBirth)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('issue_date', 'Issue Date')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaIssueDate)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('valid_until', 'Valid Until')}</div>
<div className="text-xs font-bold text-emerald-600">{safeRender(visaValidUntil)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('document_type', 'Document Type')}</div>
<div className="text-xs font-bold text-slate-800">{safeRender(visaDocType)}</div>
</div>
<div>
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('ocr_accuracy', 'OCR Accuracy')}</div>
<div className="text-xs font-bold text-emerald-600">{(visaDoc.ocr_accuracy || visaOcr.ocr_accuracy || 99)}%</div>
</div>
</div>
<div className="mt-6 pt-4 border-t border-slate-100 flex items-center justify-between text-xs text-slate-500 font-medium">
<span className="flex items-center space-x-1.5">
@ -885,41 +879,65 @@ export default function Show({ worker }) {
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="bg-white rounded-3xl w-full max-w-md p-6 relative animate-zoom-in space-y-6 border border-slate-200 shadow-2xl">
<div className="text-center space-y-3">
<div className="w-16 h-16 bg-emerald-50 text-emerald-600 rounded-full flex items-center justify-center mx-auto shadow-inner">
<CheckCircle2 className="w-8 h-8" />
</div>
<h4 className="font-extrabold text-lg text-slate-900">{t('direct_sponsoring_finalized', 'Direct Sponsoring Finalized!')}</h4>
<p className="text-xs text-slate-500 leading-relaxed">
{t('hired_under_sponsorship_desc', '{name} is successfully marked as Hired under your sponsorship! This direct matching conforms exactly with Stage 4 of our UAE MOHRE zero-middlemen flow.').replace('{name}', worker.name)}
</p>
</div>
{availabilityStatus === 'Pending Confirmation' ? (
<>
<div className="text-center space-y-3">
<div className="w-16 h-16 bg-amber-50 text-amber-600 rounded-full flex items-center justify-center mx-auto shadow-inner">
<Clock className="w-8 h-8" />
</div>
<h4 className="font-extrabold text-lg text-slate-900">{t('hire_request_sent_title', 'Hiring Request Sent')}</h4>
<p className="text-xs text-slate-500 leading-relaxed">
{t('hired_under_sponsorship_pending_desc', 'Your hiring request has been sent to {name}. The worker must accept the offer to finalize the placement.').replace('{name}', worker.name)}
</p>
</div>
<div className="flex flex-col space-y-2 text-xs font-bold">
<button
onClick={() => setShowHiredModal(false)}
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl flex items-center justify-center"
>
<span>{t('ok', 'OK')}</span>
</button>
</div>
</>
) : (
<>
<div className="text-center space-y-3">
<div className="w-16 h-16 bg-emerald-50 text-emerald-600 rounded-full flex items-center justify-center mx-auto shadow-inner">
<CheckCircle2 className="w-8 h-8" />
</div>
<h4 className="font-extrabold text-lg text-slate-900">{t('direct_sponsoring_finalized', 'Direct Sponsoring Finalized!')}</h4>
<p className="text-xs text-slate-500 leading-relaxed">
{t('hired_under_sponsorship_desc', '{name} is successfully marked as Hired under your sponsorship! This direct matching conforms exactly with Stage 4 of our UAE MOHRE zero-middlemen flow.').replace('{name}', worker.name)}
</p>
</div>
<div className="p-4 bg-blue-50/50 rounded-2xl border border-blue-100/50 space-y-2">
<span className="text-[9px] font-black text-[#185FA5] uppercase tracking-widest block">{t('next_step_post_hire_review', 'Next Step: Stage 5 Post-Hire Review')}</span>
<p className="text-[11px] text-slate-700 leading-relaxed font-bold">
{t('help_community_post_review', "Help the UAE community hire safely by posting an anonymous review describing {name}'s childcare, cooking, driving, or domestic skills.").replace('{name}', worker.name)}
</p>
</div>
<div className="p-4 bg-blue-50/50 rounded-2xl border border-blue-100/50 space-y-2">
<span className="text-[9px] font-black text-[#185FA5] uppercase tracking-widest block">{t('next_step_post_hire_review', 'Next Step: Stage 5 Post-Hire Review')}</span>
<p className="text-[11px] text-slate-700 leading-relaxed font-bold">
{t('help_community_post_review', "Help the UAE community hire safely by posting an anonymous review describing {name}'s childcare, cooking, driving, or domestic skills.").replace('{name}', worker.name)}
</p>
</div>
<div className="flex flex-col space-y-2 text-xs font-bold">
<button
onClick={() => {
setShowHiredModal(false);
setShowReviewModal(true);
}}
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl flex items-center justify-center space-x-2"
>
<Star className="w-4 h-4 fill-white" />
<span>{t('leave_trust_review', 'Leave a Trust Review')}</span>
</button>
<Link
href="/employer/workers"
className="w-full bg-slate-50 border border-slate-200 hover:bg-slate-100 text-slate-700 py-3 rounded-xl flex items-center justify-center"
>
{t('back_to_worker_directory', 'Back to Worker Directory')}
</Link>
</div>
<div className="flex flex-col space-y-2 text-xs font-bold">
<button
onClick={() => {
setShowHiredModal(false);
setShowReviewModal(true);
}}
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl flex items-center justify-center space-x-2"
>
<Star className="w-4 h-4 fill-white" />
<span>{t('leave_trust_review', 'Leave a Trust Review')}</span>
</button>
<Link
href="/employer/workers"
className="w-full bg-slate-50 border border-slate-200 hover:bg-slate-100 text-slate-700 py-3 rounded-xl flex items-center justify-center"
>
{t('back_to_worker_directory', 'Back to Worker Directory')}
</Link>
</div>
</>
)}
</div>
</div>
)}
@ -1063,43 +1081,7 @@ export default function Show({ worker }) {
</div>
)}
{/* Hired Confirmation Modal */}
{showHireConfirmModal && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="bg-white rounded-3xl w-full max-w-md p-6 relative animate-zoom-in text-center space-y-4 border border-slate-200 shadow-2xl">
<div className="mx-auto w-16 h-16 rounded-full bg-blue-50 border border-blue-100 flex items-center justify-center text-blue-600">
<CheckCircle2 className="w-8 h-8" />
</div>
<div className="space-y-2">
<h4 className="font-extrabold text-lg text-slate-900">{t('confirm_hire_title', 'Confirm Hiring')}</h4>
<p className="text-xs text-slate-600 leading-normal px-2">
{t('confirm_hire_desc', 'Verify this worker with your needs and make sure to hire this worker. If hired, you can view this worker in the Hired Workers page.')}
</p>
</div>
<div className="flex space-x-3 pt-2 text-xs font-bold">
<button
type="button"
onClick={() => setShowHireConfirmModal(false)}
className="flex-1 py-3 border border-slate-200 hover:bg-slate-50 rounded-xl transition-colors"
>
{t('cancel', 'Cancel')}
</button>
<button
type="button"
onClick={() => {
setShowHireConfirmModal(false);
handleMarkHired();
}}
className="flex-1 bg-emerald-600 hover:bg-emerald-700 text-white py-3 rounded-xl shadow-sm transition-colors"
>
{t('confirm_hire_action', 'Confirm Hire')}
</button>
</div>
</div>
</div>
)}
</EmployerLayout>
);

View File

@ -12,29 +12,29 @@
"profile": "Profile",
"sub_warning": "You need an active subscription to contact workers.",
"subscribe_now": "Subscribe now",
"employer_portal": "Employer Portal",
"employer_portal": "Sponsor Portal",
"active_until": "Active until",
"expired_renew": "Expired — Renew now",
"overview": "Overview",
"my_account": "My Account",
"profile_settings": "Profile Settings",
"sign_out": "Sign Out",
"employer_account": "Employer Account",
"employer_account": "Sponsor Account",
"welcome_title": "Welcome to Marketplace!",
"welcome_desc": "Your employer profile is verified and active with 30 days of Premium Access.",
"welcome_desc": "Your sponsor profile is verified and active with 30 days of Premium Access.",
"control_center": "Sponsor Control Center",
"dashboard_title": "Sponsor Dashboard - Verified UAE Domestic Workers",
"expired_pass_title": "Your Sponsor Subscription Pass Has Expired!",
"expired_pass_desc": "Direct communication, candidate dossiers, and messaging are locked. Renew your annual sponsor subscription now to resume hiring.",
"renew_sub": "Renew Subscription",
"expiring_pass_title": "Your Premium Sponsor Pass is expiring soon!",
"expiring_pass_desc": "Only {days} days left. Renew now to avoid losing contact access to 500+ verified candidates.",
"expiring_pass_desc": "Only {days} days left. Renew now to avoid losing contact access to 3000+ verified candidates.",
"renew_now": "Renew Now",
"sub_status": "Subscription Status",
"days_left": "Days Left",
"welcome_back": "Welcome Back",
"welcome_desc_dash": "Hire direct, MOHRE-compliant domestic workers with zero middleman commissions. Browse our updated database of candidates with OCR passport checks.",
"browse_workers": "Browse 500+ Verified Workers",
"browse_workers": "Browse 3000+ Worker Profiles",
"sponsor_pass_tier": "Sponsor Pass Tier",
"renews": "Renews",
"manage_billing": "Manage billing & invoices",
@ -112,7 +112,7 @@
"remove_from_shortlist": "Remove from shortlist",
"max_compare_alert": "You can compare a maximum of 3 candidates.",
"sponsor_pass_billing": "Sponsor Pass & Billing",
"subscription_billing_title": "Subscription Billing & Invoices - Employer Portal",
"subscription_billing_title": "Subscription Billing & Invoices - Sponsor Portal",
"recurrent_billing_failed": "Your recurrent subscription billing has FAILED!",
"recurrent_billing_failed_desc": "Card renewal failed due to insufficient funds. Retry using PayTabs to avoid profile lockout.",
"retry_payment_now": "Retry Payment Now",
@ -169,7 +169,7 @@
"no_candidates_found": "No hired workers found",
"showing_candidates": "Showing {start} to {end} of {total} hired workers",
"account_settings": "Account Settings",
"profile_employer_portal": "Profile - Employer Portal",
"profile_employer_portal": "Profile - Sponsor Portal",
"profile_updated_successfully": "Profile updated successfully",
"profile_updated_successfully_desc": "Household sponsor preferences and vetted credentials synchronized successfully.",
"profile_update_failed": "Failed to update profile",
@ -315,7 +315,7 @@
"read": "Read",
"type_compliant_message": "Type a compliant secure message...",
"conversation_messages": "Candidate Messages",
"messages_employer_portal": "Messages - Employer Portal",
"messages_employer_portal": "Messages - Sponsor Portal",
"search_conversations": "Search conversations...",
"new_conversation": "New Conversation",
"active_threads": "Active Threads",
@ -434,5 +434,9 @@
"applicants": "Applicants",
"shortlisted_workers": "Shortlisted Workers",
"help_support": "Help & Support",
"payment_history": "Payment History"
"payment_history": "Payment History",
"total_worker_profiles": "Total Worker Profiles",
"avg_monthly_hires": "Avg Monthly Hires",
"browse_all_workers": "Browse all workers",
"view_hired_list": "View hired list"
}

View File

@ -4,3 +4,83 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
/**
* Compresses and resizes an image file on the client side.
* If the file is not an image (e.g. PDF), it resolves with the original file.
*
* @param {File} file - The file to compress.
* @param {number} maxWidth - Max width of compressed image.
* @param {number} maxHeight - Max height of compressed image.
* @param {number} quality - Compression quality between 0 and 1.
* @returns {Promise<File>} - A promise that resolves to the compressed File object.
*/
export function compressImage(file, maxWidth = 1200, maxHeight = 1200, quality = 0.8) {
return new Promise((resolve) => {
if (!file || !file.type.startsWith('image/')) {
resolve(file);
return;
}
const reader = new FileReader();
reader.onload = (event) => {
const img = new Image();
img.onload = () => {
let width = img.width;
let height = img.height;
// Calculate aspect ratio
if (width > height) {
if (width > maxWidth) {
height = Math.round((height * maxWidth) / width);
width = maxWidth;
}
} else {
if (height > maxHeight) {
width = Math.round((width * maxHeight) / height);
height = maxHeight;
}
}
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) {
resolve(file);
return;
}
// Draw image
ctx.drawImage(img, 0, 0, width, height);
// Convert to blob
if (canvas.toBlob) {
canvas.toBlob(
(blob) => {
if (blob) {
// Keep original extension or use jpeg, but File constructor needs a name
const compressedFile = new File([blob], file.name.substring(0, file.name.lastIndexOf('.')) + '.jpg', {
type: 'image/jpeg',
lastModified: Date.now()
});
resolve(compressedFile);
} else {
resolve(file);
}
},
'image/jpeg',
quality
);
} else {
resolve(file);
}
};
img.onerror = () => resolve(file);
img.src = event.target.result;
};
reader.onerror = () => resolve(file);
reader.readAsDataURL(file);
});
}

View File

@ -115,6 +115,8 @@
Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']);
Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']);
Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']);
Route::post('/workers/applied-jobs/{id}/confirm-hire', [\App\Http\Controllers\Api\WorkerJobController::class, 'confirmHire']);
Route::post('/workers/applied-jobs/{id}/decline-hire', [\App\Http\Controllers\Api\WorkerJobController::class, 'declineHire']);
// Review Management (Worker reviewing Employer)
Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']);

View File

@ -353,6 +353,44 @@
Route::post('/tickets/{id}/generate-dev-response', [\App\Http\Controllers\Admin\SupportTicketController::class, 'generateDevResponse'])->name('admin.tickets.generate-dev-response');
});
// Public Stats Route
Route::get('/stats', function () {
$totalWorkerProfiles = \App\Models\Worker::count();
$totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() +
\App\Models\JobApplication::where('status', 'hired')->count();
$driver = \Illuminate\Support\Facades\DB::getDriverName();
$formatExpr = $driver === 'sqlite' ? "strftime('%Y-%m', created_at)" : "DATE_FORMAT(created_at, '%Y-%m')";
$monthlyHires = \Illuminate\Support\Facades\DB::table('job_offers')
->where('status', 'accepted')
->selectRaw("{$formatExpr} as month, count(*) as count")
->groupBy('month')
->get();
$monthlyApps = \Illuminate\Support\Facades\DB::table('job_applications')
->where('status', 'hired')
->selectRaw("{$formatExpr} as month, count(*) as count")
->groupBy('month')
->get();
$hiresByMonth = [];
foreach ($monthlyHires as $h) {
$hiresByMonth[$h->month] = ($hiresByMonth[$h->month] ?? 0) + $h->count;
}
foreach ($monthlyApps as $a) {
$hiresByMonth[$a->month] = ($hiresByMonth[$a->month] ?? 0) + $a->count;
}
$avgMonthlyHires = count($hiresByMonth) > 0 ? (array_sum($hiresByMonth) / count($hiresByMonth)) : 0;
$avgMonthlyHires = max(550, round($avgMonthlyHires));
return Inertia::render('Employer/PublicStats', [
'total_workers' => max(3000, $totalWorkerProfiles),
'avg_hires' => (int)$avgMonthlyHires,
'total_hires_all' => max(100, $totalHiredAll)
]);
})->name('public.stats');
// Employer Auth Routes — guest-only pages redirect logged-in employers to dashboard
Route::middleware('employer.guest')->group(function () {
Route::get('/employer/login', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showLogin'])->name('employer.login');
@ -514,6 +552,108 @@
]);
})->name('employer.subscription');
Route::get('/subscription/invoice/{id}/download', function ($id) {
$dbId = 1;
if (preg_match('/-(\d+)$/', $id, $matches)) {
$dbId = (int)$matches[1];
}
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
->where('id', $dbId)
->first();
$amountAed = $sub ? $sub->amount_aed : 199;
$totalStr = round($amountAed) . '.00 AED';
$vatAed = round($amountAed * 0.05, 2);
$subtotalAed = round($amountAed - $vatAed, 2);
$vatStr = number_format($vatAed, 2) . ' AED';
$subtotalStr = number_format($subtotalAed, 2) . ' AED';
$planId = $sub ? $sub->plan_id : 'premium';
$planName = $planId === 'premium' ? 'Premium Employer Pass' : (ucfirst($planId) . ' Pass');
$date = $sub ? date('Y-m-d', strtotime($sub->created_at)) : date('Y-m-d');
$sponsorName = str_replace(['(', ')'], '', auth()->user()->name ?? 'Sponsor');
$sponsorEmail = str_replace(['(', ')'], '', auth()->user()->email ?? '');
$streamContent = "0.094 0.373 0.647 rg 0 742 400 100 re f\n" .
"0.1 0.1 0.1 rg 400 742 195 100 re f\n" .
// Left header text: Migrant name
"BT\n/F2 26 Tf\n1 1 1 rg\n40 790 Td\n(Migrant) Tj\nET\n" .
"BT\n/F1 10 Tf\n1 1 1 rg\n40 770 Td\n(SPONSOR PORTAL) Tj\nET\n" .
// Right header text
"BT\n/F2 26 Tf\n1 1 1 rg\n430 790 Td\n(Invoice) Tj\nET\n" .
"BT\n/F1 10 Tf\n1 1 1 rg\n430 770 Td\n(Status: Paid) Tj\nET\n" .
// From & To section
"BT\n/F2 12 Tf\n0.2 0.2 0.2 rg\n40 690 Td\n(From:) Tj\nET\n" .
"BT\n/F1 10 Tf\n0.4 0.4 0.4 rg\n40 670 Td\n(DirectMarket Portal) Tj\nET\n" .
"BT\n/F1 10 Tf\n0.4 0.4 0.4 rg\n40 655 Td\n(Address: MOHRE Sponsor Services, Dubai, UAE) Tj\nET\n" .
"BT\n/F1 10 Tf\n0.4 0.4 0.4 rg\n40 640 Td\n(Email: support@directmarket.ae) Tj\nET\n" .
"BT\n/F2 12 Tf\n0.2 0.2 0.2 rg\n320 690 Td\n(To:) Tj\nET\n" .
"BT\n/F1 10 Tf\n0.4 0.4 0.4 rg\n320 670 Td\n(" . $sponsorName . ") Tj\nET\n" .
"BT\n/F1 10 Tf\n0.4 0.4 0.4 rg\n320 655 Td\n(Email: " . $sponsorEmail . ") Tj\nET\n" .
"BT\n/F1 10 Tf\n0.4 0.4 0.4 rg\n320 640 Td\n(Verification: Approved) Tj\nET\n" .
// Table Header
"0.094 0.373 0.647 rg 40 580 360 25 re f\n" .
"0.1 0.1 0.1 rg 400 580 155 25 re f\n" .
"BT\n/F2 10 Tf\n1 1 1 rg\n50 588 Td\n(# Item) Tj\n110 0 Td\n(Description) Tj\nET\n" .
"BT\n/F2 10 Tf\n1 1 1 rg\n410 588 Td\n(Unit Cost) Tj\n80 0 Td\n(Total) Tj\nET\n" .
// Row 1
"0.96 0.96 0.96 rg 40 550 515 25 re f\n" .
"BT\n/F1 10 Tf\n0.2 0.2 0.2 rg\n50 558 Td\n(1 Sponsor Pass) Tj\n110 0 Td\n(" . $planName . " Tier) Tj\nET\n" .
"BT\n/F1 10 Tf\n0.2 0.2 0.2 rg\n410 558 Td\n(" . $totalStr . ") Tj\n80 0 Td\n(" . $totalStr . ") Tj\nET\n" .
"0.8 0.8 0.8 RG 40 550 m 555 550 l S\n" .
// Summary block
"BT\n/F2 10 Tf\n0.3 0.3 0.3 rg\n350 510 Td\n(Subtotal) Tj\n140 0 Td\n(" . $subtotalStr . ") Tj\nET\n" .
"0.9 0.9 0.9 RG 350 500 m 555 500 l S\n" .
"BT\n/F2 10 Tf\n0.3 0.3 0.3 rg\n350 480 Td\n(VAT (5%)) Tj\n140 0 Td\n(" . $vatStr . ") Tj\nET\n" .
"0.9 0.9 0.9 RG 350 470 m 555 470 l S\n" .
"0.094 0.373 0.647 rg 350 435 205 25 re f\n" .
"BT\n/F2 10 Tf\n1 1 1 rg\n360 443 Td\n(Total) Tj\n130 0 Td\n(" . $totalStr . ") Tj\nET\n" .
// Date & Disclaimer
"BT\n/F2 10 Tf\n0.2 0.2 0.2 rg\n40 370 Td\n(Date: " . $date . ") Tj\nET\n" .
"BT\n/F2 10 Tf\n0.094 0.373 0.647 rg\n40 330 Td\n(Important:) Tj\nET\n" .
"BT\n/F1 9 Tf\n0.4 0.4 0.4 rg\n40 310 Td\n(This is an electronically generated invoice and does not require a physical signature.) Tj\nET\n" .
"BT\n/F1 9 Tf\n0.4 0.4 0.4 rg\n40 295 Td\n(Please read all terms and policies on www.directmarket.ae for refunds or queries.) Tj\nET\n" .
// Footer Waves
"0.1 0.1 0.1 rg 0 0 m 0 50 l 150 25 450 75 595 40 c 595 0 l f\n" .
"0.094 0.373 0.647 rg 0 0 m 0 35 l 200 55 400 20 595 50 c 595 0 l f\n";
$pdf = "%PDF-1.4\n" .
"1 0 obj <</Type /Catalog /Pages 2 0 R>> endobj\n" .
"2 0 obj <</Type /Pages /Kids [3 0 R] /Count 1>> endobj\n" .
"3 0 obj <</Type /Page /Parent 2 0 R /Resources 4 0 R /MediaBox [0 0 595 842] /Contents 5 0 R>> endobj\n" .
"4 0 obj <</Font <</F1 6 0 R /F2 7 0 R>>>> endobj\n" .
"5 0 obj <</Length " . strlen($streamContent) . ">> stream\n" .
$streamContent .
"\nendstream\nendobj\n" .
"6 0 obj <</Type /Font /Subtype /Type1 /BaseFont /Helvetica>> endobj\n" .
"7 0 obj <</Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold>> endobj\n" .
"xref\n" .
"0 8\n" .
"0000000000 65535 f \n" .
"trailer <</Size 8 /Root 1 0 R>>\n" .
"startxref\n" .
"%%EOF\n";
return response($pdf)
->header('Content-Type', 'application/pdf')
->header('Content-Disposition', 'attachment; filename="Invoice-' . $id . '.pdf"');
})->name('employer.subscription.invoice.download');
Route::post('/subscription/purchase', [\App\Http\Controllers\Employer\PaymentController::class, 'purchase'])->name('employer.subscription.purchase');
Route::get('/messages', [\App\Http\Controllers\Employer\MessageController::class, 'index'])->name('employer.messages');

19
scratch/fix_show.php Normal file
View File

@ -0,0 +1,19 @@
<?php
$file = 'd:/Projects/marketplace/resources/js/Pages/Employer/Workers/Show.jsx';
$content = file_get_contents($file);
$lines = explode("\n", str_replace("\r", "", $content));
// We want to replace line 716 (which is index 715) and line 717 (which is index 716)
// Let's verify line 716 has "</div>" and line 717 is whitespace.
if (strpos($lines[715], '</div>') !== false && trim($lines[716]) === '') {
$lines[715] = str_repeat(' ', 48) . ')}';
$lines[716] = str_repeat(' ', 44) . '</div>';
echo "Successfully replaced lines!\n";
} else {
echo "Line content mismatch!\n";
echo "Line 716: [" . $lines[715] . "]\n";
echo "Line 717: [" . $lines[716] . "]\n";
}
file_put_contents($file, implode("\r\n", $lines));

View File

@ -347,4 +347,204 @@ public function test_admin_can_reject_announcement_with_remarks()
'remarks' => 'Inappropriate content or location details.',
]);
}
/**
* Test employer can post and get charity announcements with contact details.
*/
public function test_employer_can_post_and_get_charity_announcement_with_contact_details()
{
$response = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->employerToken,
])->postJson('/api/employers/announcements', [
'title' => 'Free Dental Checkup Camp',
'content' => 'Emirates Charity is providing free professional dental screening, cleanings, and educational packages for domestic helpers.',
'provided_items' => 'Free Dental screening, cleanings, and wellness kits',
'event_date' => '2026-06-15',
'event_time' => '9:00 AM - 4:00 PM',
'location_details' => 'Al Quoz Community Hall, Dubai',
'location_pin' => 'https://maps.app.goo.gl/xyz',
'contact_person_name' => 'Jane Doe',
'contact_number' => '551234567',
'country_code' => '+971',
]);
$response->assertStatus(201)
->assertJson([
'success' => true,
'message' => 'Announcement posted successfully.',
])
->assertJsonStructure([
'data' => [
'announcement' => [
'id',
'title',
'body',
'type',
'status',
'charity_details' => [
'type',
'provided_items',
'event_date',
'event_time',
'location_details',
'location_pin',
'content',
'contact_person_name',
'contact_number',
'country_code',
]
]
]
]);
$announcementId = $response->json('data.announcement.id');
// Approve it so it shows up in global get endpoint
Announcement::find($announcementId)->update(['status' => 'approved']);
// Test GET /api/employers/announcements
$getResponse = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->employerToken,
])->getJson('/api/employers/announcements');
$getResponse->assertStatus(200);
$announcements = $getResponse->json('data.announcements');
$postedAnn = collect($announcements)->firstWhere('id', $announcementId);
$this->assertNotNull($postedAnn);
$this->assertEquals('Free Dental Checkup Camp', $postedAnn['title']);
$this->assertEquals('Jane Doe', $postedAnn['charity_details']['contact_person_name']);
$this->assertEquals('551234567', $postedAnn['charity_details']['contact_number']);
$this->assertEquals('+971', $postedAnn['charity_details']['country_code']);
// Test GET /api/employers/my-announcements
$getMyResponse = $this->withHeaders([
'Authorization' => 'Bearer ' . $this->employerToken,
])->getJson('/api/employers/my-announcements');
$getMyResponse->assertStatus(200);
$myAnnouncements = $getMyResponse->json('data.announcements');
$myPostedAnn = collect($myAnnouncements)->firstWhere('id', $announcementId);
$this->assertNotNull($myPostedAnn);
$this->assertEquals('Free Dental Checkup Camp', $myPostedAnn['title']);
$this->assertEquals('Jane Doe', $myPostedAnn['charity_details']['contact_person_name']);
$this->assertEquals('551234567', $myPostedAnn['charity_details']['contact_number']);
$this->assertEquals('+971', $myPostedAnn['charity_details']['country_code']);
}
/**
* Test web employer can create charity event with contact details.
*/
public function test_web_employer_can_create_charity_event()
{
$response = $this->actingAs($this->employer)
->post(route('employer.events.store'), [
'title' => 'Web Charity Camp',
'content' => 'Giving away free wellness items and tools.',
'provided_items' => 'Wellness Kits',
'event_date' => '2026-08-20',
'event_time' => '10:00 AM - 3:00 PM',
'location_details' => 'Safa Park, Dubai',
'location_pin' => 'https://maps.app.goo.gl/xyz',
'contact_person_name' => 'John Doe',
'contact_number' => '559876543',
'country_code' => '+971',
]);
$response->assertStatus(302); // Redirect on success
$this->assertDatabaseHas('announcements', [
'title' => 'Web Charity Camp',
'employer_id' => $this->employer->id,
'status' => 'pending',
]);
$announcement = Announcement::where('title', 'Web Charity Camp')->first();
$this->assertNotNull($announcement);
$body = json_decode($announcement->body, true);
$this->assertEquals('John Doe', $body['contact_person_name']);
$this->assertEquals('559876543', $body['contact_number']);
$this->assertEquals('+971', $body['country_code']);
}
/**
* Test web admin can create charity event with contact details.
*/
public function test_web_admin_can_create_charity_event()
{
$admin = User::create([
'name' => 'Admin User',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
'role' => 'admin',
]);
$response = $this->actingAs($admin)
->withSession(['user' => $admin])
->post(route('admin.events.store'), [
'title' => 'Admin Charity Camp',
'content' => 'Giving away free medical checkups.',
'provided_items' => 'Medical Checkups',
'event_date' => '2026-09-10',
'event_time' => '08:00 AM - 05:00 PM',
'location_details' => 'Community Center',
'location_pin' => 'https://maps.app.goo.gl/abc',
'contact_person_name' => 'Admin Coordinator',
'contact_number' => '501234567',
'country_code' => '+971',
]);
$response->assertStatus(302); // Redirect on success
$this->assertDatabaseHas('announcements', [
'title' => 'Admin Charity Camp',
'status' => 'approved', // Admin events are auto-approved
]);
$announcement = Announcement::where('title', 'Admin Charity Camp')->first();
$this->assertNotNull($announcement);
$body = json_decode($announcement->body, true);
$this->assertEquals('Admin Coordinator', $body['contact_person_name']);
$this->assertEquals('501234567', $body['contact_number']);
$this->assertEquals('+971', $body['country_code']);
}
/**
* Test admin approving announcement creates database notification for worker.
*/
public function test_admin_approving_announcement_creates_database_notification()
{
$admin = User::create([
'name' => 'Admin User',
'email' => 'admin@example.com',
'password' => bcrypt('password'),
'role' => 'admin',
]);
$announcement = Announcement::create([
'title' => 'Important Community Event',
'body' => 'Event description.',
'type' => 'Charity',
'employer_id' => $this->employer->id,
'status' => 'pending',
]);
$this->assertEquals(0, $this->worker->notifications()->count());
$response = $this->actingAs($admin)
->withSession(['user' => $admin])
->post(route('admin.events.approve', $announcement->id));
$response->assertStatus(302);
// Verify database notification exists for worker
$this->assertEquals(1, $this->worker->notifications()->count());
$notification = $this->worker->notifications()->first();
$this->assertNotNull($notification);
$this->assertEquals('New Event: Important Community Event', $notification->data['title']);
$this->assertEquals('announcement', $notification->data['type']);
$this->assertEquals($announcement->id, $notification->data['announcement_id']);
}
}

View File

@ -107,11 +107,19 @@ public function test_employer_can_view_jobs_list()
*/
public function test_employer_can_view_create_job_form()
{
// Seed a custom skill in the database
\App\Models\Skill::create(['name' => 'painting']);
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->get('/employer/jobs/create');
$response->assertStatus(200);
$inertiaData = $response->original->getData();
$professions = $inertiaData['page']['props']['professions'] ?? [];
$this->assertContains('Painting', $professions);
}
/**
@ -123,6 +131,8 @@ public function test_employer_can_post_job()
->withSession(['user' => $this->employer])
->post('/employer/jobs/create', [
'title' => 'Villa Cleaner',
'main_profession' => 'Cleaner',
'skills' => 'Housekeeping',
'workers_needed' => 2,
'job_type' => 'Part Time',
'location' => 'Al Barsha',
@ -138,6 +148,7 @@ public function test_employer_can_post_job()
$this->assertDatabaseHas('job_posts', [
'employer_id' => $this->employer->id,
'title' => 'Villa Cleaner',
'main_profession' => 'Cleaner',
'workers_needed' => 2,
'job_type' => 'Part Time',
'location' => 'Al Barsha',
@ -158,7 +169,7 @@ public function test_job_posting_requires_fields()
'salary' => -100,
]);
$response->assertSessionHasErrors(['title', 'workers_needed', 'location', 'salary', 'job_type', 'start_date', 'description']);
$response->assertSessionHasErrors(['title', 'main_profession', 'skills', 'workers_needed', 'location', 'salary', 'job_type', 'start_date', 'description']);
}
/**
@ -292,4 +303,63 @@ public function test_employer_can_view_shortlisted_applicants()
$response->assertStatus(200);
$response->assertSee('Shortlisted John');
}
/**
* Test employer cannot edit a job posting that has active applications.
*/
public function test_employer_cannot_edit_job_with_applications()
{
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Job to Edit',
'main_profession' => 'Cleaner',
'workers_needed' => 1,
'job_type' => 'Full Time',
'location' => 'Dubai',
'salary' => 2000,
'start_date' => '2026-07-20',
'description' => 'Will have applications.',
'status' => 'active',
]);
$worker = Worker::create([
'name' => 'Applicant Worker',
'email' => 'worker2@example.com',
'phone' => '+971500000003',
'nationality' => 'Indian',
'age' => 28,
'salary' => 2200,
'availability' => 'Available',
'experience' => '3 Years',
'religion' => 'Christian',
'bio' => 'Ready to clean.',
]);
JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker->id,
'status' => 'applied',
]);
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->post("/employer/jobs/{$job->id}/edit", [
'title' => 'Trying to Edit Title',
'main_profession' => 'Cleaner',
'skills' => 'Housekeeping',
'workers_needed' => 1,
'location' => 'Dubai',
'salary' => 2500,
'job_type' => 'Full Time',
'start_date' => '2026-07-20',
'description' => 'Will have applications.',
'status' => 'active',
]);
$response->assertSessionHasErrors(['general']);
$this->assertDatabaseHas('job_posts', [
'id' => $job->id,
'title' => 'Job to Edit', // should NOT change
]);
}
}

View File

@ -0,0 +1,120 @@
<?php
namespace Tests\Feature;
use App\Models\Conversation;
use App\Models\Message;
use App\Models\User;
use App\Models\EmployerProfile;
use App\Models\Worker;
use App\Models\JobPost;
use App\Models\JobApplication;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
use Inertia\Testing\AssertableInertia as Assert;
class EmployerMessageWebTest extends TestCase
{
use RefreshDatabase;
protected $worker;
protected $employer;
protected $jobPost;
protected function setUp(): void
{
parent::setUp();
// Create a test worker
$this->worker = Worker::create([
'name' => 'Jane Smith',
'email' => 'jane@example.com',
'phone' => '+971500000001',
'password' => bcrypt('password'),
'status' => 'Available',
'nationality' => 'Ethiopian',
'age' => 28,
'salary' => 1500.00,
'availability' => 'Immediate',
'experience' => '2 Years',
'religion' => 'Christian',
'bio' => 'Experienced housemaid seeking employment.',
]);
// Create an employer user
$this->employer = User::create([
'name' => 'John Employer',
'email' => 'employer_test@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
]);
// Create employer profile
EmployerProfile::create([
'user_id' => $this->employer->id,
'company_name' => 'John Ltd',
'phone' => '+971500000002',
'country' => 'United Arab Emirates',
'verification_status' => 'approved',
'language' => 'English',
'notifications' => true,
]);
// Create a Job Post
$this->jobPost = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Housekeeper',
'location' => 'Dubai',
'salary' => 1800,
'job_type' => 'full-time',
'status' => 'open',
'start_date' => '2026-08-01',
'description' => 'Test Job Description',
'requirements' => 'Test Requirements',
]);
}
/**
* Test show conversation auto-creates JobApplication and passes it as a prop.
*/
public function test_show_conversation_auto_creates_application_and_passes_prop()
{
// Authenticate session
session(['user' => (object)[
'id' => $this->employer->id,
'name' => $this->employer->name,
'email' => $this->employer->email,
'role' => 'employer',
'subscription_status' => 'active',
]]);
$conversation = Conversation::create([
'employer_id' => $this->employer->id,
'worker_id' => $this->worker->id,
]);
// Ensure no application exists initially
$this->assertDatabaseMissing('job_applications', [
'worker_id' => $this->worker->id,
]);
$response = $this->actingAs($this->employer)
->get(route('employer.messages.show', ['id' => $conversation->id]));
$response->assertStatus(200);
// Verify JobApplication was auto-created in database
$this->assertDatabaseHas('job_applications', [
'worker_id' => $this->worker->id,
'status' => 'applied',
]);
// Verify Inertia response has the 'application' prop
$response->assertInertia(fn (Assert $page) => $page
->component('Employer/Messages/Show')
->has('application')
->where('application.status', 'applied')
->has('application.status_history')
);
}
}

View File

@ -541,11 +541,8 @@ public function test_worker_list_api_has_full_details_and_pagination()
$this->assertCount(1, $matchingWorker['skills']);
$this->assertEquals('Ironing', $matchingWorker['skills'][0]['name']);
// Check documents object array
$this->assertIsArray($matchingWorker['documents']);
$this->assertCount(2, $matchingWorker['documents']);
$this->assertEquals('passport', $matchingWorker['documents'][0]['type']);
$this->assertEquals('visa', $matchingWorker['documents'][1]['type']);
// Check documents object array is not present
$this->assertArrayNotHasKey('documents', $matchingWorker);
}
public function test_worker_detail_api_has_full_details_without_religion_and_cleaned_ocr_data()
@ -618,16 +615,7 @@ public function test_worker_detail_api_has_full_details_without_religion_and_cle
// Assert religion is removed
$this->assertArrayNotHasKey('religion', $wData);
// Check documents object array
$this->assertIsArray($wData['documents']);
$this->assertCount(2, $wData['documents']);
// Assert visa ocr_data is cleaned up
$visaDoc = collect($wData['documents'])->firstWhere('type', 'visa');
$this->assertNotNull($visaDoc);
$this->assertArrayNotHasKey('sponsor', $visaDoc['ocr_data']);
$this->assertArrayNotHasKey('valid_until', $visaDoc['ocr_data']);
$this->assertEquals('some sponsor name', $visaDoc['ocr_data']['sponsor_name']);
$this->assertEquals('2024-02-15', $visaDoc['ocr_data']['expiry_date']);
// Check documents object array is not present
$this->assertArrayNotHasKey('documents', $wData);
}
}

View File

@ -366,4 +366,119 @@ public function test_employer_review_controller_eligibility_and_edit_window()
$response->assertJsonPath('success', false);
$response->assertJsonPath('message', 'The 7-day edit window for this review has expired.');
}
public function test_minute_based_review_eligibility()
{
config(['reminders.review_eligibility_minutes' => 10]);
$worker = $this->createTestWorker();
$employer = $this->createTestEmployer();
$job = JobPost::create([
'employer_id' => $employer->id,
'title' => 'Nanny',
'location' => 'Dubai',
'salary' => 2500,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->subDays(5)->toDateString(),
'description' => 'Test job',
'status' => 'active',
]);
// JobApplication with joining_confirmed_at exactly 9 minutes ago
$app = JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker->id,
'status' => 'hired',
'joining_confirmed_at' => now()->subMinutes(9),
]);
// 1. Worker review submission fails at 9 minutes
$response = $this->postJson('/api/workers/reviews', [
'employer_id' => $employer->id,
'job_id' => $job->id,
'rating' => 5,
'comment' => 'Great employer!',
], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(403);
$response->assertJsonPath('message', 'You can write a review only after 10 minutes from the joining date.');
// 2. Employer review submission fails at 9 minutes
$response = $this->postJson('/api/employers/reviews', [
'worker_id' => $worker->id,
'rating' => 5,
'comment' => 'Great worker!',
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(403);
$response->assertJsonPath('message', 'You can write a review only after 10 minutes from the joining date.');
// 3. Scheduler does NOT send reminder at 9 minutes
Notification::fake();
Artisan::call('app:send-reminders');
Notification::assertNothingSent();
// 4. Update joining date to 10 minutes ago
$app->joining_confirmed_at = now()->subMinutes(10);
$app->save();
// 5. Worker review submission succeeds at 10 minutes
$response = $this->postJson('/api/workers/reviews', [
'employer_id' => $employer->id,
'job_id' => $job->id,
'rating' => 5,
'comment' => 'Great employer!',
], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(201);
// 6. Employer review submission succeeds at 10 minutes
$response = $this->postJson('/api/employers/reviews', [
'worker_id' => $worker->id,
'rating' => 5,
'comment' => 'Great worker!',
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(201);
// 7. Scheduler sends reminders when updated to 10 minutes ago
$worker2 = $this->createTestWorker([
'email' => 'worker2@example.com',
'phone' => '971500000002',
'api_token' => 'worker2_api_token'
]);
$app2 = JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker2->id,
'status' => 'hired',
'joining_confirmed_at' => now()->subMinutes(10),
]);
Notification::fake();
Artisan::call('app:send-reminders');
Notification::assertSentTo(
$worker2,
WorkerReviewReminderNotification::class,
function ($notification) use ($app2) {
return $notification->applicationId === $app2->id &&
$notification->reviewLevel === '10_minutes';
}
);
Notification::assertSentTo(
$employer,
EmployerReviewReminderNotification::class,
function ($notification) use ($app2) {
return $notification->applicationId === $app2->id &&
$notification->reviewLevel === '10_minutes';
}
);
}
}

View File

@ -245,4 +245,64 @@ public function test_employer_can_delete_single_and_all_notifications()
$response->assertJsonPath('success', true);
$this->assertEquals(0, $employer->fresh()->notifications()->count());
}
public function test_push_notification_sent_when_job_status_changes()
{
$worker = $this->createTestWorker();
$employer = $this->createTestEmployer();
$jobPost = \App\Models\JobPost::create([
'employer_id' => $employer->id,
'title' => 'Test Maid Job',
'main_profession' => 'Housekeeper',
'workers_needed' => 1,
'job_type' => 'Full Time',
'location' => 'Dubai',
'salary' => 2000,
'start_date' => now()->addDays(7),
'description' => 'Test description',
'status' => 'active',
]);
\App\Models\JobApplication::create([
'worker_id' => $worker->id,
'job_id' => $jobPost->id,
'status' => 'applied'
]);
$this->assertEquals(0, $worker->notifications()->count());
// Update JobPost status
$jobPost->update(['status' => 'closed']);
$notifications = $worker->fresh()->notifications;
$this->assertEquals(1, $notifications->count());
$notification = $notifications->first();
$this->assertEquals('Job Status Changed', $notification->data['title']);
$this->assertEquals('job_status_change', $notification->data['type']);
$this->assertEquals($jobPost->id, $notification->data['job_id']);
$this->assertEquals('active', $notification->data['old_status']);
$this->assertEquals('closed', $notification->data['new_status']);
}
public function test_push_notification_sent_when_worker_status_changes()
{
$worker = $this->createTestWorker([
'status' => 'active'
]);
$this->assertEquals(0, $worker->notifications()->count());
// Update Worker status
$worker->update(['status' => 'Hired']);
$notifications = $worker->fresh()->notifications;
$this->assertEquals(1, $notifications->count());
$notification = $notifications->first();
$this->assertEquals('Profile Status Update', $notification->data['title']);
$this->assertEquals('worker_status_change', $notification->data['type']);
$this->assertEquals($worker->id, $notification->data['worker_id']);
$this->assertEquals('active', $notification->data['old_status']);
$this->assertEquals('Hired', $notification->data['new_status']);
}
}

View File

@ -404,4 +404,202 @@ public function test_worker_can_view_employer_profile_without_permissions_masks_
$response->assertJsonPath('data.employer.email', null); // Masked/null since no connection exists
$response->assertJsonPath('data.employer.phone', null);
}
public function test_worker_hired_via_job_application_appears_in_employers_list()
{
// 1. Create a worker
$worker = Worker::create([
'name' => 'Hired Worker',
'email' => 'hiredworker@example.com',
'phone' => '+971501234567',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Test',
'verified' => true,
'status' => 'active',
'api_token' => 'worker-hired-token',
]);
WorkerDocument::create([
'worker_id' => $worker->id,
'type' => 'passport',
'number' => '123456',
'file_path' => 'passports/test.pdf',
]);
// 2. Create employer with active subscription
$employer = User::create([
'name' => 'Premium Employer',
'email' => 'premium_emp@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
'subscription_status' => 'active',
'api_token' => 'premium_employer_token',
]);
EmployerProfile::create([
'user_id' => $employer->id,
'company_name' => 'Premium Corp Ltd',
'nationality' => 'UAE',
'city' => 'Abu Dhabi',
]);
\App\Models\Subscription::create([
'user_id' => $employer->id,
'plan_id' => 'premium',
'amount_aed' => 100.00,
'starts_at' => now(),
'expires_at' => now()->addDays(30),
'status' => 'active',
]);
// 3. Create a JobPost
$job = \App\Models\JobPost::create([
'employer_id' => $employer->id,
'title' => 'Housekeeper Needed',
'workers_needed' => 1,
'location' => 'Abu Dhabi',
'salary' => 2000,
'job_type' => 'Full Time',
'start_date' => now()->addDays(5),
'description' => 'Test job',
'requirements' => 'None',
'status' => 'active',
]);
// 4. Create JobApplication
$application = \App\Models\JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker->id,
'status' => 'applied',
'notes' => 'Looking forward to work',
]);
// Verify employer list is empty for the worker initially
$response = $this->withHeaders([
'Authorization' => 'Bearer worker-hired-token',
])->getJson('/api/workers/employers');
$response->assertStatus(200);
$response->assertJsonCount(0, 'data.employers');
// 5. Employer hires candidate via API
$response = $this->postJson("/api/employers/applications/{$application->id}/status", [
'status' => 'hired',
'notes' => 'Hired and approved'
], [
'Authorization' => 'Bearer premium_employer_token'
]);
$response->assertStatus(200);
// Worker confirms the hire
$response = $this->postJson("/api/workers/applied-jobs/{$application->id}/confirm-hire", [], [
'Authorization' => 'Bearer worker-hired-token'
]);
$response->assertStatus(200);
// Verify that the JobOffer with accepted status has been created
$this->assertDatabaseHas('job_offers', [
'employer_id' => $employer->id,
'worker_id' => $worker->id,
'status' => 'accepted',
]);
// 6. Hit the worker employers API and assert that the employer is now listed
$response = $this->withHeaders([
'Authorization' => 'Bearer worker-hired-token',
])->getJson('/api/workers/employers');
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.employers');
$response->assertJsonPath('data.employers.0.employer.name', 'Premium Employer');
$response->assertJsonPath('data.employers.0.employer.company_name', 'Premium Corp Ltd');
$response->assertJsonPath('data.employers.0.status', 'Active');
}
public function test_worker_hired_via_job_application_appears_without_job_offer()
{
// 1. Create a worker
$worker = Worker::create([
'name' => 'Hired Worker Legacy',
'email' => 'legacyworker@example.com',
'phone' => '+971501234560',
'language' => 'HI',
'password' => bcrypt('password'),
'nationality' => 'Indian',
'age' => 25,
'salary' => 1500,
'availability' => 'Immediate',
'experience' => 'Not Specified',
'religion' => 'Not Specified',
'bio' => 'Test',
'verified' => true,
'status' => 'active',
'api_token' => 'worker-legacy-token',
]);
WorkerDocument::create([
'worker_id' => $worker->id,
'type' => 'passport',
'number' => '123456',
'file_path' => 'passports/test.pdf',
]);
// 2. Create employer
$employer = User::create([
'name' => 'Legacy Employer',
'email' => 'legacy_emp@example.com',
'password' => bcrypt('password'),
'role' => 'employer',
'subscription_status' => 'active',
'api_token' => 'legacy_employer_token',
]);
EmployerProfile::create([
'user_id' => $employer->id,
'company_name' => 'Legacy Corp Ltd',
'nationality' => 'UAE',
'city' => 'Dubai',
]);
// 3. Create a JobPost
$job = \App\Models\JobPost::create([
'employer_id' => $employer->id,
'title' => 'Housekeeper Needed Legacy',
'workers_needed' => 1,
'location' => 'Dubai',
'salary' => 2000,
'job_type' => 'Full Time',
'start_date' => now()->addDays(5),
'description' => 'Test job',
'requirements' => 'None',
'status' => 'active',
]);
// 4. Create JobApplication with status 'hired' directly, and NO JobOffer
$application = \App\Models\JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker->id,
'status' => 'hired',
'notes' => 'Hired directly in DB previously',
]);
// 5. Hit the worker employers API and assert that the employer is listed
$response = $this->withHeaders([
'Authorization' => 'Bearer worker-legacy-token',
])->getJson('/api/workers/employers');
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.employers');
$response->assertJsonPath('data.employers.0.employer.name', 'Legacy Employer');
$response->assertJsonPath('data.employers.0.employer.company_name', 'Legacy Corp Ltd');
$response->assertJsonPath('data.employers.0.status', 'Active');
}
}

View File

@ -62,7 +62,11 @@ protected function setUp(): void
'religion' => 'Christian',
'bio' => 'Helper info',
'passport_status' => 'valid',
'main_profession' => 'Plumber',
]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$this->worker->skills()->sync([$skill->id]);
}
public function test_employer_job_crud_web()
@ -72,6 +76,8 @@ public function test_employer_job_crud_web()
->withSession(['user' => $this->employer])
->post('/employer/jobs/create', [
'title' => 'Test Mason Job',
'main_profession' => 'Plumber',
'skills' => 'Piping',
'workers_needed' => 3,
'location' => 'Dubai',
'salary' => 2000,
@ -103,6 +109,8 @@ public function test_employer_job_crud_web()
->withSession(['user' => $this->employer])
->post("/employer/jobs/{$job->id}/edit", [
'title' => 'Updated Mason Job',
'main_profession' => 'Plumber',
'skills' => 'Piping',
'workers_needed' => 2,
'location' => 'Abu Dhabi',
'salary' => 2500,
@ -137,6 +145,7 @@ public function test_worker_job_apis()
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Available Plumber Job',
'main_profession' => 'Plumber',
'location' => 'Dubai Marina',
'salary' => 3000,
'workers_needed' => 1,
@ -145,6 +154,8 @@ public function test_worker_job_apis()
'description' => 'Plumber description',
'status' => 'active',
]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job->skills()->sync([$skill->id]);
// 1. List available jobs
$response = $this->getJson('/api/workers/jobs', [
@ -155,6 +166,7 @@ public function test_worker_job_apis()
$response->assertJsonCount(1, 'data.jobs');
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
$response->assertJsonPath('data.jobs.0.posted_by', 'Employer User');
$response->assertJsonPath('data.jobs.0.status', 'new');
// 2. Retrieve job details
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
@ -164,6 +176,7 @@ public function test_worker_job_apis()
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
$response->assertJsonPath('data.job.posted_by', 'Employer User');
$response->assertJsonPath('data.job.status', 'new');
// 3. Apply for job
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
@ -177,6 +190,13 @@ public function test_worker_job_apis()
'status' => 'applied',
]);
// Verify status becomes 'applied' in list available jobs
$response = $this->getJson('/api/workers/jobs', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('data.jobs.0.status', 'applied');
// 4. Prevent duplicate job applications
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
'Authorization' => 'Bearer worker_api_token'
@ -220,6 +240,116 @@ public function test_worker_job_apis()
]);
}
public function test_employer_view_applicants_filtering()
{
// 1. Create a job post
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Filter Test Job',
'main_profession' => 'Plumber',
'location' => 'Dubai',
'salary' => 2000,
'workers_needed' => 5,
'job_type' => 'Full Time',
'start_date' => now()->addDays(5)->format('Y-m-d'),
'description' => 'Filter test job description',
'status' => 'active',
]);
// 2. Create worker A: Male, Indian, Salary 2000, Age 25, Experience 2 Years
$workerA = Worker::create([
'name' => 'Worker A',
'email' => 'workera@example.com',
'password' => bcrypt('password'),
'phone' => '971500000010',
'nationality' => 'Indian',
'gender' => 'male',
'age' => 25,
'salary' => 2000,
'experience' => '2 Years',
'status' => 'active',
'api_token' => 'worker_a_token',
'availability' => 'Immediate',
'religion' => 'Christian',
'bio' => 'Bio info',
'passport_status' => 'valid',
'main_profession' => 'Plumber',
]);
// 3. Create worker B: Female, Filipino, Salary 3000, Age 30, Experience 5 Years
$workerB = Worker::create([
'name' => 'Worker B',
'email' => 'workerb@example.com',
'password' => bcrypt('password'),
'phone' => '971500000020',
'nationality' => 'Filipino',
'gender' => 'female',
'age' => 30,
'salary' => 3000,
'experience' => '5 Years',
'status' => 'active',
'api_token' => 'worker_b_token',
'availability' => 'Immediate',
'religion' => 'Christian',
'bio' => 'Bio info',
'passport_status' => 'valid',
'main_profession' => 'Plumber',
]);
// 4. Submit applications
JobApplication::create([
'job_id' => $job->id,
'worker_id' => $workerA->id,
'status' => 'applied'
]);
JobApplication::create([
'job_id' => $job->id,
'worker_id' => $workerB->id,
'status' => 'applied'
]);
// Test filter by gender=female (should return only Worker B)
$response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?gender=female", [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.applicants');
$response->assertJsonPath('data.applicants.0.worker.name', 'Worker B');
// Test filter by nationality=Indian (should return only Worker A)
$response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?nationality=Indian", [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.applicants');
$response->assertJsonPath('data.applicants.0.worker.name', 'Worker A');
// Test filter by salary_max=2500 (should return only Worker A)
$response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?salary_max=2500", [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.applicants');
$response->assertJsonPath('data.applicants.0.worker.name', 'Worker A');
// Test filter by age range "28-35" (should return only Worker B)
$response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?age=28-35", [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.applicants');
$response->assertJsonPath('data.applicants.0.worker.name', 'Worker B');
// Test filter by experience="5 Years" (should return only Worker B)
$response = $this->getJson("/api/employers/jobs/{$job->id}/applicants?experience=5%20Years", [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonCount(1, 'data.applicants');
$response->assertJsonPath('data.applicants.0.worker.name', 'Worker B');
}
public function test_employer_job_apis_and_access_restrictions()
{
// 1. List jobs via API (premium plan by default)
@ -233,6 +363,8 @@ public function test_employer_job_apis_and_access_restrictions()
// 2. Create job via API
$response = $this->postJson('/api/employers/jobs', [
'title' => 'API Clean Job',
'main_profession' => 'Plumber',
'skills' => 'Piping',
'workers_needed' => 3,
'location' => 'Dubai JLT',
'salary' => 2200,
@ -263,6 +395,8 @@ public function test_employer_job_apis_and_access_restrictions()
// 4. Update job via API
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
'title' => 'Updated API Clean Job',
'main_profession' => 'Plumber',
'skills' => 'Piping',
'workers_needed' => 4,
'location' => 'Dubai Marina',
'salary' => 2500,
@ -507,4 +641,442 @@ public function test_job_hiring_workflow_enhancements()
'id' => $app->id
]);
}
public function test_hired_worker_visibility_and_profile_access()
{
// Create EmployerProfile with a phone number
\App\Models\EmployerProfile::create([
'user_id' => $this->employer->id,
'company_name' => 'Premium Sponsoring Group',
'phone' => '971501234567',
'city' => 'Dubai',
'nationality' => 'UAE',
]);
// 1. Create a job post
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Special Hired Job',
'main_profession' => 'Plumber',
'location' => 'Dubai Jumeirah',
'salary' => 4500,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->addDays(5)->format('Y-m-d'),
'description' => 'A special job details description',
'status' => 'active',
]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job->skills()->sync([$skill->id]);
// 2. Worker applies for the job
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(201);
$app = JobApplication::latest()->first();
// 3. Employer hires the worker by setting status to 'hired' (lowercase, as in the database)
$response = $this->postJson("/api/employers/applications/{$app->id}/status", [
'status' => 'hired',
'notes' => 'Hired through job flow'
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
// Under two-way confirmation, it should be in hire_requested status
$this->assertDatabaseHas('job_applications', [
'id' => $app->id,
'status' => 'hire_requested',
]);
// Worker confirms the hiring
$response = $this->postJson("/api/workers/applied-jobs/{$app->id}/confirm-hire", [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
// Verify status updates
$this->assertDatabaseHas('job_applications', [
'id' => $app->id,
'status' => 'hired',
]);
$this->assertDatabaseHas('workers', [
'id' => $this->worker->id,
'status' => 'Hired',
]);
// 4. Verify candidate is visible in Hired list (web: CandidateController::index)
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->get('/employer/candidates');
$response->assertStatus(200);
// Extract the Inertia prop 'selectedWorkers' to check
$inertiaData = $response->original->getData();
$selectedWorkers = $inertiaData['page']['props']['selectedWorkers'] ?? [];
$hiredList = array_values(array_filter($selectedWorkers, function ($w) {
return $w['worker_id'] === $this->worker->id;
}));
$this->assertCount(1, $hiredList);
$this->assertEquals('Hired', $hiredList[0]['status']);
// 5. Verify candidate is visible in Hired list (API: EmployerWorkerController::getCandidates)
$response = $this->getJson('/api/employers/candidates', [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
// Find the worker in candidates array
$candidates = $response->json('data.candidates');
$hiredApiList = array_values(array_filter($candidates, function ($c) {
return $c['worker_id'] === $this->worker->id;
}));
$this->assertCount(1, $hiredApiList);
$this->assertEquals('Hired', $hiredApiList[0]['status']);
// 6. Verify hired worker can view employer profile details bypassing subscription limits (WorkerProfileController::getEmployerProfile)
$response = $this->getJson("/api/workers/employers/{$this->employer->id}", [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.employer.email', 'employer@example.com');
$response->assertJsonPath('data.employer.phone', '971501234567'); // should be exposed/visible because worker is hired
// 7. Verify hired worker dashboard shows correct currently working sponsor
$response = $this->getJson('/api/workers/dashboard', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
$response->assertJsonPath('data.current_employer.id', $this->employer->id);
$response->assertJsonPath('data.currently_working_sponsor.id', $this->employer->id);
}
public function test_worker_can_decline_hire()
{
// 1. Create a job posting matching the worker's profession/skills
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Housekeeper/Nanny',
'main_profession' => 'Housekeeper',
'location' => 'Dubai Marina',
'salary' => 2500,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->addDays(5)->format('Y-m-d'),
'description' => 'Need clean house and nanny care.',
'status' => 'active',
]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job->skills()->sync([$skill->id]);
// 2. Worker applies for the job
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(201);
$app = JobApplication::latest()->first();
// 3. Employer hires the worker by setting status to 'hired'
$response = $this->postJson("/api/employers/applications/{$app->id}/status", [
'status' => 'hired',
'notes' => 'Declining test offer'
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
// Under two-way confirmation, it should be in hire_requested status
$this->assertDatabaseHas('job_applications', [
'id' => $app->id,
'status' => 'hire_requested',
]);
// Worker declines the hiring
$response = $this->postJson("/api/workers/applied-jobs/{$app->id}/decline-hire", [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
// Verify status updates
$this->assertDatabaseHas('job_applications', [
'id' => $app->id,
'status' => 'declined',
]);
$this->assertDatabaseHas('workers', [
'id' => $this->worker->id,
'status' => 'active', // Should still be active
]);
}
public function test_worker_can_view_job_matching_only_skills()
{
// 1. Create a job where main_profession is different from worker's (Plumber)
// Let's set it to 'Electrician'
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Electrician Needed',
'main_profession' => 'Electrician',
'location' => 'Dubai Marina',
'salary' => 3000,
'workers_needed' => 1,
'job_type' => 'Contract',
'start_date' => now()->addDays(2),
'description' => 'Electrician description',
'status' => 'active',
]);
// Sync the same skill 'Piping' (which the worker has)
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job->skills()->sync([$skill->id]);
// 2. Request list jobs
$response = $this->getJson('/api/workers/jobs', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
// The worker's main profession is 'Plumber', but the job's main profession is 'Electrician'.
// However, the worker has the 'Piping' skill, which matches the job.
// Therefore, the job should show up.
$response->assertJsonFragment([
'title' => 'Electrician Needed'
]);
}
public function test_worker_can_filter_and_sort_jobs()
{
// 1. Create jobs with different contractTypes and salaries
$job1 = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Contract Plumber Job',
'main_profession' => 'Plumber',
'location' => 'Dubai Marina',
'salary' => 1000,
'workers_needed' => 1,
'job_type' => 'Contract',
'start_date' => now()->addDays(2),
'description' => 'Fix pipes description',
'status' => 'active',
]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job1->skills()->sync([$skill->id]);
$job2 = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Full Time Plumber Job',
'main_profession' => 'Plumber',
'location' => 'Dubai Marina',
'salary' => 5000,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->addDays(2),
'description' => 'Manage plumbing description',
'status' => 'active',
]);
$job2->skills()->sync([$skill->id]);
// 2. Filter by job_type = Contract
$response = $this->getJson('/api/workers/jobs?job_type=Contract', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Contract Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Full Time Plumber Job']);
// 3. Search for "Manage"
$response = $this->getJson('/api/workers/jobs?search=Manage', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
// 4. Sort by PRICE: HIGH TO LOW
$response = $this->getJson('/api/workers/jobs?sortBy=PRICE:%20HIGH%20TO%20LOW', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
// The first job in list should be the one with salary 5000 (Job 2)
$this->assertEquals('Full Time Plumber Job', $response->json('data.jobs.0.title'));
// 5. Filter by job_type directly
$response = $this->getJson('/api/workers/jobs?job_type=Full%20Time', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
// 6. Filter by skills
$cookingSkill = \App\Models\Skill::firstOrCreate(['name' => 'Cooking']);
$job2->skills()->sync([$skill->id, $cookingSkill->id]);
$response = $this->getJson('/api/workers/jobs?skills=Cooking', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
// 7. Filter by salary range
$response = $this->getJson('/api/workers/jobs?salary_min=2000&salary_max=6000', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonFragment(['title' => 'Full Time Plumber Job']);
$response->assertJsonMissingExact(['title' => 'Contract Plumber Job']);
}
public function test_candidate_status_update_sends_correct_notifications_to_worker()
{
// Fake Google OAuth and FCM endpoints
\Illuminate\Support\Facades\Http::fake([
'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'fake_token'], 200),
'https://fcm.googleapis.com/v1/projects/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200),
]);
// Create standard credentials file dummy for FCMService if not exists
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
$createdFile = false;
if (!file_exists($credentialsPath)) {
file_put_contents($credentialsPath, json_encode([
'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----",
'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com',
'project_id' => 'migrant-fe5a7'
]));
$createdFile = true;
}
// Give the worker an FCM token so FcmChannel is used
$this->worker->update(['fcm_token' => 'worker_test_fcm_token']);
// Create a job post
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Test Notification Job',
'location' => 'Dubai Marina',
'salary' => 3000,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->addDays(2),
'description' => 'Description here',
'status' => 'active',
]);
// Apply for the job
$application = JobApplication::create([
'worker_id' => $this->worker->id,
'job_id' => $job->id,
'status' => 'applied'
]);
// 1. Update status to 'shortlisted' via Web endpoint
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->post("/employer/candidates/{$application->id}/status", [
'status' => 'Shortlisted',
'notes' => 'Shortlist note'
]);
$response->assertSessionHasNoErrors();
// Assert that the database notification was created for worker
$this->assertDatabaseHas('notifications', [
'notifiable_id' => $this->worker->id,
'notifiable_type' => get_class($this->worker),
]);
// Verify FCM request was triggered
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
return str_contains($request->url(), 'messages:send') &&
str_contains($request['message']['token'], 'worker_test_fcm_token') &&
str_contains($request['message']['notification']['title'], 'Job Application Update') &&
str_contains($request['message']['data']['type'], 'shortlisted');
});
// 2. Update status to 'applied' (Review) and assert notification type is 'review'
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->post("/employer/candidates/{$application->id}/status", [
'status' => 'Applied',
'notes' => 'Reviewing again'
]);
$response->assertSessionHasNoErrors();
// Verify FCM request for 'review' was triggered
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
return str_contains($request->url(), 'messages:send') &&
str_contains($request['message']['token'], 'worker_test_fcm_token') &&
str_contains($request['message']['data']['type'], 'review');
});
// Clean up temporary credentials file if we created it
if ($createdFile && file_exists($credentialsPath)) {
unlink($credentialsPath);
}
}
public function test_direct_hiring_sends_correct_notifications_to_worker()
{
// Fake Google OAuth and FCM endpoints
\Illuminate\Support\Facades\Http::fake([
'https://oauth2.googleapis.com/token' => \Illuminate\Support\Facades\Http::response(['access_token' => 'fake_token'], 200),
'https://fcm.googleapis.com/v1/projects/*' => \Illuminate\Support\Facades\Http::response(['name' => 'projects/migrant-fe5a7/messages/1234'], 200),
]);
$credentialsPath = base_path('migrant-fe5a7-firebase-adminsdk-fbsvc-f8b365ecd0.json');
$createdFile = false;
if (!file_exists($credentialsPath)) {
file_put_contents($credentialsPath, json_encode([
'private_key' => "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3\n-----END PRIVATE KEY-----",
'client_email' => 'fake-fcm@migrant.iam.gserviceaccount.com',
'project_id' => 'migrant-fe5a7'
]));
$createdFile = true;
}
$this->worker->update(['fcm_token' => 'worker_test_fcm_token']);
// 1. Create a direct Job Offer for the worker
$offer = \App\Models\JobOffer::create([
'employer_id' => $this->employer->id,
'worker_id' => $this->worker->id,
'work_date' => now()->format('Y-m-d'),
'location' => 'Dubai',
'salary' => 3000,
'notes' => 'Direct sponsoring',
'status' => 'pending',
]);
// Send a status update to 'Hired' via CandidateController (maps to pending for direct offers)
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->post("/employer/candidates/offer_{$offer->id}/status", [
'status' => 'Hired',
'notes' => 'Re-initiating direct hire offer'
]);
$response->assertSessionHasNoErrors();
// Verify FCM request for 'direct_hire_request' was triggered with correct text
\Illuminate\Support\Facades\Http::assertSent(function ($request) {
return str_contains($request->url(), 'messages:send') &&
str_contains($request['message']['token'], 'worker_test_fcm_token') &&
str_contains($request['message']['notification']['title'], 'Direct Hiring Offer') &&
str_contains($request['message']['notification']['body'], 'wants to hire you. Do you want to accept this job offer?') &&
str_contains($request['message']['data']['type'], 'direct_hire_request');
});
if ($createdFile && file_exists($credentialsPath)) {
unlink($credentialsPath);
}
}
}

View File

@ -383,7 +383,7 @@ public function test_register_with_new_api_fields()
'passport_file' => $fakePassport,
'language' => 'HI',
'nationality' => 'Kenyan',
'age' => 30,
'age' => '26-35',
'experience' => '5 Years',
'in_country' => 'true',
'visa_status' => 'Tourist Visa',
@ -399,6 +399,7 @@ public function test_register_with_new_api_fields()
$this->assertDatabaseHas('workers', [
'name' => 'John New Worker',
'phone' => '+971501111112',
'age' => '26-35',
'in_country' => true,
'visa_status' => 'Tourist Visa',
'preferred_job_type' => 'full-time',
@ -435,14 +436,12 @@ public function test_register_with_ocr_extraction()
$response->assertStatus(201);
$workerId = $response->json('data.worker.id');
$expectedAge = now()->diff(new \DateTime('1990-11-07'))->y;
$this->assertDatabaseHas('workers', [
'id' => $workerId,
'name' => 'MATAR ALI KHARBASH ALSAEDI',
'phone' => '+971509999999',
'nationality' => 'Emirati',
'age' => $expectedAge,
'age' => '26-35',
'verified' => true,
]);
@ -497,15 +496,12 @@ public function test_register_passport_and_visa_with_direct_json_payload()
$response->assertStatus(201);
$workerId = $response->json('data.worker.id');
$expectedAge = now()->diff(new \DateTime('1990-05-14'))->y;
$this->assertDatabaseHas('workers', [
'id' => $workerId,
'name' => 'Jane Doe',
'phone' => '+971508888888',
'nationality' => 'Filipino',
'age' => $expectedAge,
'age' => '36-45',
'verified' => true,
'visa_status' => 'Tourist Visa',
]);

View File

@ -23,7 +23,7 @@ export default defineConfig({
port: 5173,
cors: true,
hmr: {
host: '192.168.29.131',
host: '192.168.0.217',
},
watch: {
ignored: ['**/storage/framework/views/**'],