Merge pull request 'mohan' (#26) from mohan into master
Reviewed-on: #26
This commit is contained in:
commit
38aae3e6a3
@ -73,6 +73,7 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
# Notification Reminder Settings
|
# Notification Reminder Settings
|
||||||
WORKER_NO_RESPONSE_REMINDER_DAYS=14
|
WORKER_NO_RESPONSE_REMINDER_DAYS=14
|
||||||
REVIEW_ELIGIBILITY_MONTHS=3
|
REVIEW_ELIGIBILITY_MONTHS=3
|
||||||
|
REVIEW_ELIGIBILITY_MINUTES=10
|
||||||
REVIEW_EDIT_PERIOD_DAYS=7
|
REVIEW_EDIT_PERIOD_DAYS=7
|
||||||
EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3
|
EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS=3
|
||||||
WORKER_JOIN_CONFIRM_REMINDER_DAYS=3
|
WORKER_JOIN_CONFIRM_REMINDER_DAYS=3
|
||||||
|
|||||||
@ -374,7 +374,9 @@ private function checkWorkerNoResponse()
|
|||||||
private function checkReviewReminders()
|
private function checkReviewReminders()
|
||||||
{
|
{
|
||||||
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
|
$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'
|
// 1. Standard Job Applications with status 'hired'
|
||||||
$applications = \App\Models\JobApplication::where('status', 'hired')
|
$applications = \App\Models\JobApplication::where('status', 'hired')
|
||||||
@ -390,9 +392,16 @@ private function checkReviewReminders()
|
|||||||
if (!$employer || !$worker) continue;
|
if (!$employer || !$worker) continue;
|
||||||
|
|
||||||
$joiningDate = Carbon::parse($app->joining_confirmed_at);
|
$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
|
// Remind worker to review employer
|
||||||
if (!$this->alreadySent($worker, 'review_employer_reminder', $app, $level)) {
|
if (!$this->alreadySent($worker, 'review_employer_reminder', $app, $level)) {
|
||||||
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name}.");
|
$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;
|
if (!$worker || !$employer) continue;
|
||||||
|
|
||||||
$joiningDate = $offer->work_date ? Carbon::parse($offer->work_date) : $offer->updated_at;
|
$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
|
// Remind worker to review employer
|
||||||
if (!$this->alreadySent($worker, 'review_employer_reminder', $offer, $level)) {
|
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).");
|
$this->info("Sending review reminder to worker {$worker->name} to review employer {$employer->name} (direct offer).");
|
||||||
|
|||||||
@ -734,6 +734,9 @@ public function storeAnnouncement(Request $request)
|
|||||||
'event_time' => 'required|string',
|
'event_time' => 'required|string',
|
||||||
'location_details' => 'required|string',
|
'location_details' => 'required|string',
|
||||||
'location_pin' => 'required|string|url',
|
'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([
|
$body = json_encode([
|
||||||
@ -744,6 +747,9 @@ public function storeAnnouncement(Request $request)
|
|||||||
'location_details' => $request->location_details,
|
'location_details' => $request->location_details,
|
||||||
'location_pin' => $request->location_pin,
|
'location_pin' => $request->location_pin,
|
||||||
'content' => $request->content,
|
'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([
|
$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)
|
protected function notifyUsersOfEvent($ann)
|
||||||
{
|
{
|
||||||
@ -816,31 +822,29 @@ protected function notifyUsersOfEvent($ann)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Notify all workers
|
// Notify all workers
|
||||||
$workers = \App\Models\Worker::whereNotNull('fcm_token')->get();
|
$workers = \App\Models\Worker::all();
|
||||||
foreach ($workers as $worker) {
|
foreach ($workers as $worker) {
|
||||||
\App\Services\FCMService::sendPushNotification(
|
$worker->notify(new \App\Notifications\GenericNotification(
|
||||||
$worker->fcm_token,
|
|
||||||
$title,
|
$title,
|
||||||
$body,
|
$body,
|
||||||
[
|
[
|
||||||
'type' => 'announcement',
|
'type' => 'announcement',
|
||||||
'announcement_id' => $ann->id,
|
'announcement_id' => $ann->id,
|
||||||
]
|
]
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify all employers
|
// 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) {
|
foreach ($employers as $employer) {
|
||||||
\App\Services\FCMService::sendPushNotification(
|
$employer->notify(new \App\Notifications\GenericNotification(
|
||||||
$employer->fcm_token,
|
|
||||||
$title,
|
$title,
|
||||||
$body,
|
$body,
|
||||||
[
|
[
|
||||||
'type' => 'announcement',
|
'type' => 'announcement',
|
||||||
'announcement_id' => $ann->id,
|
'announcement_id' => $ann->id,
|
||||||
]
|
]
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -132,8 +132,22 @@ public function updateProfile(Request $request, $id)
|
|||||||
'experience' => 'required|string',
|
'experience' => 'required|string',
|
||||||
'salary' => 'nullable|numeric',
|
'salary' => 'nullable|numeric',
|
||||||
'nationality' => 'nullable|string',
|
'nationality' => 'nullable|string',
|
||||||
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
|
'visa_status' => 'nullable|string',
|
||||||
'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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
'in_country' => 'nullable',
|
'in_country' => 'nullable',
|
||||||
'preferred_job_type' => 'nullable|string',
|
'preferred_job_type' => 'nullable|string',
|
||||||
'skills' => 'nullable|string',
|
'skills' => 'nullable|string',
|
||||||
|
|||||||
@ -108,15 +108,26 @@ public function getMyAnnouncements(Request $request)
|
|||||||
|
|
||||||
$announcements = $query->skip($offset)->take($perPage)->get()
|
$announcements = $query->skip($offset)->take($perPage)->get()
|
||||||
->map(function ($announcement) {
|
->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 [
|
return [
|
||||||
'id' => $announcement->id,
|
'id' => $announcement->id,
|
||||||
'title' => $announcement->title,
|
'title' => $announcement->title,
|
||||||
'body' => $announcement->body,
|
'body' => $content,
|
||||||
'type' => $announcement->type,
|
'type' => $announcement->type,
|
||||||
'status' => $announcement->status ?? 'pending',
|
'status' => $announcement->status ?? 'pending',
|
||||||
'remarks' => $announcement->remarks,
|
'remarks' => $announcement->remarks,
|
||||||
'created_at' => $announcement->created_at->toISOString(),
|
'created_at' => $announcement->created_at->toISOString(),
|
||||||
'time_ago' => $announcement->created_at->diffForHumans(),
|
'time_ago' => $announcement->created_at->diffForHumans(),
|
||||||
|
'charity_details' => $charityDetails,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -155,12 +166,33 @@ public function createAnnouncement(Request $request)
|
|||||||
/** @var User $employer */
|
/** @var User $employer */
|
||||||
$employer = $request->attributes->get('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',
|
'title' => 'required|string|max:255',
|
||||||
'body' => 'required_without:content|string|max:5000',
|
'body' => 'required_without:content|string|max:5000',
|
||||||
'content' => 'required_without:body|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()) {
|
if ($validator->fails()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -173,26 +205,44 @@ public function createAnnouncement(Request $request)
|
|||||||
try {
|
try {
|
||||||
$bodyText = $request->body ?? $request->content;
|
$bodyText = $request->body ?? $request->content;
|
||||||
|
|
||||||
// Append extra event details if they are provided
|
if ($isCharity) {
|
||||||
$extras = [];
|
$eventTime = $request->event_time;
|
||||||
if ($request->event_date) $extras[] = "Date: " . $request->event_date;
|
if (empty($eventTime)) {
|
||||||
if ($request->event_time) $extras[] = "Time: " . $request->event_time;
|
$eventTime = $request->start_time . ' - ' . $request->end_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 (!empty($extras)) {
|
$bodyText = json_encode([
|
||||||
$bodyText .= "\n\n" . implode("\n", $extras);
|
'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([
|
$announcement = Announcement::create([
|
||||||
'title' => $request->title,
|
'title' => $request->title,
|
||||||
'body' => $bodyText,
|
'body' => $bodyText,
|
||||||
'type' => $request->type ?? 'info',
|
'type' => $request->type ?? ($isCharity ? 'charity' : 'info'),
|
||||||
'employer_id' => $employer->id,
|
'employer_id' => $employer->id,
|
||||||
'status' => 'pending',
|
'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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Announcement posted successfully.',
|
'message' => 'Announcement posted successfully.',
|
||||||
@ -200,12 +250,13 @@ public function createAnnouncement(Request $request)
|
|||||||
'announcement' => [
|
'announcement' => [
|
||||||
'id' => $announcement->id,
|
'id' => $announcement->id,
|
||||||
'title' => $announcement->title,
|
'title' => $announcement->title,
|
||||||
'body' => $announcement->body,
|
'body' => $content,
|
||||||
'type' => $announcement->type,
|
'type' => $announcement->type,
|
||||||
'status' => $announcement->status ?? 'pending',
|
'status' => $announcement->status ?? 'pending',
|
||||||
'remarks' => $announcement->remarks,
|
'remarks' => $announcement->remarks,
|
||||||
'created_at' => $announcement->created_at->toISOString(),
|
'created_at' => $announcement->created_at->toISOString(),
|
||||||
'time_ago' => $announcement->created_at->diffForHumans(),
|
'time_ago' => $announcement->created_at->diffForHumans(),
|
||||||
|
'charity_details' => $charityDetails,
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
], 201);
|
], 201);
|
||||||
|
|||||||
@ -324,14 +324,6 @@ public function register(Request $request)
|
|||||||
'address' => $request->address,
|
'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 sending email
|
||||||
try {
|
try {
|
||||||
\Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerOtpMail(
|
\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]);
|
$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
|
// Clear Cache
|
||||||
\Illuminate\Support\Facades\Cache::forget('employer_otp_' . $request->email);
|
\Illuminate\Support\Facades\Cache::forget('employer_otp_' . $request->email);
|
||||||
|
|||||||
@ -400,12 +400,7 @@ public function getDashboard(Request $request)
|
|||||||
// Recent Announcements / Events
|
// Recent Announcements / Events
|
||||||
$dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(2)->get();
|
$dbAnnouncements = \App\Models\Announcement::where('status', 'approved')->latest()->limit(2)->get();
|
||||||
$recentAnnouncements = $dbAnnouncements->map(function ($ann) {
|
$recentAnnouncements = $dbAnnouncements->map(function ($ann) {
|
||||||
$body = $ann->body;
|
$charityDetails = null;
|
||||||
$eventDate = null;
|
|
||||||
$eventTime = null;
|
|
||||||
$locationDetails = null;
|
|
||||||
$locationPin = null;
|
|
||||||
$providedItems = null;
|
|
||||||
|
|
||||||
if (strpos($ann->body, '{"type":"Charity"') === 0) {
|
if (strpos($ann->body, '{"type":"Charity"') === 0) {
|
||||||
$decoded = json_decode($ann->body, true);
|
$decoded = json_decode($ann->body, true);
|
||||||
@ -416,6 +411,7 @@ public function getDashboard(Request $request)
|
|||||||
$locationDetails = $decoded['location_details'] ?? null;
|
$locationDetails = $decoded['location_details'] ?? null;
|
||||||
$locationPin = $decoded['location_pin'] ?? null;
|
$locationPin = $decoded['location_pin'] ?? null;
|
||||||
$providedItems = $decoded['provided_items'] ?? null;
|
$providedItems = $decoded['provided_items'] ?? null;
|
||||||
|
$charityDetails = $decoded;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$body = $ann->body;
|
$body = $ann->body;
|
||||||
@ -438,6 +434,7 @@ public function getDashboard(Request $request)
|
|||||||
'provided_items' => $providedItems,
|
'provided_items' => $providedItems,
|
||||||
'created_at' => $ann->created_at->toISOString(),
|
'created_at' => $ann->created_at->toISOString(),
|
||||||
'time_ago' => $ann->created_at->diffForHumans(),
|
'time_ago' => $ann->created_at->diffForHumans(),
|
||||||
|
'charity_details' => $charityDetails,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -71,12 +71,23 @@ public function addReview(Request $request)
|
|||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
|
// Verify if completed configured period from joining date
|
||||||
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
|
$eligibilityMinutes = config('reminders.review_eligibility_minutes');
|
||||||
return response()->json([
|
if ($eligibilityMinutes !== null && $eligibilityMinutes > 0) {
|
||||||
'success' => false,
|
if ($joiningDate->diffInMinutes(now(), false) < $eligibilityMinutes) {
|
||||||
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
|
return response()->json([
|
||||||
], 403);
|
'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
|
// Check if employer has already reviewed this worker
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class EmployerWorkerController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Helper to map worker DB models to API presentation format.
|
* 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
|
// Map languages from database comma-separated string
|
||||||
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English'];
|
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English'];
|
||||||
@ -90,30 +90,6 @@ private function formatWorker(Worker $w)
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
})->toArray(),
|
})->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' => [
|
'category' => [
|
||||||
'id' => 7,
|
'id' => 7,
|
||||||
'name' => 'General Helper',
|
'name' => 'General Helper',
|
||||||
@ -440,10 +416,11 @@ public function getCandidates(Request $request)
|
|||||||
if (!$w) return null;
|
if (!$w) return null;
|
||||||
|
|
||||||
$status = 'Reviewing';
|
$status = 'Reviewing';
|
||||||
if ($app->status === 'hired') $status = 'Hired';
|
$dbStatusLower = strtolower($app->status ?? '');
|
||||||
elseif ($app->status === 'rejected') $status = 'Rejected';
|
if ($dbStatusLower === 'hired') $status = 'Hired';
|
||||||
elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent';
|
elseif ($dbStatusLower === 'rejected') $status = 'Rejected';
|
||||||
elseif ($app->status === 'applied') $status = 'Reviewing';
|
elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Offer Sent';
|
||||||
|
elseif ($dbStatusLower === 'applied') $status = 'Reviewing';
|
||||||
else $status = ucfirst($app->status);
|
else $status = ucfirst($app->status);
|
||||||
|
|
||||||
$formattedWorker = $this->formatWorker($w);
|
$formattedWorker = $this->formatWorker($w);
|
||||||
@ -465,9 +442,10 @@ public function getCandidates(Request $request)
|
|||||||
if (!$w) return null;
|
if (!$w) return null;
|
||||||
|
|
||||||
$status = 'Offer Sent';
|
$status = 'Offer Sent';
|
||||||
if ($offer->status === 'accepted') $status = 'Hired';
|
$dbOfferStatusLower = strtolower($offer->status ?? '');
|
||||||
elseif ($offer->status === 'rejected') $status = 'Rejected';
|
if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired';
|
||||||
elseif ($offer->status === 'pending') $status = 'Offer Sent';
|
elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected';
|
||||||
|
elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent';
|
||||||
|
|
||||||
$formattedWorker = $this->formatWorker($w);
|
$formattedWorker = $this->formatWorker($w);
|
||||||
|
|
||||||
@ -480,12 +458,21 @@ public function getCandidates(Request $request)
|
|||||||
]);
|
]);
|
||||||
})->filter()->values()->toArray();
|
})->filter()->values()->toArray();
|
||||||
|
|
||||||
// Merge and sort
|
// Merge and sort (filtering out duplicates for direct offers where already hired via application)
|
||||||
$mergedCandidates = array_merge($selectedWorkers, $directWorkers);
|
$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)
|
// Only display Hired candidates in the candidates pipeline (exclude active states)
|
||||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) {
|
$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
|
// 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);
|
$offerId = substr($targetId, 6);
|
||||||
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail();
|
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail();
|
||||||
|
|
||||||
$offer->update(['status' => 'accepted']);
|
$offer->update(['status' => 'pending']);
|
||||||
if ($offer->worker) {
|
$worker = $offer->worker;
|
||||||
$offer->worker->update(['status' => 'Hired']);
|
|
||||||
$worker = $offer->worker;
|
|
||||||
}
|
|
||||||
$updatedOffer = $offer;
|
$updatedOffer = $offer;
|
||||||
} else {
|
} else {
|
||||||
// Check if targetId is an application
|
// Check if targetId is an application
|
||||||
@ -804,11 +788,8 @@ public function hireCandidate(Request $request, $id = null)
|
|||||||
})->first();
|
})->first();
|
||||||
|
|
||||||
if ($application) {
|
if ($application) {
|
||||||
$application->update(['status' => 'hired']);
|
$application->update(['status' => 'hire_requested']);
|
||||||
if ($application->worker) {
|
$worker = $application->worker;
|
||||||
$application->worker->update(['status' => 'Hired']);
|
|
||||||
$worker = $application->worker;
|
|
||||||
}
|
|
||||||
$updatedApplication = $application;
|
$updatedApplication = $application;
|
||||||
} else {
|
} else {
|
||||||
// Try targeting by Worker ID directly
|
// Try targeting by Worker ID directly
|
||||||
@ -821,15 +802,13 @@ public function hireCandidate(Request $request, $id = null)
|
|||||||
], 404);
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
$worker->update(['status' => 'Hired']);
|
// Find existing direct offer or create a new one with status 'pending'
|
||||||
|
|
||||||
// Find existing direct offer
|
|
||||||
$offer = JobOffer::where('employer_id', $employerId)
|
$offer = JobOffer::where('employer_id', $employerId)
|
||||||
->where('worker_id', $worker->id)
|
->where('worker_id', $worker->id)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($offer) {
|
if ($offer) {
|
||||||
$offer->update(['status' => 'accepted']);
|
$offer->update(['status' => 'pending']);
|
||||||
$updatedOffer = $offer;
|
$updatedOffer = $offer;
|
||||||
} else {
|
} else {
|
||||||
// Find existing application
|
// Find existing application
|
||||||
@ -839,43 +818,57 @@ public function hireCandidate(Request $request, $id = null)
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($application) {
|
if ($application) {
|
||||||
$application->update(['status' => 'hired']);
|
$application->update(['status' => 'hire_requested']);
|
||||||
$updatedApplication = $application;
|
$updatedApplication = $application;
|
||||||
} else {
|
} else {
|
||||||
// Create a new direct job offer and accept it
|
// Create a new direct job offer in 'pending' status
|
||||||
$updatedOffer = JobOffer::create([
|
$updatedOffer = JobOffer::create([
|
||||||
'employer_id' => $employerId,
|
'employer_id' => $employerId,
|
||||||
'worker_id' => $worker->id,
|
'worker_id' => $worker->id,
|
||||||
'work_date' => now()->format('Y-m-d'),
|
'work_date' => now()->format('Y-m-d'),
|
||||||
'location' => 'Dubai',
|
'location' => 'Dubai',
|
||||||
'salary' => $worker->salary,
|
'salary' => $worker->salary,
|
||||||
'notes' => 'Direct sponsoring matching finalized via mobile API.',
|
'notes' => 'Direct sponsoring matching initiated via API.',
|
||||||
'status' => 'accepted',
|
'status' => 'pending',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dispatch push notification to worker
|
// Dispatch push & database notification to worker for confirmation request
|
||||||
if ($worker && $worker->fcm_token) {
|
if ($worker) {
|
||||||
\App\Services\FCMService::sendPushNotification(
|
if ($updatedApplication) {
|
||||||
$worker->fcm_token,
|
$job = $updatedApplication->jobPost;
|
||||||
"Congratulations! You've been Hired",
|
$worker->notify(new \App\Notifications\GenericNotification(
|
||||||
"Employer " . ($employer->name ?? "Employer") . " has hired you.",
|
"Action Required: Confirm Hiring",
|
||||||
[
|
"Employer " . ($employer->name ?? "Employer") . " wants to hire you for '" . ($job->title ?? 'Job Post') . "'. Please confirm.",
|
||||||
'type' => 'hired',
|
[
|
||||||
'employer_id' => $employer->id,
|
'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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Candidate successfully marked as Hired!',
|
'message' => 'Hire request initiated successfully. Awaiting candidate confirmation.',
|
||||||
'data' => [
|
'data' => [
|
||||||
'worker_id' => $worker ? $worker->id : null,
|
'worker_id' => $worker ? $worker->id : null,
|
||||||
'worker_status' => $worker ? $worker->status : 'Hired',
|
'worker_status' => $worker ? $worker->status : 'Active',
|
||||||
'application' => $updatedApplication,
|
'application' => $updatedApplication,
|
||||||
'offer' => $updatedOffer,
|
'offer' => $updatedOffer,
|
||||||
]
|
]
|
||||||
@ -1049,30 +1042,6 @@ public function getWorkerDetail(Request $request, $id)
|
|||||||
]
|
]
|
||||||
];
|
];
|
||||||
})->toArray(),
|
})->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([
|
return response()->json([
|
||||||
|
|||||||
@ -58,11 +58,10 @@ public function skills()
|
|||||||
public function sendOtp(Request $request)
|
public function sendOtp(Request $request)
|
||||||
{
|
{
|
||||||
$validator = Validator::make($request->all(), [
|
$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',
|
'email' => 'required_without:phone|nullable|email|max:255|unique:workers,email',
|
||||||
], [
|
], [
|
||||||
'phone.required_without' => 'Please provide a mobile number or email address.',
|
'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.required_without' => 'Please provide a mobile number or email address.',
|
||||||
'email.unique' => 'This email address is already registered.',
|
'email.unique' => 'This email address is already registered.',
|
||||||
]);
|
]);
|
||||||
@ -75,6 +74,17 @@ public function sendOtp(Request $request)
|
|||||||
], 422);
|
], 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)
|
// Determine identifier (phone takes priority)
|
||||||
$identifier = $request->phone ?? $request->email;
|
$identifier = $request->phone ?? $request->email;
|
||||||
$isPhone = !empty($request->phone);
|
$isPhone = !empty($request->phone);
|
||||||
@ -205,7 +215,7 @@ public function setupProfile(Request $request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if already registered
|
// Check if already registered
|
||||||
if ($request->phone && Worker::where('phone', $request->phone)->exists()) {
|
if ($request->phone && Worker::findByPhone($request->phone)) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'message' => 'This mobile number is already registered.'
|
'message' => 'This mobile number is already registered.'
|
||||||
@ -243,7 +253,7 @@ public function setupProfile(Request $request)
|
|||||||
'live_in_out' => $request->live_in_out,
|
'live_in_out' => $request->live_in_out,
|
||||||
'preferred_location' => $request->preferred_location,
|
'preferred_location' => $request->preferred_location,
|
||||||
'main_profession' => $request->main_profession,
|
'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
|
'salary' => 1500, // Default AED — updated later
|
||||||
'availability' => 'Immediate',
|
'availability' => 'Immediate',
|
||||||
'experience' => 'Not Specified',
|
'experience' => 'Not Specified',
|
||||||
@ -309,16 +319,30 @@ public function register(Request $request)
|
|||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'name' => 'required|string|max:255',
|
'name' => 'required|string|max:255',
|
||||||
'phone' => 'required|string|max:50|unique:workers,phone',
|
'phone' => 'required|string|max:50',
|
||||||
'salary' => 'required|numeric|min:0',
|
'salary' => 'required|numeric|min:0',
|
||||||
'password' => 'required|string|min:6',
|
'password' => 'required|string|min:6',
|
||||||
'skills' => 'nullable',
|
'skills' => 'nullable',
|
||||||
'language' => 'nullable|string',
|
'language' => 'nullable|string',
|
||||||
'nationality' => 'nullable|string|max:100',
|
'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',
|
'experience' => 'nullable|string|max:100',
|
||||||
'in_country' => 'nullable',
|
'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',
|
'preferred_job_type' => 'nullable|string|max:100',
|
||||||
'main_profession' => 'nullable|string|max:100',
|
'main_profession' => 'nullable|string|max:100',
|
||||||
'gender' => 'nullable|string|in:male,female,other',
|
'gender' => 'nullable|string|in:male,female,other',
|
||||||
@ -362,7 +386,6 @@ public function register(Request $request)
|
|||||||
'visa.sponsor_name' => 'nullable|string|max:255',
|
'visa.sponsor_name' => 'nullable|string|max:255',
|
||||||
'visa.ocr_accuracy' => 'nullable|numeric',
|
'visa.ocr_accuracy' => 'nullable|numeric',
|
||||||
], [
|
], [
|
||||||
'phone.unique' => 'This mobile number is already registered.',
|
|
||||||
'passport.passport_number.unique' => 'This passport number is already registered.',
|
'passport.passport_number.unique' => 'This passport number is already registered.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -374,6 +397,17 @@ public function register(Request $request)
|
|||||||
], 422);
|
], 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 {
|
try {
|
||||||
$phoneClean = preg_replace('/[^0-9]/', '', $request->phone);
|
$phoneClean = preg_replace('/[^0-9]/', '', $request->phone);
|
||||||
$email = "worker.{$phoneClean}@migrant.ae";
|
$email = "worker.{$phoneClean}@migrant.ae";
|
||||||
@ -401,7 +435,7 @@ public function register(Request $request)
|
|||||||
// Initialize fields
|
// Initialize fields
|
||||||
$name = $request->name;
|
$name = $request->name;
|
||||||
$nationality = $request->nationality ?? 'Not Specified';
|
$nationality = $request->nationality ?? 'Not Specified';
|
||||||
$age = $request->age ?? 25;
|
$age = $request->age ?? '18-25';
|
||||||
$visaStatus = $inCountry ? $request->visa_status : null;
|
$visaStatus = $inCountry ? $request->visa_status : null;
|
||||||
|
|
||||||
$passportDataInput = $request->input('passport');
|
$passportDataInput = $request->input('passport');
|
||||||
@ -430,7 +464,20 @@ public function register(Request $request)
|
|||||||
if (!empty($passportDataInput['date_of_birth'])) {
|
if (!empty($passportDataInput['date_of_birth'])) {
|
||||||
try {
|
try {
|
||||||
$dob = new \DateTime($this->normaliseDateForController($passportDataInput['date_of_birth']));
|
$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) {
|
} catch (\Exception $dateEx) {
|
||||||
logger()->error('Passport DOB parse error in register: ' . $dateEx->getMessage());
|
logger()->error('Passport DOB parse error in register: ' . $dateEx->getMessage());
|
||||||
}
|
}
|
||||||
@ -562,7 +609,7 @@ public function register(Request $request)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$worker = $request->phone
|
$worker = $request->phone
|
||||||
? Worker::where('phone', $request->phone)->first()
|
? Worker::findByPhone($request->phone)
|
||||||
: Worker::where('email', $request->email)->first();
|
: Worker::where('email', $request->email)->first();
|
||||||
|
|
||||||
if (!$worker || !Hash::check($request->password, $worker->password)) {
|
if (!$worker || !Hash::check($request->password, $worker->password)) {
|
||||||
@ -1333,7 +1380,7 @@ public function forgotPassword(Request $request)
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$worker = Worker::where('phone', $request->phone)->first();
|
$worker = Worker::findByPhone($request->phone);
|
||||||
|
|
||||||
if (!$worker) {
|
if (!$worker) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -1409,7 +1456,7 @@ public function resetPassword(Request $request)
|
|||||||
], 422);
|
], 422);
|
||||||
}
|
}
|
||||||
|
|
||||||
$worker = Worker::where('phone', $request->phone)->first();
|
$worker = Worker::findByPhone($request->phone);
|
||||||
|
|
||||||
if (!$worker) {
|
if (!$worker) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@ -18,14 +18,114 @@ class WorkerJobController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function listJobs(Request $request)
|
public function listJobs(Request $request)
|
||||||
{
|
{
|
||||||
$jobs = JobPost::where('status', 'active')
|
/** @var Worker $worker */
|
||||||
->with(['employer.employerProfile'])
|
$worker = $request->attributes->get('worker');
|
||||||
->latest()
|
|
||||||
->get()
|
$query = JobPost::whereIn('status', ['active', 'closed'])
|
||||||
->map(function ($job) {
|
->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 [
|
return [
|
||||||
'id' => $job->id,
|
'id' => $job->id,
|
||||||
'title' => $job->title,
|
'title' => $job->title,
|
||||||
|
'main_profession' => $job->main_profession,
|
||||||
|
'skills' => $job->skills->pluck('name')->toArray(),
|
||||||
'location' => $job->location,
|
'location' => $job->location,
|
||||||
'salary' => (int) $job->salary,
|
'salary' => (int) $job->salary,
|
||||||
'workers_needed' => $job->workers_needed,
|
'workers_needed' => $job->workers_needed,
|
||||||
@ -36,6 +136,7 @@ public function listJobs(Request $request)
|
|||||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||||
'posted_at' => $job->created_at->toIso8601String(),
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
'status' => $status,
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -53,8 +154,8 @@ public function listJobs(Request $request)
|
|||||||
*/
|
*/
|
||||||
public function jobDetails($id)
|
public function jobDetails($id)
|
||||||
{
|
{
|
||||||
$job = JobPost::where('status', 'active')
|
$job = JobPost::whereIn('status', ['active', 'closed'])
|
||||||
->with(['employer.employerProfile'])
|
->with(['employer.employerProfile', 'skills'])
|
||||||
->find($id);
|
->find($id);
|
||||||
|
|
||||||
if (!$job) {
|
if (!$job) {
|
||||||
@ -64,12 +165,29 @@ public function jobDetails($id)
|
|||||||
], 404);
|
], 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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'job' => [
|
'job' => [
|
||||||
'id' => $job->id,
|
'id' => $job->id,
|
||||||
'title' => $job->title,
|
'title' => $job->title,
|
||||||
|
'main_profession' => $job->main_profession,
|
||||||
|
'skills' => $job->skills->pluck('name')->toArray(),
|
||||||
'location' => $job->location,
|
'location' => $job->location,
|
||||||
'salary' => (int) $job->salary,
|
'salary' => (int) $job->salary,
|
||||||
'workers_needed' => $job->workers_needed,
|
'workers_needed' => $job->workers_needed,
|
||||||
@ -80,6 +198,7 @@ public function jobDetails($id)
|
|||||||
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
'posted_by' => $job->employer->name ?? 'Private Sponsor',
|
||||||
'posted_at' => $job->created_at->toIso8601String(),
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
'status' => $status,
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
@ -121,6 +240,19 @@ public function applyJob(Request $request, $id)
|
|||||||
'status' => 'applied'
|
'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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Successfully applied for the job.',
|
'message' => 'Successfully applied for the job.',
|
||||||
@ -148,6 +280,7 @@ public function appliedJobs(Request $request)
|
|||||||
return [
|
return [
|
||||||
'application_id' => $app->id,
|
'application_id' => $app->id,
|
||||||
'status' => $app->status,
|
'status' => $app->status,
|
||||||
|
'employer_status' => $app->employer_status,
|
||||||
'applied_at' => $app->created_at->toIso8601String(),
|
'applied_at' => $app->created_at->toIso8601String(),
|
||||||
'job' => $job ? [
|
'job' => $job ? [
|
||||||
'id' => $job->id,
|
'id' => $job->id,
|
||||||
@ -244,6 +377,8 @@ public function employerCreateJob(Request $request)
|
|||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
|
'main_profession' => 'required|string|max:255',
|
||||||
|
'skills' => 'required',
|
||||||
'workers_needed' => 'required|integer|min:1',
|
'workers_needed' => 'required|integer|min:1',
|
||||||
'location' => 'required|string|max:255',
|
'location' => 'required|string|max:255',
|
||||||
'salary' => 'required|numeric|min:0',
|
'salary' => 'required|numeric|min:0',
|
||||||
@ -264,6 +399,7 @@ public function employerCreateJob(Request $request)
|
|||||||
$job = JobPost::create([
|
$job = JobPost::create([
|
||||||
'employer_id' => $employer->id,
|
'employer_id' => $employer->id,
|
||||||
'title' => $request->title,
|
'title' => $request->title,
|
||||||
|
'main_profession' => $request->main_profession,
|
||||||
'workers_needed' => $request->workers_needed,
|
'workers_needed' => $request->workers_needed,
|
||||||
'job_type' => $request->job_type,
|
'job_type' => $request->job_type,
|
||||||
'location' => $request->location,
|
'location' => $request->location,
|
||||||
@ -274,6 +410,27 @@ public function employerCreateJob(Request $request)
|
|||||||
'status' => 'active',
|
'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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Job posted successfully.',
|
'message' => 'Job posted successfully.',
|
||||||
@ -281,6 +438,8 @@ public function employerCreateJob(Request $request)
|
|||||||
'job' => [
|
'job' => [
|
||||||
'id' => $job->id,
|
'id' => $job->id,
|
||||||
'title' => $job->title,
|
'title' => $job->title,
|
||||||
|
'main_profession' => $job->main_profession,
|
||||||
|
'skills' => $job->skills()->pluck('name')->toArray(),
|
||||||
'location' => $job->location,
|
'location' => $job->location,
|
||||||
'salary' => (int) $job->salary,
|
'salary' => (int) $job->salary,
|
||||||
'workers_needed' => $job->workers_needed,
|
'workers_needed' => $job->workers_needed,
|
||||||
@ -368,8 +527,17 @@ public function employerUpdateJob(Request $request, $id)
|
|||||||
], 404);
|
], 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(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
|
'main_profession' => 'required|string|max:255',
|
||||||
|
'skills' => 'required',
|
||||||
'workers_needed' => 'required|integer|min:1',
|
'workers_needed' => 'required|integer|min:1',
|
||||||
'location' => 'required|string|max:255',
|
'location' => 'required|string|max:255',
|
||||||
'salary' => 'required|numeric|min:0',
|
'salary' => 'required|numeric|min:0',
|
||||||
@ -390,6 +558,7 @@ public function employerUpdateJob(Request $request, $id)
|
|||||||
|
|
||||||
$job->update([
|
$job->update([
|
||||||
'title' => $request->title,
|
'title' => $request->title,
|
||||||
|
'main_profession' => $request->main_profession,
|
||||||
'workers_needed' => $request->workers_needed,
|
'workers_needed' => $request->workers_needed,
|
||||||
'job_type' => $request->job_type,
|
'job_type' => $request->job_type,
|
||||||
'location' => $request->location,
|
'location' => $request->location,
|
||||||
@ -400,6 +569,27 @@ public function employerUpdateJob(Request $request, $id)
|
|||||||
'status' => strtolower($request->status),
|
'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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'message' => 'Job updated successfully.',
|
'message' => 'Job updated successfully.',
|
||||||
@ -407,6 +597,8 @@ public function employerUpdateJob(Request $request, $id)
|
|||||||
'job' => [
|
'job' => [
|
||||||
'id' => $job->id,
|
'id' => $job->id,
|
||||||
'title' => $job->title,
|
'title' => $job->title,
|
||||||
|
'main_profession' => $job->main_profession,
|
||||||
|
'skills' => $job->skills()->pluck('name')->toArray(),
|
||||||
'location' => $job->location,
|
'location' => $job->location,
|
||||||
'salary' => (int) $job->salary,
|
'salary' => (int) $job->salary,
|
||||||
'workers_needed' => $job->workers_needed,
|
'workers_needed' => $job->workers_needed,
|
||||||
@ -496,6 +688,140 @@ public function employerJobApplicants(Request $request, $id)
|
|||||||
$query->where('status', strtolower($request->status));
|
$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');
|
$sortBy = $request->input('sort_by', 'created_at');
|
||||||
$sortOrder = $request->input('sort_order', 'desc');
|
$sortOrder = $request->input('sort_order', 'desc');
|
||||||
|
|
||||||
@ -562,7 +888,7 @@ public function employerUpdateApplicationStatus(Request $request, $id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$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',
|
'notes' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -587,9 +913,14 @@ public function employerUpdateApplicationStatus(Request $request, $id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$dbStatus = strtolower($request->status);
|
$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
|
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'
|
||||||
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
|
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
|
||||||
if (in_array($dbStatus, $contactStatuses)) {
|
if (in_array($dbStatus, $contactStatuses)) {
|
||||||
if (!User::canContactWorker($employer->id, $application->worker_id)) {
|
if (!User::canContactWorker($employer->id, $application->worker_id)) {
|
||||||
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first();
|
$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,
|
'notes' => $notes,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$empStatus = strtolower($request->status);
|
||||||
|
if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') {
|
||||||
|
$empStatus = 'review';
|
||||||
|
} elseif ($empStatus === 'hire_requested') {
|
||||||
|
$empStatus = 'hired';
|
||||||
|
}
|
||||||
|
|
||||||
$updateData = [
|
$updateData = [
|
||||||
'status' => $dbStatus,
|
'status' => $dbStatus,
|
||||||
|
'employer_status' => $empStatus,
|
||||||
'status_history' => $history,
|
'status_history' => $history,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -638,12 +977,7 @@ public function employerUpdateApplicationStatus(Request $request, $id)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If hired, also update worker status
|
if ($dbStatus === 'rejected') {
|
||||||
if ($dbStatus === 'hired') {
|
|
||||||
if ($application->worker) {
|
|
||||||
$application->worker->update(['status' => 'Hired']);
|
|
||||||
}
|
|
||||||
} elseif ($dbStatus === 'rejected') {
|
|
||||||
if ($application->worker) {
|
if ($application->worker) {
|
||||||
$application->worker->update(['status' => 'active']);
|
$application->worker->update(['status' => 'active']);
|
||||||
}
|
}
|
||||||
@ -689,6 +1023,7 @@ public function appliedJobDetail(Request $request, $id)
|
|||||||
'application' => [
|
'application' => [
|
||||||
'id' => $application->id,
|
'id' => $application->id,
|
||||||
'status' => $application->status,
|
'status' => $application->status,
|
||||||
|
'employer_status' => $application->employer_status,
|
||||||
'status_history' => $application->status_history ?: [],
|
'status_history' => $application->status_history ?: [],
|
||||||
'applied_at' => $application->created_at->toIso8601String(),
|
'applied_at' => $application->created_at->toIso8601String(),
|
||||||
'job' => $job ? [
|
'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)
|
private function triggerStatusNotification($application, $status)
|
||||||
{
|
{
|
||||||
@ -750,63 +1242,63 @@ private function triggerStatusNotification($application, $status)
|
|||||||
$job = $application->jobPost;
|
$job = $application->jobPost;
|
||||||
$employer = $job ? $job->employer : null;
|
$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
|
// When status is updated: notify worker
|
||||||
if ($worker && $worker->fcm_token) {
|
if ($worker) {
|
||||||
$title = "Job Application Update";
|
$title = "Job Application Update";
|
||||||
$body = "";
|
$body = "";
|
||||||
|
$type = 'job_application_status_update';
|
||||||
switch (strtolower($status)) {
|
switch (strtolower($status)) {
|
||||||
case 'shortlisted':
|
case 'shortlisted':
|
||||||
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
$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;
|
break;
|
||||||
case 'contacted':
|
case 'contacted':
|
||||||
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
||||||
|
$type = 'contacted';
|
||||||
break;
|
break;
|
||||||
case 'interview scheduled':
|
case 'interview scheduled':
|
||||||
case 'interview_scheduled':
|
case 'interview_scheduled':
|
||||||
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
$type = 'interview_scheduled';
|
||||||
break;
|
break;
|
||||||
case 'selected':
|
case 'selected':
|
||||||
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
$type = 'selected';
|
||||||
break;
|
break;
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and 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;
|
break;
|
||||||
case 'hired':
|
case 'hired':
|
||||||
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
$type = 'hired';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
\App\Services\FCMService::sendPushNotification(
|
$worker->notify(new \App\Notifications\GenericNotification(
|
||||||
$worker->fcm_token,
|
|
||||||
$title,
|
$title,
|
||||||
$body,
|
$body,
|
||||||
[
|
[
|
||||||
'type' => 'job_application_status_update',
|
'type' => $type,
|
||||||
'job_id' => $job->id ?? '',
|
'job_id' => $job->id ?? '',
|
||||||
'application_id' => $application->id,
|
'application_id' => $application->id,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
]
|
]
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Models\JobOffer;
|
use App\Models\JobOffer;
|
||||||
|
use App\Models\JobApplication;
|
||||||
use App\Models\Worker;
|
use App\Models\Worker;
|
||||||
use App\Models\WorkerDocument;
|
use App\Models\WorkerDocument;
|
||||||
use App\Models\ProfileView;
|
use App\Models\ProfileView;
|
||||||
@ -68,8 +69,23 @@ public function updateProfile(Request $request)
|
|||||||
|
|
||||||
$validator = Validator::make($request->all(), [
|
$validator = Validator::make($request->all(), [
|
||||||
'name' => 'nullable|string|max:255',
|
'name' => 'nullable|string|max:255',
|
||||||
'phone' => 'nullable|string|max:50|unique:workers,phone,' . $worker->id,
|
'phone' => 'nullable|string|max:50',
|
||||||
'age' => 'nullable|integer|min:18|max:70',
|
'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',
|
'nationality' => 'nullable|string|max:100',
|
||||||
'language' => 'nullable|string',
|
'language' => 'nullable|string',
|
||||||
'salary' => 'nullable|numeric|min:0',
|
'salary' => 'nullable|numeric|min:0',
|
||||||
@ -77,7 +93,7 @@ public function updateProfile(Request $request)
|
|||||||
'skills' => 'nullable|array',
|
'skills' => 'nullable|array',
|
||||||
'skills.*' => 'exists:skills,id',
|
'skills.*' => 'exists:skills,id',
|
||||||
'in_country' => 'nullable',
|
'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',
|
'preferred_job_type' => 'nullable|string|max:100',
|
||||||
'gender' => 'nullable|string|in:male,female,other',
|
'gender' => 'nullable|string|in:male,female,other',
|
||||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||||
@ -93,7 +109,6 @@ public function updateProfile(Request $request)
|
|||||||
],
|
],
|
||||||
'visa' => 'nullable|array',
|
'visa' => 'nullable|array',
|
||||||
], [
|
], [
|
||||||
'phone.unique' => 'This mobile number is already registered.',
|
|
||||||
'passport.passport_number.unique' => 'This passport number is already registered.',
|
'passport.passport_number.unique' => 'This passport number is already registered.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -105,6 +120,17 @@ public function updateProfile(Request $request)
|
|||||||
], 422);
|
], 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
|
// Document Update Check: Verify passport number matches existing passport number if already verified/saved
|
||||||
$existingPassport = $worker->documents()->where('type', 'passport')->first();
|
$existingPassport = $worker->documents()->where('type', 'passport')->first();
|
||||||
if ($existingPassport && $request->has('passport')) {
|
if ($existingPassport && $request->has('passport')) {
|
||||||
@ -139,7 +165,8 @@ public function updateProfile(Request $request)
|
|||||||
'gender',
|
'gender',
|
||||||
'live_in_out',
|
'live_in_out',
|
||||||
'preferred_location',
|
'preferred_location',
|
||||||
'fcm_token'
|
'fcm_token',
|
||||||
|
'main_profession'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Filter out null inputs if we want to support partial updates
|
// 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
|
// Dispatch push notification to employer
|
||||||
$employer = $offer->employer;
|
$employer = $offer->employer;
|
||||||
if ($employer && $employer->fcm_token) {
|
if ($employer) {
|
||||||
$statusMessage = $responseStatus === 'accepted' ? 'accepted' : 'rejected';
|
$statusMessage = $responseStatus === 'accepted' ? 'accepted' : 'rejected';
|
||||||
\App\Services\FCMService::sendPushNotification(
|
$employer->notify(new \App\Notifications\GenericNotification(
|
||||||
$employer->fcm_token,
|
|
||||||
"Job Offer " . ucfirst($statusMessage),
|
"Job Offer " . ucfirst($statusMessage),
|
||||||
"Worker " . ($worker->name ?? "Candidate") . " has " . $statusMessage . " your job offer.",
|
"Worker " . ($worker->name ?? "Candidate") . " has " . $statusMessage . " your job offer.",
|
||||||
[
|
[
|
||||||
@ -482,7 +508,7 @@ public function respondToOffer(Request $request, $id)
|
|||||||
'offer_id' => $offer->id,
|
'offer_id' => $offer->id,
|
||||||
'status' => $responseStatus,
|
'status' => $responseStatus,
|
||||||
]
|
]
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@ -587,13 +613,29 @@ public function getDashboard(Request $request)
|
|||||||
|
|
||||||
// 3. Currently working sponsor/employer
|
// 3. Currently working sponsor/employer
|
||||||
$currentSponsor = null;
|
$currentSponsor = null;
|
||||||
|
$emp = null;
|
||||||
|
$hiredAt = null;
|
||||||
|
|
||||||
$acceptedOffer = JobOffer::where('worker_id', $worker->id)
|
$acceptedOffer = JobOffer::where('worker_id', $worker->id)
|
||||||
->where('status', 'accepted')
|
->whereIn(DB::raw('LOWER(status)'), ['accepted', 'hired'])
|
||||||
->with(['employer.employerProfile', 'employer.employerReviews.worker'])
|
->with(['employer.employerProfile', 'employer.employerReviews.worker'])
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($acceptedOffer && $acceptedOffer->employer) {
|
if ($acceptedOffer) {
|
||||||
$emp = $acceptedOffer->employer;
|
$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;
|
$empProfile = $emp->employerProfile;
|
||||||
$currentSponsor = [
|
$currentSponsor = [
|
||||||
'id' => $emp->id,
|
'id' => $emp->id,
|
||||||
@ -602,9 +644,9 @@ public function getDashboard(Request $request)
|
|||||||
'nationality' => $empProfile->nationality ?? 'UAE',
|
'nationality' => $empProfile->nationality ?? 'UAE',
|
||||||
'city' => $empProfile->city ?? 'Dubai',
|
'city' => $empProfile->city ?? 'Dubai',
|
||||||
'email' => $emp->email,
|
'email' => $emp->email,
|
||||||
'phone' => $emp->phone,
|
'phone' => $empProfile->phone ?? $emp->phone,
|
||||||
'hired_at' => $acceptedOffer->updated_at->toIso8601String(),
|
'hired_at' => $hiredAt ? $hiredAt->toIso8601String() : null,
|
||||||
'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'),
|
'hired_at_formatted' => $hiredAt ? $hiredAt->format('M d, Y') : null,
|
||||||
'rating' => $emp->rating,
|
'rating' => $emp->rating,
|
||||||
'reviews_count' => $emp->reviews_count,
|
'reviews_count' => $emp->reviews_count,
|
||||||
'reviews' => $emp->employerReviews,
|
'reviews' => $emp->employerReviews,
|
||||||
@ -687,45 +729,22 @@ public function getEmployers(Request $request)
|
|||||||
$worker = $request->attributes->get('worker');
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$query = JobOffer::where('worker_id', $worker->id)
|
// Fetch JobOffers
|
||||||
->with(['employer.employerProfile', 'employer.employerReviews.worker']);
|
$offers = JobOffer::where('worker_id', $worker->id)
|
||||||
|
->with(['employer.employerProfile', 'employer.employerReviews.worker'])
|
||||||
|
->get();
|
||||||
|
|
||||||
// Filtering by status
|
// Fetch hired JobApplications
|
||||||
if ($request->filled('status')) {
|
$applications = JobApplication::where('worker_id', $worker->id)
|
||||||
$statusInput = strtolower($request->status);
|
->where('status', 'hired')
|
||||||
if ($statusInput === 'active') {
|
->with(['jobPost.employer.employerProfile', 'jobPost.employer.employerReviews.worker'])
|
||||||
$query->where('status', 'accepted');
|
->get();
|
||||||
} else {
|
|
||||||
$query->where('status', $statusInput);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sorting
|
// Map JobOffers
|
||||||
$sortBy = $request->input('sort_by', 'joining_date');
|
$mappedOffers = $offers->map(function ($offer) {
|
||||||
$sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc';
|
|
||||||
|
|
||||||
$sortColumn = 'work_date';
|
|
||||||
if ($sortBy === 'salary') {
|
|
||||||
$sortColumn = 'salary';
|
|
||||||
} elseif ($sortBy === 'status') {
|
|
||||||
$sortColumn = 'status';
|
|
||||||
} elseif ($sortBy === 'created_at') {
|
|
||||||
$sortColumn = 'created_at';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always display the current active employer (status = accepted) at the top of the list,
|
|
||||||
// followed by other employers ordered by the chosen sort column.
|
|
||||||
$query->orderByRaw("CASE WHEN status = 'accepted' THEN 0 ELSE 1 END ASC")
|
|
||||||
->orderBy($sortColumn, $sortOrder);
|
|
||||||
|
|
||||||
$perPage = (int) $request->input('per_page', 15);
|
|
||||||
$paginator = $query->paginate($perPage);
|
|
||||||
|
|
||||||
$employers = collect($paginator->items())->map(function ($offer) {
|
|
||||||
$emp = $offer->employer;
|
$emp = $offer->employer;
|
||||||
$empProfile = $emp ? $emp->employerProfile : null;
|
$empProfile = $emp ? $emp->employerProfile : null;
|
||||||
|
|
||||||
// Map 'accepted' -> 'Active' (and others to Title Case for nice presentation)
|
|
||||||
$statusFormatted = $offer->status;
|
$statusFormatted = $offer->status;
|
||||||
if (strtolower($offer->status) === 'accepted') {
|
if (strtolower($offer->status) === 'accepted') {
|
||||||
$statusFormatted = 'Active';
|
$statusFormatted = 'Active';
|
||||||
@ -736,10 +755,13 @@ public function getEmployers(Request $request)
|
|||||||
return [
|
return [
|
||||||
'offer_id' => $offer->id,
|
'offer_id' => $offer->id,
|
||||||
'joining_date' => $offer->work_date ? $offer->work_date->format('Y-m-d') : null,
|
'joining_date' => $offer->work_date ? $offer->work_date->format('Y-m-d') : null,
|
||||||
|
'joining_date_raw' => $offer->work_date,
|
||||||
'location' => $offer->location,
|
'location' => $offer->location,
|
||||||
'salary' => (int) $offer->salary,
|
'salary' => (int) $offer->salary,
|
||||||
'status' => $statusFormatted,
|
'status' => $statusFormatted,
|
||||||
|
'status_raw' => $offer->status,
|
||||||
'notes' => $offer->notes,
|
'notes' => $offer->notes,
|
||||||
|
'created_at' => $offer->created_at,
|
||||||
'employer' => $emp ? [
|
'employer' => $emp ? [
|
||||||
'id' => $emp->id,
|
'id' => $emp->id,
|
||||||
'name' => $emp->name,
|
'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([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'employers' => $employers,
|
'employers' => $employers,
|
||||||
'pagination' => [
|
'pagination' => [
|
||||||
'total' => $paginator->total(),
|
'total' => $total,
|
||||||
'per_page' => $paginator->perPage(),
|
'per_page' => $perPage,
|
||||||
'current_page' => $paginator->currentPage(),
|
'current_page' => $currentPage,
|
||||||
'last_page' => $paginator->lastPage(),
|
'last_page' => $lastPage,
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
], 200);
|
], 200);
|
||||||
@ -976,7 +1103,22 @@ public function getEmployerProfile(Request $request, $id)
|
|||||||
->where('worker_id', $worker->id)
|
->where('worker_id', $worker->id)
|
||||||
->exists();
|
->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;
|
$hasAccess = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -90,13 +90,23 @@ public function addReview(Request $request)
|
|||||||
], 403);
|
], 403);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify if completed configured months from joining date
|
// Verify if completed configured period from joining date
|
||||||
$eligibilityMonths = (int) config('reminders.review_eligibility_months', 3);
|
$eligibilityMinutes = config('reminders.review_eligibility_minutes');
|
||||||
if ($joiningDate->startOfDay()->diffInMonths(now()->startOfDay(), false) < $eligibilityMonths) {
|
if ($eligibilityMinutes !== null && $eligibilityMinutes > 0) {
|
||||||
return response()->json([
|
if ($joiningDate->diffInMinutes(now(), false) < $eligibilityMinutes) {
|
||||||
'success' => false,
|
return response()->json([
|
||||||
'message' => "You can write a review only after {$eligibilityMonths} months from the joining date."
|
'success' => false,
|
||||||
], 403);
|
'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
|
// 2. Prevent duplicate reviews for the same job/employer combination
|
||||||
|
|||||||
@ -72,6 +72,9 @@ public function store(Request $request)
|
|||||||
'event_time' => 'required|string',
|
'event_time' => 'required|string',
|
||||||
'location_details' => 'required|string',
|
'location_details' => 'required|string',
|
||||||
'location_pin' => 'required|string|url',
|
'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([
|
$body = json_encode([
|
||||||
@ -82,6 +85,9 @@ public function store(Request $request)
|
|||||||
'location_details' => $request->location_details,
|
'location_details' => $request->location_details,
|
||||||
'location_pin' => $request->location_pin,
|
'location_pin' => $request->location_pin,
|
||||||
'content' => $request->content,
|
'content' => $request->content,
|
||||||
|
'contact_person_name' => $request->contact_person_name,
|
||||||
|
'contact_number' => $request->contact_number,
|
||||||
|
'country_code' => $request->country_code ?? '+971',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$sess = session('user');
|
$sess = session('user');
|
||||||
|
|||||||
@ -54,11 +54,12 @@ public function index(Request $request)
|
|||||||
if (!$w) return null;
|
if (!$w) return null;
|
||||||
|
|
||||||
// Map DB status to UI friendly status
|
// Map DB status to UI friendly status
|
||||||
$status = 'Reviewing';
|
$status = 'Review';
|
||||||
if ($app->status === 'hired') $status = 'Hired';
|
$dbStatusLower = strtolower($app->status ?? '');
|
||||||
elseif ($app->status === 'rejected') $status = 'Rejected';
|
if ($dbStatusLower === 'hired') $status = 'Hired';
|
||||||
elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent';
|
elseif ($dbStatusLower === 'rejected') $status = 'Rejected';
|
||||||
elseif ($app->status === 'applied') $status = 'Reviewing';
|
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);
|
else $status = ucfirst($app->status);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@ -83,15 +84,27 @@ public function index(Request $request)
|
|||||||
->with('worker')
|
->with('worker')
|
||||||
->get();
|
->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;
|
$w = $offer->worker;
|
||||||
if (!$w) return null;
|
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
|
// Map DB offer status to UI friendly status
|
||||||
$status = 'Offer Sent';
|
$status = 'Offer Sent';
|
||||||
if ($offer->status === 'accepted') $status = 'Hired';
|
if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired';
|
||||||
elseif ($offer->status === 'rejected') $status = 'Rejected';
|
elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected';
|
||||||
elseif ($offer->status === 'pending') $status = 'Offer Sent';
|
elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => 'offer_' . $offer->id, // Unique string key prefix to prevent clashes
|
'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
|
// Only display Hired candidates in the candidates pipeline
|
||||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($w) {
|
$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
|
// 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)
|
public function updateStatus(Request $request, $id)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$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',
|
'notes' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -314,18 +327,31 @@ public function updateStatus(Request $request, $id)
|
|||||||
|
|
||||||
$dbStatus = 'pending';
|
$dbStatus = 'pending';
|
||||||
if (in_array(strtolower($request->status), ['hired', 'accepted'])) {
|
if (in_array(strtolower($request->status), ['hired', 'accepted'])) {
|
||||||
$dbStatus = 'accepted';
|
// Under two-way confirmation, it must NOT immediately set status to accepted/Hired.
|
||||||
$offer->worker->update(['status' => 'Hired']);
|
// It should set offer status to 'pending' (waiting for worker confirmation).
|
||||||
|
$dbStatus = 'pending';
|
||||||
} elseif (in_array(strtolower($request->status), ['rejected'])) {
|
} elseif (in_array(strtolower($request->status), ['rejected'])) {
|
||||||
$dbStatus = 'rejected';
|
$dbStatus = 'rejected';
|
||||||
$offer->worker->update(['status' => 'active']);
|
|
||||||
} elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) {
|
} elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) {
|
||||||
$dbStatus = 'pending';
|
$dbStatus = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
$offer->update(['status' => $dbStatus]);
|
$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
|
// Standard job application status update
|
||||||
@ -343,8 +369,8 @@ public function updateStatus(Request $request, $id)
|
|||||||
|
|
||||||
$dbStatus = 'applied';
|
$dbStatus = 'applied';
|
||||||
$statusLower = strtolower($request->status);
|
$statusLower = strtolower($request->status);
|
||||||
if ($statusLower === 'hired') {
|
if ($statusLower === 'hired' || $statusLower === 'hire_requested') {
|
||||||
$dbStatus = 'hired';
|
$dbStatus = 'hire_requested';
|
||||||
} elseif ($statusLower === 'rejected') {
|
} elseif ($statusLower === 'rejected') {
|
||||||
$dbStatus = 'rejected';
|
$dbStatus = 'rejected';
|
||||||
} elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') {
|
} elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') {
|
||||||
@ -355,12 +381,12 @@ public function updateStatus(Request $request, $id)
|
|||||||
$dbStatus = 'interview_scheduled';
|
$dbStatus = 'interview_scheduled';
|
||||||
} elseif ($statusLower === 'selected') {
|
} elseif ($statusLower === 'selected') {
|
||||||
$dbStatus = 'selected';
|
$dbStatus = 'selected';
|
||||||
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied') {
|
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied' || $statusLower === 'review') {
|
||||||
$dbStatus = 'applied';
|
$dbStatus = 'applied';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contact Limit check: if moving to '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'];
|
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
|
||||||
if (in_array($dbStatus, $contactStatuses)) {
|
if (in_array($dbStatus, $contactStatuses)) {
|
||||||
if (!User::canContactWorker($user->id, $app->worker_id)) {
|
if (!User::canContactWorker($user->id, $app->worker_id)) {
|
||||||
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
|
$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'),
|
'notes' => $request->input('notes'),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$empStatus = strtolower($request->status);
|
||||||
|
if ($empStatus === 'applied' || $empStatus === 'reviewing' || $empStatus === 'review') {
|
||||||
|
$empStatus = 'review';
|
||||||
|
} elseif ($empStatus === 'hire_requested') {
|
||||||
|
$empStatus = 'hired';
|
||||||
|
}
|
||||||
|
|
||||||
$updateData = [
|
$updateData = [
|
||||||
'status' => $dbStatus,
|
'status' => $dbStatus,
|
||||||
|
'employer_status' => $empStatus,
|
||||||
'status_history' => $history,
|
'status_history' => $history,
|
||||||
];
|
];
|
||||||
|
|
||||||
@ -406,12 +440,7 @@ public function updateStatus(Request $request, $id)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If hired, also update worker status
|
if ($dbStatus === 'rejected') {
|
||||||
if ($dbStatus === 'hired') {
|
|
||||||
if ($app->worker) {
|
|
||||||
$app->worker->update(['status' => 'Hired']);
|
|
||||||
}
|
|
||||||
} elseif ($dbStatus === 'rejected') {
|
|
||||||
if ($app->worker) {
|
if ($app->worker) {
|
||||||
$app->worker->update(['status' => 'active']);
|
$app->worker->update(['status' => 'active']);
|
||||||
}
|
}
|
||||||
@ -429,63 +458,63 @@ private function triggerStatusNotification($application, $status)
|
|||||||
$job = $application->jobPost;
|
$job = $application->jobPost;
|
||||||
$employer = $job ? $job->employer : null;
|
$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
|
// When status is updated: notify worker
|
||||||
if ($worker && $worker->fcm_token) {
|
if ($worker) {
|
||||||
$title = "Job Application Update";
|
$title = "Job Application Update";
|
||||||
$body = "";
|
$body = "";
|
||||||
|
$type = 'job_application_status_update';
|
||||||
switch (strtolower($status)) {
|
switch (strtolower($status)) {
|
||||||
case 'shortlisted':
|
case 'shortlisted':
|
||||||
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
$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;
|
break;
|
||||||
case 'contacted':
|
case 'contacted':
|
||||||
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
||||||
|
$type = 'contacted';
|
||||||
break;
|
break;
|
||||||
case 'interview scheduled':
|
case 'interview scheduled':
|
||||||
case 'interview_scheduled':
|
case 'interview_scheduled':
|
||||||
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
$type = 'interview_scheduled';
|
||||||
break;
|
break;
|
||||||
case 'selected':
|
case 'selected':
|
||||||
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
$type = 'selected';
|
||||||
break;
|
break;
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and 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;
|
break;
|
||||||
case 'hired':
|
case 'hired':
|
||||||
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
$type = 'hired';
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
\App\Services\FCMService::sendPushNotification(
|
$worker->notify(new \App\Notifications\GenericNotification(
|
||||||
$worker->fcm_token,
|
|
||||||
$title,
|
$title,
|
||||||
$body,
|
$body,
|
||||||
[
|
[
|
||||||
'type' => 'job_application_status_update',
|
'type' => $type,
|
||||||
'job_id' => $job->id ?? '',
|
'job_id' => $job->id ?? '',
|
||||||
'application_id' => $application->id,
|
'application_id' => $application->id,
|
||||||
'status' => $status,
|
'status' => $status,
|
||||||
]
|
]
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,6 +62,33 @@ public function index(Request $request)
|
|||||||
$totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() +
|
$totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() +
|
||||||
\App\Models\JobApplication::where('status', 'hired')->count();
|
\App\Models\JobApplication::where('status', 'hired')->count();
|
||||||
$totalActiveWorkers = \App\Models\Worker::where('status', 'active')->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 = [
|
$stats = [
|
||||||
'shortlisted_count' => $shortlistedCount,
|
'shortlisted_count' => $shortlistedCount,
|
||||||
@ -71,6 +98,8 @@ public function index(Request $request)
|
|||||||
'hired_count' => $hiredCount,
|
'hired_count' => $hiredCount,
|
||||||
'total_hired_all' => $totalHiredAll,
|
'total_hired_all' => $totalHiredAll,
|
||||||
'total_active_workers' => $totalActiveWorkers,
|
'total_active_workers' => $totalActiveWorkers,
|
||||||
|
'total_worker_profiles' => $totalWorkerProfiles,
|
||||||
|
'avg_monthly_hires' => $avgMonthlyHires,
|
||||||
'recent_failed_payment' => false
|
'recent_failed_payment' => false
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -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.');
|
->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)
|
public function store(Request $request)
|
||||||
@ -156,6 +181,8 @@ public function store(Request $request)
|
|||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
|
'main_profession' => 'required|string|max:255',
|
||||||
|
'skills' => 'required',
|
||||||
'workers_needed' => 'required|integer|min:1',
|
'workers_needed' => 'required|integer|min:1',
|
||||||
'location' => 'required|string|max:255',
|
'location' => 'required|string|max:255',
|
||||||
'salary' => 'required|numeric|min:0',
|
'salary' => 'required|numeric|min:0',
|
||||||
@ -165,9 +192,10 @@ public function store(Request $request)
|
|||||||
'requirements' => 'nullable|string',
|
'requirements' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
JobPost::create([
|
$job = JobPost::create([
|
||||||
'employer_id' => $user->id,
|
'employer_id' => $user->id,
|
||||||
'title' => $request->title,
|
'title' => $request->title,
|
||||||
|
'main_profession' => $request->main_profession,
|
||||||
'workers_needed' => $request->workers_needed,
|
'workers_needed' => $request->workers_needed,
|
||||||
'job_type' => $request->job_type,
|
'job_type' => $request->job_type,
|
||||||
'location' => $request->location,
|
'location' => $request->location,
|
||||||
@ -178,6 +206,27 @@ public function store(Request $request)
|
|||||||
'status' => 'active',
|
'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.');
|
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.');
|
->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
|
// Convert start_date format to Y-m-d for date input
|
||||||
$jobData = $job->toArray();
|
$jobData = $job->toArray();
|
||||||
@ -250,8 +299,40 @@ public function edit($id)
|
|||||||
$jobData['start_date'] = $job->start_date->format('Y-m-d');
|
$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', [
|
return Inertia::render('Employer/Jobs/Edit', [
|
||||||
'job' => $jobData,
|
'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();
|
$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([
|
$request->validate([
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
|
'main_profession' => 'required|string|max:255',
|
||||||
|
'skills' => 'required',
|
||||||
'workers_needed' => 'required|integer|min:1',
|
'workers_needed' => 'required|integer|min:1',
|
||||||
'location' => 'required|string|max:255',
|
'location' => 'required|string|max:255',
|
||||||
'salary' => 'required|numeric|min:0',
|
'salary' => 'required|numeric|min:0',
|
||||||
@ -283,6 +370,7 @@ public function update(Request $request, $id)
|
|||||||
|
|
||||||
$job->update([
|
$job->update([
|
||||||
'title' => $request->title,
|
'title' => $request->title,
|
||||||
|
'main_profession' => $request->main_profession,
|
||||||
'workers_needed' => $request->workers_needed,
|
'workers_needed' => $request->workers_needed,
|
||||||
'job_type' => $request->job_type,
|
'job_type' => $request->job_type,
|
||||||
'location' => $request->location,
|
'location' => $request->location,
|
||||||
@ -293,6 +381,27 @@ public function update(Request $request, $id)
|
|||||||
'status' => strtolower($request->status),
|
'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.');
|
return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,21 @@ private function resolveCurrentUser()
|
|||||||
return null;
|
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)
|
public static function processWorkerResponse($conv, $worker, $text)
|
||||||
{
|
{
|
||||||
$replyText = strtolower(trim($text));
|
$replyText = strtolower(trim($text));
|
||||||
@ -105,13 +120,13 @@ public function index(Request $request)
|
|||||||
->latest('updated_at')
|
->latest('updated_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$conversations = $dbConversations->map(function ($conv) {
|
$conversations = $dbConversations->map(function ($conv) use ($user) {
|
||||||
$lastMsg = $conv->messages->last();
|
$lastMsg = $conv->messages->last();
|
||||||
return [
|
return [
|
||||||
'id' => $conv->id,
|
'id' => $conv->id,
|
||||||
'worker_id' => $conv->worker_id,
|
'worker_id' => $conv->worker_id,
|
||||||
'worker_name' => $conv->worker->name ?? 'Candidate',
|
'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.',
|
'last_message' => $lastMsg->text ?? 'No messages yet.',
|
||||||
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
|
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
|
||||||
'online' => true,
|
'online' => true,
|
||||||
@ -136,13 +151,13 @@ public function show($id)
|
|||||||
->latest('updated_at')
|
->latest('updated_at')
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$conversations = $dbConversations->map(function ($conv) {
|
$conversations = $dbConversations->map(function ($conv) use ($user) {
|
||||||
$lastMsg = $conv->messages->last();
|
$lastMsg = $conv->messages->last();
|
||||||
return [
|
return [
|
||||||
'id' => $conv->id,
|
'id' => $conv->id,
|
||||||
'worker_id' => $conv->worker_id,
|
'worker_id' => $conv->worker_id,
|
||||||
'worker_name' => $conv->worker->name ?? 'Candidate',
|
'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.',
|
'last_message' => $lastMsg->text ?? 'No messages yet.',
|
||||||
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
|
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
|
||||||
'online' => true,
|
'online' => true,
|
||||||
@ -165,7 +180,7 @@ public function show($id)
|
|||||||
'id' => $activeConv->id,
|
'id' => $activeConv->id,
|
||||||
'worker_id' => $activeConv->worker_id,
|
'worker_id' => $activeConv->worker_id,
|
||||||
'worker_name' => $activeConv->worker->name ?? 'Candidate',
|
'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,
|
'online' => true,
|
||||||
'salary' => ($activeConv->worker->salary ?? 2000) . ' AED',
|
'salary' => ($activeConv->worker->salary ?? 2000) . ' AED',
|
||||||
'nationality' => $activeConv->worker->nationality ?? 'Unknown',
|
'nationality' => $activeConv->worker->nationality ?? 'Unknown',
|
||||||
@ -182,10 +197,59 @@ public function show($id)
|
|||||||
];
|
];
|
||||||
})->toArray();
|
})->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', [
|
return Inertia::render('Employer/Messages/Show', [
|
||||||
'conversations' => $conversations,
|
'conversations' => $conversations,
|
||||||
'conversation' => $conversationData,
|
'conversation' => $conversationData,
|
||||||
'initialMessages' => $initialMessages,
|
'initialMessages' => $initialMessages,
|
||||||
|
'application' => $applicationData,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -365,6 +365,16 @@ public function show($id)
|
|||||||
|
|
||||||
$visaStatus = $w->visa_status;
|
$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 = [
|
$worker = [
|
||||||
'id' => $w->id,
|
'id' => $w->id,
|
||||||
'name' => $w->name,
|
'name' => $w->name,
|
||||||
@ -392,21 +402,13 @@ public function show($id)
|
|||||||
'reviews' => $reviews,
|
'reviews' => $reviews,
|
||||||
'similar_workers' => $similarWorkers,
|
'similar_workers' => $similarWorkers,
|
||||||
'status' => $w->status,
|
'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_status' => $w->document_expiry_status,
|
||||||
'document_expiry_days' => $w->document_expiry_days,
|
'document_expiry_days' => $w->document_expiry_days,
|
||||||
'visa_expiry_date' => $w->visa_expiry_date,
|
'visa_expiry_date' => $w->visa_expiry_date,
|
||||||
'in_country' => (bool) $w->in_country,
|
'in_country' => (bool) $w->in_country,
|
||||||
'preferred_location' => $w->preferred_location,
|
'preferred_location' => $w->preferred_location,
|
||||||
'documents' => $w->documents->map(function ($doc) {
|
'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 [
|
return [
|
||||||
'id' => $doc->id,
|
'id' => $doc->id,
|
||||||
'type' => $doc->type,
|
'type' => $doc->type,
|
||||||
@ -415,7 +417,7 @@ public function show($id)
|
|||||||
'expiry_date' => $doc->expiry_date,
|
'expiry_date' => $doc->expiry_date,
|
||||||
'ocr_accuracy' => $doc->ocr_accuracy,
|
'ocr_accuracy' => $doc->ocr_accuracy,
|
||||||
'file_path' => $doc->file_path ? url($doc->file_path) : null,
|
'file_path' => $doc->file_path ? url($doc->file_path) : null,
|
||||||
'ocr_data' => $ocrData,
|
'ocr_data' => $doc->ocr_data,
|
||||||
];
|
];
|
||||||
})->toArray(),
|
})->toArray(),
|
||||||
];
|
];
|
||||||
@ -479,17 +481,18 @@ public function markHired(Request $request, $id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
$worker = Worker::findOrFail($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
|
// Check if there is an existing job offer for this employer and worker
|
||||||
$offer = JobOffer::where('employer_id', $employerId)
|
$offer = JobOffer::where('employer_id', $employerId)
|
||||||
->where('worker_id', $worker->id)
|
->where('worker_id', $worker->id)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
$application = null;
|
||||||
|
|
||||||
if ($offer) {
|
if ($offer) {
|
||||||
$offer->update(['status' => 'accepted']);
|
$offer->update(['status' => 'pending']);
|
||||||
} else {
|
} else {
|
||||||
// Also check if there's an existing job application
|
// Also check if there's an existing job application
|
||||||
$jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id');
|
$jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id');
|
||||||
@ -498,21 +501,53 @@ public function markHired(Request $request, $id)
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($application) {
|
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 {
|
} else {
|
||||||
// Create a new direct job offer with status accepted
|
// Create a new direct job offer with status pending
|
||||||
JobOffer::create([
|
$offer = JobOffer::create([
|
||||||
'employer_id' => $employerId,
|
'employer_id' => $employerId,
|
||||||
'worker_id' => $worker->id,
|
'worker_id' => $worker->id,
|
||||||
'work_date' => now()->format('Y-m-d'),
|
'work_date' => now()->format('Y-m-d'),
|
||||||
'location' => 'Dubai',
|
'location' => 'Dubai',
|
||||||
'salary' => $worker->salary,
|
'salary' => $worker->salary,
|
||||||
'notes' => 'Direct sponsoring matching finalized.',
|
'notes' => 'Direct sponsoring matching initiated.',
|
||||||
'status' => 'accepted',
|
'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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ class JobApplication extends Model
|
|||||||
'job_id',
|
'job_id',
|
||||||
'worker_id',
|
'worker_id',
|
||||||
'status',
|
'status',
|
||||||
|
'employer_status',
|
||||||
'notes',
|
'notes',
|
||||||
'status_history',
|
'status_history',
|
||||||
'joining_confirmed_at',
|
'joining_confirmed_at',
|
||||||
|
|||||||
@ -10,9 +10,38 @@ class JobPost extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes;
|
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 = [
|
protected $fillable = [
|
||||||
'employer_id',
|
'employer_id',
|
||||||
'title',
|
'title',
|
||||||
|
'main_profession',
|
||||||
'workers_needed',
|
'workers_needed',
|
||||||
'job_type',
|
'job_type',
|
||||||
'location',
|
'location',
|
||||||
@ -34,6 +63,10 @@ public function employer()
|
|||||||
return $this->belongsTo(User::class, 'employer_id');
|
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()
|
public function applications()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -11,6 +11,27 @@ class Worker extends Model
|
|||||||
{
|
{
|
||||||
use HasFactory, SoftDeletes, Notifiable;
|
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 = [
|
protected $fillable = [
|
||||||
'name',
|
'name',
|
||||||
'email',
|
'email',
|
||||||
@ -215,4 +236,36 @@ public function profileViews()
|
|||||||
{
|
{
|
||||||
return $this->hasMany(ProfileView::class);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
65
app/Notifications/GenericNotification.php
Normal file
65
app/Notifications/GenericNotification.php
Normal 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),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -59,7 +59,7 @@ public static function getAccessToken()
|
|||||||
$base64UrlSignature = self::base64UrlEncode($signature);
|
$base64UrlSignature = self::base64UrlEncode($signature);
|
||||||
$jwtToken = $signatureInput . '.' . $base64UrlSignature;
|
$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',
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
'assertion' => $jwtToken,
|
'assertion' => $jwtToken,
|
||||||
]);
|
]);
|
||||||
@ -140,7 +140,7 @@ public static function sendPushNotification($token, $title, $body, array $data =
|
|||||||
$payload['message']['data'] = $formattedData;
|
$payload['message']['data'] = $formattedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
$response = Http::withToken($accessToken)
|
$response = Http::withoutVerifying()->withToken($accessToken)
|
||||||
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload);
|
->post("https://fcm.googleapis.com/v1/projects/{$projectId}/messages:send", $payload);
|
||||||
|
|
||||||
if ($response->failed()) {
|
if ($response->failed()) {
|
||||||
|
|||||||
12
check_doc.php
Normal file
12
check_doc.php
Normal 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";
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@
|
|||||||
return [
|
return [
|
||||||
'worker_no_response_reminder_days' => env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14),
|
'worker_no_response_reminder_days' => env('WORKER_NO_RESPONSE_REMINDER_DAYS', 14),
|
||||||
'review_eligibility_months' => env('REVIEW_ELIGIBILITY_MONTHS', 3),
|
'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),
|
'review_edit_period_days' => env('REVIEW_EDIT_PERIOD_DAYS', 7),
|
||||||
'employer_hire_confirm_reminder_days' => env('EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS', '3,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'),
|
'worker_join_confirm_reminder_days' => env('WORKER_JOIN_CONFIRM_REMINDER_DAYS', '3,7,1'),
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -32,6 +32,10 @@ public function run(): void
|
|||||||
SkillSeeder::class,
|
SkillSeeder::class,
|
||||||
AdminSeeder::class,
|
AdminSeeder::class,
|
||||||
NationalitySeeder::class,
|
NationalitySeeder::class,
|
||||||
|
WorkerSeeder::class,
|
||||||
|
EmployerProfileSeeder::class,
|
||||||
|
JobPostSeeder::class,
|
||||||
|
ConversationSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
<!-- Reminder Configuration for Tests -->
|
<!-- Reminder Configuration for Tests -->
|
||||||
<env name="WORKER_NO_RESPONSE_REMINDER_DAYS" value="14"/>
|
<env name="WORKER_NO_RESPONSE_REMINDER_DAYS" value="14"/>
|
||||||
<env name="REVIEW_ELIGIBILITY_MONTHS" value="3"/>
|
<env name="REVIEW_ELIGIBILITY_MONTHS" value="3"/>
|
||||||
|
<env name="REVIEW_ELIGIBILITY_MINUTES" value="0"/>
|
||||||
<env name="REVIEW_EDIT_PERIOD_DAYS" value="7"/>
|
<env name="REVIEW_EDIT_PERIOD_DAYS" value="7"/>
|
||||||
<env name="EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS" value="3,7"/>
|
<env name="EMPLOYER_HIRE_CONFIRM_REMINDER_DAYS" value="3,7"/>
|
||||||
<env name="WORKER_JOIN_CONFIRM_REMINDER_DAYS" value="3,7,1"/>
|
<env name="WORKER_JOIN_CONFIRM_REMINDER_DAYS" value="3,7,1"/>
|
||||||
|
|||||||
@ -1017,9 +1017,16 @@
|
|||||||
"description": "Nationality of the worker."
|
"description": "Nationality of the worker."
|
||||||
},
|
},
|
||||||
"age": {
|
"age": {
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"example": 28,
|
"enum": [
|
||||||
"description": "Age of the worker."
|
"18-25",
|
||||||
|
"26-35",
|
||||||
|
"36-45",
|
||||||
|
"46-55",
|
||||||
|
"56-60"
|
||||||
|
],
|
||||||
|
"example": "26-35",
|
||||||
|
"description": "Age range of the worker."
|
||||||
},
|
},
|
||||||
"experience": {
|
"experience": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@ -4138,6 +4145,56 @@
|
|||||||
],
|
],
|
||||||
"summary": "List Available Jobs",
|
"summary": "List Available Jobs",
|
||||||
"description": "Returns a list of all active job postings available for workers to discover and apply.",
|
"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": [
|
"security": [
|
||||||
{
|
{
|
||||||
"bearerAuth": []
|
"bearerAuth": []
|
||||||
@ -4680,7 +4737,10 @@
|
|||||||
"event_date",
|
"event_date",
|
||||||
"event_time",
|
"event_time",
|
||||||
"location_details",
|
"location_details",
|
||||||
"location_pin"
|
"location_pin",
|
||||||
|
"contact_person_name",
|
||||||
|
"contact_number",
|
||||||
|
"country_code"
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"title": {
|
"title": {
|
||||||
@ -4712,6 +4772,18 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"format": "uri",
|
"format": "uri",
|
||||||
"example": "https://maps.app.goo.gl/xyz"
|
"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": {
|
"schema": {
|
||||||
"type": "integer"
|
"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": {
|
"responses": {
|
||||||
@ -7362,33 +7569,106 @@
|
|||||||
"employer": {
|
"employer": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "integer", "example": 1 },
|
"id": {
|
||||||
"name": { "type": "string", "example": "Jane Sponsor" },
|
"type": "integer",
|
||||||
"email": { "type": "string", "nullable": true, "example": "sponsor@example.com" },
|
"example": 1
|
||||||
"phone": { "type": "string", "nullable": true, "example": "+971501112222" },
|
},
|
||||||
"company_name": { "type": "string", "example": "Sponsor Co" },
|
"name": {
|
||||||
"city": { "type": "string", "nullable": true, "example": "Dubai" },
|
"type": "string",
|
||||||
"district": { "type": "string", "nullable": true, "example": "Dubai Marina" },
|
"example": "Jane Sponsor"
|
||||||
"nationality": { "type": "string", "nullable": true, "example": "Emirati" },
|
},
|
||||||
"address": { "type": "string", "nullable": true, "example": "123 Street" },
|
"email": {
|
||||||
"accommodation": { "type": "string", "nullable": true, "example": "Provided" },
|
"type": "string",
|
||||||
"language": { "type": "string", "nullable": true, "example": "English" },
|
"nullable": true,
|
||||||
"profile_photo_url": { "type": "string", "nullable": true, "example": "https://example.com/photo.jpg" },
|
"example": "sponsor@example.com"
|
||||||
"rating": { "type": "number", "format": "float", "example": 4.5 },
|
},
|
||||||
"reviews_count": { "type": "integer", "example": 12 },
|
"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": {
|
"review_summary": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"total": { "type": "integer", "example": 12 },
|
"total": {
|
||||||
"average": { "type": "number", "example": 4.5 },
|
"type": "integer",
|
||||||
|
"example": 12
|
||||||
|
},
|
||||||
|
"average": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 4.5
|
||||||
|
},
|
||||||
"stars": {
|
"stars": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"5": { "type": "integer", "example": 8 },
|
"5": {
|
||||||
"4": { "type": "integer", "example": 2 },
|
"type": "integer",
|
||||||
"3": { "type": "integer", "example": 1 },
|
"example": 8
|
||||||
"2": { "type": "integer", "example": 1 },
|
},
|
||||||
"1": { "type": "integer", "example": 0 }
|
"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": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "integer", "example": 5 },
|
"id": {
|
||||||
"title": { "type": "string", "example": "Housekeeper Needed" },
|
"type": "integer",
|
||||||
"location": { "type": "string", "example": "Dubai Marina" },
|
"example": 5
|
||||||
"salary": { "type": "integer", "example": 2500 },
|
},
|
||||||
"job_type": { "type": "string", "example": "Full Time" },
|
"title": {
|
||||||
"start_date": { "type": "string", "format": "date", "example": "2026-08-01" },
|
"type": "string",
|
||||||
"description": { "type": "string", "example": "Clean house" },
|
"example": "Housekeeper Needed"
|
||||||
"requirements": { "type": "string", "nullable": true, "example": "Experience required" },
|
},
|
||||||
"posted_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" }
|
"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": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "integer", "example": 1 },
|
"id": {
|
||||||
"rating": { "type": "integer", "example": 5 },
|
"type": "integer",
|
||||||
"title": { "type": "string", "example": "Great employer" },
|
"example": 1
|
||||||
"comment": { "type": "string", "example": "Highly recommended!" },
|
},
|
||||||
"created_at": { "type": "string", "format": "date-time", "example": "2026-07-04T12:00:00Z" },
|
"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": {
|
"worker": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "integer", "example": 2 },
|
"id": {
|
||||||
"name": { "type": "string", "example": "Worker Name" },
|
"type": "integer",
|
||||||
"nationality": { "type": "string", "example": "Filipino" }
|
"example": 2
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Worker Name"
|
||||||
|
},
|
||||||
|
"nationality": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Filipino"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7439,10 +7774,22 @@
|
|||||||
"pagination": {
|
"pagination": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"total": { "type": "integer", "example": 1 },
|
"total": {
|
||||||
"per_page": { "type": "integer", "example": 10 },
|
"type": "integer",
|
||||||
"current_page": { "type": "integer", "example": 1 },
|
"example": 1
|
||||||
"last_page": { "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"
|
"example": "Egypt"
|
||||||
},
|
},
|
||||||
"age": {
|
"age": {
|
||||||
"type": "integer",
|
"type": "string",
|
||||||
"example": 28
|
"enum": [
|
||||||
|
"18-25",
|
||||||
|
"26-35",
|
||||||
|
"36-45",
|
||||||
|
"46-55",
|
||||||
|
"56-60"
|
||||||
|
],
|
||||||
|
"example": "26-35",
|
||||||
|
"description": "Age range of the worker."
|
||||||
},
|
},
|
||||||
"salary": {
|
"salary": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@ -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">
|
<div className="w-8 h-8 bg-[#185FA5] text-white rounded-lg flex items-center justify-center font-bold text-base shadow-sm">
|
||||||
M
|
M
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center space-x-3">
|
<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">
|
<div className="w-10 h-10 bg-[#185FA5] text-white rounded-xl flex items-center justify-center font-bold text-xl shadow-md">
|
||||||
M
|
M
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Subscription Status Pill */}
|
{/* 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">
|
<button className="flex items-center space-x-3 pl-6 border-l border-slate-200 group outline-none">
|
||||||
<div className="text-right hidden sm:block max-w-[180px]">
|
<div className="text-right hidden sm:block 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-[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>
|
||||||
<div className="w-10 h-10 rounded-xl bg-blue-100 text-[#185FA5] flex items-center justify-center font-black text-xs border border-blue-200 shadow-sm transition-transform group-hover:scale-105">
|
<div className="w-10 h-10 rounded-xl bg-blue-100 text-[#185FA5] flex items-center justify-center font-black text-xs border border-blue-200 shadow-sm transition-transform group-hover:scale-105">
|
||||||
{user.name.charAt(0)}
|
{user.name.charAt(0)}
|
||||||
|
|||||||
@ -6,6 +6,15 @@ import {
|
|||||||
Calendar, MapPin, Clock, Gift, Sparkles, Search,
|
Calendar, MapPin, Clock, Gift, Sparkles, Search,
|
||||||
ChevronDown, ChevronUp, Building, User, XCircle, Eye, BellRing
|
ChevronDown, ChevronUp, Building, User, XCircle, Eye, BellRing
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogFooter
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
const hoursList = [
|
const hoursList = [
|
||||||
'12:00', '12:30', '01:00', '01:30', '02:00', '02:30',
|
'12:00', '12:30', '01:00', '01:30', '02:00', '02:30',
|
||||||
@ -17,6 +26,7 @@ const hoursList = [
|
|||||||
export default function Announcements({ initialAnnouncements }) {
|
export default function Announcements({ initialAnnouncements }) {
|
||||||
const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
|
const [announcements, setAnnouncements] = useState(initialAnnouncements || []);
|
||||||
const [isFormOpen, setIsFormOpen] = useState(false);
|
const [isFormOpen, setIsFormOpen] = useState(false);
|
||||||
|
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('All');
|
const [statusFilter, setStatusFilter] = useState('All');
|
||||||
const [expandedCards, setExpandedCards] = useState({});
|
const [expandedCards, setExpandedCards] = useState({});
|
||||||
@ -36,7 +46,10 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
event_date: '',
|
event_date: '',
|
||||||
event_time: '09:00 AM - 04:00 PM',
|
event_time: '09:00 AM - 04:00 PM',
|
||||||
location_details: '',
|
location_details: '',
|
||||||
location_pin: ''
|
location_pin: '',
|
||||||
|
contact_person_name: '',
|
||||||
|
contact_number: '',
|
||||||
|
country_code: '+971'
|
||||||
});
|
});
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
const [toastMessage, setToastMessage] = useState(null);
|
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://')) {
|
} else if (!newAnnouncement.location_pin.startsWith('http://') && !newAnnouncement.location_pin.startsWith('https://')) {
|
||||||
newErrors.location_pin = 'Must be a valid URL';
|
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) {
|
if (Object.keys(newErrors).length > 0) {
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
@ -75,7 +90,10 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
event_date: newAnnouncement.event_date,
|
event_date: newAnnouncement.event_date,
|
||||||
event_time: newAnnouncement.event_time,
|
event_time: newAnnouncement.event_time,
|
||||||
location_details: newAnnouncement.location_details,
|
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: () => {
|
onSuccess: () => {
|
||||||
setNewAnnouncement({
|
setNewAnnouncement({
|
||||||
@ -85,7 +103,10 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
event_date: '',
|
event_date: '',
|
||||||
event_time: '09:00 AM - 04:00 PM',
|
event_time: '09:00 AM - 04:00 PM',
|
||||||
location_details: '',
|
location_details: '',
|
||||||
location_pin: ''
|
location_pin: '',
|
||||||
|
contact_person_name: '',
|
||||||
|
contact_number: '',
|
||||||
|
country_code: '+971'
|
||||||
});
|
});
|
||||||
setStartTimeHour('09:00');
|
setStartTimeHour('09:00');
|
||||||
setStartTimeAmpm('AM');
|
setStartTimeAmpm('AM');
|
||||||
@ -509,6 +530,39 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<div>
|
||||||
<label className="block text-xs font-bold text-slate-600 mb-1">Description & Instructions</label>
|
<label className="block text-xs font-bold text-slate-600 mb-1">Description & Instructions</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -613,10 +667,11 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
ann.status === 'rejected' ? 'border-l-rose-500' :
|
ann.status === 'rejected' ? 'border-l-rose-500' :
|
||||||
'border-l-amber-500';
|
'border-l-amber-500';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ann.id}
|
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 */}
|
{/* Top colored accent line */}
|
||||||
<div className={`h-1.5 w-full ${
|
<div className={`h-1.5 w-full ${
|
||||||
@ -655,7 +710,7 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Control Buttons */}
|
{/* 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' && (
|
{ann.status === 'pending' && (
|
||||||
<div className="flex items-center space-x-0.5 bg-slate-50 p-0.5 rounded-lg border border-slate-200">
|
<div className="flex items-center space-x-0.5 bg-slate-50 p-0.5 rounded-lg border border-slate-200">
|
||||||
<button
|
<button
|
||||||
@ -691,6 +746,7 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
{ann.posted_by_email ? (
|
{ann.posted_by_email ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/employers?search=${encodeURIComponent(ann.posted_by_email)}`}
|
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"
|
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"
|
title="Click to view Employer Profile"
|
||||||
>
|
>
|
||||||
@ -720,7 +776,7 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
{ann.content.length > 90 && (
|
{ann.content.length > 90 && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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>
|
<span>{isExpanded ? 'Show Less' : 'Read Full Description'}</span>
|
||||||
@ -729,7 +785,7 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{ann.status === 'rejected' && ann.remarks && (
|
{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>
|
<span className="font-extrabold text-rose-900 block mb-0.5">Rejection Remarks:</span>
|
||||||
{ann.remarks}
|
{ann.remarks}
|
||||||
</div>
|
</div>
|
||||||
@ -777,6 +833,21 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -789,6 +860,7 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
href={details.location_pin}
|
href={details.location_pin}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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]"
|
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>
|
<span>View on Map</span>
|
||||||
@ -802,6 +874,122 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,8 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Gift,
|
Gift,
|
||||||
Plus
|
Plus,
|
||||||
|
User
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -44,6 +45,7 @@ const hoursList = [
|
|||||||
export default function Announcements({ initialAnnouncements }) {
|
export default function Announcements({ initialAnnouncements }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const [selectedAnnouncement, setSelectedAnnouncement] = useState(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [toastMessage, setToastMessage] = useState(null);
|
const [toastMessage, setToastMessage] = useState(null);
|
||||||
const [toastSub, setToastSub] = useState(null);
|
const [toastSub, setToastSub] = useState(null);
|
||||||
@ -72,7 +74,10 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
event_date: '',
|
event_date: '',
|
||||||
event_time: '09:00 AM - 04:00 PM',
|
event_time: '09:00 AM - 04:00 PM',
|
||||||
location_details: '',
|
location_details: '',
|
||||||
location_pin: ''
|
location_pin: '',
|
||||||
|
contact_person_name: '',
|
||||||
|
contact_number: '',
|
||||||
|
country_code: '+971'
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateEventTime = (sh, sa, eh, ea) => {
|
const updateEventTime = (sh, sa, eh, ea) => {
|
||||||
@ -165,7 +170,7 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
<span>{t('post_charity_event', 'Post Charity Event')}</span>
|
<span>{t('post_charity_event', 'Post Charity Event')}</span>
|
||||||
</button>
|
</button>
|
||||||
</DialogTrigger>
|
</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="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="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>
|
<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"
|
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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -462,7 +502,8 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ann.id}
|
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 */}
|
{/* Top Card Area */}
|
||||||
<div className="p-4 space-y-3">
|
<div className="p-4 space-y-3">
|
||||||
@ -494,7 +535,7 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
{ann.content.length > 90 && (
|
{ann.content.length > 90 && (
|
||||||
<button
|
<button
|
||||||
type="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]"
|
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>
|
<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>
|
</button>
|
||||||
)}
|
)}
|
||||||
{ann.status === 'rejected' && ann.remarks && (
|
{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}
|
<strong>{t('rejection_remarks_label', 'Remarks')}:</strong> {ann.remarks}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -531,6 +572,16 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Card Footer */}
|
{/* Bottom Card Footer */}
|
||||||
@ -541,7 +592,8 @@ export default function Announcements({ initialAnnouncements }) {
|
|||||||
href={details.location_pin}
|
href={details.location_pin}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
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>
|
<span>{t('view_on_map', 'View on Map')}</span>
|
||||||
<MapPin className="w-2.5 h-2.5" />
|
<MapPin className="w-2.5 h-2.5" />
|
||||||
@ -553,6 +605,121 @@ 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-[#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>
|
</div>
|
||||||
</EmployerLayout>
|
</EmployerLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import {
|
|||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
Users,
|
Users,
|
||||||
DollarSign,
|
TrendingUp,
|
||||||
MessageSquare
|
MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@ -161,7 +161,7 @@ export default function CreatePassword() {
|
|||||||
|
|
||||||
<div className="space-y-3 pt-6">
|
<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">
|
<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>
|
</span>
|
||||||
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
||||||
Find verified domestic workers directly.
|
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="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">
|
<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" />
|
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">500+</div>
|
<div className="font-bold text-lg">3000+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
|
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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" />
|
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">Premium</div>
|
<div className="font-bold text-lg">550+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
|
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
||||||
|
|||||||
@ -107,7 +107,7 @@ export default function ForgotPassword() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col justify-center items-center px-4 bg-slate-50 font-sans">
|
<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">
|
<div className="w-full max-w-md bg-white rounded-2xl shadow-sm border border-slate-200 p-8 space-y-6">
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
XCircle,
|
XCircle,
|
||||||
Users,
|
Users,
|
||||||
DollarSign,
|
TrendingUp,
|
||||||
MessageSquare
|
MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ export default function Login({ flash }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-50 font-sans">
|
<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) */}
|
{/* 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">
|
<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">
|
<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">
|
<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>
|
</span>
|
||||||
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
||||||
Find verified domestic workers directly.
|
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="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">
|
<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" />
|
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">500+</div>
|
<div className="font-bold text-lg">3000+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
|
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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" />
|
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">Premium</div>
|
<div className="font-bold text-lg">550+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
|
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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 className="w-full max-w-md bg-white rounded-2xl shadow-sm border border-slate-200 p-8 space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">Welcome Back</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Status Flash Alerts */}
|
{/* Status Flash Alerts */}
|
||||||
@ -201,13 +201,13 @@ export default function Login({ flash }) {
|
|||||||
disabled={processing}
|
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"
|
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>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="border-t border-slate-100 pt-6 text-center">
|
<div className="border-t border-slate-100 pt-6 text-center">
|
||||||
<p className="text-xs text-gray-600 font-medium">
|
<p className="text-xs text-gray-600 font-medium">
|
||||||
New employer?{' '}
|
New sponsor?{' '}
|
||||||
<Link href="/employer/register" className="text-[#185FA5] font-bold hover:underline">
|
<Link href="/employer/register" className="text-[#185FA5] font-bold hover:underline">
|
||||||
Create account
|
Create account
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { Clock, CheckCircle } from 'lucide-react';
|
|||||||
export default function PendingVerification() {
|
export default function PendingVerification() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col justify-center items-center px-4 bg-slate-50 font-sans">
|
<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-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">
|
<div className="w-16 h-16 bg-amber-50 rounded-full flex items-center justify-center mx-auto border border-amber-200">
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { toast } from 'sonner';
|
|||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
Users,
|
Users,
|
||||||
DollarSign,
|
TrendingUp,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
Search
|
Search
|
||||||
@ -266,7 +266,7 @@ export default function Register({ prefillData }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen grid grid-cols-1 lg:grid-cols-12 bg-slate-50 font-sans">
|
<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) */}
|
{/* 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">
|
<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">
|
<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">
|
<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>
|
</span>
|
||||||
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
||||||
Find verified domestic workers directly.
|
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="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">
|
<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" />
|
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">500+</div>
|
<div className="font-bold text-lg">3000+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
|
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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" />
|
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">Premium</div>
|
<div className="font-bold text-lg">550+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
|
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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>
|
<div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 tracking-tight">Create Account</h2>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
<form onSubmit={handleSubmit} noValidate className="space-y-4">
|
||||||
@ -359,7 +359,7 @@ export default function Register({ prefillData }) {
|
|||||||
type="email"
|
type="email"
|
||||||
value={data.email}
|
value={data.email}
|
||||||
onChange={(e) => handleInputChange('email', e.target.value)}
|
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 ${
|
className={`w-full px-3.5 py-3 rounded-xl border text-sm focus:outline-none focus:ring-2 transition-all ${
|
||||||
errors.email
|
errors.email
|
||||||
? 'border-red-500 focus:ring-red-500/20 focus:border-red-500'
|
? 'border-red-500 focus:ring-red-500/20 focus:border-red-500'
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
Lock,
|
Lock,
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
Users,
|
Users,
|
||||||
DollarSign,
|
TrendingUp,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Loader2,
|
Loader2,
|
||||||
Sparkles,
|
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="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">
|
<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" />
|
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">500+</div>
|
<div className="font-bold text-lg">3000+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
|
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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" />
|
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">Premium</div>
|
<div className="font-bold text-lg">550+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Sponsor Access</div>
|
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { Head, Link, router } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { compressImage } from '../../../lib/utils';
|
||||||
import {
|
import {
|
||||||
Loader2,
|
Loader2,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@ -9,7 +10,7 @@ import {
|
|||||||
UploadCloud,
|
UploadCloud,
|
||||||
FileText,
|
FileText,
|
||||||
Users,
|
Users,
|
||||||
DollarSign,
|
TrendingUp,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
ArrowLeft
|
ArrowLeft
|
||||||
} from 'lucide-react';
|
} 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];
|
const selectedFile = e.target.files[0];
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
if (selectedFile.size > 10240 * 1024) {
|
if (selectedFile.size > 10240 * 1024) {
|
||||||
@ -78,12 +79,17 @@ export default function UploadEmiratesId({ email }) {
|
|||||||
toast.error('The document must not exceed 10MB.');
|
toast.error('The document must not exceed 10MB.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setFrontFile(selectedFile);
|
try {
|
||||||
|
const compressedFile = await compressImage(selectedFile);
|
||||||
|
setFrontFile(compressedFile);
|
||||||
|
} catch (err) {
|
||||||
|
setFrontFile(selectedFile);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBackFileChange = (e) => {
|
const handleBackFileChange = async (e) => {
|
||||||
const selectedFile = e.target.files[0];
|
const selectedFile = e.target.files[0];
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
if (selectedFile.size > 10240 * 1024) {
|
if (selectedFile.size > 10240 * 1024) {
|
||||||
@ -91,7 +97,12 @@ export default function UploadEmiratesId({ email }) {
|
|||||||
toast.error('The document must not exceed 10MB.');
|
toast.error('The document must not exceed 10MB.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBackFile(selectedFile);
|
try {
|
||||||
|
const compressedFile = await compressImage(selectedFile);
|
||||||
|
setBackFile(compressedFile);
|
||||||
|
} catch (err) {
|
||||||
|
setBackFile(selectedFile);
|
||||||
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -203,7 +214,7 @@ export default function UploadEmiratesId({ email }) {
|
|||||||
|
|
||||||
<div className="space-y-3 pt-6">
|
<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">
|
<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>
|
</span>
|
||||||
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
||||||
Find verified domestic workers directly.
|
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="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">
|
<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" />
|
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">500+</div>
|
<div className="font-bold text-lg">3000+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
|
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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" />
|
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">Premium</div>
|
<div className="font-bold text-lg">550+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
|
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Employer */}
|
{/* Sponsor */}
|
||||||
<div>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={modalData.employer}
|
value={modalData.employer}
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
Users,
|
Users,
|
||||||
DollarSign,
|
TrendingUp,
|
||||||
MessageSquare
|
MessageSquare
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@ -139,7 +139,7 @@ export default function VerifyEmail({ email }) {
|
|||||||
|
|
||||||
<div className="space-y-3 pt-6">
|
<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">
|
<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>
|
</span>
|
||||||
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
<h1 className="text-4xl font-extrabold tracking-tight leading-tight">
|
||||||
Find verified domestic workers directly.
|
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="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">
|
<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" />
|
<Users className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">500+</div>
|
<div className="font-bold text-lg">3000+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Verified Workers</div>
|
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Worker Profiles</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<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" />
|
<TrendingUp className="w-6 h-6 text-emerald-300 mx-auto" />
|
||||||
<div className="font-bold text-lg">Premium</div>
|
<div className="font-bold text-lg">550+</div>
|
||||||
<div className="text-[10px] text-blue-100 uppercase tracking-wider font-semibold">Employer Access</div>
|
<div className="text-[9px] text-blue-100 uppercase tracking-wider font-semibold leading-tight">Avg Monthly Hires</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
<div className="bg-white/10 backdrop-blur-sm p-4 rounded-xl border border-white/10 text-center space-y-1">
|
||||||
|
|||||||
@ -24,7 +24,8 @@ import {
|
|||||||
Send,
|
Send,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
UserCheck,
|
UserCheck,
|
||||||
Lock
|
Lock,
|
||||||
|
Users
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
export default function Dashboard({
|
export default function Dashboard({
|
||||||
@ -147,27 +148,6 @@ export default function Dashboard({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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 */}
|
{/* 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="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="flex items-center justify-between">
|
||||||
@ -188,22 +168,46 @@ export default function Dashboard({
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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="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="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">
|
<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">
|
||||||
<Bookmark className="w-5 h-5" />
|
<Users className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] font-black text-purple-600 bg-purple-50 px-2 py-0.5 rounded-md border border-purple-100">
|
<span className="text-[10px] font-black text-blue-600 bg-blue-50 px-2 py-0.5 rounded-md border border-blue-100">
|
||||||
SAVED LIST
|
PLATFORM SIZE
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-semibold text-slate-400 uppercase tracking-widest">{t('saved_workers', 'Saved Workers')}</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">{stats.shortlisted_count}</div>
|
<div className="text-3xl font-black text-slate-800 mt-1">
|
||||||
|
{Math.max(3000, stats.total_worker_profiles).toLocaleString()}+
|
||||||
|
</div>
|
||||||
</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">
|
<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('open_shortlist', 'Open shortlist book')}</span>
|
<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" />
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@ -211,8 +215,8 @@ export default function Dashboard({
|
|||||||
|
|
||||||
{/* 3. Analytics & Quick Action Section */}
|
{/* 3. Analytics & Quick Action Section */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
{/* Worker Activity Analytics Module (Col 8) */}
|
{/* Worker Activity Analytics Module (Col 12) */}
|
||||||
<div className="lg:col-span-8 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
<div className="lg:col-span-12 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
||||||
<div className="pb-3 border-b border-slate-100">
|
<div className="pb-3 border-b border-slate-100">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Activity className="w-5 h-5 text-[#185FA5]" />
|
<Activity className="w-5 h-5 text-[#185FA5]" />
|
||||||
@ -260,52 +264,6 @@ export default function Dashboard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* 4. Detailed Workspace Rows (Col 12 Grid) */}
|
{/* 4. Detailed Workspace Rows (Col 12 Grid) */}
|
||||||
|
|||||||
@ -37,7 +37,18 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
|
|||||||
|
|
||||||
const openStatusModal = (app, statusPreset = '') => {
|
const openStatusModal = (app, statusPreset = '') => {
|
||||||
setSelectedApp(app);
|
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 || '');
|
setNotes(app.notes || '');
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
};
|
};
|
||||||
@ -193,10 +204,12 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
|
|||||||
<div className="flex items-center justify-between">
|
<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 ${
|
<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() === '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' :
|
applicant.status?.toLowerCase() === 'rejected' ? 'bg-rose-100 text-rose-700' :
|
||||||
'bg-amber-100 text-amber-700'
|
'bg-blue-50 text-[#185FA5]'
|
||||||
}`} onClick={() => openStatusModal(applicant)}>
|
}`} onClick={() => openStatusModal(applicant)}>
|
||||||
{applicant.status}
|
{applicant.status?.toLowerCase() === 'hire_requested' ? 'Pending Confirmation' :
|
||||||
|
applicant.status?.toLowerCase() === 'applied' ? 'Review' : applicant.status}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center space-x-1 text-[10px] font-bold text-slate-400">
|
<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">
|
<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 */}
|
{/* Modal Body */}
|
||||||
<div className="p-6 space-y-5">
|
<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 */}
|
{/* Status Selector */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Stage</label>
|
<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)}
|
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"
|
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="Applied">Review</option>
|
||||||
<option value="Shortlisted">Shortlisted (Save Candidate)</option>
|
<option value="Shortlisted">Shortlisted</option>
|
||||||
<option value="Contacted">Contacted</option>
|
|
||||||
<option value="Interview Scheduled">Interview Scheduled</option>
|
|
||||||
<option value="Selected">Selected</option>
|
|
||||||
<option value="Rejected">Rejected</option>
|
<option value="Rejected">Rejected</option>
|
||||||
<option value="Hired">Hired</option>
|
<option value="Hired">Hired</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@ -7,29 +7,31 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
MapPin,
|
MapPin,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Calendar,
|
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ShieldCheck
|
ShieldCheck,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Create({ categories: initialCategories }) {
|
export default function Create({ professions, availableSkills }) {
|
||||||
const categories = initialCategories || [
|
const categoryList = professions || [
|
||||||
'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper'
|
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
|
||||||
];
|
];
|
||||||
|
const skillList = availableSkills || [];
|
||||||
|
|
||||||
const { data, setData, post, processing, errors } = useForm({
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
title: '',
|
title: '',
|
||||||
|
main_profession: '',
|
||||||
|
skills: [],
|
||||||
workers_needed: 1,
|
workers_needed: 1,
|
||||||
location: '',
|
location: '',
|
||||||
salary: '',
|
salary: '',
|
||||||
job_type: 'Full Time',
|
job_type: 'Full Time',
|
||||||
start_date: '',
|
start_date: new Date().toISOString().split('T')[0],
|
||||||
description: '',
|
description: '',
|
||||||
requirements: ''
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [clientErrors, setClientErrors] = useState({});
|
const [clientErrors, setClientErrors] = useState({});
|
||||||
@ -42,6 +44,12 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
if (!data.title.trim()) {
|
if (!data.title.trim()) {
|
||||||
newErrors.title = 'Job title is required.';
|
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) {
|
if (!data.workers_needed || data.workers_needed < 1) {
|
||||||
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
||||||
}
|
}
|
||||||
@ -77,7 +85,7 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title="Post a Job">
|
<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">
|
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
|
||||||
<Link
|
<Link
|
||||||
@ -133,7 +141,29 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<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>
|
||||||
|
|
||||||
<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 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">
|
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<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>
|
||||||
|
|
||||||
<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>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<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>
|
||||||
|
|
||||||
<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">Start Date</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
|
||||||
<div className="relative">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
{['Full Time', 'Part Time'].map(type => (
|
||||||
<input
|
<button
|
||||||
type="date"
|
key={type}
|
||||||
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 ${
|
type="button"
|
||||||
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
onClick={() => setData('job_type', type)}
|
||||||
}`}
|
className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
|
||||||
value={data.start_date}
|
data.job_type === type
|
||||||
onChange={e => setData('start_date', e.target.value)}
|
? '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>
|
||||||
{(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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 3: Details */}
|
{/* Section 2: Detailed Requirements */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
<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" />
|
<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>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -268,19 +320,6 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -7,26 +7,32 @@ import {
|
|||||||
Briefcase,
|
Briefcase,
|
||||||
MapPin,
|
MapPin,
|
||||||
DollarSign,
|
DollarSign,
|
||||||
Calendar,
|
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
Users,
|
Users,
|
||||||
FileText,
|
FileText,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Save
|
Save,
|
||||||
|
X
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
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({
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
title: job.title || '',
|
title: job.title || '',
|
||||||
|
main_profession: job.main_profession || '',
|
||||||
|
skills: job.skills ? job.skills.map(s => s.name) : [],
|
||||||
workers_needed: job.workers_needed || 1,
|
workers_needed: job.workers_needed || 1,
|
||||||
location: job.location || '',
|
location: job.location || '',
|
||||||
salary: job.salary ? Math.round(job.salary) : '',
|
salary: job.salary ? Math.round(job.salary) : '',
|
||||||
job_type: job.job_type || 'Full Time',
|
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 || '',
|
description: job.description || '',
|
||||||
requirements: job.requirements || '',
|
|
||||||
status: job.status || 'active'
|
status: job.status || 'active'
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,6 +46,12 @@ export default function Edit({ job }) {
|
|||||||
if (!data.title.trim()) {
|
if (!data.title.trim()) {
|
||||||
newErrors.title = 'Job title is required.';
|
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) {
|
if (!data.workers_needed || data.workers_needed < 1) {
|
||||||
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
||||||
}
|
}
|
||||||
@ -78,7 +90,7 @@ export default function Edit({ job }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title="Edit Job">
|
<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">
|
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
|
||||||
<Link
|
<Link
|
||||||
@ -130,7 +142,29 @@ export default function Edit({ job }) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<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>
|
||||||
|
|
||||||
<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>
|
<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">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
{['Full Time', 'Part Time', 'Contract'].map(type => (
|
{['Full Time', 'Part Time'].map(type => (
|
||||||
<button
|
<button
|
||||||
key={type}
|
key={type}
|
||||||
type="button"
|
type="button"
|
||||||
@ -169,7 +241,7 @@ export default function Edit({ job }) {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<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">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
{[
|
{[
|
||||||
@ -195,73 +267,7 @@ export default function Edit({ job }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 2: Logistics & Pay */}
|
{/* 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" />
|
|
||||||
Logistics & Compensation
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
|
|
||||||
<div className="relative">
|
|
||||||
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. Dubai Marina"
|
|
||||||
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
|
||||||
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
|
||||||
}`}
|
|
||||||
value={data.location}
|
|
||||||
onChange={e => setData('location', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{(clientErrors.location || errors.location) && (
|
|
||||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.location || errors.location}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
|
|
||||||
<div className="relative">
|
|
||||||
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
placeholder="e.g. 2500"
|
|
||||||
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
|
||||||
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
|
||||||
}`}
|
|
||||||
value={data.salary}
|
|
||||||
onChange={e => setData('salary', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{(clientErrors.salary || errors.salary) && (
|
|
||||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.salary || errors.salary}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Start Date</label>
|
|
||||||
<div className="relative">
|
|
||||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
|
||||||
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
|
||||||
}`}
|
|
||||||
value={data.start_date}
|
|
||||||
onChange={e => setData('start_date', e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{(clientErrors.start_date || errors.start_date) && (
|
|
||||||
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.start_date || errors.start_date}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Section 3: Details */}
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
<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" />
|
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||||
@ -269,6 +275,56 @@ export default function Edit({ job }) {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-6">
|
<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">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
|
||||||
<textarea
|
<textarea
|
||||||
@ -289,19 +345,6 @@ export default function Edit({ job }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -153,7 +153,7 @@ export default function Index({ initialJobs }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title="My Jobs">
|
<EmployerLayout title="My Jobs">
|
||||||
<Head title="My Jobs - Employer Portal" />
|
<Head title="My Jobs - Sponsor Portal" />
|
||||||
|
|
||||||
<div className="space-y-8 select-none">
|
<div className="space-y-8 select-none">
|
||||||
{/* Header Actions */}
|
{/* Header Actions */}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ export default function Show({ job }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title="Job Details">
|
<EmployerLayout title="Job Details">
|
||||||
<Head title={`Job Details: ${job.title} - Employer Portal`} />
|
<Head title={`Job Details: ${job.title} - Sponsor Portal`} />
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
|
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default function Index({ conversations }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title={t('candidate_messages', 'Candidate Messages')}>
|
<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">
|
<div className="max-w-5xl mx-auto space-y-6 select-none">
|
||||||
{/* Header Actions */}
|
{/* Header Actions */}
|
||||||
|
|||||||
@ -27,17 +27,21 @@ import {
|
|||||||
UploadCloud,
|
UploadCloud,
|
||||||
FileText,
|
FileText,
|
||||||
Mic,
|
Mic,
|
||||||
Square
|
Square,
|
||||||
|
Clock
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Show({ conversation, initialMessages, conversations = [] }) {
|
export default function Show({ conversation, initialMessages, conversations = [], application = null }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [messages, setMessages] = useState(initialMessages || []);
|
const [messages, setMessages] = useState(initialMessages || []);
|
||||||
const [input, setInput] = useState('');
|
const [input, setInput] = useState('');
|
||||||
const [isTyping, setIsTyping] = useState(false);
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
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 [showAttachmentModal, setShowAttachmentModal] = useState(false);
|
||||||
const [uploadedFiles, setUploadedFiles] = useState([]);
|
const [uploadedFiles, setUploadedFiles] = useState([]);
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
@ -188,12 +192,39 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkHired = () => {
|
const openStatusModal = (app, statusPreset = '') => {
|
||||||
router.post(`/employer/workers/${conversation.worker_id}/mark-hired`, {}, {
|
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,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t('marked_hired', 'Worker successfully marked as Hired!'));
|
toast.success(t('status_updated_success', 'Applicant status updated successfully.'));
|
||||||
router.reload();
|
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>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
{conversation.worker_status !== 'hired' && conversation.worker_status !== 'hidden' ? (
|
{application ? (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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" />
|
<CheckCircle2 className="w-4 h-4 text-white" />
|
||||||
<span>{t('mark_hired_action', 'Mark Hired')}</span>
|
<span>{t('update_status', 'Update Status')}</span>
|
||||||
</button>
|
</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}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -529,38 +555,90 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Hired Confirmation Modal */}
|
{/* Status Update Modal */}
|
||||||
{showHireConfirmModal && (
|
{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="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="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">
|
||||||
<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">
|
{/* Modal Header */}
|
||||||
<CheckCircle2 className="w-8 h-8" />
|
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||||
</div>
|
<div>
|
||||||
|
<h3 className="font-black text-slate-900 text-base">Update Application Status</h3>
|
||||||
<div className="space-y-2">
|
<p className="text-[10px] font-bold text-[#185FA5] uppercase tracking-widest mt-0.5">{selectedApp?.name}</p>
|
||||||
<h4 className="font-extrabold text-lg text-slate-900">{t('confirm_hire_title', 'Confirm Hiring')}</h4>
|
</div>
|
||||||
<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
|
<button
|
||||||
type="button"
|
onClick={() => { setShowModal(false); setSelectedApp(null); }}
|
||||||
onClick={() => setShowHireConfirmModal(false)}
|
className="p-2 hover:bg-slate-50 text-slate-400 hover:text-slate-950 rounded-full transition-colors cursor-pointer"
|
||||||
className="flex-1 py-3 border border-slate-200 hover:bg-slate-50 rounded-xl transition-colors"
|
|
||||||
>
|
>
|
||||||
{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>
|
||||||
<button
|
<button
|
||||||
type="button"
|
onClick={handleSaveStatus}
|
||||||
onClick={() => {
|
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"
|
||||||
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')}
|
Save Status
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -312,7 +312,7 @@ export default function PaymentHistory({ payments = [], currentPlan, expiresAt,
|
|||||||
</div>
|
</div>
|
||||||
<div class="address-block">
|
<div class="address-block">
|
||||||
<h3>Invoice To</h3>
|
<h3>Invoice To</h3>
|
||||||
<p><strong>Employer Account</strong></p>
|
<p><strong>Sponsor Account</strong></p>
|
||||||
<p>Domestic Sponsor Household</p>
|
<p>Domestic Sponsor Household</p>
|
||||||
<p>sponsor@marketplace.ae</p>
|
<p>sponsor@marketplace.ae</p>
|
||||||
<p>Dubai, UAE</p>
|
<p>Dubai, UAE</p>
|
||||||
@ -591,7 +591,7 @@ export default function PaymentHistory({ payments = [], currentPlan, expiresAt,
|
|||||||
<div class="header">
|
<div class="header">
|
||||||
<div>
|
<div>
|
||||||
<div class="title">PAYMENT HISTORY STATEMENT</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>
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
<div><strong>Statement Date:</strong> ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}</div>
|
<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 (
|
return (
|
||||||
<EmployerLayout title={t('payment_history', 'Payment History')}>
|
<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">
|
<div className="space-y-8 select-none pb-16">
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import {
|
|||||||
XCircle
|
XCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { compressImage } from '../../lib/utils';
|
||||||
|
|
||||||
export default function Profile({ employerProfile }) {
|
export default function Profile({ employerProfile }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -109,7 +110,7 @@ export default function Profile({ employerProfile }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title={t('employer_profile_details', 'Profile Details')}>
|
<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">
|
<div className="max-w-5xl mx-auto pb-16 space-y-8 select-none animate-in fade-in duration-500">
|
||||||
{isExpired && (
|
{isExpired && (
|
||||||
@ -398,7 +399,7 @@ export default function Profile({ employerProfile }) {
|
|||||||
<span className="font-extrabold text-slate-700">{employerProfile?.emirates_id?.date_of_birth}</span>
|
<span className="font-extrabold text-slate-700">{employerProfile?.emirates_id?.date_of_birth}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between py-2">
|
<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}>
|
<span className="font-extrabold text-[#185FA5] truncate max-w-[200px]" title={employerProfile?.emirates_id?.employer}>
|
||||||
{employerProfile?.emirates_id?.employer}
|
{employerProfile?.emirates_id?.employer}
|
||||||
</span>
|
</span>
|
||||||
@ -484,11 +485,17 @@ export default function Profile({ employerProfile }) {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
setFrontFile(file);
|
try {
|
||||||
setFrontPreview(URL.createObjectURL(file));
|
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"
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
@ -517,11 +524,17 @@ export default function Profile({ employerProfile }) {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
onChange={(e) => {
|
onChange={async (e) => {
|
||||||
const file = e.target.files[0];
|
const file = e.target.files[0];
|
||||||
if (file) {
|
if (file) {
|
||||||
setBackFile(file);
|
try {
|
||||||
setBackPreview(URL.createObjectURL(file));
|
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"
|
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||||
|
|||||||
@ -56,7 +56,7 @@ export default function ProfileEdit({ employerProfile }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title={t('edit_profile', 'Edit Profile')}>
|
<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">
|
<div className="max-w-3xl mx-auto pb-16 space-y-8 select-none animate-in fade-in duration-500">
|
||||||
{/* Back Link */}
|
{/* Back Link */}
|
||||||
|
|||||||
173
resources/js/Pages/Employer/PublicStats.jsx
Normal file
173
resources/js/Pages/Employer/PublicStats.jsx
Normal 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">
|
||||||
|
© {new Date().getFullYear()} DirectMarket. All rights reserved. Compliant with UAE MOHRE guidelines.
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -78,7 +78,7 @@ export default function SelectedCandidates({
|
|||||||
|
|
||||||
// Apply Client-Side Filters
|
// Apply Client-Side Filters
|
||||||
const filteredWorkers = useMemo(() => {
|
const filteredWorkers = useMemo(() => {
|
||||||
let list = (selectedWorkers || []).filter(w => w.status !== 'Searching');
|
let list = (workers || []).filter(w => w.status !== 'Searching');
|
||||||
|
|
||||||
if (searchTerm.trim() !== '') {
|
if (searchTerm.trim() !== '') {
|
||||||
const query = searchTerm.toLowerCase();
|
const query = searchTerm.toLowerCase();
|
||||||
@ -134,7 +134,7 @@ export default function SelectedCandidates({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return list;
|
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 = () => {
|
const resetFilters = () => {
|
||||||
setFilterProfession('All Professions');
|
setFilterProfession('All Professions');
|
||||||
@ -185,7 +185,7 @@ export default function SelectedCandidates({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title={t('candidates_pipeline', 'Hired Workers')}>
|
<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 ── */}
|
{/* ── Filter Drawer ── */}
|
||||||
<FilterDrawer
|
<FilterDrawer
|
||||||
|
|||||||
@ -238,7 +238,15 @@ export default function Shortlist({ shortlistedWorkers, filtersMetadata = {} })
|
|||||||
} else if (sortBy === 'rating') {
|
} else if (sortBy === 'rating') {
|
||||||
list.sort((a, b) => b.rating - a.rating);
|
list.sort((a, b) => b.rating - a.rating);
|
||||||
} else if (sortBy === 'experience') {
|
} 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;
|
return list;
|
||||||
@ -269,7 +277,7 @@ export default function Shortlist({ shortlistedWorkers, filtersMetadata = {} })
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title={t('my_shortlist', 'My Shortlist')}>
|
<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="space-y-6 select-none pb-16">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|||||||
@ -38,7 +38,7 @@ export default function Subscription({ currentPlan, expiresAt, plans, invoices:
|
|||||||
|
|
||||||
// Failed payment simulation
|
// Failed payment simulation
|
||||||
const [simulatedFailedState, setSimulatedFailedState] = useState(false);
|
const [simulatedFailedState, setSimulatedFailedState] = useState(false);
|
||||||
const [activePlan, setActivePlan] = useState(currentPlan || 'Premium Employer Pass');
|
const [activePlan, setActivePlan] = useState(currentPlan || 'Premium Sponsor Pass');
|
||||||
|
|
||||||
// Payment History List
|
// Payment History List
|
||||||
const [invoices, setInvoices] = useState(initialInvoices || []);
|
const [invoices, setInvoices] = useState(initialInvoices || []);
|
||||||
@ -108,6 +108,8 @@ export default function Subscription({ currentPlan, expiresAt, plans, invoices:
|
|||||||
};
|
};
|
||||||
|
|
||||||
const downloadReceipt = (invoiceId) => {
|
const downloadReceipt = (invoiceId) => {
|
||||||
|
window.location.href = route('employer.subscription.invoice.download', { id: invoiceId });
|
||||||
|
|
||||||
toast.success(`📄 ${t('invoice_generated', 'Invoice {id} generated').replace('{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.')
|
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 (
|
return (
|
||||||
<EmployerLayout title={t('sponsor_pass_billing', 'Sponsor Pass & Billing')}>
|
<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">
|
<div className="space-y-8 select-none w-full pb-16">
|
||||||
|
|
||||||
|
|||||||
@ -30,7 +30,7 @@ export default function Create({ reasons = [] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title={t('create_new_ticket', 'Create New Ticket')}>
|
<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">
|
<div className="max-w-2xl select-none w-full pb-16 space-y-6">
|
||||||
|
|
||||||
|
|||||||
@ -92,7 +92,7 @@ export default function Index({ tickets, faqs = [] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title={t('help_support', 'Help & Support')}>
|
<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">
|
<div className="space-y-8 select-none w-full pb-16">
|
||||||
|
|
||||||
|
|||||||
@ -98,7 +98,7 @@ export default function Show({ ticket, replies }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title={t('ticket_details', 'Ticket Details')}>
|
<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">
|
<div className="max-w-4xl select-none w-full pb-24 space-y-6">
|
||||||
|
|
||||||
|
|||||||
@ -334,7 +334,15 @@ export default function Index({ initialWorkers = [], initialShortlistedIds = [],
|
|||||||
} else if (sortBy === 'rating') {
|
} else if (sortBy === 'rating') {
|
||||||
workers.sort((a, b) => b.rating - a.rating);
|
workers.sort((a, b) => b.rating - a.rating);
|
||||||
} else if (sortBy === 'experience') {
|
} 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;
|
return workers;
|
||||||
|
|||||||
@ -25,7 +25,8 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
User,
|
User,
|
||||||
Phone,
|
Phone,
|
||||||
MapPin
|
MapPin,
|
||||||
|
Clock
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
@ -70,8 +71,8 @@ const getExpiryStatus = (expiryDate) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const safeRender = (value) => {
|
const safeRender = (value, fallback = 'Not Extracted') => {
|
||||||
if (value === null || value === undefined) return 'N/A';
|
if (value === null || value === undefined || String(value).trim() === '') return fallback;
|
||||||
if (typeof value === 'object') {
|
if (typeof value === 'object') {
|
||||||
if (value.name) return String(value.name);
|
if (value.name) return String(value.name);
|
||||||
if (value.full_name) return String(value.full_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 emiratesIdDoc = worker.documents?.find(d => d.type === 'emirates_id' || d.type === 'eid');
|
||||||
const visaDoc = worker.documents?.find(d => d.type === 'visa');
|
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 [showReportModal, setShowReportModal] = useState(false);
|
||||||
const [reportReason, setReportReason] = useState('');
|
const [reportReason, setReportReason] = useState('');
|
||||||
const [reportDetails, setReportDetails] = useState('');
|
const [reportDetails, setReportDetails] = useState('');
|
||||||
@ -161,10 +208,10 @@ export default function Show({ worker }) {
|
|||||||
router.post(`/employer/workers/${worker.id}/mark-hired`, {}, {
|
router.post(`/employer/workers/${worker.id}/mark-hired`, {}, {
|
||||||
preserveScroll: true,
|
preserveScroll: true,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setAvailabilityStatus('Hired');
|
setAvailabilityStatus('Pending Confirmation');
|
||||||
setShowHiredModal(true);
|
setShowHiredModal(true);
|
||||||
toast.success(t('marked_hired', 'Worker successfully marked as Hired!'), {
|
toast.success(t('hire_request_sent', 'Hiring request sent successfully!'), {
|
||||||
description: t('marked_hired_desc', 'Sponsorship direct match finalized. Please leave an anonymous trust review!')
|
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>
|
<span>{t('start_direct_chat', 'Start Direct Chat')}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Stage 4 Hire: Mark Hired (Finalize Hiring Direct Flow) */}
|
{/* Stage 4 Hire: Pending Confirmation Indicator */}
|
||||||
{availabilityStatus !== 'Hired' && (
|
{availabilityStatus === 'Pending Confirmation' && (
|
||||||
<button
|
<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">
|
||||||
type="button"
|
<Clock className="w-4 h-4 text-amber-600 animate-pulse" />
|
||||||
onClick={() => setShowHireConfirmModal(true)}
|
<span>{t('pending_confirmation', 'Pending Confirmation')}</span>
|
||||||
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"
|
</div>
|
||||||
>
|
|
||||||
<CheckCircle2 className="w-4 h-4 text-white" />
|
|
||||||
<span>{t('mark_hired_action', 'Mark Hired')}</span>
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -463,73 +506,56 @@ export default function Show({ worker }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{passportDoc.ocr_data ? (
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
|
<div>
|
||||||
<div>
|
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('given_names', 'Given Names')}</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 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>
|
</div>
|
||||||
) : (
|
<div>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
|
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('surname', 'Surname')}</div>
|
||||||
<div>
|
<div className="text-xs font-bold text-slate-800">{safeRender(passportSurname)}</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>
|
||||||
)}
|
<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">
|
<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">
|
<span className="flex items-center space-x-1.5">
|
||||||
@ -689,109 +715,77 @@ export default function Show({ worker }) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
{visaDoc.ocr_data ? (() => {
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-y-4 gap-x-6">
|
||||||
const sponsorObj = visaDoc.ocr_data?.sponsor;
|
<div>
|
||||||
const isSponsorObj = sponsorObj && typeof sponsorObj === 'object' && !Array.isArray(sponsorObj);
|
<div className="text-[8px] font-black text-slate-400 uppercase tracking-tighter">{t('full_name', 'Full Name')}</div>
|
||||||
const sponsorName = isSponsorObj
|
<div className="text-xs font-bold text-slate-800">{safeRender(visaFullName)}</div>
|
||||||
? (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>
|
</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">
|
<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">
|
<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="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="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">
|
{availabilityStatus === 'Pending Confirmation' ? (
|
||||||
<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 className="text-center space-y-3">
|
||||||
</div>
|
<div className="w-16 h-16 bg-amber-50 text-amber-600 rounded-full flex items-center justify-center mx-auto shadow-inner">
|
||||||
<h4 className="font-extrabold text-lg text-slate-900">{t('direct_sponsoring_finalized', 'Direct Sponsoring Finalized!')}</h4>
|
<Clock className="w-8 h-8" />
|
||||||
<p className="text-xs text-slate-500 leading-relaxed">
|
</div>
|
||||||
{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)}
|
<h4 className="font-extrabold text-lg text-slate-900">{t('hire_request_sent_title', 'Hiring Request Sent')}</h4>
|
||||||
</p>
|
<p className="text-xs text-slate-500 leading-relaxed">
|
||||||
</div>
|
{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">
|
<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>
|
<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">
|
<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)}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col space-y-2 text-xs font-bold">
|
<div className="flex flex-col space-y-2 text-xs font-bold">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowHiredModal(false);
|
setShowHiredModal(false);
|
||||||
setShowReviewModal(true);
|
setShowReviewModal(true);
|
||||||
}}
|
}}
|
||||||
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl flex items-center justify-center space-x-2"
|
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" />
|
<Star className="w-4 h-4 fill-white" />
|
||||||
<span>{t('leave_trust_review', 'Leave a Trust Review')}</span>
|
<span>{t('leave_trust_review', 'Leave a Trust Review')}</span>
|
||||||
</button>
|
</button>
|
||||||
<Link
|
<Link
|
||||||
href="/employer/workers"
|
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"
|
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')}
|
{t('back_to_worker_directory', 'Back to Worker Directory')}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -1063,43 +1081,7 @@ export default function Show({ worker }) {
|
|||||||
</div>
|
</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>
|
</EmployerLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,29 +12,29 @@
|
|||||||
"profile": "Profile",
|
"profile": "Profile",
|
||||||
"sub_warning": "You need an active subscription to contact workers.",
|
"sub_warning": "You need an active subscription to contact workers.",
|
||||||
"subscribe_now": "Subscribe now",
|
"subscribe_now": "Subscribe now",
|
||||||
"employer_portal": "Employer Portal",
|
"employer_portal": "Sponsor Portal",
|
||||||
"active_until": "Active until",
|
"active_until": "Active until",
|
||||||
"expired_renew": "Expired — Renew now",
|
"expired_renew": "Expired — Renew now",
|
||||||
"overview": "Overview",
|
"overview": "Overview",
|
||||||
"my_account": "My Account",
|
"my_account": "My Account",
|
||||||
"profile_settings": "Profile Settings",
|
"profile_settings": "Profile Settings",
|
||||||
"sign_out": "Sign Out",
|
"sign_out": "Sign Out",
|
||||||
"employer_account": "Employer Account",
|
"employer_account": "Sponsor Account",
|
||||||
"welcome_title": "Welcome to Marketplace!",
|
"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",
|
"control_center": "Sponsor Control Center",
|
||||||
"dashboard_title": "Sponsor Dashboard - Verified UAE Domestic Workers",
|
"dashboard_title": "Sponsor Dashboard - Verified UAE Domestic Workers",
|
||||||
"expired_pass_title": "Your Sponsor Subscription Pass Has Expired!",
|
"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.",
|
"expired_pass_desc": "Direct communication, candidate dossiers, and messaging are locked. Renew your annual sponsor subscription now to resume hiring.",
|
||||||
"renew_sub": "Renew Subscription",
|
"renew_sub": "Renew Subscription",
|
||||||
"expiring_pass_title": "Your Premium Sponsor Pass is expiring soon!",
|
"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",
|
"renew_now": "Renew Now",
|
||||||
"sub_status": "Subscription Status",
|
"sub_status": "Subscription Status",
|
||||||
"days_left": "Days Left",
|
"days_left": "Days Left",
|
||||||
"welcome_back": "Welcome Back",
|
"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.",
|
"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",
|
"sponsor_pass_tier": "Sponsor Pass Tier",
|
||||||
"renews": "Renews",
|
"renews": "Renews",
|
||||||
"manage_billing": "Manage billing & invoices",
|
"manage_billing": "Manage billing & invoices",
|
||||||
@ -112,7 +112,7 @@
|
|||||||
"remove_from_shortlist": "Remove from shortlist",
|
"remove_from_shortlist": "Remove from shortlist",
|
||||||
"max_compare_alert": "You can compare a maximum of 3 candidates.",
|
"max_compare_alert": "You can compare a maximum of 3 candidates.",
|
||||||
"sponsor_pass_billing": "Sponsor Pass & Billing",
|
"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": "Your recurrent subscription billing has FAILED!",
|
||||||
"recurrent_billing_failed_desc": "Card renewal failed due to insufficient funds. Retry using PayTabs to avoid profile lockout.",
|
"recurrent_billing_failed_desc": "Card renewal failed due to insufficient funds. Retry using PayTabs to avoid profile lockout.",
|
||||||
"retry_payment_now": "Retry Payment Now",
|
"retry_payment_now": "Retry Payment Now",
|
||||||
@ -169,7 +169,7 @@
|
|||||||
"no_candidates_found": "No hired workers found",
|
"no_candidates_found": "No hired workers found",
|
||||||
"showing_candidates": "Showing {start} to {end} of {total} hired workers",
|
"showing_candidates": "Showing {start} to {end} of {total} hired workers",
|
||||||
"account_settings": "Account Settings",
|
"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": "Profile updated successfully",
|
||||||
"profile_updated_successfully_desc": "Household sponsor preferences and vetted credentials synchronized successfully.",
|
"profile_updated_successfully_desc": "Household sponsor preferences and vetted credentials synchronized successfully.",
|
||||||
"profile_update_failed": "Failed to update profile",
|
"profile_update_failed": "Failed to update profile",
|
||||||
@ -315,7 +315,7 @@
|
|||||||
"read": "Read",
|
"read": "Read",
|
||||||
"type_compliant_message": "Type a compliant secure message...",
|
"type_compliant_message": "Type a compliant secure message...",
|
||||||
"conversation_messages": "Candidate Messages",
|
"conversation_messages": "Candidate Messages",
|
||||||
"messages_employer_portal": "Messages - Employer Portal",
|
"messages_employer_portal": "Messages - Sponsor Portal",
|
||||||
"search_conversations": "Search conversations...",
|
"search_conversations": "Search conversations...",
|
||||||
"new_conversation": "New Conversation",
|
"new_conversation": "New Conversation",
|
||||||
"active_threads": "Active Threads",
|
"active_threads": "Active Threads",
|
||||||
@ -434,5 +434,9 @@
|
|||||||
"applicants": "Applicants",
|
"applicants": "Applicants",
|
||||||
"shortlisted_workers": "Shortlisted Workers",
|
"shortlisted_workers": "Shortlisted Workers",
|
||||||
"help_support": "Help & Support",
|
"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"
|
||||||
}
|
}
|
||||||
@ -4,3 +4,83 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs) {
|
export function cn(...inputs) {
|
||||||
return twMerge(clsx(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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -115,6 +115,8 @@
|
|||||||
Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']);
|
Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']);
|
||||||
Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']);
|
Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']);
|
||||||
Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']);
|
Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']);
|
||||||
|
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)
|
// Review Management (Worker reviewing Employer)
|
||||||
Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']);
|
Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']);
|
||||||
|
|||||||
140
routes/web.php
140
routes/web.php
@ -353,6 +353,44 @@
|
|||||||
Route::post('/tickets/{id}/generate-dev-response', [\App\Http\Controllers\Admin\SupportTicketController::class, 'generateDevResponse'])->name('admin.tickets.generate-dev-response');
|
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
|
// Employer Auth Routes — guest-only pages redirect logged-in employers to dashboard
|
||||||
Route::middleware('employer.guest')->group(function () {
|
Route::middleware('employer.guest')->group(function () {
|
||||||
Route::get('/employer/login', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showLogin'])->name('employer.login');
|
Route::get('/employer/login', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showLogin'])->name('employer.login');
|
||||||
@ -514,6 +552,108 @@
|
|||||||
]);
|
]);
|
||||||
})->name('employer.subscription');
|
})->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::post('/subscription/purchase', [\App\Http\Controllers\Employer\PaymentController::class, 'purchase'])->name('employer.subscription.purchase');
|
||||||
|
|
||||||
Route::get('/messages', [\App\Http\Controllers\Employer\MessageController::class, 'index'])->name('employer.messages');
|
Route::get('/messages', [\App\Http\Controllers\Employer\MessageController::class, 'index'])->name('employer.messages');
|
||||||
|
|||||||
19
scratch/fix_show.php
Normal file
19
scratch/fix_show.php
Normal 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));
|
||||||
@ -347,4 +347,204 @@ public function test_admin_can_reject_announcement_with_remarks()
|
|||||||
'remarks' => 'Inappropriate content or location details.',
|
'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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -107,11 +107,19 @@ public function test_employer_can_view_jobs_list()
|
|||||||
*/
|
*/
|
||||||
public function test_employer_can_view_create_job_form()
|
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)
|
$response = $this->actingAs($this->employer)
|
||||||
->withSession(['user' => $this->employer])
|
->withSession(['user' => $this->employer])
|
||||||
->get('/employer/jobs/create');
|
->get('/employer/jobs/create');
|
||||||
|
|
||||||
$response->assertStatus(200);
|
$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])
|
->withSession(['user' => $this->employer])
|
||||||
->post('/employer/jobs/create', [
|
->post('/employer/jobs/create', [
|
||||||
'title' => 'Villa Cleaner',
|
'title' => 'Villa Cleaner',
|
||||||
|
'main_profession' => 'Cleaner',
|
||||||
|
'skills' => 'Housekeeping',
|
||||||
'workers_needed' => 2,
|
'workers_needed' => 2,
|
||||||
'job_type' => 'Part Time',
|
'job_type' => 'Part Time',
|
||||||
'location' => 'Al Barsha',
|
'location' => 'Al Barsha',
|
||||||
@ -138,6 +148,7 @@ public function test_employer_can_post_job()
|
|||||||
$this->assertDatabaseHas('job_posts', [
|
$this->assertDatabaseHas('job_posts', [
|
||||||
'employer_id' => $this->employer->id,
|
'employer_id' => $this->employer->id,
|
||||||
'title' => 'Villa Cleaner',
|
'title' => 'Villa Cleaner',
|
||||||
|
'main_profession' => 'Cleaner',
|
||||||
'workers_needed' => 2,
|
'workers_needed' => 2,
|
||||||
'job_type' => 'Part Time',
|
'job_type' => 'Part Time',
|
||||||
'location' => 'Al Barsha',
|
'location' => 'Al Barsha',
|
||||||
@ -158,7 +169,7 @@ public function test_job_posting_requires_fields()
|
|||||||
'salary' => -100,
|
'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->assertStatus(200);
|
||||||
$response->assertSee('Shortlisted John');
|
$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
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
tests/Feature/EmployerMessageWebTest.php
Normal file
120
tests/Feature/EmployerMessageWebTest.php
Normal 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')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -541,11 +541,8 @@ public function test_worker_list_api_has_full_details_and_pagination()
|
|||||||
$this->assertCount(1, $matchingWorker['skills']);
|
$this->assertCount(1, $matchingWorker['skills']);
|
||||||
$this->assertEquals('Ironing', $matchingWorker['skills'][0]['name']);
|
$this->assertEquals('Ironing', $matchingWorker['skills'][0]['name']);
|
||||||
|
|
||||||
// Check documents object array
|
// Check documents object array is not present
|
||||||
$this->assertIsArray($matchingWorker['documents']);
|
$this->assertArrayNotHasKey('documents', $matchingWorker);
|
||||||
$this->assertCount(2, $matchingWorker['documents']);
|
|
||||||
$this->assertEquals('passport', $matchingWorker['documents'][0]['type']);
|
|
||||||
$this->assertEquals('visa', $matchingWorker['documents'][1]['type']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_worker_detail_api_has_full_details_without_religion_and_cleaned_ocr_data()
|
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
|
// Assert religion is removed
|
||||||
$this->assertArrayNotHasKey('religion', $wData);
|
$this->assertArrayNotHasKey('religion', $wData);
|
||||||
|
|
||||||
// Check documents object array
|
// Check documents object array is not present
|
||||||
$this->assertIsArray($wData['documents']);
|
$this->assertArrayNotHasKey('documents', $wData);
|
||||||
$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']);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -366,4 +366,119 @@ public function test_employer_review_controller_eligibility_and_edit_window()
|
|||||||
$response->assertJsonPath('success', false);
|
$response->assertJsonPath('success', false);
|
||||||
$response->assertJsonPath('message', 'The 7-day edit window for this review has expired.');
|
$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';
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -245,4 +245,64 @@ public function test_employer_can_delete_single_and_all_notifications()
|
|||||||
$response->assertJsonPath('success', true);
|
$response->assertJsonPath('success', true);
|
||||||
$this->assertEquals(0, $employer->fresh()->notifications()->count());
|
$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']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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.email', null); // Masked/null since no connection exists
|
||||||
$response->assertJsonPath('data.employer.phone', null);
|
$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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -62,7 +62,11 @@ protected function setUp(): void
|
|||||||
'religion' => 'Christian',
|
'religion' => 'Christian',
|
||||||
'bio' => 'Helper info',
|
'bio' => 'Helper info',
|
||||||
'passport_status' => 'valid',
|
'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()
|
public function test_employer_job_crud_web()
|
||||||
@ -72,6 +76,8 @@ public function test_employer_job_crud_web()
|
|||||||
->withSession(['user' => $this->employer])
|
->withSession(['user' => $this->employer])
|
||||||
->post('/employer/jobs/create', [
|
->post('/employer/jobs/create', [
|
||||||
'title' => 'Test Mason Job',
|
'title' => 'Test Mason Job',
|
||||||
|
'main_profession' => 'Plumber',
|
||||||
|
'skills' => 'Piping',
|
||||||
'workers_needed' => 3,
|
'workers_needed' => 3,
|
||||||
'location' => 'Dubai',
|
'location' => 'Dubai',
|
||||||
'salary' => 2000,
|
'salary' => 2000,
|
||||||
@ -103,6 +109,8 @@ public function test_employer_job_crud_web()
|
|||||||
->withSession(['user' => $this->employer])
|
->withSession(['user' => $this->employer])
|
||||||
->post("/employer/jobs/{$job->id}/edit", [
|
->post("/employer/jobs/{$job->id}/edit", [
|
||||||
'title' => 'Updated Mason Job',
|
'title' => 'Updated Mason Job',
|
||||||
|
'main_profession' => 'Plumber',
|
||||||
|
'skills' => 'Piping',
|
||||||
'workers_needed' => 2,
|
'workers_needed' => 2,
|
||||||
'location' => 'Abu Dhabi',
|
'location' => 'Abu Dhabi',
|
||||||
'salary' => 2500,
|
'salary' => 2500,
|
||||||
@ -137,6 +145,7 @@ public function test_worker_job_apis()
|
|||||||
$job = JobPost::create([
|
$job = JobPost::create([
|
||||||
'employer_id' => $this->employer->id,
|
'employer_id' => $this->employer->id,
|
||||||
'title' => 'Available Plumber Job',
|
'title' => 'Available Plumber Job',
|
||||||
|
'main_profession' => 'Plumber',
|
||||||
'location' => 'Dubai Marina',
|
'location' => 'Dubai Marina',
|
||||||
'salary' => 3000,
|
'salary' => 3000,
|
||||||
'workers_needed' => 1,
|
'workers_needed' => 1,
|
||||||
@ -145,6 +154,8 @@ public function test_worker_job_apis()
|
|||||||
'description' => 'Plumber description',
|
'description' => 'Plumber description',
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
|
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
|
||||||
|
$job->skills()->sync([$skill->id]);
|
||||||
|
|
||||||
// 1. List available jobs
|
// 1. List available jobs
|
||||||
$response = $this->getJson('/api/workers/jobs', [
|
$response = $this->getJson('/api/workers/jobs', [
|
||||||
@ -155,6 +166,7 @@ public function test_worker_job_apis()
|
|||||||
$response->assertJsonCount(1, 'data.jobs');
|
$response->assertJsonCount(1, 'data.jobs');
|
||||||
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
|
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
|
||||||
$response->assertJsonPath('data.jobs.0.posted_by', 'Employer User');
|
$response->assertJsonPath('data.jobs.0.posted_by', 'Employer User');
|
||||||
|
$response->assertJsonPath('data.jobs.0.status', 'new');
|
||||||
|
|
||||||
// 2. Retrieve job details
|
// 2. Retrieve job details
|
||||||
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
|
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
|
||||||
@ -164,6 +176,7 @@ public function test_worker_job_apis()
|
|||||||
$response->assertJsonPath('success', true);
|
$response->assertJsonPath('success', true);
|
||||||
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
|
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
|
||||||
$response->assertJsonPath('data.job.posted_by', 'Employer User');
|
$response->assertJsonPath('data.job.posted_by', 'Employer User');
|
||||||
|
$response->assertJsonPath('data.job.status', 'new');
|
||||||
|
|
||||||
// 3. Apply for job
|
// 3. Apply for job
|
||||||
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||||
@ -177,6 +190,13 @@ public function test_worker_job_apis()
|
|||||||
'status' => 'applied',
|
'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
|
// 4. Prevent duplicate job applications
|
||||||
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||||
'Authorization' => 'Bearer worker_api_token'
|
'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()
|
public function test_employer_job_apis_and_access_restrictions()
|
||||||
{
|
{
|
||||||
// 1. List jobs via API (premium plan by default)
|
// 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
|
// 2. Create job via API
|
||||||
$response = $this->postJson('/api/employers/jobs', [
|
$response = $this->postJson('/api/employers/jobs', [
|
||||||
'title' => 'API Clean Job',
|
'title' => 'API Clean Job',
|
||||||
|
'main_profession' => 'Plumber',
|
||||||
|
'skills' => 'Piping',
|
||||||
'workers_needed' => 3,
|
'workers_needed' => 3,
|
||||||
'location' => 'Dubai JLT',
|
'location' => 'Dubai JLT',
|
||||||
'salary' => 2200,
|
'salary' => 2200,
|
||||||
@ -263,6 +395,8 @@ public function test_employer_job_apis_and_access_restrictions()
|
|||||||
// 4. Update job via API
|
// 4. Update job via API
|
||||||
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
|
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
|
||||||
'title' => 'Updated API Clean Job',
|
'title' => 'Updated API Clean Job',
|
||||||
|
'main_profession' => 'Plumber',
|
||||||
|
'skills' => 'Piping',
|
||||||
'workers_needed' => 4,
|
'workers_needed' => 4,
|
||||||
'location' => 'Dubai Marina',
|
'location' => 'Dubai Marina',
|
||||||
'salary' => 2500,
|
'salary' => 2500,
|
||||||
@ -507,4 +641,442 @@ public function test_job_hiring_workflow_enhancements()
|
|||||||
'id' => $app->id
|
'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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -383,7 +383,7 @@ public function test_register_with_new_api_fields()
|
|||||||
'passport_file' => $fakePassport,
|
'passport_file' => $fakePassport,
|
||||||
'language' => 'HI',
|
'language' => 'HI',
|
||||||
'nationality' => 'Kenyan',
|
'nationality' => 'Kenyan',
|
||||||
'age' => 30,
|
'age' => '26-35',
|
||||||
'experience' => '5 Years',
|
'experience' => '5 Years',
|
||||||
'in_country' => 'true',
|
'in_country' => 'true',
|
||||||
'visa_status' => 'Tourist Visa',
|
'visa_status' => 'Tourist Visa',
|
||||||
@ -399,6 +399,7 @@ public function test_register_with_new_api_fields()
|
|||||||
$this->assertDatabaseHas('workers', [
|
$this->assertDatabaseHas('workers', [
|
||||||
'name' => 'John New Worker',
|
'name' => 'John New Worker',
|
||||||
'phone' => '+971501111112',
|
'phone' => '+971501111112',
|
||||||
|
'age' => '26-35',
|
||||||
'in_country' => true,
|
'in_country' => true,
|
||||||
'visa_status' => 'Tourist Visa',
|
'visa_status' => 'Tourist Visa',
|
||||||
'preferred_job_type' => 'full-time',
|
'preferred_job_type' => 'full-time',
|
||||||
@ -435,14 +436,12 @@ public function test_register_with_ocr_extraction()
|
|||||||
$response->assertStatus(201);
|
$response->assertStatus(201);
|
||||||
$workerId = $response->json('data.worker.id');
|
$workerId = $response->json('data.worker.id');
|
||||||
|
|
||||||
$expectedAge = now()->diff(new \DateTime('1990-11-07'))->y;
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('workers', [
|
$this->assertDatabaseHas('workers', [
|
||||||
'id' => $workerId,
|
'id' => $workerId,
|
||||||
'name' => 'MATAR ALI KHARBASH ALSAEDI',
|
'name' => 'MATAR ALI KHARBASH ALSAEDI',
|
||||||
'phone' => '+971509999999',
|
'phone' => '+971509999999',
|
||||||
'nationality' => 'Emirati',
|
'nationality' => 'Emirati',
|
||||||
'age' => $expectedAge,
|
'age' => '26-35',
|
||||||
'verified' => true,
|
'verified' => true,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -497,15 +496,12 @@ public function test_register_passport_and_visa_with_direct_json_payload()
|
|||||||
|
|
||||||
$response->assertStatus(201);
|
$response->assertStatus(201);
|
||||||
$workerId = $response->json('data.worker.id');
|
$workerId = $response->json('data.worker.id');
|
||||||
|
|
||||||
$expectedAge = now()->diff(new \DateTime('1990-05-14'))->y;
|
|
||||||
|
|
||||||
$this->assertDatabaseHas('workers', [
|
$this->assertDatabaseHas('workers', [
|
||||||
'id' => $workerId,
|
'id' => $workerId,
|
||||||
'name' => 'Jane Doe',
|
'name' => 'Jane Doe',
|
||||||
'phone' => '+971508888888',
|
'phone' => '+971508888888',
|
||||||
'nationality' => 'Filipino',
|
'nationality' => 'Filipino',
|
||||||
'age' => $expectedAge,
|
'age' => '36-45',
|
||||||
'verified' => true,
|
'verified' => true,
|
||||||
'visa_status' => 'Tourist Visa',
|
'visa_status' => 'Tourist Visa',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export default defineConfig({
|
|||||||
port: 5173,
|
port: 5173,
|
||||||
cors: true,
|
cors: true,
|
||||||
hmr: {
|
hmr: {
|
||||||
host: '192.168.29.131',
|
host: '192.168.0.217',
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ['**/storage/framework/views/**'],
|
ignored: ['**/storage/framework/views/**'],
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user