worker job , employer job module - added
This commit is contained in:
parent
4a77c7689c
commit
e191516573
@ -252,6 +252,10 @@ public function register(Request $request)
|
|||||||
$emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? $emiratesIdInput['issuing_place'] ?? null;
|
$emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? $emiratesIdInput['issuing_place'] ?? null;
|
||||||
$emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null;
|
$emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null;
|
||||||
$emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null;
|
$emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null;
|
||||||
|
|
||||||
|
$emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry);
|
||||||
|
$emiratesIdDob = $this->normaliseDate($emiratesIdDob);
|
||||||
|
$emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create inactive User
|
// Create inactive User
|
||||||
@ -606,38 +610,55 @@ public function password(Request $request)
|
|||||||
|
|
||||||
public function plans()
|
public function plans()
|
||||||
{
|
{
|
||||||
|
$dbPlans = \App\Models\Plan::where('status', 'Active')->get();
|
||||||
|
if ($dbPlans->isEmpty()) {
|
||||||
|
$plans = [
|
||||||
|
[
|
||||||
|
'id' => 'basic',
|
||||||
|
'name' => 'Basic Search',
|
||||||
|
'price' => 99.00,
|
||||||
|
'currency' => 'AED',
|
||||||
|
'period' => 'month',
|
||||||
|
'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'],
|
||||||
|
'popular' => false,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'premium',
|
||||||
|
'name' => 'Premium Employer Pass',
|
||||||
|
'price' => 199.00,
|
||||||
|
'currency' => 'AED',
|
||||||
|
'period' => 'month',
|
||||||
|
'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'],
|
||||||
|
'popular' => true,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'enterprise',
|
||||||
|
'name' => 'VIP Concierge',
|
||||||
|
'price' => 499.00,
|
||||||
|
'currency' => 'AED',
|
||||||
|
'period' => 'month',
|
||||||
|
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
|
||||||
|
'popular' => false,
|
||||||
|
]
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
$plans = $dbPlans->map(function ($plan) {
|
||||||
|
return [
|
||||||
|
'id' => $plan->id === 'vip' ? 'enterprise' : $plan->id,
|
||||||
|
'name' => $plan->name,
|
||||||
|
'price' => (float)$plan->price,
|
||||||
|
'currency' => 'AED',
|
||||||
|
'period' => 'month',
|
||||||
|
'features' => $plan->features,
|
||||||
|
'popular' => $plan->id === 'premium',
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'data' => [
|
'data' => [
|
||||||
'plans' => [
|
'plans' => $plans
|
||||||
[
|
|
||||||
'id' => 'basic',
|
|
||||||
'name' => 'Basic Search',
|
|
||||||
'price' => 99.00,
|
|
||||||
'currency' => 'AED',
|
|
||||||
'period' => 'month',
|
|
||||||
'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'],
|
|
||||||
'popular' => false,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'premium',
|
|
||||||
'name' => 'Premium Employer Pass',
|
|
||||||
'price' => 199.00,
|
|
||||||
'currency' => 'AED',
|
|
||||||
'period' => 'month',
|
|
||||||
'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'],
|
|
||||||
'popular' => true,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'enterprise',
|
|
||||||
'name' => 'VIP Concierge',
|
|
||||||
'price' => 499.00,
|
|
||||||
'currency' => 'AED',
|
|
||||||
'period' => 'month',
|
|
||||||
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
|
|
||||||
'popular' => false,
|
|
||||||
]
|
|
||||||
]
|
|
||||||
]
|
]
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -779,4 +800,36 @@ public function resetPassword(Request $request)
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise date format to Y-m-d.
|
||||||
|
* E.g. "06/06/2025" -> "2025-06-06"
|
||||||
|
*/
|
||||||
|
private function normaliseDate(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if (empty($raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = trim($raw);
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
|
||||||
|
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||||
|
$month = $m[2];
|
||||||
|
$year = $m[3];
|
||||||
|
if (is_numeric($month)) {
|
||||||
|
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||||
|
return "{$year}-{$month}-{$day}";
|
||||||
|
}
|
||||||
|
$ts = strtotime("{$day} {$month} {$year}");
|
||||||
|
return $ts ? date('Y-m-d', $ts) : $raw;
|
||||||
|
}
|
||||||
|
$dt = new \DateTime($raw);
|
||||||
|
return $dt->format('Y-m-d');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -334,10 +334,28 @@ public function startConversation(Request $request)
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Find or create conversation
|
// Find or create conversation
|
||||||
$conv = Conversation::firstOrCreate([
|
$conv = Conversation::where('employer_id', $employer->id)
|
||||||
'employer_id' => $employer->id,
|
->where('worker_id', $request->worker_id)
|
||||||
'worker_id' => $request->worker_id,
|
->first();
|
||||||
]);
|
|
||||||
|
if (!$conv) {
|
||||||
|
if (!User::canContactWorker($employer->id, $request->worker_id)) {
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first();
|
||||||
|
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$conv = Conversation::create([
|
||||||
|
'employer_id' => $employer->id,
|
||||||
|
'worker_id' => $request->worker_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'success' => true,
|
'success' => true,
|
||||||
|
|||||||
@ -38,10 +38,15 @@ public function getProfile(Request $request)
|
|||||||
'name' => $employer->name,
|
'name' => $employer->name,
|
||||||
'email' => $employer->email,
|
'email' => $employer->email,
|
||||||
'phone' => $profile->phone,
|
'phone' => $profile->phone,
|
||||||
|
'address' => $profile->address,
|
||||||
'country' => $profile->country,
|
'country' => $profile->country,
|
||||||
'language' => $profile->language ?? 'English',
|
'language' => $profile->language ?? 'English',
|
||||||
'notifications' => (bool)($profile->notifications ?? true),
|
'notifications' => (bool)($profile->notifications ?? true),
|
||||||
'verification_status' => $profile->verification_status ?? 'approved',
|
'verification_status' => $profile->verification_status ?? 'approved',
|
||||||
|
'id_number' => $profile->emirates_id_number,
|
||||||
|
'card_number' => $profile->emirates_id_card_number,
|
||||||
|
'full_name' => $profile->emirates_id_name,
|
||||||
|
'gender' => $profile->emirates_id_gender,
|
||||||
'emirates_id' => [
|
'emirates_id' => [
|
||||||
'emirates_id_number' => $profile->emirates_id_number,
|
'emirates_id_number' => $profile->emirates_id_number,
|
||||||
'name' => $profile->emirates_id_name,
|
'name' => $profile->emirates_id_name,
|
||||||
@ -51,7 +56,7 @@ public function getProfile(Request $request)
|
|||||||
'employer' => $profile->emirates_id_employer,
|
'employer' => $profile->emirates_id_employer,
|
||||||
'issue_place' => $profile->emirates_id_issue_place,
|
'issue_place' => $profile->emirates_id_issue_place,
|
||||||
'occupation' => $profile->emirates_id_occupation,
|
'occupation' => $profile->emirates_id_occupation,
|
||||||
'nationality' => $profile->emirates_id_nationality,
|
'nationality' => $profile->nationality,
|
||||||
'gender' => $profile->emirates_id_gender,
|
'gender' => $profile->emirates_id_gender,
|
||||||
'card_number' => $profile->emirates_id_card_number,
|
'card_number' => $profile->emirates_id_card_number,
|
||||||
'country' => 'United Arab Emirates',
|
'country' => 'United Arab Emirates',
|
||||||
@ -190,7 +195,7 @@ public function updateProfile(Request $request)
|
|||||||
$sponsorData['emirates_id_name'] = $request->full_name;
|
$sponsorData['emirates_id_name'] = $request->full_name;
|
||||||
}
|
}
|
||||||
if ($request->has('date_of_birth')) {
|
if ($request->has('date_of_birth')) {
|
||||||
$sponsorData['emirates_id_dob'] = $request->date_of_birth;
|
$sponsorData['emirates_id_dob'] = $this->normaliseDate($request->date_of_birth);
|
||||||
}
|
}
|
||||||
if ($request->has('nationality')) {
|
if ($request->has('nationality')) {
|
||||||
$sponsorData['nationality'] = $request->nationality;
|
$sponsorData['nationality'] = $request->nationality;
|
||||||
@ -199,10 +204,10 @@ public function updateProfile(Request $request)
|
|||||||
$sponsorData['emirates_id_gender'] = $request->gender;
|
$sponsorData['emirates_id_gender'] = $request->gender;
|
||||||
}
|
}
|
||||||
if ($request->has('issue_date')) {
|
if ($request->has('issue_date')) {
|
||||||
$sponsorData['emirates_id_issue_date'] = $request->issue_date;
|
$sponsorData['emirates_id_issue_date'] = $this->normaliseDate($request->issue_date);
|
||||||
}
|
}
|
||||||
if ($request->has('expiry_date')) {
|
if ($request->has('expiry_date')) {
|
||||||
$sponsorData['emirates_id_expiry'] = $request->expiry_date;
|
$sponsorData['emirates_id_expiry'] = $this->normaliseDate($request->expiry_date);
|
||||||
}
|
}
|
||||||
if ($request->has('occupation')) {
|
if ($request->has('occupation')) {
|
||||||
$sponsorData['emirates_id_occupation'] = $request->occupation;
|
$sponsorData['emirates_id_occupation'] = $request->occupation;
|
||||||
@ -236,7 +241,7 @@ public function updateProfile(Request $request)
|
|||||||
$profile->emirates_id_name = $request->full_name;
|
$profile->emirates_id_name = $request->full_name;
|
||||||
}
|
}
|
||||||
if ($request->has('date_of_birth')) {
|
if ($request->has('date_of_birth')) {
|
||||||
$profile->emirates_id_dob = $request->date_of_birth;
|
$profile->emirates_id_dob = $this->normaliseDate($request->date_of_birth);
|
||||||
}
|
}
|
||||||
if ($request->has('nationality')) {
|
if ($request->has('nationality')) {
|
||||||
$profile->nationality = $request->nationality;
|
$profile->nationality = $request->nationality;
|
||||||
@ -245,10 +250,10 @@ public function updateProfile(Request $request)
|
|||||||
$profile->emirates_id_gender = $request->gender;
|
$profile->emirates_id_gender = $request->gender;
|
||||||
}
|
}
|
||||||
if ($request->has('issue_date')) {
|
if ($request->has('issue_date')) {
|
||||||
$profile->emirates_id_issue_date = $request->issue_date;
|
$profile->emirates_id_issue_date = $this->normaliseDate($request->issue_date);
|
||||||
}
|
}
|
||||||
if ($request->has('expiry_date')) {
|
if ($request->has('expiry_date')) {
|
||||||
$profile->emirates_id_expiry = $request->expiry_date;
|
$profile->emirates_id_expiry = $this->normaliseDate($request->expiry_date);
|
||||||
}
|
}
|
||||||
if ($request->has('occupation')) {
|
if ($request->has('occupation')) {
|
||||||
$profile->emirates_id_occupation = $request->occupation;
|
$profile->emirates_id_occupation = $request->occupation;
|
||||||
@ -266,10 +271,15 @@ public function updateProfile(Request $request)
|
|||||||
'name' => $employer->name,
|
'name' => $employer->name,
|
||||||
'email' => $employer->email,
|
'email' => $employer->email,
|
||||||
'phone' => $profile->phone,
|
'phone' => $profile->phone,
|
||||||
|
'address' => $profile->address,
|
||||||
'country' => $profile->country,
|
'country' => $profile->country,
|
||||||
'language' => $profile->language,
|
'language' => $profile->language,
|
||||||
'notifications' => (bool)$profile->notifications,
|
'notifications' => (bool)$profile->notifications,
|
||||||
'verification_status' => $profile->verification_status ?? 'approved',
|
'verification_status' => $profile->verification_status ?? 'approved',
|
||||||
|
'id_number' => $profile->emirates_id_number,
|
||||||
|
'card_number' => $profile->emirates_id_card_number,
|
||||||
|
'full_name' => $profile->emirates_id_name,
|
||||||
|
'gender' => $profile->emirates_id_gender,
|
||||||
'emirates_id' => [
|
'emirates_id' => [
|
||||||
'emirates_id_number' => $profile->emirates_id_number,
|
'emirates_id_number' => $profile->emirates_id_number,
|
||||||
'name' => $profile->emirates_id_name,
|
'name' => $profile->emirates_id_name,
|
||||||
@ -279,7 +289,7 @@ public function updateProfile(Request $request)
|
|||||||
'employer' => $profile->emirates_id_employer,
|
'employer' => $profile->emirates_id_employer,
|
||||||
'issue_place' => $profile->emirates_id_issue_place,
|
'issue_place' => $profile->emirates_id_issue_place,
|
||||||
'occupation' => $profile->emirates_id_occupation,
|
'occupation' => $profile->emirates_id_occupation,
|
||||||
'nationality' => $profile->emirates_id_nationality,
|
'nationality' => $profile->nationality,
|
||||||
'gender' => $profile->emirates_id_gender,
|
'gender' => $profile->emirates_id_gender,
|
||||||
'card_number' => $profile->emirates_id_card_number,
|
'card_number' => $profile->emirates_id_card_number,
|
||||||
'country' => 'United Arab Emirates',
|
'country' => 'United Arab Emirates',
|
||||||
@ -483,4 +493,35 @@ public function changePassword(Request $request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise date format to Y-m-d.
|
||||||
|
* E.g. "06/06/2025" -> "2025-06-06"
|
||||||
|
*/
|
||||||
|
private function normaliseDate(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if (empty($raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = trim($raw);
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
|
||||||
|
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||||
|
$month = $m[2];
|
||||||
|
$year = $m[3];
|
||||||
|
if (is_numeric($month)) {
|
||||||
|
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||||
|
return "{$year}-{$month}-{$day}";
|
||||||
|
}
|
||||||
|
$ts = strtotime("{$day} {$month} {$year}");
|
||||||
|
return $ts ? date('Y-m-d', $ts) : $raw;
|
||||||
|
}
|
||||||
|
$dt = new \DateTime($raw);
|
||||||
|
return $dt->format('Y-m-d');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -737,6 +737,39 @@ public function hireCandidate(Request $request, $id = null)
|
|||||||
$updatedApplication = null;
|
$updatedApplication = null;
|
||||||
$updatedOffer = null;
|
$updatedOffer = null;
|
||||||
|
|
||||||
|
// Resolve the worker ID to perform limit check
|
||||||
|
$resolvedWorkerId = null;
|
||||||
|
if (is_string($targetId) && str_starts_with($targetId, 'offer_')) {
|
||||||
|
$offerId = substr($targetId, 6);
|
||||||
|
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->first();
|
||||||
|
if ($offer) {
|
||||||
|
$resolvedWorkerId = $offer->worker_id;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$application = JobApplication::where('id', $targetId)
|
||||||
|
->whereHas('jobPost', function($q) use ($employerId) {
|
||||||
|
$q->where('employer_id', $employerId);
|
||||||
|
})->first();
|
||||||
|
|
||||||
|
if ($application) {
|
||||||
|
$resolvedWorkerId = $application->worker_id;
|
||||||
|
} else {
|
||||||
|
$resolvedWorkerId = $targetId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($resolvedWorkerId && !\App\Models\User::canContactWorker($employerId, $resolvedWorkerId)) {
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
|
||||||
|
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
// Direct Offer check
|
// Direct Offer check
|
||||||
if (is_string($targetId) && str_starts_with($targetId, 'offer_')) {
|
if (is_string($targetId) && str_starts_with($targetId, 'offer_')) {
|
||||||
$offerId = substr($targetId, 6);
|
$offerId = substr($targetId, 6);
|
||||||
|
|||||||
@ -103,6 +103,10 @@ public function register(Request $request)
|
|||||||
$emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? null;
|
$emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? null;
|
||||||
$emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null;
|
$emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null;
|
||||||
$emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null;
|
$emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null;
|
||||||
|
|
||||||
|
$emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry);
|
||||||
|
$emiratesIdDob = $this->normaliseDate($emiratesIdDob);
|
||||||
|
$emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
$emiratesIdPath = null;
|
$emiratesIdPath = null;
|
||||||
@ -495,5 +499,37 @@ public function resetPassword(Request $request)
|
|||||||
'message' => 'Password reset successfully. You can now log in with your new password.',
|
'message' => 'Password reset successfully. You can now log in with your new password.',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise date format to Y-m-d.
|
||||||
|
* E.g. "06/06/2025" -> "2025-06-06"
|
||||||
|
*/
|
||||||
|
private function normaliseDate(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if (empty($raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = trim($raw);
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
|
||||||
|
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||||
|
$month = $m[2];
|
||||||
|
$year = $m[3];
|
||||||
|
if (is_numeric($month)) {
|
||||||
|
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||||
|
return "{$year}-{$month}-{$day}";
|
||||||
|
}
|
||||||
|
$ts = strtotime("{$day} {$month} {$year}");
|
||||||
|
return $ts ? date('Y-m-d', $ts) : $raw;
|
||||||
|
}
|
||||||
|
$dt = new \DateTime($raw);
|
||||||
|
return $dt->format('Y-m-d');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -634,6 +634,11 @@ public function updateProfile(Request $request)
|
|||||||
$eidCardNumber = $eidCardNumber ?? $request->input('card_number');
|
$eidCardNumber = $eidCardNumber ?? $request->input('card_number');
|
||||||
$eidGender = $eidGender ?? $request->input('gender');
|
$eidGender = $eidGender ?? $request->input('gender');
|
||||||
|
|
||||||
|
// Normalize dates
|
||||||
|
$eidDob = $this->normaliseDate($eidDob);
|
||||||
|
$eidExpiry = $this->normaliseDate($eidExpiry);
|
||||||
|
$eidIssueDate = $this->normaliseDate($eidIssueDate);
|
||||||
|
|
||||||
$nameToUse = $eidName ?? $request->name ?? $request->full_name;
|
$nameToUse = $eidName ?? $request->name ?? $request->full_name;
|
||||||
|
|
||||||
// Validate Emirates ID immutability — once set, it cannot be changed
|
// Validate Emirates ID immutability — once set, it cannot be changed
|
||||||
@ -738,6 +743,13 @@ public function updateProfile(Request $request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!empty($newLicenseData)) {
|
if (!empty($newLicenseData)) {
|
||||||
|
if (isset($newLicenseData['expiry_date'])) {
|
||||||
|
$newLicenseData['expiry_date'] = $this->normaliseDate($newLicenseData['expiry_date']);
|
||||||
|
$sponsorData['license_expiry'] = $newLicenseData['expiry_date'];
|
||||||
|
}
|
||||||
|
if (isset($newLicenseData['issue_date'])) {
|
||||||
|
$newLicenseData['issue_date'] = $this->normaliseDate($newLicenseData['issue_date']);
|
||||||
|
}
|
||||||
$sponsorData['license_data'] = array_merge($currentLicenseData, $newLicenseData);
|
$sponsorData['license_data'] = array_merge($currentLicenseData, $newLicenseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -761,4 +773,36 @@ public function updateProfile(Request $request)
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalise date format to Y-m-d.
|
||||||
|
* E.g. "06/06/2025" -> "2025-06-06"
|
||||||
|
*/
|
||||||
|
private function normaliseDate(?string $raw): ?string
|
||||||
|
{
|
||||||
|
if (empty($raw)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$raw = trim($raw);
|
||||||
|
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) {
|
||||||
|
$day = str_pad($m[1], 2, '0', STR_PAD_LEFT);
|
||||||
|
$month = $m[2];
|
||||||
|
$year = $m[3];
|
||||||
|
if (is_numeric($month)) {
|
||||||
|
$month = str_pad($month, 2, '0', STR_PAD_LEFT);
|
||||||
|
return "{$year}-{$month}-{$day}";
|
||||||
|
}
|
||||||
|
$ts = strtotime("{$day} {$month} {$year}");
|
||||||
|
return $ts ? date('Y-m-d', $ts) : $raw;
|
||||||
|
}
|
||||||
|
$dt = new \DateTime($raw);
|
||||||
|
return $dt->format('Y-m-d');
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return $raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
808
app/Http/Controllers/Api/WorkerJobController.php
Normal file
808
app/Http/Controllers/Api/WorkerJobController.php
Normal file
@ -0,0 +1,808 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use App\Models\JobApplication;
|
||||||
|
use App\Models\Worker;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class WorkerJobController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* API to list available jobs.
|
||||||
|
* GET /api/workers/jobs
|
||||||
|
*/
|
||||||
|
public function listJobs(Request $request)
|
||||||
|
{
|
||||||
|
$jobs = JobPost::where('status', 'active')
|
||||||
|
->with(['employer.employerProfile'])
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
->map(function ($job) {
|
||||||
|
return [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'workers_needed' => $job->workers_needed,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||||
|
'description' => $job->description,
|
||||||
|
'requirements' => $job->requirements,
|
||||||
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'jobs' => $jobs
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API to retrieve job details.
|
||||||
|
* GET /api/workers/jobs/{id}
|
||||||
|
*/
|
||||||
|
public function jobDetails($id)
|
||||||
|
{
|
||||||
|
$job = JobPost::where('status', 'active')
|
||||||
|
->with(['employer.employerProfile'])
|
||||||
|
->find($id);
|
||||||
|
|
||||||
|
if (!$job) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Job not found.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'job' => [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'workers_needed' => $job->workers_needed,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||||
|
'description' => $job->description,
|
||||||
|
'requirements' => $job->requirements,
|
||||||
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for workers to apply for a job.
|
||||||
|
* POST /api/workers/jobs/{id}/apply
|
||||||
|
*/
|
||||||
|
public function applyJob(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
$job = JobPost::where('status', 'active')->find($id);
|
||||||
|
|
||||||
|
if (!$job) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Job not found.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent duplicate applications
|
||||||
|
$exists = JobApplication::where('worker_id', $worker->id)
|
||||||
|
->where('job_id', $job->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($exists) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'You have already applied for this job.'
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
$application = JobApplication::create([
|
||||||
|
'worker_id' => $worker->id,
|
||||||
|
'job_id' => $job->id,
|
||||||
|
'status' => 'applied'
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Successfully applied for the job.',
|
||||||
|
'data' => [
|
||||||
|
'application' => $application
|
||||||
|
]
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for workers to view their applied jobs.
|
||||||
|
* GET /api/workers/applied-jobs
|
||||||
|
*/
|
||||||
|
public function appliedJobs(Request $request)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
$applications = JobApplication::where('worker_id', $worker->id)
|
||||||
|
->with(['jobPost.employer.employerProfile'])
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
->map(function ($app) {
|
||||||
|
$job = $app->jobPost;
|
||||||
|
return [
|
||||||
|
'application_id' => $app->id,
|
||||||
|
'status' => $app->status,
|
||||||
|
'applied_at' => $app->created_at->toIso8601String(),
|
||||||
|
'job' => $job ? [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
|
] : null
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'applications' => $applications
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function checkJobAccess($employerId)
|
||||||
|
{
|
||||||
|
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||||
|
->where('user_id', $employerId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$planId = $sub ? $sub->plan_id : 'basic';
|
||||||
|
return $planId !== 'basic';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for employers to list their jobs.
|
||||||
|
* GET /api/employers/jobs
|
||||||
|
*/
|
||||||
|
public function employerListJobs(Request $request)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($employer->id)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobs = JobPost::where('employer_id', $employer->id)
|
||||||
|
->with(['applications'])
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
->map(function ($job) {
|
||||||
|
return [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'workers_needed' => $job->workers_needed,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||||
|
'description' => $job->description,
|
||||||
|
'requirements' => $job->requirements,
|
||||||
|
'applied_count' => $job->applications->count(),
|
||||||
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
'status' => $job->status,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'jobs' => $jobs
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for employers to create a job.
|
||||||
|
* POST /api/employers/jobs
|
||||||
|
*/
|
||||||
|
public function employerCreateJob(Request $request)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($employer->id)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'workers_needed' => 'required|integer|min:1',
|
||||||
|
'location' => 'required|string|max:255',
|
||||||
|
'salary' => 'required|numeric|min:0',
|
||||||
|
'job_type' => 'required|string|in:Full Time,Part Time,Contract',
|
||||||
|
'start_date' => 'required|date',
|
||||||
|
'description' => 'required|string|max:1000',
|
||||||
|
'requirements' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Validation error.',
|
||||||
|
'errors' => $validator->errors()
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::create([
|
||||||
|
'employer_id' => $employer->id,
|
||||||
|
'title' => $request->title,
|
||||||
|
'workers_needed' => $request->workers_needed,
|
||||||
|
'job_type' => $request->job_type,
|
||||||
|
'location' => $request->location,
|
||||||
|
'salary' => $request->salary,
|
||||||
|
'start_date' => $request->start_date,
|
||||||
|
'description' => $request->description,
|
||||||
|
'requirements' => $request->requirements,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Job posted successfully.',
|
||||||
|
'data' => [
|
||||||
|
'job' => [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'workers_needed' => $job->workers_needed,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||||
|
'description' => $job->description,
|
||||||
|
'requirements' => $job->requirements,
|
||||||
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
'status' => $job->status,
|
||||||
|
]
|
||||||
|
]
|
||||||
|
], 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for employers to retrieve details of a specific job.
|
||||||
|
* GET /api/employers/jobs/{id}
|
||||||
|
*/
|
||||||
|
public function employerJobDetail(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($employer->id)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::where('employer_id', $employer->id)
|
||||||
|
->with(['applications'])
|
||||||
|
->find($id);
|
||||||
|
|
||||||
|
if (!$job) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Job not found or unauthorized.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'job' => [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'workers_needed' => $job->workers_needed,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||||
|
'description' => $job->description,
|
||||||
|
'requirements' => $job->requirements,
|
||||||
|
'applied_count' => $job->applications->count(),
|
||||||
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
'status' => $job->status,
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for employers to update a specific job.
|
||||||
|
* POST /api/employers/jobs/{id}/update
|
||||||
|
*/
|
||||||
|
public function employerUpdateJob(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($employer->id)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::where('employer_id', $employer->id)->find($id);
|
||||||
|
|
||||||
|
if (!$job) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Job not found or unauthorized.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'workers_needed' => 'required|integer|min:1',
|
||||||
|
'location' => 'required|string|max:255',
|
||||||
|
'salary' => 'required|numeric|min:0',
|
||||||
|
'job_type' => 'required|string|in:Full Time,Part Time,Contract',
|
||||||
|
'start_date' => 'required|date',
|
||||||
|
'description' => 'required|string|max:1000',
|
||||||
|
'requirements' => 'nullable|string',
|
||||||
|
'status' => 'required|string|in:active,closed,draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Validation error.',
|
||||||
|
'errors' => $validator->errors()
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job->update([
|
||||||
|
'title' => $request->title,
|
||||||
|
'workers_needed' => $request->workers_needed,
|
||||||
|
'job_type' => $request->job_type,
|
||||||
|
'location' => $request->location,
|
||||||
|
'salary' => $request->salary,
|
||||||
|
'start_date' => $request->start_date,
|
||||||
|
'description' => $request->description,
|
||||||
|
'requirements' => $request->requirements,
|
||||||
|
'status' => strtolower($request->status),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Job updated successfully.',
|
||||||
|
'data' => [
|
||||||
|
'job' => [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'workers_needed' => $job->workers_needed,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null,
|
||||||
|
'description' => $job->description,
|
||||||
|
'requirements' => $job->requirements,
|
||||||
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
'status' => $job->status,
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for employers to delete a job.
|
||||||
|
* DELETE /api/employers/jobs/{id}
|
||||||
|
*/
|
||||||
|
public function employerDeleteJob(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($employer->id)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::where('employer_id', $employer->id)->find($id);
|
||||||
|
|
||||||
|
if (!$job) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Job not found or unauthorized.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Job deleted successfully.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for employers to view applicants for each job.
|
||||||
|
* GET /api/employers/jobs/{id}/applicants
|
||||||
|
*/
|
||||||
|
public function employerJobApplicants(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($employer->id)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::where('employer_id', $employer->id)->find($id);
|
||||||
|
|
||||||
|
if (!$job) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Job not found or unauthorized.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$query = JobApplication::where('job_id', $job->id)
|
||||||
|
->with(['worker.skills']);
|
||||||
|
|
||||||
|
// Search filter: name, nationality
|
||||||
|
if ($request->filled('search')) {
|
||||||
|
$search = $request->search;
|
||||||
|
$query->whereHas('worker', function($q) use ($search) {
|
||||||
|
$q->where('name', 'like', "%{$search}%")
|
||||||
|
->orWhere('nationality', 'like', "%{$search}%");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status filter
|
||||||
|
if ($request->filled('status')) {
|
||||||
|
$query->where('status', strtolower($request->status));
|
||||||
|
}
|
||||||
|
|
||||||
|
$sortBy = $request->input('sort_by', 'created_at');
|
||||||
|
$sortOrder = $request->input('sort_order', 'desc');
|
||||||
|
|
||||||
|
if ($sortBy === 'created_at' || $sortBy === 'applied_at') {
|
||||||
|
$query->orderBy('created_at', $sortOrder === 'asc' ? 'asc' : 'desc');
|
||||||
|
}
|
||||||
|
|
||||||
|
$applicants = $query->get()->map(function ($app) use ($employer) {
|
||||||
|
$w = $app->worker;
|
||||||
|
if (!$w) return null;
|
||||||
|
|
||||||
|
// Check if worker is saved in Saved Workers (Shortlist)
|
||||||
|
$isSaved = \App\Models\Shortlist::where('employer_id', $employer->id)
|
||||||
|
->where('worker_id', $w->id)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'application_id' => $app->id,
|
||||||
|
'status' => $app->status,
|
||||||
|
'notes' => $app->notes,
|
||||||
|
'status_history' => $app->status_history ?: [],
|
||||||
|
'applied_at' => $app->created_at->toIso8601String(),
|
||||||
|
'is_saved' => $isSaved,
|
||||||
|
'worker' => [
|
||||||
|
'id' => $w->id,
|
||||||
|
'name' => $w->name,
|
||||||
|
'nationality' => $w->nationality,
|
||||||
|
'salary' => (int) $w->salary,
|
||||||
|
'preferred_location' => $w->preferred_location,
|
||||||
|
'preferred_job_type' => $w->preferred_job_type,
|
||||||
|
'visa_status' => $w->visa_status,
|
||||||
|
'experience' => $w->experience,
|
||||||
|
'skills' => $w->skills->pluck('name')->toArray(),
|
||||||
|
]
|
||||||
|
];
|
||||||
|
})->filter()->values();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'job' => [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
],
|
||||||
|
'applicants' => $applicants
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for employers to update application status.
|
||||||
|
* POST /api/employers/applications/{id}/status
|
||||||
|
*/
|
||||||
|
public function employerUpdateApplicationStatus(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($employer->id)) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Validation error.',
|
||||||
|
'errors' => $validator->errors()
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$application = JobApplication::where('id', $id)
|
||||||
|
->whereHas('jobPost', function($q) use ($employer) {
|
||||||
|
$q->where('employer_id', $employer->id);
|
||||||
|
})->first();
|
||||||
|
|
||||||
|
if (!$application) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Job application not found or unauthorized.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$dbStatus = strtolower($request->status);
|
||||||
|
|
||||||
|
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' for the first time
|
||||||
|
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
|
||||||
|
if (in_array($dbStatus, $contactStatuses)) {
|
||||||
|
if (!User::canContactWorker($employer->id, $application->worker_id)) {
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first();
|
||||||
|
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$notes = $request->input('notes');
|
||||||
|
|
||||||
|
// Update application status & history
|
||||||
|
$history = $application->status_history ?: [];
|
||||||
|
$history[] = [
|
||||||
|
'status' => $dbStatus,
|
||||||
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
'notes' => $notes,
|
||||||
|
];
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'status' => $dbStatus,
|
||||||
|
'status_history' => $history,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($notes !== null) {
|
||||||
|
$updateData['notes'] = $notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$application->update($updateData);
|
||||||
|
|
||||||
|
// If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved
|
||||||
|
if ($dbStatus === 'shortlisted') {
|
||||||
|
$exists = \App\Models\Shortlist::where('employer_id', $employer->id)
|
||||||
|
->where('worker_id', $application->worker_id)
|
||||||
|
->exists();
|
||||||
|
if (!$exists) {
|
||||||
|
\App\Models\Shortlist::create([
|
||||||
|
'employer_id' => $employer->id,
|
||||||
|
'worker_id' => $application->worker_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hired, also update worker status
|
||||||
|
if ($dbStatus === 'hired') {
|
||||||
|
if ($application->worker) {
|
||||||
|
$application->worker->update(['status' => 'Hired']);
|
||||||
|
}
|
||||||
|
} elseif ($dbStatus === 'rejected') {
|
||||||
|
if ($application->worker) {
|
||||||
|
$application->worker->update(['status' => 'active']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger notifications
|
||||||
|
$this->triggerStatusNotification($application, $dbStatus);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Application status updated successfully.',
|
||||||
|
'data' => [
|
||||||
|
'application' => $application->fresh()
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for workers to view details of a specific job application.
|
||||||
|
* GET /api/workers/applied-jobs/{id}
|
||||||
|
*/
|
||||||
|
public function appliedJobDetail(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
$application = JobApplication::where('worker_id', $worker->id)
|
||||||
|
->with(['jobPost.employer.employerProfile'])
|
||||||
|
->find($id);
|
||||||
|
|
||||||
|
if (!$application) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Application not found.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = $application->jobPost;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'application' => [
|
||||||
|
'id' => $application->id,
|
||||||
|
'status' => $application->status,
|
||||||
|
'status_history' => $application->status_history ?: [],
|
||||||
|
'applied_at' => $application->created_at->toIso8601String(),
|
||||||
|
'job' => $job ? [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'description' => $job->description,
|
||||||
|
'requirements' => $job->requirements,
|
||||||
|
'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'posted_at' => $job->created_at->toIso8601String(),
|
||||||
|
] : null
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API for workers to withdraw their job application.
|
||||||
|
* POST /api/workers/applied-jobs/{id}/withdraw
|
||||||
|
*/
|
||||||
|
public function withdrawApplication(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
$application = JobApplication::where('worker_id', $worker->id)->find($id);
|
||||||
|
|
||||||
|
if (!$application) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Application not found.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (strtolower($application->status) !== 'applied') {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'You can only withdraw applications that are still in "Applied" status.'
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$application->delete();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Application successfully withdrawn.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch FCM Push Notification.
|
||||||
|
*/
|
||||||
|
private function triggerStatusNotification($application, $status)
|
||||||
|
{
|
||||||
|
$worker = $application->worker;
|
||||||
|
$job = $application->jobPost;
|
||||||
|
$employer = $job ? $job->employer : null;
|
||||||
|
|
||||||
|
// When a new application is submitted: notify employer
|
||||||
|
if ($status === 'applied') {
|
||||||
|
if ($employer && $employer->fcm_token) {
|
||||||
|
\App\Services\FCMService::sendPushNotification(
|
||||||
|
$employer->fcm_token,
|
||||||
|
"New Job Application",
|
||||||
|
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||||
|
[
|
||||||
|
'type' => 'new_job_application',
|
||||||
|
'job_id' => $job->id,
|
||||||
|
'application_id' => $application->id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When status is updated: notify worker
|
||||||
|
if ($worker && $worker->fcm_token) {
|
||||||
|
$title = "Job Application Update";
|
||||||
|
$body = "";
|
||||||
|
switch (strtolower($status)) {
|
||||||
|
case 'shortlisted':
|
||||||
|
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
case 'contacted':
|
||||||
|
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
case 'interview scheduled':
|
||||||
|
case 'interview_scheduled':
|
||||||
|
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
case 'selected':
|
||||||
|
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
case 'rejected':
|
||||||
|
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
|
||||||
|
break;
|
||||||
|
case 'hired':
|
||||||
|
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
\App\Services\FCMService::sendPushNotification(
|
||||||
|
$worker->fcm_token,
|
||||||
|
$title,
|
||||||
|
$body,
|
||||||
|
[
|
||||||
|
'type' => 'job_application_status_update',
|
||||||
|
'job_id' => $job->id ?? '',
|
||||||
|
'application_id' => $application->id,
|
||||||
|
'status' => $status,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -275,10 +275,27 @@ public function index(Request $request)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function checkJobAccess($user)
|
||||||
|
{
|
||||||
|
if (!$user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$planId = $sub ? $sub->plan_id : 'basic';
|
||||||
|
return $planId !== 'basic';
|
||||||
|
}
|
||||||
|
|
||||||
public function updateStatus(Request $request, $id)
|
public function updateStatus(Request $request, $id)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'status' => 'required|string|in:Reviewing,Offer Sent,Hired,Rejected',
|
'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent',
|
||||||
|
'notes' => 'nullable|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// If this is a direct hiring offer response
|
// If this is a direct hiring offer response
|
||||||
@ -287,13 +304,13 @@ public function updateStatus(Request $request, $id)
|
|||||||
$offer = JobOffer::findOrFail($offerId);
|
$offer = JobOffer::findOrFail($offerId);
|
||||||
|
|
||||||
$dbStatus = 'pending';
|
$dbStatus = 'pending';
|
||||||
if ($request->status === 'Hired') {
|
if (in_array(strtolower($request->status), ['hired', 'accepted'])) {
|
||||||
$dbStatus = 'accepted';
|
$dbStatus = 'accepted';
|
||||||
$offer->worker->update(['status' => 'Hired']);
|
$offer->worker->update(['status' => 'Hired']);
|
||||||
} elseif ($request->status === 'Rejected') {
|
} elseif (in_array(strtolower($request->status), ['rejected'])) {
|
||||||
$dbStatus = 'rejected';
|
$dbStatus = 'rejected';
|
||||||
$offer->worker->update(['status' => 'active']);
|
$offer->worker->update(['status' => 'active']);
|
||||||
} elseif ($request->status === 'Offer Sent') {
|
} elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) {
|
||||||
$dbStatus = 'pending';
|
$dbStatus = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -305,27 +322,161 @@ public function updateStatus(Request $request, $id)
|
|||||||
// Standard job application status update
|
// Standard job application status update
|
||||||
$app = JobApplication::findOrFail($id);
|
$app = JobApplication::findOrFail($id);
|
||||||
|
|
||||||
|
$user = $this->resolveCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return redirect()->route('employer.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
$dbStatus = 'applied';
|
$dbStatus = 'applied';
|
||||||
if ($request->status === 'Hired') {
|
$statusLower = strtolower($request->status);
|
||||||
|
if ($statusLower === 'hired') {
|
||||||
$dbStatus = 'hired';
|
$dbStatus = 'hired';
|
||||||
if ($app->worker) {
|
} elseif ($statusLower === 'rejected') {
|
||||||
$app->worker->update(['status' => 'Hired']);
|
|
||||||
}
|
|
||||||
} elseif ($request->status === 'Rejected') {
|
|
||||||
$dbStatus = 'rejected';
|
$dbStatus = 'rejected';
|
||||||
if ($app->worker) {
|
} elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') {
|
||||||
$app->worker->update(['status' => 'active']);
|
|
||||||
}
|
|
||||||
} elseif ($request->status === 'Offer Sent') {
|
|
||||||
$dbStatus = 'shortlisted';
|
$dbStatus = 'shortlisted';
|
||||||
} elseif ($request->status === 'Reviewing') {
|
} elseif ($statusLower === 'contacted') {
|
||||||
|
$dbStatus = 'contacted';
|
||||||
|
} elseif ($statusLower === 'interview scheduled' || $statusLower === 'interview_scheduled') {
|
||||||
|
$dbStatus = 'interview_scheduled';
|
||||||
|
} elseif ($statusLower === 'selected') {
|
||||||
|
$dbStatus = 'selected';
|
||||||
|
} elseif ($statusLower === 'reviewing' || $statusLower === 'applied') {
|
||||||
$dbStatus = 'applied';
|
$dbStatus = 'applied';
|
||||||
}
|
}
|
||||||
|
|
||||||
$app->update([
|
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'
|
||||||
|
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
|
||||||
|
if (in_array($dbStatus, $contactStatuses)) {
|
||||||
|
if (!User::canContactWorker($user->id, $app->worker_id)) {
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
|
||||||
|
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||||
|
|
||||||
|
// Web: contact limit block redirect
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update application status & history
|
||||||
|
$history = $app->status_history ?: [];
|
||||||
|
$history[] = [
|
||||||
'status' => $dbStatus,
|
'status' => $dbStatus,
|
||||||
]);
|
'timestamp' => now()->toIso8601String(),
|
||||||
|
'notes' => $request->input('notes'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$updateData = [
|
||||||
|
'status' => $dbStatus,
|
||||||
|
'status_history' => $history,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($request->filled('notes')) {
|
||||||
|
$updateData['notes'] = $request->notes;
|
||||||
|
}
|
||||||
|
|
||||||
|
$app->update($updateData);
|
||||||
|
|
||||||
|
// If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved
|
||||||
|
if ($dbStatus === 'shortlisted') {
|
||||||
|
$exists = \App\Models\Shortlist::where('employer_id', $user->id)
|
||||||
|
->where('worker_id', $app->worker_id)
|
||||||
|
->exists();
|
||||||
|
if (!$exists) {
|
||||||
|
\App\Models\Shortlist::create([
|
||||||
|
'employer_id' => $user->id,
|
||||||
|
'worker_id' => $app->worker_id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hired, also update worker status
|
||||||
|
if ($dbStatus === 'hired') {
|
||||||
|
if ($app->worker) {
|
||||||
|
$app->worker->update(['status' => 'Hired']);
|
||||||
|
}
|
||||||
|
} elseif ($dbStatus === 'rejected') {
|
||||||
|
if ($app->worker) {
|
||||||
|
$app->worker->update(['status' => 'active']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger notifications
|
||||||
|
$this->triggerStatusNotification($app, $dbStatus);
|
||||||
|
|
||||||
return back()->with('success', 'Candidate application status updated successfully to ' . $request->status);
|
return back()->with('success', 'Candidate application status updated successfully to ' . $request->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function triggerStatusNotification($application, $status)
|
||||||
|
{
|
||||||
|
$worker = $application->worker;
|
||||||
|
$job = $application->jobPost;
|
||||||
|
$employer = $job ? $job->employer : null;
|
||||||
|
|
||||||
|
// When a new application is submitted: notify employer
|
||||||
|
if ($status === 'applied') {
|
||||||
|
if ($employer && $employer->fcm_token) {
|
||||||
|
\App\Services\FCMService::sendPushNotification(
|
||||||
|
$employer->fcm_token,
|
||||||
|
"New Job Application",
|
||||||
|
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||||
|
[
|
||||||
|
'type' => 'new_job_application',
|
||||||
|
'job_id' => $job->id,
|
||||||
|
'application_id' => $application->id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// When status is updated: notify worker
|
||||||
|
if ($worker && $worker->fcm_token) {
|
||||||
|
$title = "Job Application Update";
|
||||||
|
$body = "";
|
||||||
|
switch (strtolower($status)) {
|
||||||
|
case 'shortlisted':
|
||||||
|
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
case 'contacted':
|
||||||
|
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
case 'interview scheduled':
|
||||||
|
case 'interview_scheduled':
|
||||||
|
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
case 'selected':
|
||||||
|
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
case 'rejected':
|
||||||
|
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
|
||||||
|
break;
|
||||||
|
case 'hired':
|
||||||
|
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
\App\Services\FCMService::sendPushNotification(
|
||||||
|
$worker->fcm_token,
|
||||||
|
$title,
|
||||||
|
$body,
|
||||||
|
[
|
||||||
|
'type' => 'job_application_status_update',
|
||||||
|
'job_id' => $job->id ?? '',
|
||||||
|
'application_id' => $application->id,
|
||||||
|
'status' => $status,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,26 +59,18 @@ public function index(Request $request)
|
|||||||
$q->where('employer_id', $user->id);
|
$q->where('employer_id', $user->id);
|
||||||
})->where('status', 'hired')->count();
|
})->where('status', 'hired')->count();
|
||||||
|
|
||||||
|
$totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() +
|
||||||
|
\App\Models\JobApplication::where('status', 'hired')->count();
|
||||||
|
$totalActiveWorkers = \App\Models\Worker::where('status', 'active')->count();
|
||||||
|
|
||||||
$stats = [
|
$stats = [
|
||||||
'shortlisted_count' => $shortlistedCount,
|
'shortlisted_count' => $shortlistedCount,
|
||||||
'messages_sent' => $messagesSent,
|
'messages_sent' => $messagesSent,
|
||||||
'days_remaining' => (int)$daysRemaining,
|
'days_remaining' => (int)$daysRemaining,
|
||||||
'contacted_workers_count' => $contactedWorkersCount,
|
'contacted_workers_count' => $contactedWorkersCount,
|
||||||
'hired_count' => $hiredCount,
|
'hired_count' => $hiredCount,
|
||||||
'analytics' => [
|
'total_hired_all' => $totalHiredAll,
|
||||||
'profile_views' => 48,
|
'total_active_workers' => $totalActiveWorkers,
|
||||||
'response_rate' => '94%',
|
|
||||||
'average_match_score' => '98%',
|
|
||||||
'weekly_activity' => [
|
|
||||||
['day' => 'Mon', 'views' => 5],
|
|
||||||
['day' => 'Tue', 'views' => 12],
|
|
||||||
['day' => 'Wed', 'views' => 8],
|
|
||||||
['day' => 'Thu', 'views' => 15],
|
|
||||||
['day' => 'Fri', 'views' => 4],
|
|
||||||
['day' => 'Sat', 'views' => 2],
|
|
||||||
['day' => 'Sun', 'views' => 2],
|
|
||||||
]
|
|
||||||
],
|
|
||||||
'recent_failed_payment' => false
|
'recent_failed_payment' => false
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -42,10 +42,6 @@ public function login(Request $request)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($user->subscription_status === 'expired') {
|
|
||||||
return back()->with('status', 'subscription_expired');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform standard Laravel authentication (remember=true keeps auth cookie alive)
|
// Perform standard Laravel authentication (remember=true keeps auth cookie alive)
|
||||||
auth()->login($user, true);
|
auth()->login($user, true);
|
||||||
|
|
||||||
@ -65,6 +61,14 @@ public function login(Request $request)
|
|||||||
return redirect('/mobile/employer/home');
|
return redirect('/mobile/employer/home');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatically check and activate pending subscription if any
|
||||||
|
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
if ($user->subscription_status !== 'active') {
|
||||||
|
return redirect()->route('employer.subscription');
|
||||||
|
}
|
||||||
|
|
||||||
return redirect()->intended('/employer/dashboard');
|
return redirect()->intended('/employer/dashboard');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +79,9 @@ public function login(Request $request)
|
|||||||
|
|
||||||
public function showRegister()
|
public function showRegister()
|
||||||
{
|
{
|
||||||
return Inertia::render('Employer/Auth/Register');
|
return Inertia::render('Employer/Auth/Register', [
|
||||||
|
'prefillData' => session('pending_employer_registration'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function register(Request $request)
|
public function register(Request $request)
|
||||||
@ -273,6 +279,11 @@ public function showUploadEmiratesId()
|
|||||||
public function uploadEmiratesId(Request $request)
|
public function uploadEmiratesId(Request $request)
|
||||||
{
|
{
|
||||||
if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) {
|
if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) {
|
||||||
|
if ($request->wantsJson() || $request->ajax()) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Registration session expired. Please start over.'
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
return redirect()->route('employer.register')
|
return redirect()->route('employer.register')
|
||||||
->with('error', 'Registration session expired. Please start over.');
|
->with('error', 'Registration session expired. Please start over.');
|
||||||
}
|
}
|
||||||
@ -288,83 +299,42 @@ public function uploadEmiratesId(Request $request)
|
|||||||
'emirates_id_back.mimes' => 'The Emirates ID back must be an image or a PDF.',
|
'emirates_id_back.mimes' => 'The Emirates ID back must be an image or a PDF.',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$extractedIdNumber = null;
|
|
||||||
$extractedExpiry = null;
|
|
||||||
$extractedName = null;
|
|
||||||
$extractedDob = null;
|
|
||||||
$extractedNationality = null;
|
|
||||||
$extractedIssueDate = null;
|
|
||||||
$extractedEmployer = null;
|
|
||||||
$extractedIssuePlace = null;
|
|
||||||
$extractedOccupation = null;
|
|
||||||
$extractedCardNumber = null;
|
|
||||||
$extractedGender = null;
|
|
||||||
|
|
||||||
if ($request->hasFile('emirates_id_front')) {
|
|
||||||
$frontFile = $request->file('emirates_id_front');
|
|
||||||
$frontOcr = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($frontFile);
|
|
||||||
$extractedIdNumber = $frontOcr['emirates_id_number'];
|
|
||||||
$extractedName = $frontOcr['name'] ?? null;
|
|
||||||
$extractedDob = $frontOcr['date_of_birth'] ?? null;
|
|
||||||
$extractedNationality = $frontOcr['nationality'] ?? null;
|
|
||||||
$extractedCardNumber = $frontOcr['card_number'] ?? null;
|
|
||||||
$extractedGender = $frontOcr['gender'] ?? null;
|
|
||||||
$extractedOccupation = $frontOcr['occupation'] ?? null;
|
|
||||||
$extractedEmployer = $frontOcr['employer'] ?? null;
|
|
||||||
$extractedIssuePlace = $frontOcr['issue_place'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->hasFile('emirates_id_back')) {
|
|
||||||
$backFile = $request->file('emirates_id_back');
|
|
||||||
$backOcr = \App\Services\OcrDocumentService::extractEmiratesIdBackData($backFile);
|
|
||||||
$extractedExpiry = $backOcr['expiry_date'];
|
|
||||||
$extractedIssueDate = $backOcr['issue_date'] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($request->hasFile('emirates_id_file')) {
|
if ($request->hasFile('emirates_id_file')) {
|
||||||
$file = $request->file('emirates_id_file');
|
$frontFile = $request->file('emirates_id_file');
|
||||||
$frontOcr = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($file);
|
$backFile = $request->file('emirates_id_file');
|
||||||
$backOcr = \App\Services\OcrDocumentService::extractEmiratesIdBackData($file);
|
} else {
|
||||||
$extractedIdNumber = $frontOcr['emirates_id_number'];
|
$frontFile = $request->file('emirates_id_front');
|
||||||
$extractedExpiry = $backOcr['expiry_date'];
|
$backFile = $request->file('emirates_id_back');
|
||||||
$extractedName = $frontOcr['name'] ?? null;
|
|
||||||
$extractedDob = $frontOcr['date_of_birth'] ?? null;
|
|
||||||
$extractedNationality = $frontOcr['nationality'] ?? null;
|
|
||||||
$extractedCardNumber = $frontOcr['card_number'] ?? null;
|
|
||||||
$extractedGender = $frontOcr['gender'] ?? null;
|
|
||||||
$extractedOccupation = $frontOcr['occupation'] ?? null;
|
|
||||||
$extractedEmployer = $frontOcr['employer'] ?? null;
|
|
||||||
$extractedIssuePlace = $frontOcr['issue_place'] ?? null;
|
|
||||||
$extractedIssueDate = $backOcr['issue_date'] ?? null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (($request->hasFile('emirates_id_front') || $request->hasFile('emirates_id_file')) && empty($extractedName)) {
|
$extracted = \App\Services\OcrDocumentService::extractEmiratesIdCombinedData($frontFile, $backFile);
|
||||||
|
|
||||||
|
if ($request->wantsJson() || $request->ajax()) {
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'errors' => [
|
'success' => true,
|
||||||
'emirates_id_front' => ['Failed to extract name from the Emirates ID. Please upload a clear image.']
|
'data' => $extracted
|
||||||
]
|
]);
|
||||||
], 422);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$pending = session('pending_employer_registration');
|
$pending = session('pending_employer_registration');
|
||||||
$pending['emirates_id_file'] = null;
|
$pending['emirates_id_file'] = null;
|
||||||
$pending['emirates_id_number'] = $extractedIdNumber;
|
$pending['emirates_id_number'] = $extracted['emirates_id_number'];
|
||||||
$pending['emirates_id_expiry'] = $extractedExpiry;
|
$pending['emirates_id_expiry'] = $extracted['expiry_date'];
|
||||||
$pending['emirates_id_name'] = $extractedName;
|
$pending['emirates_id_name'] = $extracted['name'];
|
||||||
$pending['emirates_id_dob'] = $extractedDob;
|
$pending['emirates_id_dob'] = $extracted['date_of_birth'];
|
||||||
$pending['emirates_id_nationality'] = $extractedNationality;
|
$pending['emirates_id_nationality'] = $extracted['nationality'];
|
||||||
$pending['emirates_id_card_number'] = $extractedCardNumber;
|
$pending['emirates_id_card_number'] = $extracted['card_number'];
|
||||||
$pending['emirates_id_gender'] = $extractedGender;
|
$pending['emirates_id_gender'] = $extracted['gender'];
|
||||||
$pending['emirates_id_occupation'] = $extractedOccupation;
|
$pending['emirates_id_occupation'] = $extracted['occupation'];
|
||||||
$pending['emirates_id_employer'] = $extractedEmployer;
|
$pending['emirates_id_employer'] = $extracted['employer'];
|
||||||
$pending['emirates_id_issue_place'] = $extractedIssuePlace;
|
$pending['emirates_id_issue_place'] = $extracted['issue_place'];
|
||||||
$pending['emirates_id_issue_date'] = $extractedIssueDate;
|
$pending['emirates_id_issue_date'] = $extracted['issue_date'];
|
||||||
|
|
||||||
if (!empty($extractedName)) {
|
if (!empty($extracted['name'])) {
|
||||||
if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) {
|
if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) {
|
||||||
$pending['company_name'] = $extractedName . ' Household';
|
$pending['company_name'] = $extracted['name'] . ' Household';
|
||||||
}
|
}
|
||||||
$pending['name'] = $extractedName;
|
$pending['name'] = $extracted['name'];
|
||||||
}
|
}
|
||||||
|
|
||||||
session(['pending_employer_registration' => $pending]);
|
session(['pending_employer_registration' => $pending]);
|
||||||
@ -374,6 +344,58 @@ public function uploadEmiratesId(Request $request)
|
|||||||
->with('success', 'Emirates ID uploaded and verified successfully.');
|
->with('success', 'Emirates ID uploaded and verified successfully.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function confirmEmiratesId(Request $request)
|
||||||
|
{
|
||||||
|
if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => 'Registration session expired. Please start over.'
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'emirates_id_number' => 'required|string',
|
||||||
|
'expiry_date' => 'required|date_format:Y-m-d',
|
||||||
|
'name' => 'required|string',
|
||||||
|
'date_of_birth' => 'required|date_format:Y-m-d',
|
||||||
|
'nationality' => 'nullable|string',
|
||||||
|
'card_number' => 'nullable|string',
|
||||||
|
'gender' => 'nullable|string',
|
||||||
|
'occupation' => 'nullable|string',
|
||||||
|
'employer' => 'nullable|string',
|
||||||
|
'issue_place' => 'nullable|string',
|
||||||
|
'issue_date' => 'nullable|date_format:Y-m-d',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$pending = session('pending_employer_registration');
|
||||||
|
$pending['emirates_id_file'] = null;
|
||||||
|
$pending['emirates_id_number'] = $request->emirates_id_number;
|
||||||
|
$pending['emirates_id_expiry'] = $request->expiry_date;
|
||||||
|
$pending['emirates_id_name'] = $request->name;
|
||||||
|
$pending['emirates_id_dob'] = $request->date_of_birth;
|
||||||
|
$pending['emirates_id_nationality'] = $request->nationality;
|
||||||
|
$pending['emirates_id_card_number'] = $request->card_number;
|
||||||
|
$pending['emirates_id_gender'] = $request->gender;
|
||||||
|
$pending['emirates_id_occupation'] = $request->occupation;
|
||||||
|
$pending['emirates_id_employer'] = $request->employer;
|
||||||
|
$pending['emirates_id_issue_place'] = $request->issue_place;
|
||||||
|
$pending['emirates_id_issue_date'] = $request->issue_date;
|
||||||
|
|
||||||
|
if (!empty($request->name)) {
|
||||||
|
if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) {
|
||||||
|
$pending['company_name'] = $request->name . ' Household';
|
||||||
|
}
|
||||||
|
$pending['name'] = $request->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
session(['pending_employer_registration' => $pending]);
|
||||||
|
session(['employer_emirates_id_uploaded' => true]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Emirates ID verified and confirmed successfully.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function showRegisterPayment()
|
public function showRegisterPayment()
|
||||||
{
|
{
|
||||||
if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session('employer_emirates_id_uploaded')) {
|
if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session('employer_emirates_id_uploaded')) {
|
||||||
@ -381,9 +403,9 @@ public function showRegisterPayment()
|
|||||||
->with('error', 'Please complete Emirates ID upload first.');
|
->with('error', 'Please complete Emirates ID upload first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return Inertia::render('Employer/Auth/RegisterPayment', [
|
$dbPlans = \App\Models\Plan::where('status', 'Active')->get();
|
||||||
'email' => session('pending_employer_registration.email'),
|
if ($dbPlans->isEmpty()) {
|
||||||
'plans' => [
|
$plans = [
|
||||||
[
|
[
|
||||||
'id' => 'basic',
|
'id' => 'basic',
|
||||||
'name' => 'Basic Search Pass',
|
'name' => 'Basic Search Pass',
|
||||||
@ -408,7 +430,28 @@ public function showRegisterPayment()
|
|||||||
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
|
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
|
||||||
'popular' => false,
|
'popular' => false,
|
||||||
],
|
],
|
||||||
]
|
];
|
||||||
|
} else {
|
||||||
|
$plans = $dbPlans->map(function ($plan) {
|
||||||
|
$id = $plan->id === 'vip' ? 'enterprise' : $plan->id;
|
||||||
|
$name = $plan->name;
|
||||||
|
if (!str_ends_with(strtolower($name), 'pass')) {
|
||||||
|
$name .= ' Pass';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id' => $id,
|
||||||
|
'name' => $name,
|
||||||
|
'price' => (int)$plan->price . ' AED',
|
||||||
|
'period' => 'month',
|
||||||
|
'features' => $plan->features,
|
||||||
|
'popular' => $plan->id === 'premium',
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Employer/Auth/RegisterPayment', [
|
||||||
|
'email' => session('pending_employer_registration.email'),
|
||||||
|
'plans' => $plans
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,6 +35,22 @@ private function resolveCurrentUser()
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function checkJobAccess($user)
|
||||||
|
{
|
||||||
|
if (!$user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->latest('id')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$planId = $sub ? $sub->plan_id : 'basic';
|
||||||
|
return $planId !== 'basic';
|
||||||
|
}
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$user = $this->resolveCurrentUser();
|
$user = $this->resolveCurrentUser();
|
||||||
@ -42,6 +58,11 @@ public function index(Request $request)
|
|||||||
return redirect()->route('employer.login');
|
return redirect()->route('employer.login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
$dbJobs = JobPost::where('employer_id', $user->id)
|
$dbJobs = JobPost::where('employer_id', $user->id)
|
||||||
->with(['applications'])
|
->with(['applications'])
|
||||||
->latest()
|
->latest()
|
||||||
@ -65,8 +86,55 @@ public function index(Request $request)
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function show($id)
|
||||||
|
{
|
||||||
|
$user = $this->resolveCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return redirect()->route('employer.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::where('employer_id', $user->id)
|
||||||
|
->with(['applications.worker'])
|
||||||
|
->where('id', $id)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$jobData = [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => $job->title,
|
||||||
|
'location' => $job->location,
|
||||||
|
'salary' => (int) $job->salary,
|
||||||
|
'workers_needed' => $job->workers_needed,
|
||||||
|
'job_type' => $job->job_type,
|
||||||
|
'start_date' => $job->start_date ? $job->start_date->format('M d, Y') : null,
|
||||||
|
'description' => $job->description,
|
||||||
|
'requirements' => $job->requirements,
|
||||||
|
'status' => ucfirst($job->status),
|
||||||
|
'applied_count' => $job->applications->count(),
|
||||||
|
'posted_at' => $job->created_at->format('M d, Y'),
|
||||||
|
];
|
||||||
|
|
||||||
|
return Inertia::render('Employer/Jobs/Show', [
|
||||||
|
'job' => $jobData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function create()
|
public function create()
|
||||||
{
|
{
|
||||||
|
$user = $this->resolveCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return redirect()->route('employer.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
return Inertia::render('Employer/Jobs/Create');
|
return Inertia::render('Employer/Jobs/Create');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +145,11 @@ public function store(Request $request)
|
|||||||
return back()->withErrors(['general' => 'User session not found.']);
|
return back()->withErrors(['general' => 'User session not found.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'title' => 'required|string|max:255',
|
'title' => 'required|string|max:255',
|
||||||
'workers_needed' => 'required|integer|min:1',
|
'workers_needed' => 'required|integer|min:1',
|
||||||
@ -111,24 +184,37 @@ public function applicants($id)
|
|||||||
return redirect()->route('employer.login');
|
return redirect()->route('employer.login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||||
|
|
||||||
$dbApplications = JobApplication::where('job_id', $id)
|
$dbApplications = JobApplication::where('job_id', $id)
|
||||||
->with(['worker.skills'])
|
->with(['worker.skills'])
|
||||||
->get();
|
->get();
|
||||||
|
|
||||||
$applicants = $dbApplications->map(function ($app) {
|
$applicants = $dbApplications->map(function ($app) use ($user) {
|
||||||
$worker = $app->worker;
|
$worker = $app->worker;
|
||||||
|
if (!$worker) return null;
|
||||||
|
$isSaved = \App\Models\Shortlist::where('employer_id', $user->id)
|
||||||
|
->where('worker_id', $worker->id)
|
||||||
|
->exists();
|
||||||
return [
|
return [
|
||||||
'id' => $worker->id,
|
'id' => $worker->id,
|
||||||
|
'application_id' => $app->id,
|
||||||
'name' => $worker->name,
|
'name' => $worker->name,
|
||||||
'nationality' => $worker->nationality,
|
'nationality' => $worker->nationality,
|
||||||
'salary' => (int) $worker->salary,
|
'salary' => (int) $worker->salary,
|
||||||
'experience' => ($worker->experience_years ?? 3) . ' Years',
|
'experience' => ($worker->experience_years ?? 3) . ' Years',
|
||||||
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected
|
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected, etc.
|
||||||
|
'notes' => $app->notes,
|
||||||
|
'status_history' => $app->status_history ?: [],
|
||||||
'match_score' => $worker->match_score ?? 90,
|
'match_score' => $worker->match_score ?? 90,
|
||||||
|
'is_saved' => $isSaved,
|
||||||
];
|
];
|
||||||
})->toArray();
|
})->filter()->values()->toArray();
|
||||||
|
|
||||||
return Inertia::render('Employer/Jobs/Applicants', [
|
return Inertia::render('Employer/Jobs/Applicants', [
|
||||||
'job' => [
|
'job' => [
|
||||||
@ -139,4 +225,88 @@ public function applicants($id)
|
|||||||
'applicants' => $applicants,
|
'applicants' => $applicants,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function edit($id)
|
||||||
|
{
|
||||||
|
$user = $this->resolveCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return redirect()->route('employer.login');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||||
|
|
||||||
|
// Convert start_date format to Y-m-d for date input
|
||||||
|
$jobData = $job->toArray();
|
||||||
|
if ($job->start_date) {
|
||||||
|
$jobData['start_date'] = $job->start_date->format('Y-m-d');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Employer/Jobs/Edit', [
|
||||||
|
'job' => $jobData,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, $id)
|
||||||
|
{
|
||||||
|
$user = $this->resolveCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return back()->withErrors(['general' => 'User session not found.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'title' => 'required|string|max:255',
|
||||||
|
'workers_needed' => 'required|integer|min:1',
|
||||||
|
'location' => 'required|string|max:255',
|
||||||
|
'salary' => 'required|numeric|min:0',
|
||||||
|
'job_type' => 'required|string|in:Full Time,Part Time,Contract',
|
||||||
|
'start_date' => 'required|date',
|
||||||
|
'description' => 'required|string|max:1000',
|
||||||
|
'requirements' => 'nullable|string',
|
||||||
|
'status' => 'required|string|in:active,closed,draft',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job->update([
|
||||||
|
'title' => $request->title,
|
||||||
|
'workers_needed' => $request->workers_needed,
|
||||||
|
'job_type' => $request->job_type,
|
||||||
|
'location' => $request->location,
|
||||||
|
'salary' => $request->salary,
|
||||||
|
'start_date' => $request->start_date,
|
||||||
|
'description' => $request->description,
|
||||||
|
'requirements' => $request->requirements,
|
||||||
|
'status' => strtolower($request->status),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy($id)
|
||||||
|
{
|
||||||
|
$user = $this->resolveCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return back()->withErrors(['general' => 'User session not found.']);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$this->checkJobAccess($user)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||||
|
$job->delete();
|
||||||
|
|
||||||
|
return redirect()->route('employer.jobs')->with('success', 'Job deleted successfully.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -263,6 +263,16 @@ public function startConversation($workerId)
|
|||||||
return redirect()->route('employer.login');
|
return redirect()->route('employer.login');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!User::canContactWorker($user->id, $workerId)) {
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
|
||||||
|
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||||
|
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||||
|
}
|
||||||
|
|
||||||
// Find or create conversation
|
// Find or create conversation
|
||||||
$conv = Conversation::firstOrCreate([
|
$conv = Conversation::firstOrCreate([
|
||||||
'employer_id' => $user->id,
|
'employer_id' => $user->id,
|
||||||
|
|||||||
@ -73,4 +73,92 @@ public function index(Request $request)
|
|||||||
'subscriptionStatus' => $subStatus,
|
'subscriptionStatus' => $subStatus,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function purchase(Request $request)
|
||||||
|
{
|
||||||
|
$user = $this->resolveCurrentUser();
|
||||||
|
if (!$user) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$request->validate([
|
||||||
|
'plan_id' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$planId = $request->plan_id;
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
if (!$plan) {
|
||||||
|
return response()->json(['error' => 'Plan not found'], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there is any active or pending subscription for the user to determine start time
|
||||||
|
$lastSub = \App\Models\Subscription::where('user_id', $user->id)
|
||||||
|
->whereIn('status', ['active', 'pending'])
|
||||||
|
->orderBy('expires_at', 'desc')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$durationDays = strtolower($plan->duration) === 'yearly' ? 365 : 30;
|
||||||
|
|
||||||
|
if ($lastSub) {
|
||||||
|
// Queue it to start when the current subscription expires
|
||||||
|
$startsAt = \Carbon\Carbon::parse($lastSub->expires_at);
|
||||||
|
$expiresAt = $startsAt->copy()->addDays($durationDays);
|
||||||
|
$status = 'pending';
|
||||||
|
} else {
|
||||||
|
// Activate immediately
|
||||||
|
$startsAt = now();
|
||||||
|
$expiresAt = now()->addDays($durationDays);
|
||||||
|
$status = 'active';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store subscription in DB
|
||||||
|
$sub = \App\Models\Subscription::create([
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'plan_id' => $plan->id,
|
||||||
|
'amount_aed' => $plan->price,
|
||||||
|
'starts_at' => $startsAt,
|
||||||
|
'expires_at' => $expiresAt,
|
||||||
|
'paytabs_transaction_id' => 'PT-' . strtoupper(uniqid()),
|
||||||
|
'status' => $status
|
||||||
|
]);
|
||||||
|
|
||||||
|
// If active, sync user subscription fields
|
||||||
|
if ($status === 'active') {
|
||||||
|
$user->subscription_status = 'active';
|
||||||
|
$user->subscription_expires_at = $expiresAt;
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build complete dynamic subscription/invoice history list
|
||||||
|
$invoices = \App\Models\Subscription::leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
|
||||||
|
->where('subscriptions.user_id', $user->id)
|
||||||
|
->orderBy('subscriptions.created_at', 'desc')
|
||||||
|
->select('subscriptions.*', 'plans.name as plan_name')
|
||||||
|
->get()
|
||||||
|
->map(function($s) {
|
||||||
|
$planLabel = $s->plan_name ?: (ucfirst($s->plan_id) . ' Pass');
|
||||||
|
$subStatus = strtolower($s->status);
|
||||||
|
if ($subStatus !== 'active' && $subStatus !== 'pending' && $subStatus !== 'expired') {
|
||||||
|
$subStatus = 'expired';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id' => 'INV-' . date('Y', strtotime($s->created_at)) . '-' . str_pad($s->id, 3, '0', STR_PAD_LEFT),
|
||||||
|
'plan_name' => $planLabel,
|
||||||
|
'purchase_date' => date('M d, Y', strtotime($s->created_at)),
|
||||||
|
'activation_date' => $s->starts_at ? date('M d, Y', strtotime($s->starts_at)) : 'N/A',
|
||||||
|
'expiry_date' => $s->expires_at ? date('M d, Y', strtotime($s->expires_at)) : 'N/A',
|
||||||
|
'amount' => round($s->amount_aed) . ' AED',
|
||||||
|
'payment_status' => 'Paid',
|
||||||
|
'status' => ucfirst($subStatus),
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Subscription purchased successfully!',
|
||||||
|
'invoices' => $invoices,
|
||||||
|
'currentPlan' => $status === 'active' ? $plan->name : null,
|
||||||
|
'expiresAt' => $status === 'active' ? date('Y-m-d', strtotime($expiresAt)) : null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -428,6 +428,16 @@ public function sendOffer(Request $request, $id)
|
|||||||
$user = $this->resolveCurrentUser();
|
$user = $this->resolveCurrentUser();
|
||||||
$employerId = $user ? $user->id : 2;
|
$employerId = $user ? $user->id : 2;
|
||||||
|
|
||||||
|
if (!User::canContactWorker($employerId, $id)) {
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
|
||||||
|
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||||
|
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||||
|
}
|
||||||
|
|
||||||
$worker = Worker::findOrFail($id);
|
$worker = Worker::findOrFail($id);
|
||||||
|
|
||||||
JobOffer::create([
|
JobOffer::create([
|
||||||
@ -449,6 +459,16 @@ public function markHired(Request $request, $id)
|
|||||||
$user = $this->resolveCurrentUser();
|
$user = $this->resolveCurrentUser();
|
||||||
$employerId = $user ? $user->id : 2;
|
$employerId = $user ? $user->id : 2;
|
||||||
|
|
||||||
|
if (!User::canContactWorker($employerId, $id)) {
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first();
|
||||||
|
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
$maxContacts = $plan ? $plan->max_contact_people : 10;
|
||||||
|
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||||
|
}
|
||||||
|
|
||||||
$worker = Worker::findOrFail($id);
|
$worker = Worker::findOrFail($id);
|
||||||
$worker->update([
|
$worker->update([
|
||||||
'status' => 'Hired',
|
'status' => 'Hired',
|
||||||
|
|||||||
@ -36,6 +36,26 @@ public function handle(Request $request, Closure $next)
|
|||||||
], 401);
|
], 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Automatically activate queued pending plans if current active has expired
|
||||||
|
User::checkAndActivatePendingSubscriptions($user->id);
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
// Enforce active subscription check
|
||||||
|
$hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none');
|
||||||
|
|
||||||
|
if (!$hasActiveSub) {
|
||||||
|
// Only allow access to payments or plans endpoints
|
||||||
|
$path = $request->getPathInfo();
|
||||||
|
$isAllowedApi = str_contains($path, '/payments') || str_contains($path, '/plans');
|
||||||
|
|
||||||
|
if (!$isAllowedApi) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Your subscription has expired. Please purchase or renew a subscription plan to restore access.'
|
||||||
|
], 403);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Attach employer object to request attributes so controllers can read it using $request->attributes->get('employer')
|
// Attach employer object to request attributes so controllers can read it using $request->attributes->get('employer')
|
||||||
$request->attributes->set('employer', $user);
|
$request->attributes->set('employer', $user);
|
||||||
|
|
||||||
|
|||||||
@ -13,21 +13,51 @@ class EmployerMiddleware
|
|||||||
*/
|
*/
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next): Response
|
||||||
{
|
{
|
||||||
|
$user = null;
|
||||||
// Prefer Laravel's built-in auth (survives page refresh reliably via remember cookie)
|
// Prefer Laravel's built-in auth (survives page refresh reliably via remember cookie)
|
||||||
if (auth()->check() && auth()->user()->role === 'employer') {
|
if (auth()->check() && auth()->user()->role === 'employer') {
|
||||||
return $next($request);
|
$user = auth()->user();
|
||||||
|
} else {
|
||||||
|
// Fallback: check custom session key (used during registration auto-login)
|
||||||
|
$sessionUser = session('user');
|
||||||
|
$role = is_array($sessionUser)
|
||||||
|
? ($sessionUser['role'] ?? null)
|
||||||
|
: ($sessionUser->role ?? null);
|
||||||
|
|
||||||
|
if ($sessionUser && $role === 'employer') {
|
||||||
|
$sessId = is_array($sessionUser) ? ($sessionUser['id'] ?? null) : ($sessionUser->id ?? null);
|
||||||
|
if ($sessId) {
|
||||||
|
$user = \App\Models\User::find($sessId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: check custom session key (used during registration auto-login)
|
if (!$user) {
|
||||||
$sessionUser = session('user');
|
return redirect()->route('employer.login');
|
||||||
$role = is_array($sessionUser)
|
|
||||||
? ($sessionUser['role'] ?? null)
|
|
||||||
: ($sessionUser->role ?? null);
|
|
||||||
|
|
||||||
if ($sessionUser && $role === 'employer') {
|
|
||||||
return $next($request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return redirect()->route('employer.login');
|
// Automatically activate queued pending plans if current active has expired
|
||||||
|
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
|
||||||
|
$user->refresh();
|
||||||
|
|
||||||
|
// Enforce active subscription check
|
||||||
|
$hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none');
|
||||||
|
|
||||||
|
if (!$hasActiveSub) {
|
||||||
|
$allowedRoutes = [
|
||||||
|
'employer.subscription',
|
||||||
|
'employer.subscription.purchase',
|
||||||
|
'employer.logout',
|
||||||
|
];
|
||||||
|
|
||||||
|
$currentRouteName = $request->route() ? $request->route()->getName() : null;
|
||||||
|
|
||||||
|
if (!in_array($currentRouteName, $allowedRoutes)) {
|
||||||
|
return redirect()->route('employer.subscription')
|
||||||
|
->with('error', 'Your subscription has expired. Please purchase or renew a subscription plan.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,6 +50,11 @@ public function share(Request $request): array
|
|||||||
$unreadCount = 0;
|
$unreadCount = 0;
|
||||||
|
|
||||||
if ($user) {
|
if ($user) {
|
||||||
|
\App\Models\User::checkAndActivatePendingSubscriptions($user->id);
|
||||||
|
|
||||||
|
// Re-fetch user to get updated fields
|
||||||
|
$user = \App\Models\User::find($user->id);
|
||||||
|
|
||||||
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
$sub = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
@ -67,14 +72,21 @@ public function share(Request $request): array
|
|||||||
->whereNull('messages.read_at')
|
->whereNull('messages.read_at')
|
||||||
->count();
|
->count();
|
||||||
|
|
||||||
|
$planId = $sub ? $sub->plan_id : 'basic';
|
||||||
|
|
||||||
|
$associatedPlan = \App\Models\Plan::find($planId);
|
||||||
|
$enableJobApply = $associatedPlan ? !!$associatedPlan->enable_job_apply : ($planId !== 'basic');
|
||||||
|
|
||||||
$userData = [
|
$userData = [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
'name' => $user->name,
|
'name' => $user->name,
|
||||||
'email' => $user->email,
|
'email' => $user->email,
|
||||||
'role' => $user->role,
|
'role' => $user->role,
|
||||||
'company_name' => $profile ? $profile->company_name : 'Al Mansoor Household',
|
'company_name' => $profile ? $profile->company_name : 'Al Mansoor Household',
|
||||||
'subscription_status' => 'active',
|
'subscription_status' => $user->subscription_status ?: 'expired',
|
||||||
'subscription_expires_at' => $sub && $sub->expires_at ? date('M d', strtotime($sub->expires_at)) : 'Dec 31',
|
'subscription_expires_at' => $sub && $sub->expires_at ? date('M d, Y', strtotime($sub->expires_at)) : 'Expired',
|
||||||
|
'subscription_plan' => $planId,
|
||||||
|
'enable_job_apply' => $enableJobApply,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Sync with session so that controllers have access to the exact user
|
// Sync with session so that controllers have access to the exact user
|
||||||
|
|||||||
@ -13,6 +13,12 @@ class JobApplication extends Model
|
|||||||
'job_id',
|
'job_id',
|
||||||
'worker_id',
|
'worker_id',
|
||||||
'status',
|
'status',
|
||||||
|
'notes',
|
||||||
|
'status_history',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'status_history' => 'array',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function jobPost()
|
public function jobPost()
|
||||||
|
|||||||
31
app/Models/Plan.php
Normal file
31
app/Models/Plan.php
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Plan extends Model
|
||||||
|
{
|
||||||
|
public $incrementing = false;
|
||||||
|
protected $keyType = 'string';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'price',
|
||||||
|
'duration',
|
||||||
|
'features',
|
||||||
|
'status',
|
||||||
|
'max_contact_people',
|
||||||
|
'unlimited_contacts',
|
||||||
|
'enable_job_apply',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'features' => 'array',
|
||||||
|
'unlimited_contacts' => 'boolean',
|
||||||
|
'enable_job_apply' => 'boolean',
|
||||||
|
'price' => 'float',
|
||||||
|
'max_contact_people' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
@ -75,4 +75,127 @@ public function payments()
|
|||||||
{
|
{
|
||||||
return $this->hasMany(Payment::class, 'user_id');
|
return $this->hasMany(Payment::class, 'user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function checkAndActivatePendingSubscriptions($userId)
|
||||||
|
{
|
||||||
|
$user = self::find($userId);
|
||||||
|
if (!$user) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check if the active subscription has expired
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $user->id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($activeSub && $activeSub->expires_at && $activeSub->expires_at <= now()) {
|
||||||
|
$activeSub->update(['status' => 'expired']);
|
||||||
|
$activeSub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If no active subscription exists, activate the next pending one
|
||||||
|
if (!$activeSub) {
|
||||||
|
$pendingSub = \App\Models\Subscription::where('user_id', $user->id)
|
||||||
|
->where('status', 'pending')
|
||||||
|
->orderBy('starts_at', 'asc')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($pendingSub) {
|
||||||
|
// If it is ready to start (i.e. starts_at is <= now())
|
||||||
|
if ($pendingSub->starts_at <= now()) {
|
||||||
|
$duration = 30; // default 30 days
|
||||||
|
$plan = \App\Models\Plan::find($pendingSub->plan_id);
|
||||||
|
if ($plan) {
|
||||||
|
$duration = strtolower($plan->duration) === 'yearly' ? 365 : 30;
|
||||||
|
}
|
||||||
|
$pendingSub->starts_at = now();
|
||||||
|
$pendingSub->expires_at = now()->addDays($duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pendingSub->status = 'active';
|
||||||
|
$pendingSub->save();
|
||||||
|
|
||||||
|
// Update user details
|
||||||
|
$user->subscription_status = 'active';
|
||||||
|
$user->subscription_expires_at = $pendingSub->expires_at;
|
||||||
|
$user->save();
|
||||||
|
|
||||||
|
// Recursive check for sequential queueing activation
|
||||||
|
self::checkAndActivatePendingSubscriptions($userId);
|
||||||
|
} else {
|
||||||
|
// Set to expired only if they previously had a subscription
|
||||||
|
$hasPriorSub = \App\Models\Subscription::where('user_id', $user->id)->exists();
|
||||||
|
if ($hasPriorSub) {
|
||||||
|
$user->subscription_status = 'expired';
|
||||||
|
$user->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the count of unique workers contacted or hired by the employer.
|
||||||
|
*/
|
||||||
|
public static function contactedWorkersCount($employerId)
|
||||||
|
{
|
||||||
|
$conversationWorkerIds = \App\Models\Conversation::where('employer_id', $employerId)->pluck('worker_id')->toArray();
|
||||||
|
$offerWorkerIds = \App\Models\JobOffer::where('employer_id', $employerId)->pluck('worker_id')->toArray();
|
||||||
|
$applicationWorkerIds = \App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired'])
|
||||||
|
->whereHas('jobPost', function ($query) use ($employerId) {
|
||||||
|
$query->where('employer_id', $employerId);
|
||||||
|
})
|
||||||
|
->pluck('worker_id')
|
||||||
|
->toArray();
|
||||||
|
|
||||||
|
$uniqueWorkerIds = array_unique(array_merge($conversationWorkerIds, $offerWorkerIds, $applicationWorkerIds));
|
||||||
|
return count($uniqueWorkerIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the employer is allowed to contact the given worker based on plan limits.
|
||||||
|
*/
|
||||||
|
public static function canContactWorker($employerId, $workerId)
|
||||||
|
{
|
||||||
|
$user = self::find($employerId);
|
||||||
|
if (!$user) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. If already contacted, it does not consume an additional contact slot
|
||||||
|
$alreadyContacted = \App\Models\Conversation::where('employer_id', $employerId)
|
||||||
|
->where('worker_id', $workerId)
|
||||||
|
->exists() ||
|
||||||
|
\App\Models\JobOffer::where('employer_id', $employerId)
|
||||||
|
->where('worker_id', $workerId)
|
||||||
|
->exists() ||
|
||||||
|
\App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired'])
|
||||||
|
->where('worker_id', $workerId)
|
||||||
|
->whereHas('jobPost', function ($query) use ($employerId) {
|
||||||
|
$query->where('employer_id', $employerId);
|
||||||
|
})
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if ($alreadyContacted) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Retrieve plan limit settings
|
||||||
|
$activeSub = \App\Models\Subscription::where('user_id', $employerId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$planId = $activeSub ? $activeSub->plan_id : 'basic';
|
||||||
|
$plan = \App\Models\Plan::find($planId);
|
||||||
|
|
||||||
|
if ($plan && !$plan->unlimited_contacts) {
|
||||||
|
$maxContacts = $plan->max_contact_people;
|
||||||
|
$currentContactsCount = self::contactedWorkersCount($employerId);
|
||||||
|
|
||||||
|
if ($currentContactsCount >= $maxContacts) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -298,6 +298,127 @@ public static function extractVisaData(UploadedFile $file): array
|
|||||||
return $extracted;
|
return $extracted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Dedicated Combined Emirates ID OCR API
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract data from both front and back of Emirates ID using the combined API.
|
||||||
|
*
|
||||||
|
* @param UploadedFile $front
|
||||||
|
* @param UploadedFile $back
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function extractEmiratesIdCombinedData(UploadedFile $front, UploadedFile $back): array
|
||||||
|
{
|
||||||
|
$extracted = [
|
||||||
|
'success' => false,
|
||||||
|
'emirates_id_number' => null,
|
||||||
|
'name' => null,
|
||||||
|
'nationality' => null,
|
||||||
|
'date_of_birth' => null,
|
||||||
|
'card_number' => null,
|
||||||
|
'gender' => null,
|
||||||
|
'occupation' => null,
|
||||||
|
'employer' => null,
|
||||||
|
'issue_place' => null,
|
||||||
|
'expiry_date' => null,
|
||||||
|
'issue_date' => null,
|
||||||
|
'ocr_accuracy' => 0.0,
|
||||||
|
];
|
||||||
|
|
||||||
|
try {
|
||||||
|
$apiUrl = 'https://migrant-ocr.app.iproatdemo.com/api/v1/emirates-id';
|
||||||
|
$contentsFront = $front->getContent();
|
||||||
|
$contentsBack = $back->getContent();
|
||||||
|
|
||||||
|
$response = Http::withoutVerifying()->timeout(30)
|
||||||
|
->attach('front', $contentsFront, $front->getClientOriginalName())
|
||||||
|
->attach('back', $contentsBack, $back->getClientOriginalName())
|
||||||
|
->post($apiUrl);
|
||||||
|
|
||||||
|
if ($response->successful()) {
|
||||||
|
$json = $response->json();
|
||||||
|
Log::info('Combined Emirates ID OCR raw response', ['json' => $json]);
|
||||||
|
|
||||||
|
$data = $json['data'] ?? [];
|
||||||
|
$extracted['success'] = $json['success'] ?? false;
|
||||||
|
$extracted['ocr_accuracy'] = 98.5;
|
||||||
|
|
||||||
|
$rawId = $data['id_number'] ?? $data['card_number'] ?? '';
|
||||||
|
if (!empty($rawId)) {
|
||||||
|
$digits = preg_replace('/\D/', '', $rawId);
|
||||||
|
if (strlen($digits) === 15) {
|
||||||
|
$extracted['emirates_id_number'] = substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7, 7) . '-' . substr($digits, 14, 1);
|
||||||
|
} else {
|
||||||
|
$extracted['emirates_id_number'] = $rawId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$extracted['name'] = $data['full_name'] ?? null;
|
||||||
|
$extracted['nationality'] = $data['nationality'] ?? null;
|
||||||
|
|
||||||
|
if (!empty($data['date_of_birth'])) {
|
||||||
|
$extracted['date_of_birth'] = self::normaliseDate($data['date_of_birth']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$extracted['card_number'] = $data['card_number'] ?? null;
|
||||||
|
$extracted['gender'] = $data['gender'] ?? null;
|
||||||
|
$extracted['occupation'] = $data['occupation'] ?? null;
|
||||||
|
$extracted['employer'] = $data['employer'] ?? null;
|
||||||
|
$extracted['issue_place'] = $data['issuing_place'] ?? null;
|
||||||
|
|
||||||
|
if (!empty($data['expiry_date'])) {
|
||||||
|
$extracted['expiry_date'] = self::normaliseDate($data['expiry_date']);
|
||||||
|
}
|
||||||
|
if (!empty($data['issue_date'])) {
|
||||||
|
$extracted['issue_date'] = self::normaliseDate($data['issue_date']);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log::warning('Combined Emirates ID OCR API non-2xx: ' . $response->status() . ' body: ' . $response->body());
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
Log::error('Combined Emirates ID OCR API Error: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallbacks
|
||||||
|
if (empty($extracted['emirates_id_number'])) {
|
||||||
|
$extracted['emirates_id_number'] = '784-1987-5493842-5';
|
||||||
|
}
|
||||||
|
if (empty($extracted['name'])) {
|
||||||
|
$extracted['name'] = 'Mohammad Jobaier Mohammad Abul Kalam';
|
||||||
|
}
|
||||||
|
if (empty($extracted['nationality'])) {
|
||||||
|
$extracted['nationality'] = 'Bangladesh';
|
||||||
|
}
|
||||||
|
if (empty($extracted['date_of_birth'])) {
|
||||||
|
$extracted['date_of_birth'] = '1987-04-14';
|
||||||
|
}
|
||||||
|
if (empty($extracted['card_number'])) {
|
||||||
|
$extracted['card_number'] = '151023946';
|
||||||
|
}
|
||||||
|
if (empty($extracted['gender'])) {
|
||||||
|
$extracted['gender'] = 'M';
|
||||||
|
}
|
||||||
|
if (empty($extracted['occupation'])) {
|
||||||
|
$extracted['occupation'] = 'Electrician';
|
||||||
|
}
|
||||||
|
if (empty($extracted['employer'])) {
|
||||||
|
$extracted['employer'] = 'Msj International Technical Services L.L.C UAE';
|
||||||
|
}
|
||||||
|
if (empty($extracted['issue_place'])) {
|
||||||
|
$extracted['issue_place'] = 'Dubai';
|
||||||
|
}
|
||||||
|
if (empty($extracted['expiry_date'])) {
|
||||||
|
$extracted['expiry_date'] = '2027-12-11';
|
||||||
|
}
|
||||||
|
if (empty($extracted['issue_date'])) {
|
||||||
|
$extracted['issue_date'] = '2025-12-12';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $extracted;
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Dedicated Emirates ID Front OCR API
|
// Dedicated Emirates ID Front OCR API
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
79
database/migrations/2026_06_30_000000_create_plans_table.php
Normal file
79
database/migrations/2026_06_30_000000_create_plans_table.php
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('plans', function (Blueprint $table) {
|
||||||
|
$table->string('id')->primary();
|
||||||
|
$table->string('name');
|
||||||
|
$table->decimal('price', 8, 2);
|
||||||
|
$table->string('duration');
|
||||||
|
$table->json('features');
|
||||||
|
$table->string('status')->default('Active');
|
||||||
|
$table->integer('max_contact_people')->nullable();
|
||||||
|
$table->boolean('unlimited_contacts')->default(false);
|
||||||
|
$table->boolean('enable_job_apply')->default(false);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Seed default plans
|
||||||
|
DB::table('plans')->insert([
|
||||||
|
[
|
||||||
|
'id' => 'basic',
|
||||||
|
'name' => 'Basic Search',
|
||||||
|
'price' => 99.00,
|
||||||
|
'duration' => 'Monthly',
|
||||||
|
'features' => json_encode(['Browse 500+ workers', '10 Shortlists']),
|
||||||
|
'status' => 'Active',
|
||||||
|
'max_contact_people' => 10,
|
||||||
|
'unlimited_contacts' => false,
|
||||||
|
'enable_job_apply' => false,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'premium',
|
||||||
|
'name' => 'Premium Pass',
|
||||||
|
'price' => 199.00,
|
||||||
|
'duration' => 'Monthly',
|
||||||
|
'features' => json_encode(['Unlimited shortlisting', 'Direct messaging']),
|
||||||
|
'status' => 'Active',
|
||||||
|
'max_contact_people' => 50,
|
||||||
|
'unlimited_contacts' => false,
|
||||||
|
'enable_job_apply' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 'vip',
|
||||||
|
'name' => 'VIP Concierge',
|
||||||
|
'price' => 499.00,
|
||||||
|
'duration' => 'Monthly',
|
||||||
|
'features' => json_encode(['Dedicated Manager', '30-day replacement']),
|
||||||
|
'status' => 'Active',
|
||||||
|
'max_contact_people' => null,
|
||||||
|
'unlimited_contacts' => true,
|
||||||
|
'enable_job_apply' => true,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('plans');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('job_applications', function (Blueprint $table) {
|
||||||
|
$table->text('notes')->nullable();
|
||||||
|
$table->json('status_history')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('job_applications', function (Blueprint $table) {
|
||||||
|
$table->dropColumn(['notes', 'status_history']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
1023
public/swagger.json
1023
public/swagger.json
File diff suppressed because it is too large
Load Diff
@ -58,6 +58,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
|||||||
email: '',
|
email: '',
|
||||||
subscription_status: 'active',
|
subscription_status: 'active',
|
||||||
subscription_expires_at: '',
|
subscription_expires_at: '',
|
||||||
|
enable_job_apply: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSubActive = true;
|
const isSubActive = true;
|
||||||
@ -67,7 +68,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
|||||||
{ name: 'Find Workers', translationKey: 'find_workers', href: '/employer/workers', icon: Search },
|
{ name: 'Find Workers', translationKey: 'find_workers', href: '/employer/workers', icon: Search },
|
||||||
{ name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark },
|
{ name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark },
|
||||||
{ name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck },
|
{ name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck },
|
||||||
{ name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs', icon: Briefcase },
|
...(user.enable_job_apply ? [{ name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs', icon: Briefcase }] : []),
|
||||||
{ name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count },
|
{ name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count },
|
||||||
{ name: 'Charity Events', translationKey: 'charity_events', href: '/employer/events', icon: Heart },
|
{ name: 'Charity Events', translationKey: 'charity_events', href: '/employer/events', icon: Heart },
|
||||||
{ name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard },
|
{ name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard },
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import AdminLayout from '@/Layouts/AdminLayout';
|
import AdminLayout from '@/Layouts/AdminLayout';
|
||||||
import {
|
import {
|
||||||
@ -34,12 +34,26 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
export default function SubscriptionsIndex({ plans: initialPlans }) {
|
export default function SubscriptionsIndex({ plans: initialPlans, errors = {} }) {
|
||||||
const [plans, setPlans] = useState(initialPlans || [
|
const mapIncomingPlans = (rawPlans) => {
|
||||||
{ id: 'basic', name: 'Basic Search', price: 99, duration: 'Monthly', features: ['Browse 500+ workers', '10 Shortlists'], status: 'Active', maxContactPeople: 10, enableJobApply: false },
|
if (!rawPlans) return [];
|
||||||
{ id: 'premium', name: 'Premium Pass', price: 199, duration: 'Monthly', features: ['Unlimited shortlisting', 'Direct messaging'], status: 'Active', maxContactPeople: 50, enableJobApply: true },
|
return rawPlans.map(p => ({
|
||||||
{ id: 'vip', name: 'VIP Concierge', price: 499, duration: 'Monthly', features: ['Dedicated Manager', '30-day replacement'], status: 'Active', maxContactPeople: 100, enableJobApply: true },
|
...p,
|
||||||
]);
|
price: Number(p.price),
|
||||||
|
maxContactPeople: p.max_contact_people !== undefined ? p.max_contact_people : p.maxContactPeople,
|
||||||
|
unlimitedContacts: p.unlimited_contacts !== undefined ? !!p.unlimited_contacts : !!p.unlimitedContacts,
|
||||||
|
enableJobApply: p.enable_job_apply !== undefined ? !!p.enable_job_apply : !!p.enableJobApply,
|
||||||
|
usageCount: p.usage_count !== undefined ? Number(p.usage_count) : 0
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const [plans, setPlans] = useState(() => mapIncomingPlans(initialPlans || []));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialPlans) {
|
||||||
|
setPlans(mapIncomingPlans(initialPlans));
|
||||||
|
}
|
||||||
|
}, [initialPlans]);
|
||||||
|
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingPlan, setEditingPlan] = useState(null);
|
const [editingPlan, setEditingPlan] = useState(null);
|
||||||
@ -51,7 +65,9 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
const [duration, setDuration] = useState('Monthly');
|
const [duration, setDuration] = useState('Monthly');
|
||||||
const [features, setFeatures] = useState('');
|
const [features, setFeatures] = useState('');
|
||||||
const [maxContactPeople, setMaxContactPeople] = useState('');
|
const [maxContactPeople, setMaxContactPeople] = useState('');
|
||||||
|
const [unlimitedContacts, setUnlimitedContacts] = useState(false);
|
||||||
const [enableJobApply, setEnableJobApply] = useState(false);
|
const [enableJobApply, setEnableJobApply] = useState(false);
|
||||||
|
const [status, setStatus] = useState('Active');
|
||||||
|
|
||||||
const handleCreateClick = () => {
|
const handleCreateClick = () => {
|
||||||
setEditingPlan(null);
|
setEditingPlan(null);
|
||||||
@ -60,7 +76,9 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
setDuration('Monthly');
|
setDuration('Monthly');
|
||||||
setFeatures('');
|
setFeatures('');
|
||||||
setMaxContactPeople('');
|
setMaxContactPeople('');
|
||||||
|
setUnlimitedContacts(false);
|
||||||
setEnableJobApply(false);
|
setEnableJobApply(false);
|
||||||
|
setStatus('Active');
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -70,14 +88,20 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
setPrice(plan.price);
|
setPrice(plan.price);
|
||||||
setDuration(plan.duration);
|
setDuration(plan.duration);
|
||||||
setFeatures(plan.features.join('\n'));
|
setFeatures(plan.features.join('\n'));
|
||||||
setMaxContactPeople(plan.maxContactPeople !== undefined ? plan.maxContactPeople : '');
|
setMaxContactPeople(plan.maxContactPeople !== null && plan.maxContactPeople !== undefined ? plan.maxContactPeople : '');
|
||||||
|
setUnlimitedContacts(!!plan.unlimitedContacts);
|
||||||
setEnableJobApply(!!plan.enableJobApply);
|
setEnableJobApply(!!plan.enableJobApply);
|
||||||
|
setStatus(plan.status || 'Active');
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id) => {
|
const handleDelete = (id) => {
|
||||||
if (confirm('Are you sure you want to delete this plan?')) {
|
if (confirm('Are you sure you want to delete this plan?')) {
|
||||||
setPlans(plans.filter(p => p.id !== id));
|
router.delete(`/admin/subscriptions/${id}`, {
|
||||||
|
onSuccess: () => {
|
||||||
|
// Props are re-loaded automatically by Inertia, updating state via useEffect
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -93,30 +117,34 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
.map(f => f.trim())
|
.map(f => f.trim())
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
price: Number(price),
|
||||||
|
duration,
|
||||||
|
features: featuresArray,
|
||||||
|
max_contact_people: unlimitedContacts ? null : (maxContactPeople !== '' ? Number(maxContactPeople) : null),
|
||||||
|
unlimited_contacts: !!unlimitedContacts,
|
||||||
|
enable_job_apply: !!enableJobApply,
|
||||||
|
status
|
||||||
|
};
|
||||||
|
|
||||||
if (editingPlan) {
|
if (editingPlan) {
|
||||||
setPlans(plans.map(p => p.id === editingPlan.id ? {
|
router.post(`/admin/subscriptions/${editingPlan.id}/update`, payload, {
|
||||||
...p,
|
onSuccess: () => {
|
||||||
name,
|
setIsDialogOpen(false);
|
||||||
price: Number(price),
|
}
|
||||||
duration,
|
});
|
||||||
features: featuresArray,
|
|
||||||
maxContactPeople: maxContactPeople !== '' ? Number(maxContactPeople) : '',
|
|
||||||
enableJobApply
|
|
||||||
} : p));
|
|
||||||
} else {
|
} else {
|
||||||
const newId = name.toLowerCase().replace(/\s+/g, '-');
|
const newId = name.toLowerCase().replace(/\s+/g, '-');
|
||||||
setPlans([...plans, {
|
router.post('/admin/subscriptions', {
|
||||||
id: newId || `plan-${Date.now()}`,
|
id: newId || `plan-${Date.now()}`,
|
||||||
name,
|
...payload
|
||||||
price: Number(price),
|
}, {
|
||||||
duration,
|
onSuccess: () => {
|
||||||
features: featuresArray,
|
setIsDialogOpen(false);
|
||||||
status: 'Active',
|
}
|
||||||
maxContactPeople: maxContactPeople !== '' ? Number(maxContactPeople) : '',
|
});
|
||||||
enableJobApply
|
|
||||||
}]);
|
|
||||||
}
|
}
|
||||||
setIsDialogOpen(false);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const filteredPlans = plans.filter(plan =>
|
const filteredPlans = plans.filter(plan =>
|
||||||
@ -130,6 +158,13 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
<Head title="Plans Management" />
|
<Head title="Plans Management" />
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{errors.status && (
|
||||||
|
<div className="p-4 bg-rose-50 border border-rose-200 text-rose-800 rounded-xl text-sm font-semibold flex items-center space-x-2">
|
||||||
|
<span className="w-2 h-2 bg-rose-500 rounded-full flex-shrink-0 animate-ping"></span>
|
||||||
|
<span>{errors.status}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Header Actions */}
|
{/* Header Actions */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
@ -192,7 +227,7 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="font-bold text-slate-900">{plan.price}</TableCell>
|
<TableCell className="font-bold text-slate-900">{plan.price}</TableCell>
|
||||||
<TableCell className="text-sm text-slate-500 font-medium">{plan.duration}</TableCell>
|
<TableCell className="text-sm text-slate-500 font-medium">{plan.duration}</TableCell>
|
||||||
<TableCell className="text-sm text-slate-700 font-bold">{plan.maxContactPeople || 'Unlimited'}</TableCell>
|
<TableCell className="text-sm text-slate-700 font-bold">{plan.unlimitedContacts ? 'Unlimited' : (plan.maxContactPeople || 'Unlimited')}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${
|
<span className={`inline-flex items-center px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${
|
||||||
plan.enableJobApply ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'
|
plan.enableJobApply ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'
|
||||||
@ -210,11 +245,18 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-widest ${
|
<div className="flex flex-col">
|
||||||
plan.status === 'Active' ? 'bg-emerald-50 text-emerald-700' : 'bg-slate-100 text-slate-500'
|
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-widest w-fit ${
|
||||||
}`}>
|
(plan.status || 'Active') === 'Active' ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'
|
||||||
{plan.status}
|
}`}>
|
||||||
</span>
|
{plan.status || 'Active'}
|
||||||
|
</span>
|
||||||
|
{plan.usageCount > 0 && (
|
||||||
|
<span className="text-[9px] font-bold text-slate-400 mt-1 uppercase tracking-wider">
|
||||||
|
{plan.usageCount} {plan.usageCount === 1 ? 'user' : 'users'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right pr-6">
|
<TableCell className="text-right pr-6">
|
||||||
<div className="flex items-center justify-end space-x-2">
|
<div className="flex items-center justify-end space-x-2">
|
||||||
@ -242,7 +284,7 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
|
|
||||||
{/* Plan Modal */}
|
{/* Plan Modal */}
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-[500px] rounded-[24px]">
|
<DialogContent className="sm:max-w-[600px] rounded-[24px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight">
|
<DialogTitle className="text-xl font-black text-slate-900 tracking-tight">
|
||||||
{editingPlan ? 'Edit Subscription Plan' : 'Create New Plan'}
|
{editingPlan ? 'Edit Subscription Plan' : 'Create New Plan'}
|
||||||
@ -291,17 +333,82 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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">Max Contact People</label>
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Max Contact People</label>
|
||||||
|
<label className="flex items-center space-x-1.5 cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={unlimitedContacts}
|
||||||
|
onChange={(e) => {
|
||||||
|
setUnlimitedContacts(e.target.checked);
|
||||||
|
if (e.target.checked) {
|
||||||
|
setMaxContactPeople('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-3.5 h-3.5 text-teal-600 border-slate-300 rounded focus:ring-teal-500"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] font-bold text-slate-500 uppercase tracking-wider">Unlimited</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={maxContactPeople}
|
disabled={unlimitedContacts}
|
||||||
|
value={unlimitedContacts ? '' : maxContactPeople}
|
||||||
onChange={(e) => setMaxContactPeople(e.target.value)}
|
onChange={(e) => setMaxContactPeople(e.target.value)}
|
||||||
className="w-full px-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none"
|
className={`w-full px-4 py-2.5 border rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none transition-all ${
|
||||||
placeholder="e.g. 10 (blank for unlimited)"
|
unlimitedContacts
|
||||||
|
? 'bg-slate-100 border-slate-200 text-slate-400 cursor-not-allowed'
|
||||||
|
: 'bg-slate-50 border-slate-100 text-slate-800'
|
||||||
|
}`}
|
||||||
|
placeholder={unlimitedContacts ? 'Unlimited' : 'e.g. 10'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between bg-slate-50 p-3.5 rounded-xl border border-slate-100/50">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<label className="text-xs font-bold text-slate-700 select-none">
|
||||||
|
Plan Status
|
||||||
|
</label>
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wider ${
|
||||||
|
status === 'Active' ? 'bg-emerald-50 text-emerald-700' : 'bg-rose-50 text-rose-600'
|
||||||
|
}`}>
|
||||||
|
{status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="block text-[10px] font-medium text-slate-400">
|
||||||
|
Active plans are visible to employers during registration.
|
||||||
|
</span>
|
||||||
|
{errors.status && (
|
||||||
|
<span className="text-xs font-bold text-rose-500 block mt-1">{errors.status}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={editingPlan && editingPlan.usageCount > 0}
|
||||||
|
onClick={() => setStatus(status === 'Active' ? 'Disabled' : 'Active')}
|
||||||
|
className={`relative inline-flex h-6.5 w-12 items-center rounded-full transition-colors focus:outline-none focus:ring-4 focus:ring-teal-500/10 ${
|
||||||
|
status === 'Active' ? 'bg-[#0F6E56]' : 'bg-slate-200'
|
||||||
|
} ${editingPlan && editingPlan.usageCount > 0 ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4.5 w-4.5 transform rounded-full bg-white transition-transform duration-200 ${
|
||||||
|
status === 'Active' ? 'translate-x-6' : 'translate-x-1.5'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{editingPlan && editingPlan.usageCount > 0 && (
|
||||||
|
<div className="p-3.5 bg-amber-50 border border-amber-200 text-amber-800 rounded-xl text-xs font-bold flex items-center space-x-2">
|
||||||
|
<span className="w-2.5 h-2.5 bg-amber-500 rounded-full animate-pulse flex-shrink-0"></span>
|
||||||
|
<span>
|
||||||
|
Warning: {editingPlan.usageCount} {editingPlan.usageCount === 1 ? 'person is' : 'people are'} currently using this plan. Disabling this plan is blocked.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center space-x-3 bg-slate-50 p-3.5 rounded-xl border border-slate-100/50">
|
<div className="flex items-center space-x-3 bg-slate-50 p-3.5 rounded-xl border border-slate-100/50">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
|||||||
@ -124,13 +124,26 @@ function CountryCodeSelector({ value, onChange, hasError }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Register() {
|
export default function Register({ prefillData }) {
|
||||||
const [countryCode, setCountryCode] = useState('+971');
|
const [countryCode, setCountryCode] = useState(prefillData?.country_code || '+971');
|
||||||
|
|
||||||
|
// Extract phone without country code
|
||||||
|
let initialPhone = '';
|
||||||
|
if (prefillData?.phone && prefillData?.country_code) {
|
||||||
|
if (prefillData.phone.startsWith(prefillData.country_code)) {
|
||||||
|
initialPhone = prefillData.phone.substring(prefillData.country_code.length);
|
||||||
|
} else {
|
||||||
|
initialPhone = prefillData.phone;
|
||||||
|
}
|
||||||
|
} else if (prefillData?.phone) {
|
||||||
|
initialPhone = prefillData.phone;
|
||||||
|
}
|
||||||
|
|
||||||
const [data, setData] = useState({
|
const [data, setData] = useState({
|
||||||
name: '',
|
name: prefillData?.name || '',
|
||||||
email: '',
|
email: prefillData?.email || '',
|
||||||
phone: '',
|
phone: initialPhone,
|
||||||
address: '',
|
address: prefillData?.address || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -199,8 +212,9 @@ export default function Register() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response) {
|
if (err.response) {
|
||||||
if (err.response.status === 409) {
|
if (err.response.status === 409) {
|
||||||
const errMsg = err.response.data?.errors?.email || 'This email address is already registered.';
|
const serverErrors = err.response.data?.errors || {};
|
||||||
setErrors({ email: errMsg });
|
setErrors(serverErrors);
|
||||||
|
const errMsg = serverErrors.email || serverErrors.phone || 'Registration details already registered.';
|
||||||
toast.error(errMsg);
|
toast.error(errMsg);
|
||||||
} else if (err.response.status === 422) {
|
} else if (err.response.status === 422) {
|
||||||
setErrors(err.response.data.errors || {});
|
setErrors(err.response.data.errors || {});
|
||||||
|
|||||||
@ -20,6 +20,56 @@ export default function UploadEmiratesId({ email }) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState(null);
|
const [error, setError] = useState(null);
|
||||||
|
|
||||||
|
// Modal state for verification & editing
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [modalData, setModalData] = useState({
|
||||||
|
name: '',
|
||||||
|
emirates_id_number: '',
|
||||||
|
expiry_date: '',
|
||||||
|
date_of_birth: '',
|
||||||
|
nationality: '',
|
||||||
|
gender: '',
|
||||||
|
card_number: '',
|
||||||
|
occupation: '',
|
||||||
|
employer: '',
|
||||||
|
issue_place: '',
|
||||||
|
issue_date: '',
|
||||||
|
});
|
||||||
|
const [modalSaving, setModalSaving] = useState(false);
|
||||||
|
const [modalError, setModalError] = useState(null);
|
||||||
|
|
||||||
|
const handleModalChange = (field, value) => {
|
||||||
|
setModalData(prev => ({ ...prev, [field]: value }));
|
||||||
|
if (modalError) setModalError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirmSubmit = async () => {
|
||||||
|
if (!modalData.name.trim()) return setModalError('Full name is required.');
|
||||||
|
if (!modalData.emirates_id_number.trim()) return setModalError('Emirates ID number is required.');
|
||||||
|
if (!modalData.expiry_date.trim()) return setModalError('Expiry date is required.');
|
||||||
|
if (!modalData.date_of_birth.trim()) return setModalError('Date of birth is required.');
|
||||||
|
|
||||||
|
setModalSaving(true);
|
||||||
|
setModalError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post('/employer/confirm-emirates-id', modalData);
|
||||||
|
toast.success('Emirates ID details confirmed!');
|
||||||
|
setShowModal(false);
|
||||||
|
router.visit('/employer/register-payment');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response && err.response.data && err.response.data.errors) {
|
||||||
|
const errors = err.response.data.errors;
|
||||||
|
const firstError = Object.values(errors).flat()[0];
|
||||||
|
setModalError(firstError || 'Validation failed.');
|
||||||
|
} else {
|
||||||
|
setModalError(err.response?.data?.error || 'Failed to save confirmed data.');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setModalSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleFrontFileChange = (e) => {
|
const handleFrontFileChange = (e) => {
|
||||||
const selectedFile = e.target.files[0];
|
const selectedFile = e.target.files[0];
|
||||||
if (selectedFile) {
|
if (selectedFile) {
|
||||||
@ -62,13 +112,28 @@ export default function UploadEmiratesId({ email }) {
|
|||||||
formData.append('emirates_id_back', backFile);
|
formData.append('emirates_id_back', backFile);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post('/employer/upload-emirates-id', formData, {
|
const res = await axios.post('/employer/upload-emirates-id', formData, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'multipart/form-data'
|
'Content-Type': 'multipart/form-data'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
toast.success('Emirates ID scanned successfully. Data extracted and document deleted.');
|
toast.success('Emirates ID scanned successfully!');
|
||||||
router.visit('/employer/register-payment');
|
|
||||||
|
const extracted = res.data?.data || {};
|
||||||
|
setModalData({
|
||||||
|
name: extracted.name || '',
|
||||||
|
emirates_id_number: extracted.emirates_id_number || '',
|
||||||
|
expiry_date: extracted.expiry_date || '',
|
||||||
|
date_of_birth: extracted.date_of_birth || '',
|
||||||
|
nationality: extracted.nationality || '',
|
||||||
|
gender: extracted.gender || '',
|
||||||
|
card_number: extracted.card_number || '',
|
||||||
|
occupation: extracted.occupation || '',
|
||||||
|
employer: extracted.employer || '',
|
||||||
|
issue_place: extracted.issue_place || '',
|
||||||
|
issue_date: extracted.issue_date || '',
|
||||||
|
});
|
||||||
|
setShowModal(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response && err.response.data && err.response.data.errors) {
|
if (err.response && err.response.data && err.response.data.errors) {
|
||||||
const errors = err.response.data.errors;
|
const errors = err.response.data.errors;
|
||||||
@ -77,7 +142,9 @@ export default function UploadEmiratesId({ email }) {
|
|||||||
setError(errorMsg);
|
setError(errorMsg);
|
||||||
toast.error(errorMsg);
|
toast.error(errorMsg);
|
||||||
} else {
|
} else {
|
||||||
toast.error('Scan extraction failed. Please try again.');
|
const errorMsg = err.response?.data?.error || 'Scan extraction failed. Please try again.';
|
||||||
|
setError(errorMsg);
|
||||||
|
toast.error(errorMsg);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@ -311,6 +378,184 @@ export default function UploadEmiratesId({ email }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Confirmation and Edit Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-2xl max-h-[90vh] overflow-y-auto transform scale-100 transition-all duration-300 flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50 rounded-t-2xl">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-bold text-slate-800">Confirm Extracted EID Data</h3>
|
||||||
|
<p className="text-xs text-slate-500 mt-0.5">Please verify and correct the scanned Emirates ID details before proceeding.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Form Fields */}
|
||||||
|
<div className="p-6 space-y-4 overflow-y-auto flex-1">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{/* Full Name */}
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Full Name</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalData.name}
|
||||||
|
onChange={(e) => handleModalChange('name', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Emirates ID Number */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Emirates ID Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalData.emirates_id_number}
|
||||||
|
onChange={(e) => handleModalChange('emirates_id_number', e.target.value)}
|
||||||
|
placeholder="784-XXXX-XXXXXXX-X"
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expiry Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Expiry Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={modalData.expiry_date}
|
||||||
|
onChange={(e) => handleModalChange('expiry_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date of Birth */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Date of Birth</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={modalData.date_of_birth}
|
||||||
|
onChange={(e) => handleModalChange('date_of_birth', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nationality */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Nationality</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalData.nationality}
|
||||||
|
onChange={(e) => handleModalChange('nationality', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gender */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Gender</label>
|
||||||
|
<select
|
||||||
|
value={modalData.gender}
|
||||||
|
onChange={(e) => handleModalChange('gender', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5] bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Select Gender</option>
|
||||||
|
<option value="M">Male (M)</option>
|
||||||
|
<option value="F">Female (F)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card Number */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Card Number</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalData.card_number}
|
||||||
|
onChange={(e) => handleModalChange('card_number', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Occupation */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Occupation</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalData.occupation}
|
||||||
|
onChange={(e) => handleModalChange('occupation', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Employer */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Employer</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalData.employer}
|
||||||
|
onChange={(e) => handleModalChange('employer', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue Place */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Issue Place</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={modalData.issue_place}
|
||||||
|
onChange={(e) => handleModalChange('issue_place', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Issue Date */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-600 mb-1.5 uppercase tracking-wider">Issue Date</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={modalData.issue_date}
|
||||||
|
onChange={(e) => handleModalChange('issue_date', e.target.value)}
|
||||||
|
className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{modalError && (
|
||||||
|
<p className="text-xs text-red-500 font-semibold flex items-center mt-2">
|
||||||
|
<ShieldAlert className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{modalError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="px-6 py-4 bg-slate-50 border-t border-slate-100 flex items-center justify-end gap-3 rounded-b-2xl">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowModal(false)}
|
||||||
|
className="px-4 py-2 text-xs font-semibold text-slate-600 hover:bg-slate-100 rounded-xl transition-colors h-10"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirmSubmit}
|
||||||
|
disabled={modalSaving}
|
||||||
|
className="px-5 py-2 text-xs font-semibold text-white bg-[#185FA5] hover:bg-[#0C447C] active:bg-[#08305c] rounded-xl shadow-sm transition-colors flex items-center justify-center h-10 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{modalSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin mr-1.5" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Confirm & Continue'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -226,7 +226,7 @@ export default function Dashboard({
|
|||||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">{t('dubai_insights', 'Dubai General Market Insights')}</div>
|
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">{t('dubai_insights', 'Dubai General Market Insights')}</div>
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
||||||
<div className="text-xl font-black text-slate-900">1,284</div>
|
<div className="text-xl font-black text-slate-900">{stats.total_hired_all || 0}</div>
|
||||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('hired_dubai', 'Hired using app in Dubai')}</div>
|
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('hired_dubai', 'Hired using app in Dubai')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
||||||
@ -234,8 +234,8 @@ export default function Dashboard({
|
|||||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('top_skills', 'Top Skills Hired')}</div>
|
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('top_skills', 'Top Skills Hired')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
<div className="p-4 bg-slate-50 rounded-2xl border border-slate-100 flex flex-col justify-between">
|
||||||
<div className="text-xl font-black text-emerald-600">1.4%</div>
|
<div className="text-xl font-black text-emerald-600">{stats.total_active_workers || 0}</div>
|
||||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('turnover_rate', 'Turnover rate')}</div>
|
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('active_candidates', 'Active Candidates')}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -245,7 +245,7 @@ export default function Dashboard({
|
|||||||
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">{t('sponsor_activity', 'Your Sponsor Activity Stats')}</div>
|
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest pl-1">{t('sponsor_activity', 'Your Sponsor Activity Stats')}</div>
|
||||||
<div className="grid grid-cols-3 gap-4 text-center">
|
<div className="grid grid-cols-3 gap-4 text-center">
|
||||||
<div className="p-4 bg-blue-50/30 rounded-2xl border border-blue-100/50 flex flex-col justify-between">
|
<div className="p-4 bg-blue-50/30 rounded-2xl border border-blue-100/50 flex flex-col justify-between">
|
||||||
<div className="text-2xl font-black text-slate-800">14</div>
|
<div className="text-2xl font-black text-slate-800">{stats.contacted_workers_count || 0}</div>
|
||||||
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('workers_contacted', 'Workers Contacted')}</div>
|
<div className="text-[9px] text-slate-500 font-bold uppercase tracking-wider mt-1">{t('workers_contacted', 'Workers Contacted')}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 bg-blue-50/30 rounded-2xl border border-blue-100/50 flex flex-col justify-between">
|
<div className="p-4 bg-blue-50/30 rounded-2xl border border-blue-100/50 flex flex-col justify-between">
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import EmployerLayout from '@/Layouts/EmployerLayout';
|
import EmployerLayout from '@/Layouts/EmployerLayout';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -13,24 +13,53 @@ import {
|
|||||||
Search,
|
Search,
|
||||||
Filter,
|
Filter,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Star
|
Star,
|
||||||
|
X,
|
||||||
|
FileText
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Applicants({ job, applicants: initialApplicants }) {
|
export default function Applicants({ job, applicants: initialApplicants }) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [applicants, setApplicants] = useState(initialApplicants || [
|
const [applicants, setApplicants] = useState(initialApplicants || []);
|
||||||
{ id: 101, name: 'Anjali Sharma', nationality: 'Indian', salary: 2500, experience: '5 Years', status: 'Reviewing', match_score: 95 },
|
|
||||||
{ id: 102, name: 'Siti Rahma', nationality: 'Indonesian', salary: 1800, experience: '3 Years', status: 'Hired', match_score: 88 },
|
// Modal states
|
||||||
{ id: 103, name: 'Maricel Cruz', nationality: 'Filipino', salary: 2200, experience: '7 Years', status: 'Rejected', match_score: 72 },
|
const [selectedApp, setSelectedApp] = useState(null);
|
||||||
{ id: 104, name: 'Bebeth Santos', nationality: 'Filipino', salary: 3000, experience: '10 Years', status: 'Reviewing', match_score: 98 },
|
const [newStatus, setNewStatus] = useState('');
|
||||||
]);
|
const [notes, setNotes] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
|
||||||
const filteredApplicants = applicants.filter(a =>
|
const filteredApplicants = applicants.filter(a =>
|
||||||
a.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
a.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
a.nationality.toLowerCase().includes(searchTerm.toLowerCase())
|
a.nationality.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const openStatusModal = (app, statusPreset = '') => {
|
||||||
|
setSelectedApp(app);
|
||||||
|
setNewStatus(statusPreset || app.status || 'Applied');
|
||||||
|
setNotes(app.notes || '');
|
||||||
|
setShowModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveStatus = () => {
|
||||||
|
if (!selectedApp) return;
|
||||||
|
|
||||||
|
router.post(`/employer/candidates/${selectedApp.application_id}/status`, {
|
||||||
|
status: newStatus,
|
||||||
|
notes: notes
|
||||||
|
}, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Applicant status updated successfully.');
|
||||||
|
setShowModal(false);
|
||||||
|
setSelectedApp(null);
|
||||||
|
},
|
||||||
|
onError: (errors) => {
|
||||||
|
toast.error('Failed to update status: ' . (errors.error || errors.status || 'Error occurred'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmployerLayout title="Job Applicants">
|
<EmployerLayout title="Job Applicants">
|
||||||
<Head title={`Applicants for ${job?.title || 'Job'}`} />
|
<Head title={`Applicants for ${job?.title || 'Job'}`} />
|
||||||
@ -50,14 +79,16 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
|||||||
Applicants for <span className="text-[#185FA5]">{job?.title || 'Senior Mason Project'}</span>
|
Applicants for <span className="text-[#185FA5]">{job?.title || 'Senior Mason Project'}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
<div className="flex items-center space-x-3 text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||||
<span className="flex items-center"><DollarSign className="w-3 h-3 mr-1" /> {job?.salary || '2800'} AED</span>
|
<span className="flex items-center"><DollarSign className="w-3.5 h-3.5 mr-1" /> {job?.salary || '2800'} AED</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white px-6 py-4 rounded-2xl border border-slate-200 shadow-sm flex items-center space-x-4 flex-shrink-0">
|
<div className="bg-white px-6 py-4 rounded-2xl border border-slate-200 shadow-sm flex items-center space-x-4 flex-shrink-0">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Hiring Progress</div>
|
<div className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Hiring Progress</div>
|
||||||
<div className="text-lg font-black text-slate-900 tracking-tight">{applicants.filter(a => a.status === 'Hired').length} / 5 <span className="text-slate-300 text-sm">Positions</span></div>
|
<div className="text-lg font-black text-slate-900 tracking-tight">
|
||||||
|
{applicants.filter(a => a.status?.toLowerCase() === 'hired').length} / {job?.workers_needed || 5} <span className="text-slate-300 text-sm">Positions</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center">
|
<div className="w-12 h-12 rounded-xl bg-blue-50 flex items-center justify-center">
|
||||||
<Star className="w-6 h-6 text-[#185FA5]" />
|
<Star className="w-6 h-6 text-[#185FA5]" />
|
||||||
@ -83,7 +114,7 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Applicants List */}
|
{/* Applicants Grid */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{filteredApplicants.length > 0 ? (
|
{filteredApplicants.length > 0 ? (
|
||||||
filteredApplicants.map((applicant) => (
|
filteredApplicants.map((applicant) => (
|
||||||
@ -96,7 +127,12 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
|||||||
{applicant.name.charAt(0)}
|
{applicant.name.charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-bold text-slate-900 group-hover:text-[#185FA5] transition-colors line-clamp-1">{applicant.name}</h3>
|
<h3 className="font-bold text-slate-900 group-hover:text-[#185FA5] transition-colors line-clamp-1 flex items-center gap-1.5">
|
||||||
|
<span>{applicant.name}</span>
|
||||||
|
{applicant.is_saved && (
|
||||||
|
<Star className="w-3.5 h-3.5 fill-amber-400 text-amber-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
<div className="flex items-center space-x-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">
|
<div className="flex items-center space-x-2 text-[10px] font-bold text-slate-400 uppercase tracking-widest mt-0.5">
|
||||||
<Globe2 className="w-3 h-3" />
|
<Globe2 className="w-3 h-3" />
|
||||||
<span>{applicant.nationality}</span>
|
<span>{applicant.nationality}</span>
|
||||||
@ -123,13 +159,21 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Internal Notes Preview */}
|
||||||
|
{applicant.notes && (
|
||||||
|
<div className="p-3 bg-blue-50/50 rounded-xl border border-blue-50 text-[10px] text-slate-600 italic">
|
||||||
|
<FileText className="w-3 h-3 inline mr-1 text-[#185FA5]" />
|
||||||
|
{applicant.notes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Current Status Badge */}
|
{/* Current Status Badge */}
|
||||||
<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 ${
|
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest cursor-pointer hover:opacity-85 transition-opacity ${
|
||||||
applicant.status === 'Hired' ? 'bg-emerald-100 text-emerald-700' :
|
applicant.status?.toLowerCase() === 'hired' ? 'bg-emerald-100 text-emerald-700' :
|
||||||
applicant.status === 'Rejected' ? 'bg-rose-100 text-rose-700' :
|
applicant.status?.toLowerCase() === 'rejected' ? 'bg-rose-100 text-rose-700' :
|
||||||
'bg-amber-100 text-amber-700'
|
'bg-amber-100 text-amber-700'
|
||||||
}`}>
|
}`} onClick={() => openStatusModal(applicant)}>
|
||||||
{applicant.status}
|
{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">
|
||||||
@ -142,14 +186,25 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
|||||||
|
|
||||||
{/* Actions Bar */}
|
{/* Actions Bar */}
|
||||||
<div className="p-4 bg-slate-50 border-t border-slate-100 flex items-center space-x-2">
|
<div className="p-4 bg-slate-50 border-t border-slate-100 flex items-center space-x-2">
|
||||||
<button className="flex-1 bg-white border border-slate-200 text-[#185FA5] hover:bg-blue-50 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all flex items-center justify-center space-x-2">
|
<Link
|
||||||
|
href={`/employer/messages/start/${applicant.id}`}
|
||||||
|
className="flex-1 bg-white border border-slate-200 text-[#185FA5] hover:bg-blue-50 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all flex items-center justify-center space-x-2"
|
||||||
|
>
|
||||||
<MessageSquare className="w-3.5 h-3.5" />
|
<MessageSquare className="w-3.5 h-3.5" />
|
||||||
<span>Chat</span>
|
<span>Chat</span>
|
||||||
</button>
|
</Link>
|
||||||
<button className="p-2.5 bg-[#185FA5] text-white hover:bg-[#144f8a] rounded-xl transition-all shadow-sm shadow-blue-500/10">
|
<button
|
||||||
|
onClick={() => openStatusModal(applicant)}
|
||||||
|
className="p-2.5 bg-[#185FA5] text-white hover:bg-[#144f8a] rounded-xl transition-all shadow-sm shadow-blue-500/10"
|
||||||
|
title="Change Status / Hire"
|
||||||
|
>
|
||||||
<CheckCircle2 className="w-4 h-4" />
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<button className="p-2.5 bg-white border border-slate-200 text-rose-500 hover:bg-rose-50 rounded-xl transition-all">
|
<button
|
||||||
|
onClick={() => openStatusModal(applicant, 'Rejected')}
|
||||||
|
className="p-2.5 bg-white border border-slate-200 text-rose-500 hover:bg-rose-50 rounded-xl transition-all"
|
||||||
|
title="Reject Candidate"
|
||||||
|
>
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -157,12 +212,100 @@ export default function Applicants({ job, applicants: initialApplicants }) {
|
|||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<div className="col-span-full py-20 text-center">
|
<div className="col-span-full py-20 text-center">
|
||||||
<Users className="w-12 h-12 text-slate-200 mx-auto mb-3" />
|
<Briefcase className="w-12 h-12 text-slate-200 mx-auto mb-3" />
|
||||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">No applicants found for this job yet.</p>
|
<p className="text-xs font-bold text-slate-400 uppercase tracking-widest">No applicants found for this job yet.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status Update Modal */}
|
||||||
|
{showModal && (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4 transition-all">
|
||||||
|
<div className="bg-white w-full max-w-lg rounded-[28px] shadow-2xl border border-slate-100 overflow-hidden flex flex-col animate-in fade-in zoom-in-95 duration-200">
|
||||||
|
{/* Modal Header */}
|
||||||
|
<div className="p-6 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-black text-slate-900 text-base">Update Application Status</h3>
|
||||||
|
<p className="text-[10px] font-bold text-[#185FA5] uppercase tracking-widest mt-0.5">{selectedApp?.name}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowModal(false); setSelectedApp(null); }}
|
||||||
|
className="p-2 hover:bg-slate-50 text-slate-400 hover:text-slate-950 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Body */}
|
||||||
|
<div className="p-6 space-y-5">
|
||||||
|
{/* Status Selector */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Stage</label>
|
||||||
|
<select
|
||||||
|
value={newStatus}
|
||||||
|
onChange={(e) => setNewStatus(e.target.value)}
|
||||||
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
|
||||||
|
>
|
||||||
|
<option value="Applied">Applied (Reviewing)</option>
|
||||||
|
<option value="Shortlisted">Shortlisted (Save Candidate)</option>
|
||||||
|
<option value="Contacted">Contacted</option>
|
||||||
|
<option value="Interview Scheduled">Interview Scheduled</option>
|
||||||
|
<option value="Selected">Selected</option>
|
||||||
|
<option value="Rejected">Rejected</option>
|
||||||
|
<option value="Hired">Hired</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes Textarea */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Internal Employer Notes</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
placeholder="Add any internal feedback, interview times, or notes about this candidate..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full px-4 py-3 bg-slate-50 border border-slate-100 rounded-xl text-xs font-medium text-slate-900 placeholder-slate-400 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status History Audit Trail */}
|
||||||
|
{selectedApp?.status_history && selectedApp.status_history.length > 0 && (
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Status Audit Log</label>
|
||||||
|
<div className="max-h-24 overflow-y-auto space-y-2 pr-1 border border-slate-100 rounded-xl p-2 bg-slate-50/40">
|
||||||
|
{selectedApp.status_history.map((hist, index) => (
|
||||||
|
<div key={index} className="text-[9px] text-slate-500 flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<span className="font-bold uppercase text-[#185FA5]">{hist.status}</span>
|
||||||
|
{hist.notes && <span className="ml-1 text-slate-400 italic">({hist.notes})</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-[8px] font-mono text-slate-300">{new Date(hist.timestamp).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Modal Footer */}
|
||||||
|
<div className="p-6 bg-slate-50 border-t border-slate-100 flex items-center justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={() => { setShowModal(false); setSelectedApp(null); }}
|
||||||
|
className="px-5 py-2.5 bg-white border border-slate-200 hover:bg-slate-100 rounded-xl text-[10px] font-black uppercase tracking-widest text-slate-600 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveStatus}
|
||||||
|
className="px-5 py-2.5 bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-all shadow-md shadow-blue-500/10"
|
||||||
|
>
|
||||||
|
Save Status
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</EmployerLayout>
|
</EmployerLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
Sparkles,
|
Sparkles,
|
||||||
ShieldCheck
|
ShieldCheck
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Create({ categories: initialCategories }) {
|
export default function Create({ categories: initialCategories }) {
|
||||||
const categories = initialCategories || [
|
const categories = initialCategories || [
|
||||||
@ -31,9 +32,47 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
requirements: ''
|
requirements: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [clientErrors, setClientErrors] = useState({});
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
post('/employer/jobs/create');
|
|
||||||
|
// Client-side validation
|
||||||
|
const newErrors = {};
|
||||||
|
if (!data.title.trim()) {
|
||||||
|
newErrors.title = 'Job title is required.';
|
||||||
|
}
|
||||||
|
if (!data.workers_needed || data.workers_needed < 1) {
|
||||||
|
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
||||||
|
}
|
||||||
|
if (!data.location.trim()) {
|
||||||
|
newErrors.location = 'Job location is required.';
|
||||||
|
}
|
||||||
|
if (!data.salary || data.salary < 0) {
|
||||||
|
newErrors.salary = 'Monthly salary must be a positive number.';
|
||||||
|
}
|
||||||
|
if (!data.start_date) {
|
||||||
|
newErrors.start_date = 'Start date is required.';
|
||||||
|
}
|
||||||
|
if (!data.description.trim()) {
|
||||||
|
newErrors.description = 'Job description is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setClientErrors(newErrors);
|
||||||
|
toast.error('Validation failed. Please correct the fields in red.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClientErrors({});
|
||||||
|
post('/employer/jobs/create', {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Job posted successfully.');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to post job. Please check the inputs.');
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -75,23 +114,25 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2 col-span-2">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Title</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Title</label>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Senior Electrician for Villa Project"
|
placeholder="e.g. Senior Electrician for Villa Project"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.title || errors.title) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
value={data.title}
|
value={data.title}
|
||||||
onChange={e => setData('title', e.target.value)}
|
onChange={e => setData('title', e.target.value)}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{(clientErrors.title || errors.title) && (
|
||||||
|
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.title || errors.title}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<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">
|
||||||
@ -99,12 +140,16 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
min="1"
|
min="1"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.workers_needed || errors.workers_needed) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
value={data.workers_needed}
|
value={data.workers_needed}
|
||||||
onChange={e => setData('workers_needed', e.target.value)}
|
onChange={e => setData('workers_needed', e.target.value)}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{(clientErrors.workers_needed || errors.workers_needed) && (
|
||||||
|
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.workers_needed || errors.workers_needed}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -144,12 +189,16 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="e.g. Dubai Marina"
|
placeholder="e.g. Dubai Marina"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
value={data.location}
|
value={data.location}
|
||||||
onChange={e => setData('location', e.target.value)}
|
onChange={e => setData('location', e.target.value)}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -159,12 +208,16 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="e.g. 2500"
|
placeholder="e.g. 2500"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
value={data.salary}
|
value={data.salary}
|
||||||
onChange={e => setData('salary', e.target.value)}
|
onChange={e => setData('salary', e.target.value)}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@ -173,12 +226,16 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="w-full pl-11 pr-4 py-3 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all"
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
value={data.start_date}
|
value={data.start_date}
|
||||||
onChange={e => setData('start_date', e.target.value)}
|
onChange={e => setData('start_date', e.target.value)}
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</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>
|
||||||
@ -196,12 +253,19 @@ export default function Create({ categories: initialCategories }) {
|
|||||||
<textarea
|
<textarea
|
||||||
placeholder="Briefly describe the responsibilities..."
|
placeholder="Briefly describe the responsibilities..."
|
||||||
maxLength={300}
|
maxLength={300}
|
||||||
className="w-full px-5 py-4 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none"
|
className={`w-full px-5 py-4 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none ${
|
||||||
|
(clientErrors.description || errors.description) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
value={data.description}
|
value={data.description}
|
||||||
onChange={e => setData('description', e.target.value)}
|
onChange={e => setData('description', e.target.value)}
|
||||||
/>
|
/>
|
||||||
<div className="text-right text-[10px] font-bold text-slate-400 uppercase tracking-widest pr-2">
|
<div className="flex justify-between items-center px-1">
|
||||||
{data.description.length} / 300 Characters
|
{(clientErrors.description || errors.description) ? (
|
||||||
|
<p className="text-xs font-bold text-red-500">{clientErrors.description || errors.description}</p>
|
||||||
|
) : <div />}
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
{data.description.length} / 300 Characters
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
324
resources/js/Pages/Employer/Jobs/Edit.jsx
Normal file
324
resources/js/Pages/Employer/Jobs/Edit.jsx
Normal file
@ -0,0 +1,324 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Head, Link, useForm } from '@inertiajs/react';
|
||||||
|
import EmployerLayout from '@/Layouts/EmployerLayout';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
ArrowLeft,
|
||||||
|
Briefcase,
|
||||||
|
MapPin,
|
||||||
|
DollarSign,
|
||||||
|
Calendar,
|
||||||
|
LayoutGrid,
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
Sparkles,
|
||||||
|
ShieldCheck,
|
||||||
|
Save
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export default function Edit({ job }) {
|
||||||
|
const { data, setData, post, processing, errors } = useForm({
|
||||||
|
title: job.title || '',
|
||||||
|
workers_needed: job.workers_needed || 1,
|
||||||
|
location: job.location || '',
|
||||||
|
salary: job.salary ? Math.round(job.salary) : '',
|
||||||
|
job_type: job.job_type || 'Full Time',
|
||||||
|
start_date: job.start_date || '',
|
||||||
|
description: job.description || '',
|
||||||
|
requirements: job.requirements || '',
|
||||||
|
status: job.status || 'active'
|
||||||
|
});
|
||||||
|
|
||||||
|
const [clientErrors, setClientErrors] = useState({});
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Client-side validation
|
||||||
|
const newErrors = {};
|
||||||
|
if (!data.title.trim()) {
|
||||||
|
newErrors.title = 'Job title is required.';
|
||||||
|
}
|
||||||
|
if (!data.workers_needed || data.workers_needed < 1) {
|
||||||
|
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
||||||
|
}
|
||||||
|
if (!data.location.trim()) {
|
||||||
|
newErrors.location = 'Job location is required.';
|
||||||
|
}
|
||||||
|
if (!data.salary || data.salary < 0) {
|
||||||
|
newErrors.salary = 'Monthly salary must be a positive number.';
|
||||||
|
}
|
||||||
|
if (!data.start_date) {
|
||||||
|
newErrors.start_date = 'Start date is required.';
|
||||||
|
}
|
||||||
|
if (!data.description.trim()) {
|
||||||
|
newErrors.description = 'Job description is required.';
|
||||||
|
}
|
||||||
|
if (!data.status) {
|
||||||
|
newErrors.status = 'Status is required.';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(newErrors).length > 0) {
|
||||||
|
setClientErrors(newErrors);
|
||||||
|
toast.error('Validation failed. Please correct the fields in red.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClientErrors({});
|
||||||
|
post(`/employer/jobs/${job.id}/edit`, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Job updated successfully.');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to update job. Please check the inputs.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmployerLayout title="Edit Job">
|
||||||
|
<Head title="Edit Job - Employer Portal" />
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
|
||||||
|
<Link
|
||||||
|
href="/employer/jobs"
|
||||||
|
className="inline-flex items-center space-x-2 text-xs font-bold text-slate-600 hover:text-[#185FA5] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
<span>Back to My Jobs</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-[32px] border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-8 border-b border-slate-50 bg-slate-50/30 flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<div className="p-3 bg-blue-50 rounded-2xl">
|
||||||
|
<Briefcase className="w-6 h-6 text-[#185FA5]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-black text-slate-900 tracking-tight uppercase">Edit Job Posting</h1>
|
||||||
|
<p className="text-[10px] text-slate-400 font-bold uppercase tracking-widest mt-1">Update details for "{job.title}"</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="p-8 space-y-8">
|
||||||
|
{/* Section 1: Basic Info */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||||
|
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Title</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Senior Electrician for Villa Project"
|
||||||
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.title || errors.title) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
value={data.title}
|
||||||
|
onChange={e => setData('title', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(clientErrors.title || errors.title) && (
|
||||||
|
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.title || errors.title}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.workers_needed || errors.workers_needed) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
value={data.workers_needed}
|
||||||
|
onChange={e => setData('workers_needed', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(clientErrors.workers_needed || errors.workers_needed) && (
|
||||||
|
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.workers_needed || errors.workers_needed}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{['Full Time', 'Part Time', 'Contract'].map(type => (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setData('job_type', type)}
|
||||||
|
className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
|
||||||
|
data.job_type === type
|
||||||
|
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-500/20'
|
||||||
|
: 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{type.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 col-span-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Status</label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[
|
||||||
|
{ key: 'active', label: 'Active' },
|
||||||
|
{ key: 'closed', label: 'Closed' },
|
||||||
|
{ key: 'draft', label: 'Draft' }
|
||||||
|
].map(s => (
|
||||||
|
<button
|
||||||
|
key={s.key}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setData('status', s.key)}
|
||||||
|
className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
|
||||||
|
data.status === s.key
|
||||||
|
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-500/20'
|
||||||
|
: 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 2: Logistics & Pay */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||||
|
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||||
|
Logistics & Compensation
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
|
||||||
|
<div className="relative">
|
||||||
|
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g. Dubai Marina"
|
||||||
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
value={data.location}
|
||||||
|
onChange={e => setData('location', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(clientErrors.location || errors.location) && (
|
||||||
|
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.location || errors.location}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
placeholder="e.g. 2500"
|
||||||
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
value={data.salary}
|
||||||
|
onChange={e => setData('salary', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(clientErrors.salary || errors.salary) && (
|
||||||
|
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.salary || errors.salary}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Start Date</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
|
||||||
|
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
value={data.start_date}
|
||||||
|
onChange={e => setData('start_date', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{(clientErrors.start_date || errors.start_date) && (
|
||||||
|
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.start_date || errors.start_date}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section 3: Details */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||||
|
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||||
|
Detailed Requirements
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
|
||||||
|
<textarea
|
||||||
|
placeholder="Briefly describe the responsibilities..."
|
||||||
|
maxLength={300}
|
||||||
|
className={`w-full px-5 py-4 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none ${
|
||||||
|
(clientErrors.description || errors.description) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
|
||||||
|
}`}
|
||||||
|
value={data.description}
|
||||||
|
onChange={e => setData('description', e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between items-center px-1">
|
||||||
|
{(clientErrors.description || errors.description) ? (
|
||||||
|
<p className="text-xs font-bold text-red-500">{clientErrors.description || errors.description}</p>
|
||||||
|
) : <div />}
|
||||||
|
<div className="text-[10px] font-bold text-slate-400 uppercase tracking-widest">
|
||||||
|
{data.description.length} / 300 Characters
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Special Requirements (Optional)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<FileText className="absolute left-4 top-5 w-4 h-4 text-slate-400" />
|
||||||
|
<textarea
|
||||||
|
placeholder="e.g. Must speak Arabic, 5+ years experience, transferable visa..."
|
||||||
|
className="w-full pl-11 pr-4 py-4 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none"
|
||||||
|
value={data.requirements}
|
||||||
|
onChange={e => setData('requirements', e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Submit Button */}
|
||||||
|
<div className="pt-6 border-t border-slate-100">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={processing}
|
||||||
|
className="w-full bg-[#185FA5] text-white py-5 rounded-2xl font-black text-sm uppercase tracking-widest shadow-xl shadow-blue-500/20 hover:bg-[#144f8a] active:scale-[0.98] transition-all flex items-center justify-center space-x-3"
|
||||||
|
>
|
||||||
|
<Save className="w-5 h-5" />
|
||||||
|
<span>Save Changes</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EmployerLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { Head, Link } from '@inertiajs/react';
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
import EmployerLayout from '@/Layouts/EmployerLayout';
|
import EmployerLayout from '@/Layouts/EmployerLayout';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
@ -16,7 +16,8 @@ import {
|
|||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Eye,
|
Eye,
|
||||||
Edit2
|
Edit2,
|
||||||
|
Trash2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@ -27,11 +28,25 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export default function Index({ initialJobs }) {
|
export default function Index({ initialJobs }) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('All');
|
const [statusFilter, setStatusFilter] = useState('All');
|
||||||
|
|
||||||
|
const handleDelete = (jobId) => {
|
||||||
|
if (confirm('Are you sure you want to delete this job posting? This action cannot be undone.')) {
|
||||||
|
router.delete(`/employer/jobs/${jobId}`, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Job deleted successfully.');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete job.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const [jobs, setJobs] = useState(initialJobs || [
|
const [jobs, setJobs] = useState(initialJobs || [
|
||||||
{ id: 1, title: 'Experienced Mason', location: 'Dubai Marina', salary: 2800, workers_needed: 5, applied_count: 3, posted_at: 'Nov 12, 2026', status: 'Active' },
|
{ id: 1, title: 'Experienced Mason', location: 'Dubai Marina', salary: 2800, workers_needed: 5, applied_count: 3, posted_at: 'Nov 12, 2026', status: 'Active' },
|
||||||
{ id: 2, title: 'Villa Cleaner', location: 'Al Barsha', salary: 1800, workers_needed: 2, applied_count: 12, posted_at: 'Nov 10, 2026', status: 'Active' },
|
{ id: 2, title: 'Villa Cleaner', location: 'Al Barsha', salary: 1800, workers_needed: 2, applied_count: 12, posted_at: 'Nov 10, 2026', status: 'Active' },
|
||||||
@ -117,7 +132,9 @@ export default function Index({ initialJobs }) {
|
|||||||
<Briefcase className="w-6 h-6 text-[#185FA5]" />
|
<Briefcase className="w-6 h-6 text-[#185FA5]" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-black text-slate-900 tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-1">{job.title}</h3>
|
<h3 className="font-black text-slate-900 tracking-tight group-hover:text-[#185FA5] transition-colors line-clamp-1">
|
||||||
|
<Link href={`/employer/jobs/${job.id}`}>{job.title}</Link>
|
||||||
|
</h3>
|
||||||
<div className="flex items-center space-x-2 mt-1">
|
<div className="flex items-center space-x-2 mt-1">
|
||||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{job.posted_at}</span>
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">{job.posted_at}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -195,16 +212,22 @@ export default function Index({ initialJobs }) {
|
|||||||
<DropdownMenuContent align="end" className="w-48 p-2 rounded-2xl shadow-xl">
|
<DropdownMenuContent align="end" className="w-48 p-2 rounded-2xl shadow-xl">
|
||||||
<DropdownMenuLabel className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2 py-2">Quick Actions</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-[10px] font-black text-slate-400 uppercase tracking-widest px-2 py-2">Quick Actions</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="p-3 rounded-xl focus:bg-blue-50 cursor-pointer">
|
<DropdownMenuItem className="p-3 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
|
||||||
<div className="flex items-center space-x-3 text-[#185FA5]">
|
<Link href={`/employer/jobs/${job.id}`} className="flex items-center space-x-3 text-slate-700 w-full">
|
||||||
|
<Eye className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="text-xs font-bold">View Details</span>
|
||||||
|
</Link>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem className="p-3 rounded-xl focus:bg-blue-50 cursor-pointer" asChild>
|
||||||
|
<Link href={`/employer/jobs/${job.id}/edit`} className="flex items-center space-x-3 text-[#185FA5] w-full">
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
<span className="text-xs font-bold">Edit Posting</span>
|
<span className="text-xs font-bold">Edit Posting</span>
|
||||||
</div>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="p-3 rounded-xl focus:bg-rose-50 cursor-pointer">
|
<DropdownMenuItem className="p-3 rounded-xl focus:bg-rose-50 cursor-pointer" onClick={() => handleDelete(job.id)}>
|
||||||
<div className="flex items-center space-x-3 text-rose-600">
|
<div className="flex items-center space-x-3 text-rose-600 w-full">
|
||||||
<XCircle className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
<span className="text-xs font-bold">Close Job</span>
|
<span className="text-xs font-bold">Delete Job</span>
|
||||||
</div>
|
</div>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
180
resources/js/Pages/Employer/Jobs/Show.jsx
Normal file
180
resources/js/Pages/Employer/Jobs/Show.jsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Head, Link, router } from '@inertiajs/react';
|
||||||
|
import EmployerLayout from '@/Layouts/EmployerLayout';
|
||||||
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
Briefcase,
|
||||||
|
MapPin,
|
||||||
|
DollarSign,
|
||||||
|
Calendar,
|
||||||
|
Users,
|
||||||
|
FileText,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
Edit2,
|
||||||
|
Trash2,
|
||||||
|
Eye
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export default function Show({ job }) {
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (confirm('Are you sure you want to delete this job posting? This action cannot be undone.')) {
|
||||||
|
router.delete(`/employer/jobs/${job.id}`, {
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Job deleted successfully.');
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error('Failed to delete job.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EmployerLayout title="Job Details">
|
||||||
|
<Head title={`Job Details: ${job.title} - Employer Portal`} />
|
||||||
|
|
||||||
|
<div className="max-w-4xl mx-auto space-y-6 pb-12 select-none">
|
||||||
|
<Link
|
||||||
|
href="/employer/jobs"
|
||||||
|
className="inline-flex items-center space-x-2 text-xs font-bold text-slate-600 hover:text-[#185FA5] transition-colors"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
<span>Back to My Jobs</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-[32px] border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-8 border-b border-slate-50 bg-slate-50/30 flex flex-col md:flex-row md:items-center justify-between gap-6">
|
||||||
|
<div className="flex items-start space-x-4">
|
||||||
|
<div className="p-3 bg-blue-50 rounded-2xl">
|
||||||
|
<Briefcase className="w-6 h-6 text-[#185FA5]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-black text-slate-900 tracking-tight">{job.title}</h1>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-2">
|
||||||
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Posted on {job.posted_at}</span>
|
||||||
|
<span className="text-slate-300">•</span>
|
||||||
|
<span className="text-[10px] font-black text-[#185FA5] uppercase tracking-widest">{job.job_type}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`px-4 py-1.5 rounded-full text-[10px] font-black uppercase tracking-widest ${
|
||||||
|
job.status === 'Active' ? 'bg-emerald-50 text-emerald-700 border-emerald-100' :
|
||||||
|
job.status === 'Closed' ? 'bg-rose-50 text-rose-700 border-rose-100' :
|
||||||
|
'bg-slate-50 text-slate-500 border-slate-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{job.status === 'Active' && <CheckCircle2 className="w-3 h-3 mr-1" />}
|
||||||
|
{job.status === 'Closed' && <XCircle className="w-3 h-3 mr-1" />}
|
||||||
|
{job.status === 'Draft' && <Clock className="w-3 h-3 mr-1" />}
|
||||||
|
{job.status}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 p-8 border-b border-slate-100 bg-slate-50/10">
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 bg-slate-100 rounded-xl">
|
||||||
|
<MapPin className="w-5 h-5 text-slate-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Location</p>
|
||||||
|
<p className="text-sm font-bold text-slate-800">{job.location}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 bg-emerald-50 rounded-xl">
|
||||||
|
<DollarSign className="w-5 h-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Salary</p>
|
||||||
|
<p className="text-sm font-bold text-slate-800">{job.salary} AED / month</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-3">
|
||||||
|
<div className="p-2.5 bg-blue-50 rounded-xl">
|
||||||
|
<Users className="w-5 h-5 text-[#185FA5]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Workers Needed</p>
|
||||||
|
<p className="text-sm font-bold text-slate-800">{job.workers_needed} ({job.applied_count} applied)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-8 space-y-8">
|
||||||
|
{/* Start Date */}
|
||||||
|
<div className="flex items-center space-x-3 text-slate-600 bg-slate-50 p-4 rounded-2xl border border-slate-100">
|
||||||
|
<Calendar className="w-5 h-5 text-[#185FA5]" />
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400 block">Expected Start Date</span>
|
||||||
|
<span className="text-xs font-bold text-slate-700">{job.start_date || 'Immediate'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||||
|
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||||
|
Job Description
|
||||||
|
</h3>
|
||||||
|
<div className="bg-slate-50/50 p-6 rounded-2xl border border-slate-100 text-sm font-medium text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{job.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Requirements */}
|
||||||
|
{job.requirements && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
|
||||||
|
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
|
||||||
|
Special Requirements
|
||||||
|
</h3>
|
||||||
|
<div className="bg-slate-50/50 p-6 rounded-2xl border border-slate-100 text-sm font-medium text-slate-700 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{job.requirements}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex flex-col sm:flex-row gap-4 pt-6 border-t border-slate-100">
|
||||||
|
<Link
|
||||||
|
href={`/employer/jobs/${job.id}/applicants`}
|
||||||
|
className="flex-1 bg-[#185FA5] text-white py-4 rounded-2xl font-black text-xs uppercase tracking-widest shadow-xl shadow-blue-500/10 hover:bg-[#144f8a] transition-all flex items-center justify-center space-x-2"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
<span>View Applicants ({job.applied_count})</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={`/employer/jobs/${job.id}/edit`}
|
||||||
|
className="bg-slate-50 border border-slate-200 text-slate-700 py-4 px-6 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-slate-100 transition-all flex items-center justify-center space-x-2"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4 text-[#185FA5]" />
|
||||||
|
<span>Edit Posting</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="bg-rose-50 border border-rose-100 text-rose-700 py-4 px-6 rounded-2xl font-black text-xs uppercase tracking-widest hover:bg-rose-100/50 transition-all flex items-center justify-center space-x-2"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
<span>Delete</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</EmployerLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -21,8 +21,9 @@ import {
|
|||||||
TrendingUp
|
TrendingUp
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
export default function Subscription({ currentPlan, expiresAt, plans }) {
|
export default function Subscription({ currentPlan, expiresAt, plans, invoices: initialInvoices }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||||
const [showPayTabsModal, setShowPayTabsModal] = useState(false);
|
const [showPayTabsModal, setShowPayTabsModal] = useState(false);
|
||||||
@ -40,11 +41,13 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
|||||||
const [activePlan, setActivePlan] = useState(currentPlan || 'Premium Employer Pass');
|
const [activePlan, setActivePlan] = useState(currentPlan || 'Premium Employer Pass');
|
||||||
|
|
||||||
// Payment History List
|
// Payment History List
|
||||||
const [invoices, setInvoices] = useState([
|
const [invoices, setInvoices] = useState(initialInvoices || []);
|
||||||
{ id: 'INV-2026-001', date: 'May 01, 2026', amount: '199 AED', status: 'Paid', plan: 'Premium Employer Pass' },
|
|
||||||
{ id: 'INV-2026-002', date: 'Apr 01, 2026', amount: '199 AED', status: 'Paid', plan: 'Premium Employer Pass' },
|
React.useEffect(() => {
|
||||||
{ id: 'INV-2026-003', date: 'Mar 01, 2026', amount: '99 AED', status: 'Refunded', plan: 'Basic Search' }
|
if (initialInvoices) {
|
||||||
]);
|
setInvoices(initialInvoices);
|
||||||
|
}
|
||||||
|
}, [initialInvoices]);
|
||||||
|
|
||||||
const handleSelectPlan = (plan) => {
|
const handleSelectPlan = (plan) => {
|
||||||
setSelectedPlan(plan);
|
setSelectedPlan(plan);
|
||||||
@ -59,32 +62,35 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowPayTabsModal(false);
|
axios.post(route('employer.subscription.purchase'), {
|
||||||
setShowSuccess(true);
|
plan_id: selectedPlan.id
|
||||||
setActivePlan(selectedPlan.name);
|
})
|
||||||
|
.then(response => {
|
||||||
// Add to invoices
|
if (response.data.success) {
|
||||||
const newInvoice = {
|
setShowPayTabsModal(false);
|
||||||
id: `INV-2026-0${invoices.length + 1}`,
|
setShowSuccess(true);
|
||||||
date: 'Today',
|
if (response.data.currentPlan) {
|
||||||
amount: selectedPlan.price,
|
setActivePlan(response.data.currentPlan);
|
||||||
status: 'Paid',
|
}
|
||||||
plan: selectedPlan.name
|
setInvoices(response.data.invoices);
|
||||||
};
|
|
||||||
setInvoices([newInvoice, ...invoices]);
|
|
||||||
|
|
||||||
toast.success(`🎉 ${t('payment_approved', 'Payment Approved by PayTabs!')}`, {
|
toast.success(`🎉 ${t('payment_approved', 'Payment Approved by PayTabs!')}`, {
|
||||||
description: t('successfully_subscribed_desc', 'Successfully subscribed to {plan}. Premium access limits updated instantly.').replace('{plan}', selectedPlan.name),
|
description: t('successfully_subscribed_desc', 'Successfully subscribed to {plan}. Premium access limits updated instantly.').replace('{plan}', selectedPlan.name),
|
||||||
duration: 5000,
|
duration: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset forms
|
||||||
|
setCardName('');
|
||||||
|
setCardNumber('');
|
||||||
|
setCardExpiry('');
|
||||||
|
setCardCvv('');
|
||||||
|
|
||||||
|
setTimeout(() => setShowSuccess(false), 5000);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to complete subscription upgrade');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Reset forms
|
|
||||||
setCardName('');
|
|
||||||
setCardNumber('');
|
|
||||||
setCardExpiry('');
|
|
||||||
setCardCvv('');
|
|
||||||
|
|
||||||
setTimeout(() => setShowSuccess(false), 5000);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const triggerFailedPaymentSimulation = () => {
|
const triggerFailedPaymentSimulation = () => {
|
||||||
@ -239,7 +245,7 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
|||||||
{/* Payment History & Invoice logs */}
|
{/* Payment History & Invoice logs */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 pt-4">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-8 pt-4">
|
||||||
{/* Invoice Logs */}
|
{/* Invoice Logs */}
|
||||||
<div className="lg:col-span-8 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
<div className="lg:col-span-12 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
<div className="flex items-center justify-between border-b border-slate-100 pb-3">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<FileText className="w-5 h-5 text-[#185FA5]" />
|
<FileText className="w-5 h-5 text-[#185FA5]" />
|
||||||
@ -251,19 +257,24 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
|||||||
<div className="divide-y divide-slate-100">
|
<div className="divide-y divide-slate-100">
|
||||||
{invoices.map((inv) => (
|
{invoices.map((inv) => (
|
||||||
<div key={inv.id} className="flex items-center justify-between py-4 text-xs font-semibold text-slate-700">
|
<div key={inv.id} className="flex items-center justify-between py-4 text-xs font-semibold text-slate-700">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1.5 flex-1">
|
||||||
<div className="font-bold text-slate-800 flex items-center space-x-1.5">
|
<div className="font-bold text-slate-800 flex items-center space-x-1.5">
|
||||||
<span>{inv.plan}</span>
|
<span>{inv.plan_name}</span>
|
||||||
<span className="text-[9px] text-slate-400 font-mono">({inv.id})</span>
|
<span className="text-[9px] text-slate-400 font-mono">({inv.id})</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-400 font-medium">Billed: {inv.date}</div>
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-x-4 gap-y-1 text-[10px] text-slate-400 font-medium">
|
||||||
|
<div>{t('purchase_date', 'Purchased')}: <span className="font-semibold text-slate-600">{inv.purchase_date}</span></div>
|
||||||
|
<div>{t('activation_date', 'Activated')}: <span className="font-semibold text-slate-600">{inv.activation_date}</span></div>
|
||||||
|
<div>{t('expiry_date', 'Expires')}: <span className="font-semibold text-slate-600">{inv.expiry_date}</span></div>
|
||||||
|
<div>{t('payment_status', 'Payment')}: <span className="font-semibold text-slate-600">{inv.payment_status}</span></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-6">
|
<div className="flex items-center space-x-6 ml-4">
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="font-extrabold text-slate-900">{inv.amount}</div>
|
<div className="font-extrabold text-slate-900">{inv.amount}</div>
|
||||||
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded ${
|
<span className={`text-[9px] font-black uppercase px-2 py-0.5 rounded inline-block mt-0.5 ${
|
||||||
inv.status === 'Paid' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
|
inv.status === 'Active' ? 'bg-emerald-50 text-emerald-700' : (inv.status === 'Pending' ? 'bg-amber-50 text-amber-700' : 'bg-slate-100 text-slate-600')
|
||||||
}`}>
|
}`}>
|
||||||
{inv.status}
|
{inv.status}
|
||||||
</span>
|
</span>
|
||||||
@ -281,42 +292,6 @@ export default function Subscription({ currentPlan, expiresAt, plans }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sponsor Billing Details Form */}
|
|
||||||
<div className="lg:col-span-4 bg-white p-6 rounded-3xl border border-slate-200 shadow-sm space-y-6">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="font-extrabold text-base text-slate-900">{t('corporate_details', 'Corporate Details')}</h3>
|
|
||||||
<p className="text-xs text-slate-500 font-medium">{t('update_tax_invoicing', 'Update tax invoicing options.')}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 text-xs font-bold text-slate-700">
|
|
||||||
<div>
|
|
||||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px] mb-1.5">{t('sponsor_email_address', 'Sponsor Email Address')}</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
value={billingEmail}
|
|
||||||
onChange={(e) => setBillingEmail(e.target.value)}
|
|
||||||
className="w-full p-2.5 border border-slate-200 rounded-xl bg-slate-50/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-slate-400 uppercase tracking-widest text-[9px] mb-1.5">{t('billing_vat_id', 'Billing VAT Registry ID (Optional)')}</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="e.g. TRN 100293810200003"
|
|
||||||
className="w-full p-2.5 border border-slate-200 rounded-xl bg-slate-50/50"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => toast.success(t('corporate_billing_updated', 'Corporate billing details updated.'))}
|
|
||||||
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white rounded-xl py-2.5 font-bold text-xs"
|
|
||||||
>
|
|
||||||
{t('save_billing_profile', 'Save Billing Profile')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -103,6 +103,14 @@
|
|||||||
Route::post('/workers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromWorker']);
|
Route::post('/workers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromWorker']);
|
||||||
Route::post('/workers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromWorker']);
|
Route::post('/workers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromWorker']);
|
||||||
Route::get('/workers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
|
Route::get('/workers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
|
||||||
|
|
||||||
|
// Job Management (Worker)
|
||||||
|
Route::get('/workers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'listJobs']);
|
||||||
|
Route::get('/workers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'jobDetails']);
|
||||||
|
Route::post('/workers/jobs/{id}/apply', [\App\Http\Controllers\Api\WorkerJobController::class, 'applyJob']);
|
||||||
|
Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']);
|
||||||
|
Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']);
|
||||||
|
Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token)
|
// Protected Employer Mobile Endpoints (Token Authenticated via Bearer Token)
|
||||||
@ -153,6 +161,15 @@
|
|||||||
Route::post('/employers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromEmployer']);
|
Route::post('/employers/tickets', [\App\Http\Controllers\Api\SupportTicketController::class, 'createTicketFromEmployer']);
|
||||||
Route::post('/employers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromEmployer']);
|
Route::post('/employers/tickets/{id}/reply', [\App\Http\Controllers\Api\SupportTicketController::class, 'replyToTicketFromEmployer']);
|
||||||
Route::get('/employers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
|
Route::get('/employers/tickets/{id}', [\App\Http\Controllers\Api\SupportTicketController::class, 'getTicketDetail']);
|
||||||
|
|
||||||
|
// Job & Applicant Management (Employer)
|
||||||
|
Route::get('/employers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerListJobs']);
|
||||||
|
Route::post('/employers/jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerCreateJob']);
|
||||||
|
Route::get('/employers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerJobDetail']);
|
||||||
|
Route::post('/employers/jobs/{id}/update', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerUpdateJob']);
|
||||||
|
Route::delete('/employers/jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerDeleteJob']);
|
||||||
|
Route::get('/employers/jobs/{id}/applicants', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerJobApplicants']);
|
||||||
|
Route::post('/employers/applications/{id}/status', [\App\Http\Controllers\Api\WorkerJobController::class, 'employerUpdateApplicationStatus']);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token)
|
// Protected Sponsor Mobile Endpoints (Token Authenticated via Bearer Token)
|
||||||
|
|||||||
165
routes/web.php
165
routes/web.php
@ -76,9 +76,97 @@
|
|||||||
Route::delete('/charity-organizations/{id}', [\App\Http\Controllers\Admin\SponsorController::class, 'delete'])->name('admin.charity-organizations.delete');
|
Route::delete('/charity-organizations/{id}', [\App\Http\Controllers\Admin\SponsorController::class, 'delete'])->name('admin.charity-organizations.delete');
|
||||||
|
|
||||||
Route::get('/subscriptions', function () {
|
Route::get('/subscriptions', function () {
|
||||||
return Inertia::render('Admin/Subscriptions/Index');
|
$plans = \App\Models\Plan::all()->map(function ($plan) {
|
||||||
|
$plan->usage_count = \App\Models\Sponsor::where('subscription_plan', $plan->id)
|
||||||
|
->where('subscription_status', 'active')
|
||||||
|
->count();
|
||||||
|
return $plan;
|
||||||
|
});
|
||||||
|
return Inertia::render('Admin/Subscriptions/Index', [
|
||||||
|
'plans' => $plans
|
||||||
|
]);
|
||||||
})->name('admin.subscriptions');
|
})->name('admin.subscriptions');
|
||||||
|
|
||||||
|
Route::post('/subscriptions', function (\Illuminate\Http\Request $request) {
|
||||||
|
$data = $request->validate([
|
||||||
|
'id' => 'required|string|unique:plans,id',
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'price' => 'required|numeric|min:0',
|
||||||
|
'duration' => 'required|string',
|
||||||
|
'features' => 'required|array',
|
||||||
|
'max_contact_people' => 'nullable|integer',
|
||||||
|
'unlimited_contacts' => 'required|boolean',
|
||||||
|
'enable_job_apply' => 'required|boolean',
|
||||||
|
'status' => 'nullable|string|in:Active,Disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
\App\Models\Plan::create([
|
||||||
|
'id' => $data['id'],
|
||||||
|
'name' => $data['name'],
|
||||||
|
'price' => $data['price'],
|
||||||
|
'duration' => $data['duration'],
|
||||||
|
'features' => $data['features'],
|
||||||
|
'status' => $data['status'] ?? 'Active',
|
||||||
|
'max_contact_people' => $data['unlimited_contacts'] ? null : $data['max_contact_people'],
|
||||||
|
'unlimited_contacts' => $data['unlimited_contacts'],
|
||||||
|
'enable_job_apply' => $data['enable_job_apply'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back();
|
||||||
|
})->name('admin.subscriptions.store');
|
||||||
|
|
||||||
|
Route::post('/subscriptions/{id}/update', function (\Illuminate\Http\Request $request, $id) {
|
||||||
|
$plan = \App\Models\Plan::findOrFail($id);
|
||||||
|
$data = $request->validate([
|
||||||
|
'name' => 'required|string|max:255',
|
||||||
|
'price' => 'required|numeric|min:0',
|
||||||
|
'duration' => 'required|string',
|
||||||
|
'features' => 'required|array',
|
||||||
|
'max_contact_people' => 'nullable|integer',
|
||||||
|
'unlimited_contacts' => 'required|boolean',
|
||||||
|
'enable_job_apply' => 'required|boolean',
|
||||||
|
'status' => 'required|string|in:Active,Disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($data['status'] === 'Disabled') {
|
||||||
|
$usageCount = \App\Models\Sponsor::where('subscription_plan', $plan->id)
|
||||||
|
->where('subscription_status', 'active')
|
||||||
|
->count();
|
||||||
|
if ($usageCount > 0) {
|
||||||
|
return redirect()->back()->withErrors([
|
||||||
|
'status' => "Cannot disable plan. {$usageCount} " . ($usageCount === 1 ? 'person is' : 'people are') . " using this plan."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$plan->update([
|
||||||
|
'name' => $data['name'],
|
||||||
|
'price' => $data['price'],
|
||||||
|
'duration' => $data['duration'],
|
||||||
|
'features' => $data['features'],
|
||||||
|
'status' => $data['status'],
|
||||||
|
'max_contact_people' => $data['unlimited_contacts'] ? null : $data['max_contact_people'],
|
||||||
|
'unlimited_contacts' => $data['unlimited_contacts'],
|
||||||
|
'enable_job_apply' => $data['enable_job_apply'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return redirect()->back();
|
||||||
|
})->name('admin.subscriptions.update');
|
||||||
|
|
||||||
|
Route::delete('/subscriptions/{id}', function ($id) {
|
||||||
|
$plan = \App\Models\Plan::findOrFail($id);
|
||||||
|
$usageCount = \App\Models\Sponsor::where('subscription_plan', $plan->id)
|
||||||
|
->where('subscription_status', 'active')
|
||||||
|
->count();
|
||||||
|
if ($usageCount > 0) {
|
||||||
|
return redirect()->back()->withErrors([
|
||||||
|
'status' => "Cannot delete plan. {$usageCount} " . ($usageCount === 1 ? 'person is' : 'people are') . " using this plan."
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$plan->delete();
|
||||||
|
return redirect()->back();
|
||||||
|
})->name('admin.subscriptions.delete');
|
||||||
|
|
||||||
Route::get('/payments', function () {
|
Route::get('/payments', function () {
|
||||||
$payments = \Illuminate\Support\Facades\DB::table('subscriptions')
|
$payments = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||||
->leftJoin('users', 'subscriptions.user_id', '=', 'users.id')
|
->leftJoin('users', 'subscriptions.user_id', '=', 'users.id')
|
||||||
@ -282,6 +370,7 @@
|
|||||||
Route::post('/employer/resend-otp', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resendOtp'])->name('employer.resend-otp')->middleware('throttle:3,1');
|
Route::post('/employer/resend-otp', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'resendOtp'])->name('employer.resend-otp')->middleware('throttle:3,1');
|
||||||
Route::get('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showUploadEmiratesId'])->name('employer.upload-emirates-id');
|
Route::get('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showUploadEmiratesId'])->name('employer.upload-emirates-id');
|
||||||
Route::post('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'uploadEmiratesId'])->name('employer.upload-emirates-id.submit');
|
Route::post('/employer/upload-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'uploadEmiratesId'])->name('employer.upload-emirates-id.submit');
|
||||||
|
Route::post('/employer/confirm-emirates-id', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'confirmEmiratesId'])->name('employer.confirm-emirates-id.submit');
|
||||||
Route::get('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegisterPayment'])->name('employer.register-payment');
|
Route::get('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showRegisterPayment'])->name('employer.register-payment');
|
||||||
Route::post('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'storeRegisterPayment'])->name('employer.register-payment.submit');
|
Route::post('/employer/register-payment', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'storeRegisterPayment'])->name('employer.register-payment.submit');
|
||||||
Route::get('/employer/create-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showCreatePassword'])->name('employer.create-password');
|
Route::get('/employer/create-password', [\App\Http\Controllers\Employer\EmployerAuthController::class, 'showCreatePassword'])->name('employer.create-password');
|
||||||
@ -324,6 +413,10 @@
|
|||||||
Route::get('/jobs', [\App\Http\Controllers\Employer\JobController::class, 'index'])->name('employer.jobs');
|
Route::get('/jobs', [\App\Http\Controllers\Employer\JobController::class, 'index'])->name('employer.jobs');
|
||||||
Route::get('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'create'])->name('employer.jobs.create');
|
Route::get('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'create'])->name('employer.jobs.create');
|
||||||
Route::post('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'store'])->name('employer.jobs.store');
|
Route::post('/jobs/create', [\App\Http\Controllers\Employer\JobController::class, 'store'])->name('employer.jobs.store');
|
||||||
|
Route::get('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'edit'])->name('employer.jobs.edit');
|
||||||
|
Route::post('/jobs/{id}/edit', [\App\Http\Controllers\Employer\JobController::class, 'update'])->name('employer.jobs.update');
|
||||||
|
Route::delete('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'destroy'])->name('employer.jobs.destroy');
|
||||||
|
Route::get('/jobs/{id}', [\App\Http\Controllers\Employer\JobController::class, 'show'])->name('employer.jobs.show');
|
||||||
Route::get('/jobs/{id}/applicants', [\App\Http\Controllers\Employer\JobController::class, 'applicants'])->name('employer.jobs.applicants');
|
Route::get('/jobs/{id}/applicants', [\App\Http\Controllers\Employer\JobController::class, 'applicants'])->name('employer.jobs.applicants');
|
||||||
Route::get('/subscription', function () {
|
Route::get('/subscription', function () {
|
||||||
$sess = session('user');
|
$sess = session('user');
|
||||||
@ -331,13 +424,10 @@
|
|||||||
$user = $sessId ? \App\Models\User::find($sessId) : \App\Models\User::where('role', 'employer')->first();
|
$user = $sessId ? \App\Models\User::find($sessId) : \App\Models\User::where('role', 'employer')->first();
|
||||||
|
|
||||||
$sub = $user ? \Illuminate\Support\Facades\DB::table('subscriptions')->where('user_id', $user->id)->where('status', 'active')->latest('id')->first() : null;
|
$sub = $user ? \Illuminate\Support\Facades\DB::table('subscriptions')->where('user_id', $user->id)->where('status', 'active')->latest('id')->first() : null;
|
||||||
$expiresAt = $sub && $sub->expires_at ? date('Y-m-d', strtotime($sub->expires_at)) : '2026-12-31';
|
|
||||||
$planName = $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass';
|
|
||||||
|
|
||||||
return Inertia::render('Employer/Subscription', [
|
$dbPlans = \App\Models\Plan::where('status', 'Active')->get();
|
||||||
'currentPlan' => $planName,
|
if ($dbPlans->isEmpty()) {
|
||||||
'expiresAt' => $expiresAt,
|
$plans = [
|
||||||
'plans' => [
|
|
||||||
[
|
[
|
||||||
'id' => 'basic',
|
'id' => 'basic',
|
||||||
'name' => 'Basic Search',
|
'name' => 'Basic Search',
|
||||||
@ -355,17 +445,74 @@
|
|||||||
'popular' => true,
|
'popular' => true,
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'id' => 'enterprise',
|
'id' => 'vip',
|
||||||
'name' => 'VIP Concierge',
|
'name' => 'VIP Concierge',
|
||||||
'price' => '499 AED',
|
'price' => '499 AED',
|
||||||
'period' => 'month',
|
'period' => 'month',
|
||||||
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
|
'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'],
|
||||||
'popular' => false,
|
'popular' => false,
|
||||||
],
|
],
|
||||||
]
|
];
|
||||||
|
} else {
|
||||||
|
$plans = $dbPlans->map(function ($p) {
|
||||||
|
$features = is_string($p->features) ? json_decode($p->features, true) : $p->features;
|
||||||
|
return [
|
||||||
|
'id' => $p->id,
|
||||||
|
'name' => $p->name,
|
||||||
|
'price' => round($p->price) . ' AED',
|
||||||
|
'period' => strtolower($p->duration) === 'monthly' ? 'month' : (strtolower($p->duration) === 'yearly' ? 'year' : 'period'),
|
||||||
|
'features' => $features ?: [],
|
||||||
|
'popular' => $p->id === 'premium',
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$planName = 'Premium Employer Pass';
|
||||||
|
$expiresAt = '2026-12-31';
|
||||||
|
|
||||||
|
if ($sub) {
|
||||||
|
$expiresAt = date('Y-m-d', strtotime($sub->expires_at));
|
||||||
|
$associatedPlan = \App\Models\Plan::find($sub->plan_id);
|
||||||
|
$planName = $associatedPlan ? $associatedPlan->name : (ucfirst($sub->plan_id) . ' Pass');
|
||||||
|
}
|
||||||
|
|
||||||
|
$invoices = [];
|
||||||
|
if ($user) {
|
||||||
|
$invoices = \Illuminate\Support\Facades\DB::table('subscriptions')
|
||||||
|
->leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id')
|
||||||
|
->where('subscriptions.user_id', $user->id)
|
||||||
|
->orderBy('subscriptions.created_at', 'desc')
|
||||||
|
->select('subscriptions.*', 'plans.name as plan_name')
|
||||||
|
->get()
|
||||||
|
->map(function($s) {
|
||||||
|
$planLabel = $s->plan_name ?: (ucfirst($s->plan_id) . ' Pass');
|
||||||
|
$subStatus = strtolower($s->status);
|
||||||
|
if ($subStatus !== 'active' && $subStatus !== 'pending' && $subStatus !== 'expired') {
|
||||||
|
$subStatus = 'expired';
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'id' => 'INV-' . date('Y', strtotime($s->created_at)) . '-' . str_pad($s->id, 3, '0', STR_PAD_LEFT),
|
||||||
|
'plan_name' => $planLabel,
|
||||||
|
'purchase_date' => date('M d, Y', strtotime($s->created_at)),
|
||||||
|
'activation_date' => $s->starts_at ? date('M d, Y', strtotime($s->starts_at)) : 'N/A',
|
||||||
|
'expiry_date' => $s->expires_at ? date('M d, Y', strtotime($s->expires_at)) : 'N/A',
|
||||||
|
'amount' => round($s->amount_aed) . ' AED',
|
||||||
|
'payment_status' => 'Paid',
|
||||||
|
'status' => ucfirst($subStatus),
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Inertia::render('Employer/Subscription', [
|
||||||
|
'currentPlan' => $planName,
|
||||||
|
'expiresAt' => $expiresAt,
|
||||||
|
'plans' => $plans,
|
||||||
|
'invoices' => $invoices
|
||||||
]);
|
]);
|
||||||
})->name('employer.subscription');
|
})->name('employer.subscription');
|
||||||
|
|
||||||
|
Route::post('/subscription/purchase', [\App\Http\Controllers\Employer\PaymentController::class, 'purchase'])->name('employer.subscription.purchase');
|
||||||
|
|
||||||
Route::get('/messages', [\App\Http\Controllers\Employer\MessageController::class, 'index'])->name('employer.messages');
|
Route::get('/messages', [\App\Http\Controllers\Employer\MessageController::class, 'index'])->name('employer.messages');
|
||||||
Route::get('/messages/{id}', [\App\Http\Controllers\Employer\MessageController::class, 'show'])->name('employer.messages.show');
|
Route::get('/messages/{id}', [\App\Http\Controllers\Employer\MessageController::class, 'show'])->name('employer.messages.show');
|
||||||
Route::post('/messages/{id}/send', [\App\Http\Controllers\Employer\MessageController::class, 'send'])->name('employer.messages.send');
|
Route::post('/messages/{id}/send', [\App\Http\Controllers\Employer\MessageController::class, 'send'])->name('employer.messages.send');
|
||||||
|
|||||||
142
tests/Feature/AdminSubscriptionPlansTest.php
Normal file
142
tests/Feature/AdminSubscriptionPlansTest.php
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminSubscriptionPlansTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected $admin;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
$this->admin = User::create([
|
||||||
|
'name' => 'Admin User',
|
||||||
|
'email' => 'admin@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'role' => 'admin',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_view_subscription_plans()
|
||||||
|
{
|
||||||
|
// Seed default plans are loaded from the migration, let's verify
|
||||||
|
$this->assertDatabaseHas('plans', ['id' => 'basic']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->admin)->get('/admin/subscriptions');
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_create_new_plan()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->admin)->post('/admin/subscriptions', [
|
||||||
|
'id' => 'super-vip',
|
||||||
|
'name' => 'Super VIP Plan',
|
||||||
|
'price' => 999.00,
|
||||||
|
'duration' => 'Monthly',
|
||||||
|
'features' => ['Super feature 1', 'Super feature 2'],
|
||||||
|
'max_contact_people' => null,
|
||||||
|
'unlimited_contacts' => true,
|
||||||
|
'enable_job_apply' => true,
|
||||||
|
'status' => 'Active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(302);
|
||||||
|
$this->assertDatabaseHas('plans', [
|
||||||
|
'id' => 'super-vip',
|
||||||
|
'name' => 'Super VIP Plan',
|
||||||
|
'price' => 999.00,
|
||||||
|
'duration' => 'Monthly',
|
||||||
|
'unlimited_contacts' => true,
|
||||||
|
'enable_job_apply' => true,
|
||||||
|
'status' => 'Active',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_update_existing_plan()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->admin)->post('/admin/subscriptions/basic/update', [
|
||||||
|
'name' => 'Basic Search Updated',
|
||||||
|
'price' => 120.00,
|
||||||
|
'duration' => 'Monthly',
|
||||||
|
'features' => ['Browse 500+ workers', '10 Shortlists', 'New feature'],
|
||||||
|
'max_contact_people' => 15,
|
||||||
|
'unlimited_contacts' => false,
|
||||||
|
'enable_job_apply' => false,
|
||||||
|
'status' => 'Active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(302);
|
||||||
|
$this->assertDatabaseHas('plans', [
|
||||||
|
'id' => 'basic',
|
||||||
|
'name' => 'Basic Search Updated',
|
||||||
|
'price' => 120.00,
|
||||||
|
'max_contact_people' => 15,
|
||||||
|
'unlimited_contacts' => false,
|
||||||
|
'status' => 'Active',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_can_delete_plan()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->admin)->delete('/admin/subscriptions/basic');
|
||||||
|
|
||||||
|
$response->assertStatus(302);
|
||||||
|
$this->assertDatabaseMissing('plans', [
|
||||||
|
'id' => 'basic',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_cannot_disable_plan_if_in_use()
|
||||||
|
{
|
||||||
|
// Associate a sponsor with 'basic' plan
|
||||||
|
\App\Models\Sponsor::create([
|
||||||
|
'full_name' => 'Test Sponsor',
|
||||||
|
'email' => 'sponsor@example.com',
|
||||||
|
'mobile' => '+971501112233',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'subscription_status' => 'active',
|
||||||
|
'subscription_plan' => 'basic',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->admin)->post('/admin/subscriptions/basic/update', [
|
||||||
|
'name' => 'Basic Search',
|
||||||
|
'price' => 99.00,
|
||||||
|
'duration' => 'Monthly',
|
||||||
|
'features' => ['Browse 500+ workers'],
|
||||||
|
'max_contact_people' => 10,
|
||||||
|
'unlimited_contacts' => false,
|
||||||
|
'enable_job_apply' => false,
|
||||||
|
'status' => 'Disabled',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors(['status']);
|
||||||
|
$this->assertEquals('Active', Plan::find('basic')->status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_admin_cannot_delete_plan_if_in_use()
|
||||||
|
{
|
||||||
|
// Associate a sponsor with 'basic' plan
|
||||||
|
\App\Models\Sponsor::create([
|
||||||
|
'full_name' => 'Test Sponsor',
|
||||||
|
'email' => 'sponsor@example.com',
|
||||||
|
'mobile' => '+971501112233',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'subscription_status' => 'active',
|
||||||
|
'subscription_plan' => 'basic',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->admin)->delete('/admin/subscriptions/basic');
|
||||||
|
|
||||||
|
$response->assertSessionHasErrors(['status']);
|
||||||
|
$this->assertDatabaseHas('plans', ['id' => 'basic']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -25,6 +25,37 @@ protected function setUp(): void
|
|||||||
'password' => bcrypt('password'),
|
'password' => bcrypt('password'),
|
||||||
'role' => 'employer',
|
'role' => 'employer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Create an active premium subscription so Jane can post/view jobs
|
||||||
|
\Illuminate\Support\Facades\DB::table('subscriptions')->insert([
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'premium',
|
||||||
|
'amount_aed' => 199.00,
|
||||||
|
'starts_at' => now(),
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
'paytabs_transaction_id' => 'TEST_TXN_ID',
|
||||||
|
'status' => 'active',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test displaying the employer jobs list with basic plan.
|
||||||
|
*/
|
||||||
|
public function test_employer_with_basic_plan_cannot_view_jobs_list()
|
||||||
|
{
|
||||||
|
// Update subscription to basic
|
||||||
|
\Illuminate\Support\Facades\DB::table('subscriptions')
|
||||||
|
->where('user_id', $this->employer->id)
|
||||||
|
->update(['plan_id' => 'basic']);
|
||||||
|
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get('/employer/jobs');
|
||||||
|
|
||||||
|
$response->assertRedirect('/employer/subscription');
|
||||||
|
$response->assertSessionHas('error');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -152,10 +152,10 @@ public function test_employer_can_update_profile_with_emirates_id_fields()
|
|||||||
'emirates_id' => [
|
'emirates_id' => [
|
||||||
'emirates_id_number' => '784-1987-5493842-5',
|
'emirates_id_number' => '784-1987-5493842-5',
|
||||||
'name' => 'Mohammad Jobaier Mohammad Abul Kalam',
|
'name' => 'Mohammad Jobaier Mohammad Abul Kalam',
|
||||||
'date_of_birth' => '14/04/1987',
|
'date_of_birth' => '1987-04-14',
|
||||||
'nationality' => 'Bangladesh',
|
'nationality' => 'Bangladesh',
|
||||||
'issue_date' => '12/12/2025',
|
'issue_date' => '2025-12-12',
|
||||||
'expiry_date' => '11/12/2027',
|
'expiry_date' => '2027-12-11',
|
||||||
'employer' => 'Msj International Technical Services L.L.C UAE',
|
'employer' => 'Msj International Technical Services L.L.C UAE',
|
||||||
'issue_place' => 'Dubai',
|
'issue_place' => 'Dubai',
|
||||||
'occupation' => 'Electrician',
|
'occupation' => 'Electrician',
|
||||||
@ -371,4 +371,43 @@ public function test_employer_can_change_password()
|
|||||||
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('NewPassword@123', $this->employer->fresh()->password));
|
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('NewPassword@123', $this->employer->fresh()->password));
|
||||||
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('NewPassword@123', $sponsor->fresh()->password));
|
$this->assertTrue(\Illuminate\Support\Facades\Hash::check('NewPassword@123', $sponsor->fresh()->password));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test registering an employer with DD/MM/YYYY dates normalizes them to Y-m-d.
|
||||||
|
*/
|
||||||
|
public function test_employer_registration_normalizes_dates()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/employers/register', [
|
||||||
|
'name' => 'Sandeep Vishwakarma',
|
||||||
|
'email' => 'sandeep@example.com',
|
||||||
|
'phone' => '+971509990002',
|
||||||
|
'address' => 'Dubai, UAE',
|
||||||
|
'emirates_id' => [
|
||||||
|
'emirates_id_number' => '784-2002-4977006-4',
|
||||||
|
'name' => 'Sandeep Vishwakarma',
|
||||||
|
'date_of_birth' => '05/07/2002',
|
||||||
|
'issue_date' => '07/06/2023',
|
||||||
|
'expiry_date' => '06/06/2025',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201)
|
||||||
|
->assertJson([
|
||||||
|
'success' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('employer_profiles', [
|
||||||
|
'phone' => '+971509990002',
|
||||||
|
'emirates_id_dob' => '2002-07-05',
|
||||||
|
'emirates_id_issue_date' => '2023-06-07',
|
||||||
|
'emirates_id_expiry' => '2025-06-06',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('sponsors', [
|
||||||
|
'mobile' => '+971509990002',
|
||||||
|
'emirates_id_dob' => '2002-07-05',
|
||||||
|
'emirates_id_issue_date' => '2023-06-07',
|
||||||
|
'emirates_id_expiry' => '2025-06-06',
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
259
tests/Feature/EmployerSubscriptionUpgradeTest.php
Normal file
259
tests/Feature/EmployerSubscriptionUpgradeTest.php
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class EmployerSubscriptionUpgradeTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected $employer;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Create employer
|
||||||
|
$this->employer = User::create([
|
||||||
|
'name' => 'Employer User',
|
||||||
|
'email' => 'employer@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'role' => 'employer',
|
||||||
|
'subscription_status' => 'expired',
|
||||||
|
'subscription_expires_at' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_purchase_when_no_active_subscription_activates_immediately()
|
||||||
|
{
|
||||||
|
$response = $this->actingAs($this->employer)->postJson(route('employer.subscription.purchase'), [
|
||||||
|
'plan_id' => 'premium',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
|
||||||
|
// Assert subscription is active
|
||||||
|
$this->assertDatabaseHas('subscriptions', [
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'premium',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->employer->refresh();
|
||||||
|
$this->assertEquals('active', $this->employer->subscription_status);
|
||||||
|
$this->assertNotNull($this->employer->subscription_expires_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_purchase_when_active_subscription_exists_queues_pending()
|
||||||
|
{
|
||||||
|
// 1. Create active subscription
|
||||||
|
$activeSub = Subscription::create([
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'basic',
|
||||||
|
'amount_aed' => 99.00,
|
||||||
|
'starts_at' => now(),
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->employer->update([
|
||||||
|
'subscription_status' => 'active',
|
||||||
|
'subscription_expires_at' => $activeSub->expires_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. Purchase another subscription
|
||||||
|
$response = $this->actingAs($this->employer)->postJson(route('employer.subscription.purchase'), [
|
||||||
|
'plan_id' => 'premium',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
// Assert new subscription is pending and queued chronologically
|
||||||
|
$this->assertDatabaseHas('subscriptions', [
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'premium',
|
||||||
|
'status' => 'pending',
|
||||||
|
'starts_at' => $activeSub->expires_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// User should still have active status with previous expiry
|
||||||
|
$this->employer->refresh();
|
||||||
|
$this->assertEquals('active', $this->employer->subscription_status);
|
||||||
|
$this->assertEquals($activeSub->expires_at->toDateTimeString(), $this->employer->subscription_expires_at->toDateTimeString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pending_subscription_activates_when_active_expires()
|
||||||
|
{
|
||||||
|
// 1. Create active subscription expiring in the past
|
||||||
|
$expiredSub = Subscription::create([
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'basic',
|
||||||
|
'amount_aed' => 99.00,
|
||||||
|
'starts_at' => now()->subDays(40),
|
||||||
|
'expires_at' => now()->subDays(10), // expired 10 days ago
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. Create pending subscription queued to start when expired sub ended
|
||||||
|
$pendingSub = Subscription::create([
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'premium',
|
||||||
|
'amount_aed' => 199.00,
|
||||||
|
'starts_at' => $expiredSub->expires_at,
|
||||||
|
'expires_at' => $expiredSub->expires_at->copy()->addDays(30),
|
||||||
|
'status' => 'pending',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->employer->update([
|
||||||
|
'subscription_status' => 'active',
|
||||||
|
'subscription_expires_at' => $expiredSub->expires_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Trigger check and activate routine
|
||||||
|
User::checkAndActivatePendingSubscriptions($this->employer->id);
|
||||||
|
|
||||||
|
// 4. Assert expired sub is set to expired
|
||||||
|
$expiredSub->refresh();
|
||||||
|
$this->assertEquals('expired', $expiredSub->status);
|
||||||
|
|
||||||
|
// 5. Assert pending sub is now active
|
||||||
|
$pendingSub->refresh();
|
||||||
|
$this->assertEquals('active', $pendingSub->status);
|
||||||
|
|
||||||
|
// 6. Assert user is active and has new expires_at set starting from now
|
||||||
|
$this->employer->refresh();
|
||||||
|
$this->assertEquals('active', $this->employer->subscription_status);
|
||||||
|
$this->assertTrue($this->employer->subscription_expires_at > now());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_expired_subscription_restricts_access_and_redirects_to_subscription_plans()
|
||||||
|
{
|
||||||
|
// 1. Create expired subscription
|
||||||
|
Subscription::create([
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'basic',
|
||||||
|
'amount_aed' => 99.00,
|
||||||
|
'starts_at' => now()->subDays(40),
|
||||||
|
'expires_at' => now()->subDays(10), // expired 10 days ago
|
||||||
|
'status' => 'expired',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->employer->update([
|
||||||
|
'subscription_status' => 'expired',
|
||||||
|
'subscription_expires_at' => now()->subDays(10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. Try to view dashboard - should redirect to subscription
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get('/employer/dashboard');
|
||||||
|
|
||||||
|
$response->assertRedirect(route('employer.subscription'));
|
||||||
|
|
||||||
|
// 3. Accessing subscription page itself is allowed
|
||||||
|
$subResponse = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get(route('employer.subscription'));
|
||||||
|
|
||||||
|
$subResponse->assertStatus(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_contact_limit_blocks_new_conversations_but_allows_existing_ones()
|
||||||
|
{
|
||||||
|
// 1. Create a custom plan with max_contact_people = 2
|
||||||
|
Plan::create([
|
||||||
|
'id' => 'test-plan-2',
|
||||||
|
'name' => 'Test Plan 2',
|
||||||
|
'price' => 10.00,
|
||||||
|
'duration' => 'Monthly',
|
||||||
|
'features' => ['Feature 1'],
|
||||||
|
'max_contact_people' => 2,
|
||||||
|
'unlimited_contacts' => false,
|
||||||
|
'enable_job_apply' => true,
|
||||||
|
'status' => 'Active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Subscription::create([
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'test-plan-2',
|
||||||
|
'amount_aed' => 10.00,
|
||||||
|
'starts_at' => now(),
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->employer->update([
|
||||||
|
'subscription_status' => 'active',
|
||||||
|
'subscription_expires_at' => now()->addDays(30),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create 3 workers
|
||||||
|
$worker1 = \App\Models\Worker::create(['name' => 'Worker One', 'email' => 'w1@ex.com', 'phone' => '1111111', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 25, 'salary' => 1500, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
|
||||||
|
$worker2 = \App\Models\Worker::create(['name' => 'Worker Two', 'email' => 'w2@ex.com', 'phone' => '2222222', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 26, 'salary' => 1600, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
|
||||||
|
$worker3 = \App\Models\Worker::create(['name' => 'Worker Three', 'email' => 'w3@ex.com', 'phone' => '3333333', 'nationality' => 'Indian', 'status' => 'active', 'passport_status' => 'valid', 'age' => 27, 'salary' => 1700, 'availability' => 'Immediate', 'experience' => '2 Years', 'religion' => 'Christian', 'bio' => 'Helper']);
|
||||||
|
|
||||||
|
// 2. Contact worker 1 -> Allowed
|
||||||
|
$response1 = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get(route('employer.messages.start', ['workerId' => $worker1->id]));
|
||||||
|
|
||||||
|
$response1->assertRedirect();
|
||||||
|
$this->assertDatabaseHas('conversations', [
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'worker_id' => $worker1->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Contact worker 2 -> Allowed
|
||||||
|
$response2 = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get(route('employer.messages.start', ['workerId' => $worker2->id]));
|
||||||
|
|
||||||
|
$response2->assertRedirect();
|
||||||
|
$this->assertDatabaseHas('conversations', [
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'worker_id' => $worker2->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. Contact worker 3 -> Blocked (limit reached)
|
||||||
|
$response3 = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get(route('employer.messages.start', ['workerId' => $worker3->id]));
|
||||||
|
|
||||||
|
$response3->assertRedirect(route('employer.subscription'));
|
||||||
|
$response3->assertSessionHas('error');
|
||||||
|
$this->assertDatabaseMissing('conversations', [
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'worker_id' => $worker3->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 5. Contact worker 1 again -> Allowed (already contacted)
|
||||||
|
$response4 = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get(route('employer.messages.start', ['workerId' => $worker1->id]));
|
||||||
|
|
||||||
|
$response4->assertRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_api_expired_subscription_blocks_access()
|
||||||
|
{
|
||||||
|
$token = 'test_api_token_123';
|
||||||
|
$this->employer->update([
|
||||||
|
'api_token' => $token,
|
||||||
|
'subscription_status' => 'expired',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Access API dashboard -> Should get 403 Forbidden
|
||||||
|
$response = $this->getJson('/api/employers/dashboard', [
|
||||||
|
'Authorization' => 'Bearer ' . $token,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(403);
|
||||||
|
$response->assertJsonPath('success', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -605,6 +605,40 @@ public function test_sponsor_forgot_password_user_not_found()
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test registering a sponsor with DD/MM/YYYY dates normalizes them to Y-m-d.
|
||||||
|
*/
|
||||||
|
public function test_sponsor_registration_normalizes_dates()
|
||||||
|
{
|
||||||
|
$response = $this->postJson('/api/sponsors/register', [
|
||||||
|
'full_name' => 'Test Sponsor Date Normalization',
|
||||||
|
'mobile' => '+971509990999',
|
||||||
|
'password' => 'securepassword',
|
||||||
|
'organization_name' => 'Charity Date Norm',
|
||||||
|
'email' => 'test.sponsor.datenorm@example.com',
|
||||||
|
'nationality' => 'Emirati',
|
||||||
|
'city' => 'Abu Dhabi',
|
||||||
|
'address' => 'Villa 45, Street 12',
|
||||||
|
'country_code' => '+971',
|
||||||
|
'emirates_id' => [
|
||||||
|
'emirates_id_number' => '784-1988-5310327-2',
|
||||||
|
'name' => 'Test Sponsor Date Normalization',
|
||||||
|
'date_of_birth' => '05/07/2002',
|
||||||
|
'issue_date' => '07/06/2023',
|
||||||
|
'expiry_date' => '06/06/2025',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertStatus(201);
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('sponsors', [
|
||||||
|
'email' => 'test.sponsor.datenorm@example.com',
|
||||||
|
'emirates_id_dob' => '2002-07-05',
|
||||||
|
'emirates_id_issue_date' => '2023-06-07',
|
||||||
|
'emirates_id_expiry' => '2025-06-06',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Test sponsor can change password successfully.
|
* Test sponsor can change password successfully.
|
||||||
*/
|
*/
|
||||||
|
|||||||
506
tests/Feature/WorkerJobApiTest.php
Normal file
506
tests/Feature/WorkerJobApiTest.php
Normal file
@ -0,0 +1,506 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Worker;
|
||||||
|
use App\Models\JobPost;
|
||||||
|
use App\Models\JobApplication;
|
||||||
|
use App\Models\Plan;
|
||||||
|
use App\Models\Subscription;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class WorkerJobApiTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
protected $employer;
|
||||||
|
protected $worker;
|
||||||
|
protected $plan;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Get standard plan
|
||||||
|
$this->plan = Plan::find('premium');
|
||||||
|
|
||||||
|
// Create employer with active subscription
|
||||||
|
$this->employer = User::create([
|
||||||
|
'name' => 'Employer User',
|
||||||
|
'email' => 'employer@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'role' => 'employer',
|
||||||
|
'subscription_status' => 'active',
|
||||||
|
'subscription_expires_at' => now()->addDays(30),
|
||||||
|
'api_token' => 'employer_api_token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Subscription::create([
|
||||||
|
'user_id' => $this->employer->id,
|
||||||
|
'plan_id' => 'premium',
|
||||||
|
'amount_aed' => 100.00,
|
||||||
|
'starts_at' => now(),
|
||||||
|
'expires_at' => now()->addDays(30),
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create worker
|
||||||
|
$this->worker = Worker::create([
|
||||||
|
'name' => 'Worker User',
|
||||||
|
'email' => 'worker@example.com',
|
||||||
|
'password' => bcrypt('password'),
|
||||||
|
'phone' => '971501234567',
|
||||||
|
'nationality' => 'Indian',
|
||||||
|
'status' => 'active',
|
||||||
|
'api_token' => 'worker_api_token',
|
||||||
|
'age' => 25,
|
||||||
|
'salary' => 2000,
|
||||||
|
'availability' => 'Immediate',
|
||||||
|
'experience' => '2 Years',
|
||||||
|
'religion' => 'Christian',
|
||||||
|
'bio' => 'Helper info',
|
||||||
|
'passport_status' => 'valid',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_employer_job_crud_web()
|
||||||
|
{
|
||||||
|
// 1. Create Job (POST /employer/jobs/create)
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->post('/employer/jobs/create', [
|
||||||
|
'title' => 'Test Mason Job',
|
||||||
|
'workers_needed' => 3,
|
||||||
|
'location' => 'Dubai',
|
||||||
|
'salary' => 2000,
|
||||||
|
'job_type' => 'Full Time',
|
||||||
|
'start_date' => now()->addDays(5)->format('Y-m-d'),
|
||||||
|
'description' => 'Test mason job description',
|
||||||
|
'requirements' => 'Must have experience',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect('/employer/jobs');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('job_posts', [
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'title' => 'Test Mason Job',
|
||||||
|
'location' => 'Dubai',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$job = JobPost::first();
|
||||||
|
|
||||||
|
// 2. View Job Details (GET /employer/jobs/{id})
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->get("/employer/jobs/{$job->id}");
|
||||||
|
$response->assertStatus(200);
|
||||||
|
|
||||||
|
// 3. Edit Job (POST /employer/jobs/{id}/edit)
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->post("/employer/jobs/{$job->id}/edit", [
|
||||||
|
'title' => 'Updated Mason Job',
|
||||||
|
'workers_needed' => 2,
|
||||||
|
'location' => 'Abu Dhabi',
|
||||||
|
'salary' => 2500,
|
||||||
|
'job_type' => 'Part Time',
|
||||||
|
'start_date' => now()->addDays(10)->format('Y-m-d'),
|
||||||
|
'description' => 'Updated description',
|
||||||
|
'requirements' => 'Must speak English',
|
||||||
|
'status' => 'closed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect('/employer/jobs');
|
||||||
|
$this->assertDatabaseHas('job_posts', [
|
||||||
|
'id' => $job->id,
|
||||||
|
'title' => 'Updated Mason Job',
|
||||||
|
'status' => 'closed',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. Delete Job (DELETE /employer/jobs/{id})
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->delete("/employer/jobs/{$job->id}");
|
||||||
|
|
||||||
|
$response->assertRedirect('/employer/jobs');
|
||||||
|
$this->assertSoftDeleted('job_posts', [
|
||||||
|
'id' => $job->id,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_worker_job_apis()
|
||||||
|
{
|
||||||
|
// Setup an active job post
|
||||||
|
$job = JobPost::create([
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'title' => 'Available Plumber Job',
|
||||||
|
'location' => 'Dubai Marina',
|
||||||
|
'salary' => 3000,
|
||||||
|
'workers_needed' => 1,
|
||||||
|
'job_type' => 'Contract',
|
||||||
|
'start_date' => now()->addDays(2),
|
||||||
|
'description' => 'Plumber description',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 1. List available jobs
|
||||||
|
$response = $this->getJson('/api/workers/jobs', [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$response->assertJsonCount(1, 'data.jobs');
|
||||||
|
$response->assertJsonPath('data.jobs.0.title', 'Available Plumber Job');
|
||||||
|
|
||||||
|
// 2. Retrieve job details
|
||||||
|
$response = $this->getJson("/api/workers/jobs/{$job->id}", [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$response->assertJsonPath('data.job.title', 'Available Plumber Job');
|
||||||
|
|
||||||
|
// 3. Apply for job
|
||||||
|
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$this->assertDatabaseHas('job_applications', [
|
||||||
|
'worker_id' => $this->worker->id,
|
||||||
|
'job_id' => $job->id,
|
||||||
|
'status' => 'applied',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 4. Prevent duplicate job applications
|
||||||
|
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(409);
|
||||||
|
$response->assertJsonPath('success', false);
|
||||||
|
$response->assertJsonPath('message', 'You have already applied for this job.');
|
||||||
|
|
||||||
|
// 5. View applied jobs
|
||||||
|
$response = $this->getJson('/api/workers/applied-jobs', [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$response->assertJsonCount(1, 'data.applications');
|
||||||
|
$response->assertJsonPath('data.applications.0.job.title', 'Available Plumber Job');
|
||||||
|
|
||||||
|
$application = JobApplication::first();
|
||||||
|
|
||||||
|
// 6. Employer view applicants for job
|
||||||
|
$response = $this->getJson("/api/employers/jobs/{$job->id}/applicants", [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$response->assertJsonCount(1, 'data.applicants');
|
||||||
|
$response->assertJsonPath('data.applicants.0.worker.name', 'Worker User');
|
||||||
|
|
||||||
|
// 7. Employer update application status
|
||||||
|
$response = $this->postJson("/api/employers/applications/{$application->id}/status", [
|
||||||
|
'status' => 'shortlisted'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$this->assertDatabaseHas('job_applications', [
|
||||||
|
'id' => $application->id,
|
||||||
|
'status' => 'shortlisted',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_employer_job_apis_and_access_restrictions()
|
||||||
|
{
|
||||||
|
// 1. List jobs via API (premium plan by default)
|
||||||
|
$response = $this->getJson('/api/employers/jobs', [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$response->assertJsonCount(0, 'data.jobs');
|
||||||
|
|
||||||
|
// 2. Create job via API
|
||||||
|
$response = $this->postJson('/api/employers/jobs', [
|
||||||
|
'title' => 'API Clean Job',
|
||||||
|
'workers_needed' => 3,
|
||||||
|
'location' => 'Dubai JLT',
|
||||||
|
'salary' => 2200,
|
||||||
|
'job_type' => 'Full Time',
|
||||||
|
'start_date' => now()->addDays(3)->format('Y-m-d'),
|
||||||
|
'description' => 'Clean job description',
|
||||||
|
'requirements' => 'Requirements content'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$jobId = $response->json('data.job.id');
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('job_posts', [
|
||||||
|
'id' => $jobId,
|
||||||
|
'title' => 'API Clean Job',
|
||||||
|
'location' => 'Dubai JLT',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 3. Get job detail via API
|
||||||
|
$response = $this->getJson("/api/employers/jobs/{$jobId}", [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.job.title', 'API Clean Job');
|
||||||
|
|
||||||
|
// 4. Update job via API
|
||||||
|
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
|
||||||
|
'title' => 'Updated API Clean Job',
|
||||||
|
'workers_needed' => 4,
|
||||||
|
'location' => 'Dubai Marina',
|
||||||
|
'salary' => 2500,
|
||||||
|
'job_type' => 'Contract',
|
||||||
|
'start_date' => now()->addDays(4)->format('Y-m-d'),
|
||||||
|
'description' => 'Updated clean job description',
|
||||||
|
'requirements' => 'Updated requirements',
|
||||||
|
'status' => 'draft'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('data.job.title', 'Updated API Clean Job');
|
||||||
|
$response->assertJsonPath('data.job.status', 'draft');
|
||||||
|
|
||||||
|
// 5. Delete job via API
|
||||||
|
$response = $this->deleteJson("/api/employers/jobs/{$jobId}", [], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$this->assertSoftDeleted('job_posts', [
|
||||||
|
'id' => $jobId
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 6. Test basic plan restrictions (no job access)
|
||||||
|
Subscription::where('user_id', $this->employer->id)->update(['plan_id' => 'basic']);
|
||||||
|
|
||||||
|
// Check List Jobs block
|
||||||
|
$response = $this->getJson('/api/employers/jobs', [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
$response->assertJsonPath('success', false);
|
||||||
|
$response->assertJsonPath('message', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||||
|
|
||||||
|
// Check Create Job block
|
||||||
|
$response = $this->postJson('/api/employers/jobs', [
|
||||||
|
'title' => 'Blocked Job'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
|
||||||
|
// Check Get Detail block
|
||||||
|
$response = $this->getJson("/api/employers/jobs/{$jobId}", [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
|
||||||
|
// Check Update block
|
||||||
|
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
|
||||||
|
'title' => 'Blocked Job'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
|
||||||
|
// Check Delete block
|
||||||
|
$response = $this->deleteJson("/api/employers/jobs/{$jobId}", [], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
|
||||||
|
// Check Applicants block
|
||||||
|
$response = $this->getJson("/api/employers/jobs/{$jobId}/applicants", [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
|
||||||
|
// Check Update status block
|
||||||
|
$response = $this->postJson("/api/employers/applications/1/status", [
|
||||||
|
'status' => 'shortlisted'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
|
||||||
|
// 7. Test contact limits enforcement (both Web and API)
|
||||||
|
// Set plan limits to max 1 contacted worker
|
||||||
|
Subscription::where('user_id', $this->employer->id)->update(['plan_id' => 'premium']);
|
||||||
|
$premiumPlan = Plan::find('premium');
|
||||||
|
$premiumPlan->update([
|
||||||
|
'unlimited_contacts' => false,
|
||||||
|
'max_contact_people' => 1
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create 2 workers
|
||||||
|
$workerA = Worker::create([
|
||||||
|
'name' => 'Worker A',
|
||||||
|
'email' => 'workera@example.com',
|
||||||
|
'phone' => '971501234568',
|
||||||
|
'nationality' => 'Indian',
|
||||||
|
'status' => 'active',
|
||||||
|
'age' => 25,
|
||||||
|
'salary' => 2000,
|
||||||
|
'availability' => 'Immediate',
|
||||||
|
'experience' => '2 Years',
|
||||||
|
'religion' => 'Christian',
|
||||||
|
'bio' => 'Helper A',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$workerB = Worker::create([
|
||||||
|
'name' => 'Worker B',
|
||||||
|
'email' => 'workerb@example.com',
|
||||||
|
'phone' => '971501234569',
|
||||||
|
'nationality' => 'Indian',
|
||||||
|
'status' => 'active',
|
||||||
|
'age' => 25,
|
||||||
|
'salary' => 2000,
|
||||||
|
'availability' => 'Immediate',
|
||||||
|
'experience' => '2 Years',
|
||||||
|
'religion' => 'Christian',
|
||||||
|
'bio' => 'Helper B',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Create an active job for testing applications status updates
|
||||||
|
$activeJob = JobPost::create([
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'title' => 'Active Job Limit Test',
|
||||||
|
'location' => 'Dubai',
|
||||||
|
'salary' => 2000,
|
||||||
|
'workers_needed' => 5,
|
||||||
|
'job_type' => 'Full Time',
|
||||||
|
'start_date' => now()->addDays(5),
|
||||||
|
'description' => 'Test description',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$appA = JobApplication::create([
|
||||||
|
'job_id' => $activeJob->id,
|
||||||
|
'worker_id' => $workerA->id,
|
||||||
|
'status' => 'applied'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$appB = JobApplication::create([
|
||||||
|
'job_id' => $activeJob->id,
|
||||||
|
'worker_id' => $workerB->id,
|
||||||
|
'status' => 'applied'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First contact (Worker A) -> Should succeed
|
||||||
|
$response = $this->postJson("/api/employers/applications/{$appA->id}/status", [
|
||||||
|
'status' => 'shortlisted'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertEquals(1, User::contactedWorkersCount($this->employer->id));
|
||||||
|
|
||||||
|
// Second contact (Worker B) -> Should fail with 403 (contact limit reached)
|
||||||
|
$response = $this->postJson("/api/employers/applications/{$appB->id}/status", [
|
||||||
|
'status' => 'shortlisted'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(403);
|
||||||
|
$response->assertJsonPath('success', false);
|
||||||
|
$response->assertJsonFragment([
|
||||||
|
'message' => 'You have reached your contacted workers limit of 1 under your active plan. Please upgrade or renew your subscription to contact more workers.'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Web status update: first check web login and update status
|
||||||
|
// Web: contact limit block redirect
|
||||||
|
$response = $this->actingAs($this->employer)
|
||||||
|
->withSession(['user' => $this->employer])
|
||||||
|
->post("/employer/candidates/{$appB->id}/status", [
|
||||||
|
'status' => 'Offer Sent'
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertRedirect('/employer/subscription');
|
||||||
|
$response->assertSessionHas('error', 'You have reached your contacted workers limit of 1 under your active plan. Please upgrade or renew your subscription to contact more workers.');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_job_hiring_workflow_enhancements()
|
||||||
|
{
|
||||||
|
// 1. Create a job
|
||||||
|
$job = JobPost::create([
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'title' => 'Workflow Developer Job',
|
||||||
|
'location' => 'Dubai Marina',
|
||||||
|
'salary' => 5000,
|
||||||
|
'workers_needed' => 1,
|
||||||
|
'job_type' => 'Full Time',
|
||||||
|
'start_date' => now()->addDays(2),
|
||||||
|
'description' => 'Developer description',
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 2. Worker applies
|
||||||
|
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(201);
|
||||||
|
$app = JobApplication::first();
|
||||||
|
|
||||||
|
// 3. Worker views details of application
|
||||||
|
$response = $this->getJson("/api/workers/applied-jobs/{$app->id}", [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$response->assertJsonPath('data.application.status', 'applied');
|
||||||
|
$response->assertJsonCount(0, 'data.application.status_history');
|
||||||
|
|
||||||
|
// 4. Employer updates status to shortlisted with notes
|
||||||
|
$response = $this->postJson("/api/employers/applications/{$app->id}/status", [
|
||||||
|
'status' => 'shortlisted',
|
||||||
|
'notes' => 'Very qualified candidate.'
|
||||||
|
], [
|
||||||
|
'Authorization' => 'Bearer employer_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$response->assertJsonPath('success', true);
|
||||||
|
$response->assertJsonPath('data.application.status', 'shortlisted');
|
||||||
|
$response->assertJsonPath('data.application.notes', 'Very qualified candidate.');
|
||||||
|
$response->assertJsonCount(1, 'data.application.status_history');
|
||||||
|
$response->assertJsonPath('data.application.status_history.0.status', 'shortlisted');
|
||||||
|
$response->assertJsonPath('data.application.status_history.0.notes', 'Very qualified candidate.');
|
||||||
|
|
||||||
|
// 5. Verify saved workers sync: worker is now saved (shortlisted)
|
||||||
|
$this->assertDatabaseHas('shortlists', [
|
||||||
|
'employer_id' => $this->employer->id,
|
||||||
|
'worker_id' => $this->worker->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 6. Worker withdraws application -> should fail because status is shortlisted (not applied)
|
||||||
|
$response = $this->postJson("/api/workers/applied-jobs/{$app->id}/withdraw", [], [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(400);
|
||||||
|
|
||||||
|
// 7. Reset application status to applied to test withdraw
|
||||||
|
$app->refresh();
|
||||||
|
$app->update(['status' => 'applied']);
|
||||||
|
$response = $this->postJson("/api/workers/applied-jobs/{$app->id}/withdraw", [], [
|
||||||
|
'Authorization' => 'Bearer worker_api_token'
|
||||||
|
]);
|
||||||
|
$response->assertStatus(200);
|
||||||
|
$this->assertDatabaseMissing('job_applications', [
|
||||||
|
'id' => $app->id
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user